• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SEED-platform / seed / #8755

10 Dec 2024 09:20PM UTC coverage: 78.792% (-0.008%) from 78.8%
#8755

push

coveralls-python

web-flow
Fix organization delete - ensure there is a chance to confirm and that it works in one step. (#4851)

* new confirm modal for removing organizations, rearrange inventory deletion so as to not try to remove org before the org is empty

* linting

* update translations and css

* lint

---------

Co-authored-by: Alex Swindler <alex.swindler@nrel.gov>
Co-authored-by: Katherine Fleming <2205659+kflemin@users.noreply.github.com>

25 of 38 new or added lines in 2 files covered. (65.79%)

4 existing lines in 1 file now uncovered.

18472 of 23444 relevant lines covered (78.79%)

0.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.49
/seed/views/v3/organizations.py
1
"""
2
SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors.
3
See also https://github.com/SEED-platform/seed/blob/main/LICENSE.md
4
"""
5

6
import contextlib
1✔
7
import functools
1✔
8
import json
1✔
9
import locale
1✔
10
import logging
1✔
11
import operator
1✔
12
from io import BytesIO
1✔
13
from numbers import Number
1✔
14
from pathlib import Path
1✔
15

16
import numpy as np
1✔
17
from django.conf import settings
18
from django.contrib.auth.decorators import permission_required
19
from django.contrib.auth.forms import PasswordResetForm
20
from django.contrib.postgres.aggregates.general import ArrayAgg
21
from django.core.exceptions import ObjectDoesNotExist
22
from django.core.files.uploadedfile import SimpleUploadedFile
23
from django.db.models import F, Value
24
from django.http import HttpResponse, JsonResponse
25
from django.utils.decorators import method_decorator
26
from drf_yasg import openapi
1✔
27
from drf_yasg.utils import swagger_auto_schema
1✔
28
from rest_framework import status, viewsets
1✔
29
from rest_framework.decorators import action
1✔
30
from rest_framework.response import Response
1✔
31
from xlsxwriter import Workbook
1✔
32

33
from seed import tasks
1✔
34
from seed.audit_template.audit_template import toggle_audit_template_sync
1✔
35
from seed.data_importer.models import ImportFile, ImportRecord
1✔
36
from seed.data_importer.tasks import save_raw_data
1✔
37
from seed.decorators import ajax_request_class
1✔
38
from seed.landing.models import SEEDUser as User
1✔
39
from seed.lib.superperms.orgs.decorators import has_hierarchy_access, has_perm_class
1✔
40
from seed.lib.superperms.orgs.models import ROLE_MEMBER, ROLE_OWNER, ROLE_VIEWER, AccessLevelInstance, Organization, OrganizationUser
1✔
41
from seed.models import (
1✔
42
    AUDIT_IMPORT,
43
    GREEN_BUTTON,
44
    PORTFOLIO_METER_USAGE,
45
    SEED_DATA_SOURCES,
46
    Column,
47
    Cycle,
48
    FilterGroup,
49
    Property,
50
    PropertyAuditLog,
51
    PropertyState,
52
    PropertyView,
53
    ReportConfiguration,
54
    TaxLot,
55
    TaxLotAuditLog,
56
    TaxLotState,
57
    TaxLotView,
58
)
59
from seed.models import StatusLabel as Label
1✔
60
from seed.serializers.column_mappings import SaveColumnMappingsRequestPayloadSerializer
1✔
61
from seed.serializers.columns import ColumnSerializer
1✔
62
from seed.serializers.organizations import SaveSettingsSerializer, SharedFieldsReturnSerializer
1✔
63
from seed.serializers.pint import add_pint_unit_suffix, apply_display_unit_preferences
1✔
64
from seed.serializers.report_configurations import ReportConfigurationSerializer
1✔
65
from seed.utils.api import api_endpoint_class
1✔
66
from seed.utils.api_schema import AutoSchemaHelper
1✔
67
from seed.utils.encrypt import decrypt, encrypt
1✔
68
from seed.utils.geocode import geocode_buildings
1✔
69
from seed.utils.match import match_merge_link
1✔
70
from seed.utils.merge import merge_properties
1✔
71
from seed.utils.organizations import create_organization, create_suborganization, set_default_2fa_method
1✔
72
from seed.utils.properties import pair_unpair_property_taxlot
1✔
73
from seed.utils.public import public_feed
1✔
74
from seed.utils.salesforce import toggle_salesforce_sync
1✔
75
from seed.utils.users import get_js_role
1✔
76

77
_log = logging.getLogger(__name__)
1✔
78

79

80
def _dict_org(request, organizations):
1✔
81
    """returns a dictionary of an organization's data."""
82

83
    orgs = []
1✔
84
    for o in organizations:
1✔
85
        org_cycles = Cycle.objects.filter(organization=o).only("id", "name").order_by("name")
1✔
86
        cycles = []
1✔
87
        for c in org_cycles:
1✔
88
            cycles.append(
1✔
89
                {
90
                    "name": c.name,
91
                    "cycle_id": c.pk,
92
                    "num_properties": PropertyView.objects.filter(cycle=c).count(),
93
                    "num_taxlots": TaxLotView.objects.filter(cycle=c).count(),
94
                }
95
            )
96

97
        # We don't wish to double count sub organization memberships.
98
        org_users = (
1✔
99
            OrganizationUser.objects.select_related("user")
100
            .only("role_level", "user__first_name", "user__last_name", "user__email", "user__id")
101
            .filter(organization=o)
102
        )
103

104
        owners = []
1✔
105
        role_level = None
1✔
106
        user_is_owner = False
1✔
107
        for ou in org_users:
1✔
108
            if ou.role_level == ROLE_OWNER:
1✔
109
                owners.append({"first_name": ou.user.first_name, "last_name": ou.user.last_name, "email": ou.user.email, "id": ou.user.id})
1✔
110

111
                if ou.user == request.user:
1✔
112
                    user_is_owner = True
1✔
113

114
            if ou.user == request.user:
1✔
115
                role_level = get_js_role(ou.role_level)
1✔
116

117
        org = {
1✔
118
            "name": o.name,
119
            "org_id": o.id,
120
            "id": o.id,
121
            "number_of_users": len(org_users),
122
            "user_is_owner": user_is_owner,
123
            "user_role": role_level,
124
            "owners": owners,
125
            "sub_orgs": _dict_org(request, o.child_orgs.all()),
126
            "is_parent": o.is_parent,
127
            "parent_id": o.parent_id,
128
            "display_units_eui": o.display_units_eui,
129
            "display_units_ghg": o.display_units_ghg,
130
            "display_units_ghg_intensity": o.display_units_ghg_intensity,
131
            "display_units_water_use": o.display_units_water_use,
132
            "display_units_wui": o.display_units_wui,
133
            "display_units_area": o.display_units_area,
134
            "display_decimal_places": o.display_decimal_places,
135
            "cycles": cycles,
136
            "created": o.created.strftime("%Y-%m-%d") if o.created else "",
137
            "mapquest_api_key": o.mapquest_api_key or "",
138
            "geocoding_enabled": o.geocoding_enabled,
139
            "better_analysis_api_key": o.better_analysis_api_key or "",
140
            "better_host_url": settings.BETTER_HOST,
141
            "property_display_field": o.property_display_field,
142
            "taxlot_display_field": o.taxlot_display_field,
143
            "display_meter_units": dict(sorted(o.display_meter_units.items(), key=lambda item: (item[0], item[1]))),
144
            "display_meter_water_units": dict(sorted(o.display_meter_water_units.items(), key=lambda item: (item[0], item[1]))),
145
            "thermal_conversion_assumption": o.thermal_conversion_assumption,
146
            "comstock_enabled": o.comstock_enabled,
147
            "new_user_email_from": o.new_user_email_from,
148
            "new_user_email_subject": o.new_user_email_subject,
149
            "new_user_email_content": o.new_user_email_content,
150
            "new_user_email_signature": o.new_user_email_signature,
151
            "at_organization_token": o.at_organization_token,
152
            "at_host_url": settings.AUDIT_TEMPLATE_HOST,
153
            "audit_template_user": o.audit_template_user,
154
            "audit_template_password": decrypt(o.audit_template_password)[0] if o.audit_template_password else "",
155
            "audit_template_city_id": o.audit_template_city_id,
156
            "audit_template_conditional_import": o.audit_template_conditional_import,
157
            "audit_template_report_type": o.audit_template_report_type,
158
            "audit_template_status_types": o.audit_template_status_types,
159
            "audit_template_sync_enabled": o.audit_template_sync_enabled,
160
            "salesforce_enabled": o.salesforce_enabled,
161
            "ubid_threshold": o.ubid_threshold,
162
            "inventory_count": o.property_set.count() + o.taxlot_set.count(),
163
            "access_level_names": o.access_level_names,
164
            "public_feed_enabled": o.public_feed_enabled,
165
            "public_feed_labels": o.public_feed_labels,
166
            "public_geojson_enabled": o.public_geojson_enabled,
167
            "default_reports_x_axis_options": ColumnSerializer(
168
                Column.objects.filter(organization=o, table_name="PropertyState", is_option_for_reports_x_axis=True), many=True
169
            ).data,
170
            "default_reports_y_axis_options": ColumnSerializer(
171
                Column.objects.filter(organization=o, table_name="PropertyState", is_option_for_reports_y_axis=True), many=True
172
            ).data,
173
            "require_2fa": o.require_2fa,
174
        }
175
        orgs.append(org)
1✔
176

177
    return orgs
1✔
178

179

180
def _dict_org_brief(request, organizations):
1✔
181
    """returns a brief dictionary of an organization's data."""
182

183
    organization_roles = list(OrganizationUser.objects.filter(user=request.user).values("organization_id", "role_level"))
×
184

185
    role_levels = {}
×
186
    for r in organization_roles:
×
187
        role_levels[r["organization_id"]] = get_js_role(r["role_level"])
×
188

189
    orgs = []
×
190
    for o in organizations:
×
191
        user_role = None
×
192
        with contextlib.suppress(KeyError):
×
193
            user_role = role_levels[o.id]
×
194

195
        org = {
×
196
            "name": o.name,
197
            "org_id": o.id,
198
            "parent_id": o.parent_org_id,
199
            "is_parent": o.is_parent,
200
            "id": o.id,
201
            "user_role": user_role,
202
            "display_decimal_places": o.display_decimal_places,
203
            "salesforce_enabled": o.salesforce_enabled,
204
            "access_level_names": o.access_level_names,
205
            "audit_template_conditional_import": o.audit_template_conditional_import,
206
            "property_display_field": o.property_display_field,
207
            "taxlot_display_field": o.taxlot_display_field,
208
        }
209
        orgs.append(org)
×
210

211
    return orgs
×
212

213

214
class OrganizationViewSet(viewsets.ViewSet):
1✔
215
    # allow using `pk` in url path for authorization (i.e., for has_perm_class)
216
    authz_org_id_kwarg = "pk"
1✔
217

218
    @ajax_request_class
1✔
219
    @has_perm_class("requires_owner")
1✔
220
    @action(detail=True, methods=["DELETE"])
1✔
221
    def columns(self, request, pk=None):
1✔
222
        """
223
        Delete all columns for an organization. This method is typically not recommended if there
224
        are data in the inventory as it will invalidate all extra_data fields. This also removes
225
        all the column mappings that existed.
226

227
        ---
228
        parameters:
229
            - name: pk
230
              description: The organization_id
231
              required: true
232
              paramType: path
233
        type:
234
            status:
235
                description: success or error
236
                type: string
237
                required: true
238
            column_mappings_deleted_count:
239
                description: Number of column_mappings that were deleted
240
                type: integer
241
                required: true
242
            columns_deleted_count:
243
                description: Number of columns that were deleted
244
                type: integer
245
                required: true
246
        """
247
        try:
1✔
248
            org = Organization.objects.get(pk=pk)
1✔
249
            c_count, cm_count = Column.delete_all(org)
1✔
250
            return JsonResponse(
1✔
251
                {
252
                    "status": "success",
253
                    "column_mappings_deleted_count": cm_count,
254
                    "columns_deleted_count": c_count,
255
                }
256
            )
257
        except Organization.DoesNotExist:
×
258
            return JsonResponse(
×
259
                {"status": "error", "message": f"organization with with id {pk} does not exist"}, status=status.HTTP_404_NOT_FOUND
260
            )
261

262
    @swagger_auto_schema(
1✔
263
        manual_parameters=[
264
            AutoSchemaHelper.query_integer_field("import_file_id", required=True, description="Import file id"),
265
            openapi.Parameter("id", openapi.IN_PATH, type=openapi.TYPE_INTEGER, description="Organization id"),
266
        ],
267
        request_body=SaveColumnMappingsRequestPayloadSerializer,
268
        responses={200: "success response"},
269
    )
270
    @api_endpoint_class
1✔
271
    @ajax_request_class
1✔
272
    @has_perm_class("requires_member")
1✔
273
    @has_hierarchy_access(param_import_file_id="import_file_id")
1✔
274
    @action(detail=True, methods=["POST"])
1✔
275
    def column_mappings(self, request, pk=None):
1✔
276
        """
277
        Saves the mappings between the raw headers of an ImportFile and the
278
        destination fields in the `to_table_name` model which should be either
279
        PropertyState or TaxLotState
280

281
        Valid source_type values are found in ``seed.models.SEED_DATA_SOURCES``
282
        """
283
        import_file_id = request.query_params.get("import_file_id")
1✔
284
        if import_file_id is None:
1✔
285
            return JsonResponse(
×
286
                {"status": "error", "message": "Query param `import_file_id` is required"}, status=status.HTTP_400_BAD_REQUEST
287
            )
288
        try:
1✔
289
            ImportFile.objects.get(pk=import_file_id)
1✔
290
            organization = Organization.objects.get(pk=pk)
1✔
291
        except ImportFile.DoesNotExist:
×
292
            return JsonResponse({"status": "error", "message": "No import file found"}, status=status.HTTP_404_NOT_FOUND)
×
293
        except Organization.DoesNotExist:
×
294
            return JsonResponse({"status": "error", "message": "No organization found"}, status=status.HTTP_404_NOT_FOUND)
×
295

296
        try:
1✔
297
            Column.create_mappings(request.data.get("mappings", []), organization, request.user, import_file_id)
1✔
298
        except PermissionError as e:
1✔
299
            return JsonResponse({"status": "error", "message": str(e)})
1✔
300

301
        else:
302
            return JsonResponse({"status": "success"})
1✔
303

304
    @swagger_auto_schema(
1✔
305
        manual_parameters=[
306
            AutoSchemaHelper.query_boolean_field(
307
                "brief", required=False, description="If true, only return high-level organization details"
308
            )
309
        ]
310
    )
311
    @api_endpoint_class
1✔
312
    @ajax_request_class
1✔
313
    def list(self, request):
1✔
314
        """
315
        Retrieves all orgs the user has access to.
316
        """
317

318
        # if brief==true only return high-level organization details
319
        brief = json.loads(request.query_params.get("brief", "false"))
1✔
320

321
        if brief:
1✔
322
            if request.user.is_superuser:
×
323
                qs = Organization.objects.only(
×
324
                    "id", "name", "parent_org_id", "display_decimal_places", "salesforce_enabled", "access_level_names"
325
                )
326
            else:
327
                qs = request.user.orgs.only(
×
328
                    "id", "name", "parent_org_id", "display_decimal_places", "salesforce_enabled", "access_level_names"
329
                )
330

331
            orgs = _dict_org_brief(request, qs)
×
332
            if len(orgs) == 0:
×
333
                return JsonResponse(
×
334
                    {
335
                        "status": "error",
336
                        "message": "Your SEED account is not associated with any organizations. " "Please contact a SEED administrator.",
337
                    },
338
                    status=status.HTTP_401_UNAUTHORIZED,
339
                )
340
            else:
341
                return JsonResponse({"organizations": orgs})
×
342
        else:
343
            if request.user.is_superuser:
1✔
344
                qs = Organization.objects.all()
×
345
            else:
346
                qs = request.user.orgs.all()
1✔
347

348
            orgs = _dict_org(request, qs)
1✔
349
            if len(orgs) == 0:
1✔
350
                return JsonResponse(
×
351
                    {
352
                        "status": "error",
353
                        "message": "Your SEED account is not associated with any organizations. " "Please contact a SEED administrator.",
354
                    },
355
                    status=status.HTTP_401_UNAUTHORIZED,
356
                )
357
            else:
358
                return JsonResponse({"organizations": orgs})
1✔
359

360
    @api_endpoint_class
1✔
361
    @ajax_request_class
1✔
362
    @has_perm_class("requires_owner")
1✔
363
    def destroy(self, request, pk=None):
1✔
364
        """
365
        Starts a background task to delete an organization and all related data.
366
        """
367

NEW
368
        return JsonResponse(tasks.delete_organization_and_inventory(pk))
×
369

370
    @api_endpoint_class
1✔
371
    @ajax_request_class
1✔
372
    @has_perm_class("requires_viewer")
1✔
373
    def retrieve(self, request, pk=None):
1✔
374
        """
375
        Retrieves a single organization by id.
376
        """
377
        org_id = pk
1✔
378
        brief = json.loads(request.query_params.get("brief", "false"))
1✔
379

380
        if org_id is None:
1✔
381
            return JsonResponse({"status": "error", "message": "no organization_id sent"}, status=status.HTTP_400_BAD_REQUEST)
×
382

383
        try:
1✔
384
            org = Organization.objects.get(pk=org_id)
1✔
385
        except Organization.DoesNotExist:
×
386
            return JsonResponse({"status": "error", "message": "organization does not exist"}, status=status.HTTP_404_NOT_FOUND)
×
387
        if (
1✔
388
            not request.user.is_superuser
389
            and not OrganizationUser.objects.filter(
390
                user=request.user, organization=org, role_level__in=[ROLE_OWNER, ROLE_MEMBER, ROLE_VIEWER]
391
            ).exists()
392
        ):
393
            # TODO: better permission and return 401 or 403
394
            return JsonResponse({"status": "error", "message": "user is not the owner of the org"}, status=status.HTTP_403_FORBIDDEN)
×
395

396
        if brief:
1✔
397
            org = _dict_org_brief(request, [org])[0]
×
398
        else:
399
            org = _dict_org(request, [org])[0]
1✔
400

401
        return JsonResponse(
1✔
402
            {
403
                "status": "success",
404
                "organization": org,
405
            }
406
        )
407

408
    @swagger_auto_schema(
1✔
409
        request_body=AutoSchemaHelper.schema_factory(
410
            {
411
                "organization_name": "string",
412
                "user_id": "integer",
413
            },
414
            required=["organization_name", "user_id"],
415
            description="Properties:\n"
416
            "- organization_name: The new organization name\n"
417
            "- user_id: The user ID (primary key) to be used as the owner of the new organization",
418
        )
419
    )
420
    @api_endpoint_class
1✔
421
    @ajax_request_class
1✔
422
    def create(self, request):
1✔
423
        """
424
        Creates a new organization.
425
        """
426
        body = request.data
1✔
427
        user = User.objects.get(pk=body["user_id"])
1✔
428
        org_name = body["organization_name"]
1✔
429

430
        if not request.user.is_superuser and request.user.id != user.id:
1✔
431
            return JsonResponse({"status": "error", "message": "not authorized"}, status=status.HTTP_403_FORBIDDEN)
×
432

433
        if Organization.objects.filter(name=org_name).exists():
1✔
434
            return JsonResponse({"status": "error", "message": "Organization name already exists"}, status=status.HTTP_409_CONFLICT)
1✔
435

436
        org, _, _ = create_organization(user, org_name, org_name)
1✔
437
        return JsonResponse({"status": "success", "message": "Organization created", "organization": _dict_org(request, [org])[0]})
1✔
438

439
    @api_endpoint_class
1✔
440
    @ajax_request_class
1✔
441
    @method_decorator(permission_required("seed.can_access_admin"))
1✔
442
    @action(detail=True, methods=["DELETE"])
1✔
443
    def inventory(self, request, pk=None):
1✔
444
        """
445
        Starts a background task to delete all properties & taxlots
446
        in an org.
447
        """
448
        return JsonResponse(tasks.delete_organization_inventory(pk))
×
449

450
    @swagger_auto_schema(
1✔
451
        request_body=SaveSettingsSerializer,
452
    )
453
    @api_endpoint_class
1✔
454
    @ajax_request_class
1✔
455
    @has_perm_class("requires_owner")
1✔
456
    @action(detail=True, methods=["PUT"])
1✔
457
    def save_settings(self, request, pk=None):
1✔
458
        """
459
        Saves an organization's settings: name, query threshold, shared fields, etc
460
        """
461
        body = request.data
1✔
462
        org = Organization.objects.get(pk=pk)
1✔
463
        posted_org = body.get("organization", None)
1✔
464
        if posted_org is None:
1✔
465
            return JsonResponse({"status": "error", "message": "malformed request"}, status=status.HTTP_400_BAD_REQUEST)
1✔
466

467
        desired_threshold = posted_org.get("query_threshold", None)
1✔
468
        if desired_threshold is not None:
1✔
469
            org.query_threshold = desired_threshold
1✔
470

471
        desired_name = posted_org.get("name", None)
1✔
472
        if desired_name is not None:
1✔
473
            org.name = desired_name
1✔
474

475
        def is_valid_choice(choice_tuples, s):
1✔
476
            """choice_tuples is std model ((value, label), ...)"""
477
            return (s is not None) and (s in [choice[0] for choice in choice_tuples])
1✔
478

479
        def warn_bad_pint_spec(kind, unit_string):
1✔
480
            if unit_string is not None:
1✔
481
                _log.warn(f"got bad {kind} unit string {unit_string} for org {org.name}")
×
482

483
        def warn_bad_units(kind, unit_string):
1✔
484
            _log.warn(f"got bad {kind} unit string {unit_string} for org {org.name}")
×
485

486
        desired_display_units_eui = posted_org.get("display_units_eui")
1✔
487
        if is_valid_choice(Organization.MEASUREMENT_CHOICES_EUI, desired_display_units_eui):
1✔
488
            org.display_units_eui = desired_display_units_eui
×
489
        else:
490
            warn_bad_pint_spec("eui", desired_display_units_eui)
1✔
491

492
        desired_display_units_ghg = posted_org.get("display_units_ghg")
1✔
493
        if is_valid_choice(Organization.MEASUREMENT_CHOICES_GHG, desired_display_units_ghg):
1✔
494
            org.display_units_ghg = desired_display_units_ghg
×
495
        else:
496
            warn_bad_pint_spec("ghg", desired_display_units_ghg)
1✔
497

498
        desired_display_units_ghg_intensity = posted_org.get("display_units_ghg_intensity")
1✔
499
        if is_valid_choice(Organization.MEASUREMENT_CHOICES_GHG_INTENSITY, desired_display_units_ghg_intensity):
1✔
500
            org.display_units_ghg_intensity = desired_display_units_ghg_intensity
×
501
        else:
502
            warn_bad_pint_spec("ghg_intensity", desired_display_units_ghg_intensity)
1✔
503

504
        desired_display_units_water_use = posted_org.get("display_units_water_use")
1✔
505
        if is_valid_choice(Organization.MEASUREMENT_CHOICES_WATER_USE, desired_display_units_water_use):
1✔
506
            org.display_units_water_use = desired_display_units_water_use
×
507
        else:
508
            warn_bad_pint_spec("water_use", desired_display_units_water_use)
1✔
509

510
        desired_display_units_wui = posted_org.get("display_units_wui")
1✔
511
        if is_valid_choice(Organization.MEASUREMENT_CHOICES_WUI, desired_display_units_wui):
1✔
512
            org.display_units_wui = desired_display_units_wui
×
513
        else:
514
            warn_bad_pint_spec("wui", desired_display_units_wui)
1✔
515

516
        desired_display_units_area = posted_org.get("display_units_area")
1✔
517
        if is_valid_choice(Organization.MEASUREMENT_CHOICES_AREA, desired_display_units_area):
1✔
518
            org.display_units_area = desired_display_units_area
×
519
        else:
520
            warn_bad_pint_spec("area", desired_display_units_area)
1✔
521

522
        desired_display_decimal_places = posted_org.get("display_decimal_places")
1✔
523
        if isinstance(desired_display_decimal_places, int) and desired_display_decimal_places >= 0:
1✔
524
            org.display_decimal_places = desired_display_decimal_places
×
525
        elif desired_display_decimal_places is not None:
1✔
526
            _log.warn(f"got bad sig figs {desired_display_decimal_places} for org {org.name}")
×
527

528
        desired_display_meter_units = posted_org.get("display_meter_units")
1✔
529
        if desired_display_meter_units:
1✔
530
            org.display_meter_units = desired_display_meter_units
×
531

532
        desired_display_meter_water_units = posted_org.get("display_meter_water_units")
1✔
533
        if desired_display_meter_water_units:
1✔
534
            org.display_meter_water_units = desired_display_meter_water_units
×
535

536
        desired_thermal_conversion_assumption = posted_org.get("thermal_conversion_assumption")
1✔
537
        if is_valid_choice(Organization.THERMAL_CONVERSION_ASSUMPTION_CHOICES, desired_thermal_conversion_assumption):
1✔
538
            org.thermal_conversion_assumption = desired_thermal_conversion_assumption
×
539

540
        # Update MapQuest API Key if it's been changed
541
        mapquest_api_key = posted_org.get("mapquest_api_key", "")
1✔
542
        if mapquest_api_key != org.mapquest_api_key:
1✔
543
            org.mapquest_api_key = mapquest_api_key
×
544

545
        # Update geocoding_enabled option
546
        geocoding_enabled = posted_org.get("geocoding_enabled", True)
1✔
547
        if geocoding_enabled != org.geocoding_enabled:
1✔
548
            org.geocoding_enabled = geocoding_enabled
×
549

550
        # Update public_feed_enabled option
551
        public_feed_enabled = posted_org.get("public_feed_enabled")
1✔
552
        if public_feed_enabled:
1✔
553
            org.public_feed_enabled = True
×
554
        elif org.public_feed_enabled:
1✔
555
            org.public_feed_enabled = False
×
556
            org.public_feed_labels = False
×
557
            org.public_geojson_enabled = False
×
558

559
        # Update public_feed_labels option
560
        public_feed_labels = posted_org.get("public_feed_labels", False)
1✔
561
        if public_feed_enabled and public_feed_labels != org.public_feed_labels:
1✔
562
            org.public_feed_labels = public_feed_labels
×
563

564
        # Update public_geojson_enabled option
565
        public_geojson_enabled = posted_org.get("public_geojson_enabled", False)
1✔
566
        if public_feed_enabled and public_geojson_enabled != org.public_geojson_enabled:
1✔
567
            org.public_geojson_enabled = public_geojson_enabled
×
568

569
        # Update BETTER Analysis API Key if it's been changed
570
        better_analysis_api_key = posted_org.get("better_analysis_api_key", "").strip()
1✔
571
        if better_analysis_api_key != org.better_analysis_api_key:
1✔
572
            org.better_analysis_api_key = better_analysis_api_key
×
573

574
        # Update property_display_field option
575
        property_display_field = posted_org.get("property_display_field", "address_line_1")
1✔
576
        if property_display_field != org.property_display_field:
1✔
577
            org.property_display_field = property_display_field
×
578

579
        # Update taxlot_display_field option
580
        taxlot_display_field = posted_org.get("taxlot_display_field", "address_line_1")
1✔
581
        if taxlot_display_field != org.taxlot_display_field:
1✔
582
            org.taxlot_display_field = taxlot_display_field
×
583

584
        # update new user email from option
585
        new_user_email_from = posted_org.get("new_user_email_from")
1✔
586
        if new_user_email_from != org.new_user_email_from:
1✔
587
            org.new_user_email_from = new_user_email_from
1✔
588
        if not org.new_user_email_from:
1✔
589
            org.new_user_email_from = Organization._meta.get_field("new_user_email_from").get_default()
1✔
590

591
        # update new user email subject option
592
        new_user_email_subject = posted_org.get("new_user_email_subject")
1✔
593
        if new_user_email_subject != org.new_user_email_subject:
1✔
594
            org.new_user_email_subject = new_user_email_subject
1✔
595
        if not org.new_user_email_subject:
1✔
596
            org.new_user_email_subject = Organization._meta.get_field("new_user_email_subject").get_default()
1✔
597

598
        # update new user email content option
599
        new_user_email_content = posted_org.get("new_user_email_content")
1✔
600
        if new_user_email_content != org.new_user_email_content:
1✔
601
            org.new_user_email_content = new_user_email_content
1✔
602
        if not org.new_user_email_content:
1✔
603
            org.new_user_email_content = Organization._meta.get_field("new_user_email_content").get_default()
1✔
604
        if "{{sign_up_link}}" not in org.new_user_email_content:
1✔
605
            org.new_user_email_content += "\n\nSign up here: {{sign_up_link}}"
×
606

607
        # update new user email signature option
608
        new_user_email_signature = posted_org.get("new_user_email_signature")
1✔
609
        if new_user_email_signature != org.new_user_email_signature:
1✔
610
            org.new_user_email_signature = new_user_email_signature
1✔
611
        if not org.new_user_email_signature:
1✔
612
            org.new_user_email_signature = Organization._meta.get_field("new_user_email_signature").get_default()
1✔
613

614
        # update default_reports_x_axis_options
615
        default_reports_x_axis_options = sorted(posted_org.get("default_reports_x_axis_options", []))
1✔
616
        current_default_reports_x_axis_options = Column.objects.filter(organization=org, is_option_for_reports_x_axis=True).order_by("id")
1✔
617
        if default_reports_x_axis_options != list(current_default_reports_x_axis_options.values_list("id", flat=True)):
1✔
618
            current_default_reports_x_axis_options.update(is_option_for_reports_x_axis=False)
1✔
619
            Column.objects.filter(organization=org, table_name="PropertyState", id__in=default_reports_x_axis_options).update(
1✔
620
                is_option_for_reports_x_axis=True
621
            )
622

623
        # update default_reports_y_axis_options
624
        default_reports_y_axis_options = sorted(posted_org.get("default_reports_y_axis_options", []))
1✔
625
        current_default_reports_y_axis_options = Column.objects.filter(organization=org, is_option_for_reports_y_axis=True).order_by("id")
1✔
626
        if default_reports_y_axis_options != list(current_default_reports_y_axis_options.values_list("id", flat=True)):
1✔
627
            current_default_reports_y_axis_options.update(is_option_for_reports_y_axis=False)
1✔
628
            Column.objects.filter(organization=org, table_name="PropertyState", id__in=default_reports_y_axis_options).update(
1✔
629
                is_option_for_reports_y_axis=True
630
            )
631

632
        comstock_enabled = posted_org.get("comstock_enabled", False)
1✔
633
        if comstock_enabled != org.comstock_enabled:
1✔
634
            org.comstock_enabled = comstock_enabled
×
635

636
        at_organization_token = posted_org.get("at_organization_token", False)
1✔
637
        if at_organization_token != org.at_organization_token:
1✔
638
            org.at_organization_token = at_organization_token
1✔
639

640
        audit_template_user = posted_org.get("audit_template_user", False)
1✔
641
        if audit_template_user != org.audit_template_user:
1✔
642
            org.audit_template_user = audit_template_user
1✔
643

644
        audit_template_password = posted_org.get("audit_template_password", False)
1✔
645
        if audit_template_password != org.audit_template_password:
1✔
646
            org.audit_template_password = encrypt(audit_template_password)
1✔
647

648
        audit_template_report_type = posted_org.get("audit_template_report_type", False)
1✔
649
        if audit_template_report_type != org.audit_template_report_type:
1✔
650
            org.audit_template_report_type = audit_template_report_type
1✔
651

652
        audit_template_status_types = posted_org.get("audit_template_status_types", False)
1✔
653
        if audit_template_status_types != org.audit_template_status_types:
1✔
654
            org.audit_template_status_types = audit_template_status_types
1✔
655

656
        audit_template_city_id = posted_org.get("audit_template_city_id", False)
1✔
657
        if audit_template_city_id != org.audit_template_city_id:
1✔
658
            org.audit_template_city_id = audit_template_city_id
1✔
659

660
        audit_template_conditional_import = posted_org.get("audit_template_conditional_import", False)
1✔
661
        if audit_template_conditional_import != org.audit_template_conditional_import:
1✔
662
            org.audit_template_conditional_import = audit_template_conditional_import
1✔
663

664
        audit_template_sync_enabled = posted_org.get("audit_template_sync_enabled", False)
1✔
665
        if audit_template_sync_enabled != org.audit_template_sync_enabled:
1✔
666
            org.audit_template_sync_enabled = audit_template_sync_enabled
×
667
            # if audit_template_sync_enabled was toggled, must start/stop auto sync functionality
668
            toggle_audit_template_sync(audit_template_sync_enabled, org.id)
×
669

670
        salesforce_enabled = posted_org.get("salesforce_enabled", False)
1✔
671
        if salesforce_enabled != org.salesforce_enabled:
1✔
672
            org.salesforce_enabled = salesforce_enabled
×
673
            # if salesforce_enabled was toggled, must start/stop auto sync functionality
674
            toggle_salesforce_sync(salesforce_enabled, org.id)
×
675

676
        require_2fa = posted_org.get("require_2fa", False)
1✔
677
        if require_2fa != org.require_2fa:
1✔
678
            org.require_2fa = require_2fa
×
679
            if require_2fa:
×
680
                set_default_2fa_method(org)
×
681

682
        # update the ubid threshold option
683
        ubid_threshold = posted_org.get("ubid_threshold")
1✔
684
        if ubid_threshold is not None and ubid_threshold != org.ubid_threshold:
1✔
685
            if type(ubid_threshold) not in {float, int} or ubid_threshold < 0 or ubid_threshold > 1:
×
686
                return JsonResponse(
×
687
                    {"status": "error", "message": "ubid_threshold must be a float between 0 and 1"}, status=status.HTTP_400_BAD_REQUEST
688
                )
689

690
            org.ubid_threshold = ubid_threshold
×
691

692
        org.save()
1✔
693

694
        # Update the selected exportable fields.
695
        new_public_column_names = posted_org.get("public_fields", None)
1✔
696
        if new_public_column_names is not None:
1✔
697
            old_public_columns = Column.objects.filter(organization=org, shared_field_type=Column.SHARED_PUBLIC)
1✔
698
            # turn off sharing in the old_pub_fields
699
            for col in old_public_columns:
1✔
700
                col.shared_field_type = Column.SHARED_NONE
×
701
                col.save()
×
702

703
            # for now just iterate over this to grab the new columns.
704
            for col in new_public_column_names:
1✔
705
                new_col = Column.objects.filter(organization=org, id=col["id"])
1✔
706
                if len(new_col) == 1:
1✔
707
                    new_col = new_col.first()
1✔
708
                    new_col.shared_field_type = Column.SHARED_PUBLIC
1✔
709
                    new_col.save()
1✔
710

711
        return JsonResponse({"status": "success"})
1✔
712

713
    @api_endpoint_class
1✔
714
    @ajax_request_class
1✔
715
    @has_perm_class("requires_member")
1✔
716
    @action(detail=True, methods=["GET"])
1✔
717
    def query_threshold(self, request, pk=None):
1✔
718
        """
719
        Returns the "query_threshold" for an org.  Searches from
720
        members of sibling orgs must return at least this many buildings
721
        from orgs they do not belong to, or else buildings from orgs they
722
        don't belong to will be removed from the results.
723
        """
724
        org = Organization.objects.get(pk=pk)
1✔
725
        return JsonResponse({"status": "success", "query_threshold": org.query_threshold})
1✔
726

727
    @swagger_auto_schema(responses={200: SharedFieldsReturnSerializer})
1✔
728
    @api_endpoint_class
1✔
729
    @ajax_request_class
1✔
730
    @has_perm_class("requires_member")
1✔
731
    @action(detail=True, methods=["GET"])
1✔
732
    def shared_fields(self, request, pk=None):
1✔
733
        """
734
        Retrieves all fields marked as shared for the organization. Will only return used fields.
735
        """
736
        result = {"status": "success", "public_fields": []}
1✔
737

738
        columns = Column.retrieve_all(pk, "property", True)
1✔
739
        for c in columns:
1✔
740
            if c["sharedFieldType"] == "Public":
×
741
                new_column = {
×
742
                    "table_name": c["table_name"],
743
                    "name": c["name"],
744
                    "column_name": c["column_name"],
745
                    # this is the field name in the db. The other name can have tax_
746
                    "display_name": c["display_name"],
747
                }
748
                result["public_fields"].append(new_column)
×
749

750
        return JsonResponse(result)
1✔
751

752
    @swagger_auto_schema(
1✔
753
        request_body=AutoSchemaHelper.schema_factory(
754
            {
755
                "sub_org_name": "string",
756
                "sub_org_owner_email": "string",
757
            },
758
            required=["sub_org_name", "sub_org_owner_email"],
759
            description="Properties:\n"
760
            "- sub_org_name: Name of the new sub organization\n"
761
            "- sub_org_owner_email: Email of the owner of the sub organization, which must already exist",
762
        )
763
    )
764
    @api_endpoint_class
1✔
765
    @ajax_request_class
1✔
766
    @has_perm_class("requires_member")
1✔
767
    @action(detail=True, methods=["POST"])
1✔
768
    def sub_org(self, request, pk=None):
1✔
769
        """
770
        Creates a child org of a parent org.
771
        """
772
        body = request.data
1✔
773
        org = Organization.objects.get(pk=pk)
1✔
774
        email = body["sub_org_owner_email"].lower()
1✔
775
        try:
1✔
776
            user = User.objects.get(username=email)
1✔
777
        except User.DoesNotExist:
×
778
            return JsonResponse(
×
779
                {"status": "error", "message": "User with email address (%s) does not exist" % email}, status=status.HTTP_400_BAD_REQUEST
780
            )
781

782
        created, mess_or_org, _ = create_suborganization(user, org, body["sub_org_name"], ROLE_OWNER)
1✔
783
        if created:
1✔
784
            return JsonResponse({"status": "success", "organization_id": mess_or_org.pk})
1✔
785
        else:
786
            return JsonResponse({"status": "error", "message": mess_or_org}, status=status.HTTP_409_CONFLICT)
×
787

788
    @api_endpoint_class
1✔
789
    @ajax_request_class
1✔
790
    @has_perm_class("requires_viewer")
1✔
791
    @action(detail=True, methods=["GET"])
1✔
792
    def matching_criteria_columns(self, request, pk=None):
1✔
793
        """
794
        Retrieve all matching criteria columns for an org.
795
        """
796
        try:
1✔
797
            org = Organization.objects.get(pk=pk)
1✔
798
        except ObjectDoesNotExist:
×
799
            return JsonResponse(
×
800
                {"status": "error", "message": "Could not retrieve organization at pk = " + str(pk)}, status=status.HTTP_404_NOT_FOUND
801
            )
802

803
        matching_criteria_column_names = dict(
1✔
804
            org.column_set.filter(is_matching_criteria=True)
805
            .values("table_name")
806
            .annotate(column_names=ArrayAgg("column_name"))
807
            .values_list("table_name", "column_names")
808
        )
809

810
        return JsonResponse(matching_criteria_column_names)
1✔
811

812
    @api_endpoint_class
1✔
813
    @ajax_request_class
1✔
814
    @has_perm_class("requires_member")
1✔
815
    @action(detail=True, methods=["GET"])
1✔
816
    def geocoding_columns(self, request, pk=None):
1✔
817
        """
818
        Retrieve all geocoding columns for an org.
819
        """
820
        try:
1✔
821
            org = Organization.objects.get(pk=pk)
1✔
822
        except ObjectDoesNotExist:
×
823
            return JsonResponse(
×
824
                {"status": "error", "message": "Could not retrieve organization at pk = " + str(pk)}, status=status.HTTP_404_NOT_FOUND
825
            )
826

827
        geocoding_columns_qs = org.column_set.filter(geocoding_order__gt=0).order_by("geocoding_order").values("table_name", "column_name")
1✔
828

829
        geocoding_columns = {
1✔
830
            "PropertyState": [],
831
            "TaxLotState": [],
832
        }
833

834
        for col in geocoding_columns_qs:
1✔
835
            geocoding_columns[col["table_name"]].append(col["column_name"])
1✔
836

837
        return JsonResponse(geocoding_columns)
1✔
838

839
    def _format_property_display_field(self, view, org):
1✔
840
        try:
×
841
            return getattr(view.state, org.property_display_field)
×
842
        except AttributeError:
×
843
            return None
×
844

845
    def setup_report_data(self, organization_id, access_level_instance, cycles, x_var, y_var, filter_group_id=None, additional_columns=[]):
1✔
846
        all_property_views = (
1✔
847
            PropertyView.objects.select_related("property", "state")
848
            .filter(
849
                property__organization_id=organization_id,
850
                property__access_level_instance__lft__gte=access_level_instance.lft,
851
                property__access_level_instance__rgt__lte=access_level_instance.rgt,
852
                cycle_id__in=cycles,
853
            )
854
            .order_by("id")
855
        )
856

857
        if filter_group_id:
1✔
858
            filter_group = FilterGroup.objects.get(pk=filter_group_id)
1✔
859
            all_property_views = filter_group.views(all_property_views)
1✔
860

861
        # annotate properties with fields
862
        def get_column_model_field(column):
1✔
863
            if column.is_extra_data:
1✔
864
                return F("state__extra_data__" + column.column_name)
×
865
            elif column.derived_column:
1✔
866
                return F("state__derived_data__" + column.column_name)
×
867
            else:
868
                return F("state__" + column.column_name)
1✔
869

870
        fields = {
1✔
871
            **{column.column_name: get_column_model_field(column) for column in additional_columns},
872
            "x": get_column_model_field(x_var),
873
            "y": get_column_model_field(y_var),
874
        }
875
        if x_var == "Count":
1✔
876
            fields["x"] = Value(1)
×
877
        for k, v in fields.items():
1✔
878
            all_property_views = all_property_views.annotate(**{k: v})
1✔
879

880
        return {"all_property_views": all_property_views, "field_data": fields}
1✔
881

882
    def get_raw_report_data(self, organization_id, cycles, all_property_views, fields):
1✔
883
        organization = Organization.objects.get(pk=organization_id)
1✔
884
        # get data for each cycle
885
        results = []
1✔
886
        for cycle in cycles:
1✔
887
            property_views = all_property_views.annotate(yr_e=Value(str(cycle.end.year))).filter(cycle_id=cycle)
1✔
888
            data = [apply_display_unit_preferences(organization, d) for d in property_views.values("id", *fields.keys(), "yr_e")]
1✔
889

890
            # count before and after we prune the empty ones
891
            # watch out not to prune boolean fields
892
            count_total = len(data)
1✔
893
            data = [d for d in data if (d["x"] or d["x"] is False or d["x"] is True) and (d["y"] or d["y"] is False or d["y"] is True)]
1✔
894
            count_with_data = len(data)
1✔
895
            result = {
1✔
896
                "cycle_id": cycle.pk,
897
                "chart_data": data,
898
                "property_counts": {
899
                    "yr_e": cycle.end.strftime("%Y"),
900
                    "num_properties": count_total,
901
                    "num_properties_w-data": count_with_data,
902
                },
903
            }
904
            results.append(result)
1✔
905
        return results
1✔
906

907
    @swagger_auto_schema(
1✔
908
        manual_parameters=[
909
            AutoSchemaHelper.query_string_field("x_var", required=True, description="Raw column name for x axis"),
910
            AutoSchemaHelper.query_string_field("y_var", required=True, description="Raw column name for y axis"),
911
            AutoSchemaHelper.query_string_field(
912
                "start", required=True, description='Start time, in the format "2018-12-31T23:53:00-08:00"'
913
            ),
914
            AutoSchemaHelper.query_string_field("end", required=True, description='End time, in the format "2018-12-31T23:53:00-08:00"'),
915
        ]
916
    )
917
    @api_endpoint_class
1✔
918
    @ajax_request_class
1✔
919
    @has_perm_class("requires_viewer")
1✔
920
    @action(detail=True, methods=["GET"])
1✔
921
    def report(self, request, pk=None):
1✔
922
        """Retrieve a summary report for charting x vs y"""
923
        params = {
1✔
924
            "x_var": request.query_params.get("x_var", None),
925
            "y_var": request.query_params.get("y_var", None),
926
            "access_level_instance_id": request.query_params.get("access_level_instance_id", None),
927
            "cycle_ids": request.query_params.getlist("cycle_ids", None),
928
        }
929

930
        user_ali = AccessLevelInstance.objects.get(pk=self.request.access_level_instance_id)
1✔
931
        filter_group_id = request.query_params.get("filter_group_id", None)
1✔
932
        if params["access_level_instance_id"] is None:
1✔
933
            ali = user_ali
1✔
934
        else:
935
            try:
1✔
936
                selected_ali = AccessLevelInstance.objects.get(pk=params["access_level_instance_id"])
1✔
937
                if not (selected_ali == user_ali or selected_ali.is_descendant_of(user_ali)):
1✔
938
                    raise AccessLevelInstance.DoesNotExist
1✔
939
            except (AccessLevelInstance.DoesNotExist, AssertionError):
1✔
940
                return Response({"status": "error", "message": "No such ali"}, status=status.HTTP_400_BAD_REQUEST)
1✔
941
            else:
942
                ali = selected_ali
1✔
943

944
        excepted_params = ["x_var", "y_var", "cycle_ids"]
1✔
945
        missing_params = [p for p in excepted_params if p not in params]
1✔
946
        if missing_params:
1✔
947
            return Response(
×
948
                {"status": "error", "message": "Missing params: {}".format(", ".join(missing_params))}, status=status.HTTP_400_BAD_REQUEST
949
            )
950

951
        cycles = Cycle.objects.filter(id__in=params["cycle_ids"])
1✔
952
        x_var = Column.objects.get(column_name=params["x_var"], organization=pk, table_name="PropertyState")
1✔
953
        y_var = Column.objects.get(column_name=params["y_var"], organization=pk, table_name="PropertyState")
1✔
954
        report_data = self.setup_report_data(pk, ali, cycles, x_var, y_var, filter_group_id)
1✔
955
        data = self.get_raw_report_data(pk, cycles, report_data["all_property_views"], report_data["field_data"])
1✔
956
        axis_data = self.get_axis_data(
1✔
957
            pk, ali, cycles, params["x_var"], params["y_var"], report_data["all_property_views"], report_data["field_data"]
958
        )
959

960
        data = {
1✔
961
            "chart_data": functools.reduce(operator.iadd, [d["chart_data"] for d in data], []),
962
            "property_counts": [d["property_counts"] for d in data],
963
            "axis_data": axis_data,
964
        }
965

966
        return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
1✔
967

968
    @swagger_auto_schema(
1✔
969
        manual_parameters=[
970
            AutoSchemaHelper.query_string_field("x_var", required=True, description="Raw column name for x axis"),
971
            AutoSchemaHelper.query_string_field(
972
                "y_var",
973
                required=True,
974
                description='Raw column name for y axis, must be one of: "gross_floor_area", "property_type", "year_built"',
975
            ),
976
            AutoSchemaHelper.query_string_field(
977
                "start", required=True, description='Start time, in the format "2018-12-31T23:53:00-08:00"'
978
            ),
979
            AutoSchemaHelper.query_string_field("end", required=True, description='End time, in the format "2018-12-31T23:53:00-08:00"'),
980
        ]
981
    )
982
    @api_endpoint_class
1✔
983
    @ajax_request_class
1✔
984
    @has_perm_class("requires_viewer")
1✔
985
    @action(detail=True, methods=["GET"])
1✔
986
    def report_aggregated(self, request, pk=None):
1✔
987
        """Retrieve a summary report for charting x vs y aggregated by y_var"""
988
        # get params
989
        params = {
1✔
990
            "x_var": request.query_params.get("x_var", None),
991
            "y_var": request.query_params.get("y_var", None),
992
            "cycle_ids": request.query_params.getlist("cycle_ids", None),
993
            "access_level_instance_id": request.query_params.get("access_level_instance_id", None),
994
        }
995
        filter_group_id = request.query_params.get("filter_group_id", None)
1✔
996
        user_ali = AccessLevelInstance.objects.get(pk=self.request.access_level_instance_id)
1✔
997
        if params["access_level_instance_id"] is None:
1✔
998
            ali = user_ali
1✔
999
        else:
1000
            try:
1✔
1001
                selected_ali = AccessLevelInstance.objects.get(pk=params["access_level_instance_id"])
1✔
1002
                if not (selected_ali == user_ali or selected_ali.is_descendant_of(user_ali)):
1✔
1003
                    raise AccessLevelInstance.DoesNotExist
×
1004
            except (AccessLevelInstance.DoesNotExist, AssertionError):
×
1005
                return Response({"status": "error", "message": "No such ali"}, status=status.HTTP_400_BAD_REQUEST)
×
1006
            else:
1007
                ali = selected_ali
1✔
1008

1009
        # error if missing
1010
        missing_params = [p for (p, v) in params.items() if v is None]
1✔
1011
        if missing_params:
1✔
1012
            return Response(
1✔
1013
                {"status": "error", "message": "Missing params: {}".format(", ".join(missing_params))}, status=status.HTTP_400_BAD_REQUEST
1014
            )
1015

1016
        # get data
1017
        cycles = Cycle.objects.filter(id__in=params["cycle_ids"])
1✔
1018
        x_var = Column.objects.get(column_name=params["x_var"], organization=pk, table_name="PropertyState")
1✔
1019
        y_var = Column.objects.get(column_name=params["y_var"], organization=pk, table_name="PropertyState")
1✔
1020
        report_data = self.setup_report_data(pk, ali, cycles, x_var, y_var, filter_group_id)
1✔
1021
        data = self.get_raw_report_data(pk, cycles, report_data["all_property_views"], report_data["field_data"])
1✔
1022
        chart_data = []
1✔
1023
        property_counts = []
1✔
1024

1025
        # set bins and choose agg type. treat booleans as discrete
1026
        ys = [building["y"] for datum in data for building in datum["chart_data"] if building["y"] is not None]
1✔
1027
        if ys and isinstance(ys[0], Number) and ys[0] is not True and ys[0] is not False:
1✔
1028
            bins = np.histogram_bin_edges(ys, bins=5)
1✔
1029

1030
            # special case for year built: make bins integers
1031
            # year built is in x axis, but it shows up in y_var variable
1032
            if params["y_var"] == "year_built":
1✔
1033
                bins = bins.astype(int)
×
1034

1035
            aggregate_data = self.continuous_aggregate_data
1✔
1036
        else:
1037
            bins = list(set(ys))
1✔
1038
            aggregate_data = self.discrete_aggregate_data
1✔
1039

1040
        for datum in data:
1✔
1041
            buildings = datum["chart_data"]
1✔
1042
            yr_e = datum["property_counts"]["yr_e"]
1✔
1043
            chart_data.extend(aggregate_data(yr_e, buildings, bins, count=params["x_var"] == "Count"))
1✔
1044
            property_counts.append(datum["property_counts"])
1✔
1045

1046
        # Send back to client
1047
        result = {
1✔
1048
            "status": "success",
1049
            "aggregated_data": {"chart_data": chart_data, "property_counts": property_counts},
1050
        }
1051

1052
        return Response(result, status=status.HTTP_200_OK)
1✔
1053

1054
    def continuous_aggregate_data(self, yr_e, buildings, bins, count=False):
1✔
1055
        buildings = [b for b in buildings if b["x"] is not None and b["y"] is not None]
1✔
1056
        binplace = np.digitize([b["y"] for b in buildings], bins)
1✔
1057
        xs = [b["x"] for b in buildings]
1✔
1058

1059
        results = []
1✔
1060
        for i in range(len(bins) - 1):
1✔
1061
            bin = f"{round(bins[i], 2)} - {round(bins[i+1], 2)}"
1✔
1062
            values = np.array(xs)[np.where(binplace == i + 1)]
1✔
1063
            x = sum(values) if count else np.average(values).item()
1✔
1064
            results.append({"y": bin, "x": None if np.isnan(x) else x, "yr_e": yr_e})
1✔
1065

1066
        return results
1✔
1067

1068
    def discrete_aggregate_data(self, yr_e, buildings, bins, count=False):
1✔
1069
        xs_by_bin = {bin: [] for bin in bins}
1✔
1070

1071
        for building in buildings:
1✔
1072
            xs_by_bin[building["y"]].append(building["x"])
×
1073

1074
        results = []
1✔
1075
        for bin, xs in xs_by_bin.items():
1✔
1076
            if count:
×
1077
                x = sum(xs)
×
1078
            elif len(xs) == 0:
×
1079
                x = None
×
1080
            else:
1081
                x = sum(xs) / len(xs)
×
1082

1083
            results.append({"y": bin, "x": x, "yr_e": yr_e})
×
1084

1085
        return results
1✔
1086

1087
    @swagger_auto_schema(
1✔
1088
        manual_parameters=[
1089
            AutoSchemaHelper.query_string_field("x_var", required=True, description="Raw column name for x axis"),
1090
            AutoSchemaHelper.query_string_field("x_label", required=True, description="Label for x axis"),
1091
            AutoSchemaHelper.query_string_field("y_var", required=True, description="Raw column name for y axis"),
1092
            AutoSchemaHelper.query_string_field("y_label", required=True, description="Label for y axis"),
1093
            AutoSchemaHelper.query_string_field(
1094
                "start", required=True, description='Start time, in the format "2018-12-31T23:53:00-08:00"'
1095
            ),
1096
            AutoSchemaHelper.query_string_field("end", required=True, description='End time, in the format "2018-12-31T23:53:00-08:00"'),
1097
        ]
1098
    )
1099
    @api_endpoint_class
1✔
1100
    @ajax_request_class
1✔
1101
    @has_perm_class("requires_viewer")
1✔
1102
    @action(detail=True, methods=["GET"])
1✔
1103
    def report_export(self, request, pk=None):
1✔
1104
        """
1105
        Export a report as a spreadsheet
1106
        """
1107
        access_level_instance = AccessLevelInstance.objects.get(pk=self.request.access_level_instance_id)
1✔
1108

1109
        # get params
1110
        params = {
1✔
1111
            "x_var": request.query_params.get("x_var", None),
1112
            "x_label": request.query_params.get("x_label", None),
1113
            "y_var": request.query_params.get("y_var", None),
1114
            "y_label": request.query_params.get("y_label", None),
1115
            "cycle_ids": request.query_params.getlist("cycle_ids", None),
1116
        }
1117
        filter_group_id = request.query_params.get("filter_group_id", None)
1✔
1118

1119
        # error if missing
1120
        excepted_params = ["x_var", "x_label", "y_var", "y_label", "cycle_ids"]
1✔
1121
        missing_params = [p for p in excepted_params if p not in params]
1✔
1122
        if missing_params:
1✔
1123
            return Response(
×
1124
                {"status": "error", "message": "Missing params: {}".format(", ".join(missing_params))}, status=status.HTTP_400_BAD_REQUEST
1125
            )
1126

1127
        response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
1✔
1128
        response["Content-Disposition"] = 'attachment; filename="report-data"'
1✔
1129

1130
        # Create WB
1131
        output = BytesIO()
1✔
1132
        wb = Workbook(output, {"remove_timezone": True})
1✔
1133

1134
        # Create sheets
1135
        count_sheet = wb.add_worksheet("Counts")
1✔
1136
        base_sheet = wb.add_worksheet("Raw")
1✔
1137
        agg_sheet = wb.add_worksheet("Agg")
1✔
1138

1139
        # Enable bold format and establish starting cells
1140
        bold = wb.add_format({"bold": True})
1✔
1141
        data_row_start = 0
1✔
1142
        data_col_start = 0
1✔
1143

1144
        # Write all headers across all sheets
1145
        count_sheet.write(data_row_start, data_col_start, "Year Ending", bold)
1✔
1146
        count_sheet.write(data_row_start, data_col_start + 1, "Properties with Data", bold)
1✔
1147
        count_sheet.write(data_row_start, data_col_start + 2, "Total Properties", bold)
1✔
1148

1149
        agg_sheet.write(data_row_start, data_col_start, request.query_params.get("x_label"), bold)
1✔
1150
        agg_sheet.write(data_row_start, data_col_start + 1, request.query_params.get("y_label"), bold)
1✔
1151
        agg_sheet.write(data_row_start, data_col_start + 2, "Year Ending", bold)
1✔
1152

1153
        # Gather base data
1154
        cycles = Cycle.objects.filter(id__in=params["cycle_ids"])
1✔
1155
        matching_columns = Column.objects.filter(organization_id=pk, is_matching_criteria=True, table_name="PropertyState")
1✔
1156
        x_var = Column.objects.get(column_name=params["x_var"], organization=pk, table_name="PropertyState")
1✔
1157
        y_var = Column.objects.get(column_name=params["y_var"], organization=pk, table_name="PropertyState")
1✔
1158
        report_data = self.setup_report_data(
1✔
1159
            pk, access_level_instance, cycles, x_var, y_var, filter_group_id, additional_columns=matching_columns
1160
        )
1161
        data = self.get_raw_report_data(pk, cycles, report_data["all_property_views"], report_data["field_data"])
1✔
1162

1163
        base_sheet.write(data_row_start, data_col_start, "ID", bold)
1✔
1164

1165
        for i, matching_column in enumerate(matching_columns):
1✔
1166
            base_sheet.write(data_row_start, data_col_start + i, matching_column.display_name, bold)
1✔
1167
        base_sheet.write(data_row_start, data_col_start + len(matching_columns) + 0, params["x_label"], bold)
1✔
1168
        base_sheet.write(data_row_start, data_col_start + len(matching_columns) + 1, params["y_label"], bold)
1✔
1169
        base_sheet.write(data_row_start, data_col_start + len(matching_columns) + 2, "Year Ending", bold)
1✔
1170

1171
        base_row = data_row_start + 1
1✔
1172
        agg_row = data_row_start + 1
1✔
1173
        count_row = data_row_start + 1
1✔
1174

1175
        for cycle_results in data:
1✔
1176
            total_count = cycle_results["property_counts"]["num_properties"]
1✔
1177
            with_data_count = cycle_results["property_counts"]["num_properties_w-data"]
1✔
1178
            yr_e = cycle_results["property_counts"]["yr_e"]
1✔
1179

1180
            # Write Counts
1181
            count_sheet.write(count_row, data_col_start, yr_e)
1✔
1182
            count_sheet.write(count_row, data_col_start + 1, with_data_count)
1✔
1183
            count_sheet.write(count_row, data_col_start + 2, total_count)
1✔
1184

1185
            count_row += 1
1✔
1186

1187
            # Write Base/Raw Data
1188
            data_rows = cycle_results["chart_data"]
1✔
1189
            for datum in data_rows:
1✔
1190
                del datum["id"]
1✔
1191
                for i, k in enumerate(datum.keys()):
1✔
1192
                    base_sheet.write(base_row, data_col_start + i, datum.get(k))
1✔
1193

1194
                base_row += 1
1✔
1195

1196
            # set bins and choose agg type
1197
            ys = [building["y"] for datum in data for building in datum["chart_data"]]
1✔
1198
            if ys and isinstance(ys[0], Number):
1✔
1199
                bins = np.histogram_bin_edges(ys, bins=5)
1✔
1200
                aggregate_data = self.continuous_aggregate_data
1✔
1201
            else:
1202
                bins = list(set(ys))
1✔
1203
                aggregate_data = self.discrete_aggregate_data
1✔
1204

1205
            # Gather and write Agg data
1206
            for agg_datum in aggregate_data(yr_e, data_rows, bins, count=params["x_var"] == "Count"):
1✔
1207
                agg_sheet.write(agg_row, data_col_start, agg_datum.get("x"))
1✔
1208
                agg_sheet.write(agg_row, data_col_start + 1, agg_datum.get("y"))
1✔
1209
                agg_sheet.write(agg_row, data_col_start + 2, agg_datum.get("yr_e"))
1✔
1210

1211
                agg_row += 1
1✔
1212

1213
        wb.close()
1✔
1214

1215
        xlsx_data = output.getvalue()
1✔
1216

1217
        response.write(xlsx_data)
1✔
1218

1219
        return response
1✔
1220

1221
    def get_axis_stats(self, organization, cycle, axis, axis_var, views, ali):
1✔
1222
        """returns axis_name, access_level_instance name, sum, mean, min, max, 5%, 25%, 50%, 75%, 99%
1223
        exclude categorical and boolean from stats
1224
        """
1225

1226
        filtered_properties = views.filter(
1✔
1227
            property__access_level_instance__lft__gte=ali.lft, property__access_level_instance__rgt__lte=ali.rgt, cycle_id=cycle.id
1228
        )
1229

1230
        data = [
1✔
1231
            d[axis]
1232
            for d in [apply_display_unit_preferences(organization, d) for d in filtered_properties.values(axis)]
1233
            if axis in d and d[axis] is not None and d[axis] is not True and d[axis] is not False and isinstance(d[axis], (int, float))
1234
        ]
1235

1236
        if len(data) > 0:
1✔
1237
            percentiles = np.percentile(data, [5, 25, 50, 75, 95])
1✔
1238
            # order the cols: sum, min, 5%, 25%, mean, median (50%), 75, 95, max
1239
            return [
1✔
1240
                axis_var,
1241
                ali.name,
1242
                sum(data),
1243
                np.amin(data),
1244
                percentiles[0],
1245
                percentiles[1],
1246
                np.mean(data),
1247
                percentiles[2],
1248
                percentiles[3],
1249
                percentiles[4],
1250
                np.amax(data),
1251
            ]
1252
        else:
1253
            return [axis_var, ali.name, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1✔
1254

1255
    def get_axis_data(self, organization_id, access_level_instance, cycles, x_var, y_var, all_property_views, fields):
1✔
1256
        axis_data = {}
1✔
1257
        axes = {"x": x_var, "y": y_var}
1✔
1258
        organization = Organization.objects.get(pk=organization_id)
1✔
1259

1260
        # initialize
1261
        for cycle in cycles:
1✔
1262
            axis_data[cycle.name] = {}
1✔
1263

1264
        for axis in axes:
1✔
1265
            if axes[axis] != "Count":
1✔
1266
                columns = Column.objects.filter(organization_id=organization_id, column_name=axes[axis], table_name="PropertyState")
1✔
1267
                if not columns:
1✔
1268
                    return {}
×
1269

1270
                column = columns[0]
1✔
1271
                if not column.data_type or column.data_type == "None":
1✔
1272
                    data_type = "float"
×
1273
                else:
1274
                    data_type = Column.DB_TYPES[column.data_type]
1✔
1275

1276
                # Get column label
1277
                serialized_column = ColumnSerializer(column).data
1✔
1278
                add_pint_unit_suffix(organization, serialized_column)
1✔
1279
                for cycle in cycles:
1✔
1280
                    name_to_display = (
1✔
1281
                        serialized_column["display_name"] if serialized_column["display_name"] != "" else serialized_column["column_name"]
1282
                    )
1283
                    axis_data[cycle.name][name_to_display] = {}
1✔
1284
                    stats = self.get_axis_stats(organization, cycle, axis, axes[axis], all_property_views, access_level_instance)
1✔
1285
                    axis_data[cycle.name][name_to_display]["values"] = self.clean_axis_data(data_type, stats)
1✔
1286

1287
                    children = access_level_instance.get_children()
1✔
1288
                    if len(children):
1✔
1289
                        axis_data[cycle.name][name_to_display]["children"] = {}
1✔
1290
                        for child_ali in children:
1✔
1291
                            stats = self.get_axis_stats(organization, cycle, axis, axes[axis], all_property_views, child_ali)
1✔
1292
                            axis_data[cycle.name][name_to_display]["children"][child_ali.name] = self.clean_axis_data(data_type, stats)
1✔
1293

1294
        return axis_data
1✔
1295

1296
    def clean_axis_data(self, data_type, data):
1✔
1297
        if data_type == "float":
1✔
1298
            return data[1:3] + np.round(data[3:], decimals=2).tolist()
1✔
1299
        elif data_type == "integer":
1✔
1300
            return data[1:3] + np.round(data[3:]).tolist()
1✔
1301

1302
    @has_perm_class("requires_member")
1✔
1303
    @ajax_request_class
1✔
1304
    @action(detail=True, methods=["GET"])
1✔
1305
    def geocode_api_key_exists(self, request, pk=None):
1✔
1306
        """
1307
        Returns true if the organization has a mapquest api key
1308
        """
1309
        org = Organization.objects.get(id=pk)
1✔
1310

1311
        return bool(org.mapquest_api_key)
1✔
1312

1313
    @has_perm_class("requires_member")
1✔
1314
    @ajax_request_class
1✔
1315
    @action(detail=True, methods=["GET"])
1✔
1316
    def geocoding_enabled(self, request, pk=None):
1✔
1317
        """
1318
        Returns the organization's geocoding_enabled setting
1319
        """
1320
        org = Organization.objects.get(id=pk)
1✔
1321

1322
        return org.geocoding_enabled
1✔
1323

1324
    @api_endpoint_class
1✔
1325
    @ajax_request_class
1✔
1326
    @has_perm_class("requires_owner")
1✔
1327
    @action(detail=True, methods=["POST"])
1✔
1328
    def reset_all_passwords(self, request, pk=None):
1✔
1329
        """
1330
        Resets all user passwords in organization
1331
        """
1332
        org_users = OrganizationUser.objects.filter(organization=pk).select_related("user")
×
1333
        for org_user in org_users:
×
1334
            form = PasswordResetForm({"email": org_user.user.email})
×
1335
            if form.is_valid():
×
1336
                org_user.user.password = ""
×
1337
                org_user.user.save()
×
1338
                form.save(
×
1339
                    from_email=settings.PASSWORD_RESET_EMAIL,
1340
                    subject_template_name="landing/password_reset_subject.txt",
1341
                    email_template_name="landing/password_reset_forced_email.html",
1342
                )
1343

1344
        return JsonResponse({"status": "success", "message": "passwords reset"})
×
1345

1346
    @has_perm_class("requires_superuser")
1✔
1347
    @ajax_request_class
1✔
1348
    @action(detail=True, methods=["GET"])
1✔
1349
    def insert_sample_data(self, request, pk=None):
1✔
1350
        """
1351
        Create a button for new users to import data below if no data exists
1352
        """
1353
        org = Organization.objects.get(id=pk)
×
1354
        cycles = Cycle.objects.filter(organization=org)
×
1355
        if cycles.count() == 0:
×
1356
            return JsonResponse({"status": "error", "message": "there must be at least 1 cycle"}, status=status.HTTP_400_BAD_REQUEST)
×
1357

1358
        cycle = cycles.first()
×
1359
        if PropertyView.objects.filter(cycle=cycle).count() > 0 or TaxLotView.objects.filter(cycle=cycle).count() > 0:
×
1360
            return JsonResponse(
×
1361
                {"status": "error", "message": "the cycle must not contain any properties or tax lots"}, status=status.HTTP_400_BAD_REQUEST
1362
            )
1363

1364
        taxlot_details = {
×
1365
            "jurisdiction_tax_lot_id": "A-12345",
1366
            "city": "Boring",
1367
            "organization_id": pk,
1368
            "extra_data": {"Note": "This is my first note"},
1369
        }
1370

1371
        taxlot_state = TaxLotState(**taxlot_details)
×
1372
        taxlot_state.save()
×
1373
        taxlot_1 = TaxLot.objects.create(organization=org)
×
1374
        taxview = TaxLotView.objects.create(taxlot=taxlot_1, cycle=cycle, state=taxlot_state)
×
1375

1376
        TaxLotAuditLog.objects.create(organization=org, state=taxlot_state, record_type=AUDIT_IMPORT, name="Import Creation")
×
1377

1378
        filename_pd = "property_sample_data.json"
×
1379
        filepath_pd = f"{Path(__file__).parent.absolute()}/../../tests/data/{filename_pd}"
×
1380

1381
        with open(filepath_pd, encoding=locale.getpreferredencoding(False)) as file:
×
1382
            property_details = json.load(file)
×
1383

1384
        property_views = []
×
1385
        properties = []
×
1386
        ids = []
×
1387
        for dic in property_details:
×
1388
            dic["organization_id"] = pk
×
1389

1390
            state = PropertyState(**dic)
×
1391
            state.save()
×
1392
            ids.append(state.id)
×
1393

1394
            property_1 = Property.objects.create(organization=org)
×
1395
            properties.append(property_1)
×
1396
            propertyview = PropertyView.objects.create(property=property_1, cycle=cycle, state=state)
×
1397
            property_views.append(propertyview)
×
1398

1399
            # create labels and add to records
1400
            new_label, _created = Label.objects.get_or_create(color="red", name="Housing", super_organization=org)
×
1401
            if state.extra_data.get("Note") == "Residential":
×
1402
                propertyview.labels.add(new_label)
×
1403

1404
            PropertyAuditLog.objects.create(organization=org, state=state, record_type=AUDIT_IMPORT, name="Import Creation")
×
1405

1406
            # Geocoding - need mapquest API (should add comment for new users)
1407
            geocode = PropertyState.objects.filter(id__in=ids)
×
1408
            geocode_buildings(geocode)
×
1409

1410
        # Create a merge of the last 2 properties
1411
        state_ids_to_merge = ids[-2:]
×
1412
        merged_state = merge_properties(state_ids_to_merge, pk, "Manual Match")
×
1413
        view = merged_state.propertyview_set.first()
×
1414
        match_merge_link(merged_state, view.property.access_level_instance, view.cycle)
×
1415

1416
        # pair a property to tax lot
1417
        property_id = property_views[0].id
×
1418
        taxlot_id = taxview.id
×
1419
        pair_unpair_property_taxlot(property_id, taxlot_id, org, True)
×
1420

1421
        # create column for Note
1422
        Column.objects.get_or_create(
×
1423
            organization=org,
1424
            table_name="PropertyState",
1425
            column_name="Note",
1426
            is_extra_data=True,  # Column objects representing raw/header rows are NEVER extra data
1427
        )
1428

1429
        import_record = ImportRecord.objects.create(name="Auto-Populate", super_organization=org, access_level_instance=self.org.root)
×
1430

1431
        # Interval Data
1432
        filename = "PM Meter Data.xlsx"  # contains meter data for bsyncr and BETTER
×
1433
        filepath = f"{Path(__file__).parent.absolute()}/data/{filename}"
×
1434

1435
        with open(filepath, "rb") as content:
×
1436
            import_meterdata = ImportFile.objects.create(
×
1437
                import_record=import_record,
1438
                source_type=SEED_DATA_SOURCES[PORTFOLIO_METER_USAGE][1],
1439
                uploaded_filename=filename,
1440
                file=SimpleUploadedFile(name=filename, content=content.read()),
1441
                cycle=cycle,
1442
            )
1443

1444
        save_raw_data(import_meterdata.id)
×
1445

1446
        # Greenbutton Import
1447
        filename = "example-GreenButton-data.xml"
×
1448
        filepath = f"{Path(__file__).parent.absolute()}/data/{filename}"
×
1449

1450
        with open(filepath, "rb") as content:
×
1451
            import_greenbutton = ImportFile.objects.create(
×
1452
                import_record=import_record,
1453
                source_type=SEED_DATA_SOURCES[GREEN_BUTTON][1],
1454
                uploaded_filename=filename,
1455
                file=SimpleUploadedFile(name=filename, content=content.read()),
1456
                cycle=cycle,
1457
                matching_results_data={"property_id": properties[7].id},
1458
            )
1459

1460
        save_raw_data(import_greenbutton.id)
×
1461

1462
        return JsonResponse({"status": "success"})
×
1463

1464
    @ajax_request_class
1✔
1465
    def public_feed_json(self, request, pk):
1✔
1466
        """
1467
        Returns all property and taxlot state data for a given organization as a json object. The results are ordered by "state.update".
1468

1469
        Optional and configurable url query_params:
1470
        :query_param labels: comma separated list of case sensitive label names. Results will include inventory that has any of the listed labels. Default is all inventory
1471
        :query_param cycles: comma separated list of cycle ids. Results include inventory from the listed cycles. Default is all cycles
1472
        :query_param properties: boolean to return properties. Default is True
1473
        :query_param taxlots: boolan to return taxlots. Default is True
1474
        :query_param page: integer page number
1475
        :query_param per_page: integer results per page
1476

1477
        Example requests:
1478
        {seed_url}/api/v3/organizations/public_feed.json?{query_param1}={value1}&{query_param2}={value2}
1479
        dev1.seed-platform.org/api/v3/organizations/1/public_feed.json
1480
        dev1.seed-platform.org/api/v3/organizations/1/public_feed.json?page=2&labels=Compliant&cycles=1,2,3&taxlots=False
1481
        """
1482
        try:
×
1483
            org = Organization.objects.get(pk=pk)
×
1484
        except Organization.DoesNotExist:
×
1485
            return JsonResponse({"erorr": "Organization does not exist"}, status=status.HTTP_404_NOT_FOUND)
×
1486

1487
        feed = public_feed(org, request)
×
1488

1489
        return JsonResponse(feed, json_dumps_params={"indent": 4}, status=status.HTTP_200_OK)
×
1490

1491
    @ajax_request_class
1✔
1492
    @has_perm_class("requires_viewer")
1✔
1493
    @action(detail=True, methods=["GET"])
1✔
1494
    def report_configurations(self, request, pk):
1✔
1495
        user_ali = AccessLevelInstance.objects.get(pk=self.request.access_level_instance_id)
×
1496
        configs = ReportConfiguration.objects.filter(
×
1497
            organization_id=pk,
1498
            access_level_instance__lft__gte=user_ali.lft,
1499
            access_level_instance__rgt__lte=user_ali.rgt,
1500
        )
1501
        return JsonResponse({"data": ReportConfigurationSerializer(configs, many=True).data}, status=status.HTTP_200_OK)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc