diff --git a/bc_obps/common/fixtures/dashboard/bciers/internal.json b/bc_obps/common/fixtures/dashboard/bciers/internal.json index a4e569e7ca..fdeb193113 100644 --- a/bc_obps/common/fixtures/dashboard/bciers/internal.json +++ b/bc_obps/common/fixtures/dashboard/bciers/internal.json @@ -38,7 +38,6 @@ }, { "title": "Transfers", - "href": "/registration", "icon": "Layers", "content": "Transfer operations and facilities between Operators and view transfers.", "links": [ @@ -48,13 +47,8 @@ }, { "title": "Transfer an operation or facility", - "href": "/registration/transfer", - "conditions": [ - { - "allowedRoles": ["cas_analyst"], - "value": true - } - ] + "href": "/registration/transfers/transfer-entity", + "allowedRoles": ["cas_analyst"] } ] }, diff --git a/bc_obps/common/fixtures/dashboard/registration/internal.json b/bc_obps/common/fixtures/dashboard/registration/internal.json deleted file mode 100644 index fb4a62dad8..0000000000 --- a/bc_obps/common/fixtures/dashboard/registration/internal.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "model": "common.dashboarddata", - "fields": { - "name": "Internal Registration Dashboard", - "data": { - "dashboard": "registration", - "access_roles": [ - "cas_admin", - "cas_analyst", - "cas_director", - "cas_view_only" - ], - "tiles": [ - { - "title": "Transfers", - "icon": "File", - "content": "TBD here.", - "href": "/registration/transfers" - } - ] - } - } - } -] diff --git a/bc_obps/common/migrations/0010_dashboarddata.py b/bc_obps/common/migrations/0010_dashboarddata.py index b863512f2f..3b8992940e 100644 --- a/bc_obps/common/migrations/0010_dashboarddata.py +++ b/bc_obps/common/migrations/0010_dashboarddata.py @@ -12,7 +12,6 @@ def load_dashboard_fixtures(apps, schema_editor): # Below file does not exist in the repository but I'm keeping it here for reference # 'common/fixtures/dashboard/registration/operation/external.json', 'common/fixtures/dashboard/registration/external.json', - 'common/fixtures/dashboard/registration/internal.json', 'common/fixtures/dashboard/reporting/external.json', 'common/fixtures/dashboard/reporting/internal.json', ] diff --git a/bc_obps/common/migrations/0012_update_dashboard_data.py b/bc_obps/common/migrations/0012_update_dashboard_data.py index 7c44b2afdb..22b0b85fab 100644 --- a/bc_obps/common/migrations/0012_update_dashboard_data.py +++ b/bc_obps/common/migrations/0012_update_dashboard_data.py @@ -12,7 +12,6 @@ def load_dashboard_fixtures(apps, schema_editor): 'common/fixtures/dashboard/administration/external.json', 'common/fixtures/dashboard/administration/internal.json', 'common/fixtures/dashboard/registration/external.json', - 'common/fixtures/dashboard/registration/internal.json', 'common/fixtures/dashboard/reporting/external.json', 'common/fixtures/dashboard/reporting/internal.json', 'common/fixtures/dashboard/coam/external.json', diff --git a/bc_obps/common/migrations/0016_update_dashboard_data.py b/bc_obps/common/migrations/0016_update_dashboard_data.py index c43f8b6c1f..7a30988bee 100644 --- a/bc_obps/common/migrations/0016_update_dashboard_data.py +++ b/bc_obps/common/migrations/0016_update_dashboard_data.py @@ -12,7 +12,6 @@ def load_dashboard_fixtures(apps, schema_editor): 'common/fixtures/dashboard/administration/external.json', 'common/fixtures/dashboard/administration/internal.json', 'common/fixtures/dashboard/registration/external.json', - 'common/fixtures/dashboard/registration/internal.json', 'common/fixtures/dashboard/reporting/external.json', 'common/fixtures/dashboard/reporting/internal.json', 'common/fixtures/dashboard/coam/external.json', diff --git a/bc_obps/common/migrations/0017_update_dashboard_data.py b/bc_obps/common/migrations/0017_update_dashboard_data.py index 8b28569c7c..93855eef50 100644 --- a/bc_obps/common/migrations/0017_update_dashboard_data.py +++ b/bc_obps/common/migrations/0017_update_dashboard_data.py @@ -12,7 +12,6 @@ def load_dashboard_fixtures(apps, schema_editor): 'common/fixtures/dashboard/administration/external.json', 'common/fixtures/dashboard/administration/internal.json', 'common/fixtures/dashboard/registration/external.json', - 'common/fixtures/dashboard/registration/internal.json', 'common/fixtures/dashboard/reporting/external.json', 'common/fixtures/dashboard/reporting/internal.json', 'common/fixtures/dashboard/coam/external.json', diff --git a/bc_obps/common/migrations/0020_update_dashboard_data.py b/bc_obps/common/migrations/0020_update_dashboard_data.py index 2d5113fba4..64baef8ddf 100644 --- a/bc_obps/common/migrations/0020_update_dashboard_data.py +++ b/bc_obps/common/migrations/0020_update_dashboard_data.py @@ -12,7 +12,6 @@ def load_dashboard_fixtures(apps, schema_editor): 'common/fixtures/dashboard/administration/external.json', 'common/fixtures/dashboard/administration/internal.json', 'common/fixtures/dashboard/registration/external.json', - 'common/fixtures/dashboard/registration/internal.json', 'common/fixtures/dashboard/reporting/external.json', 'common/fixtures/dashboard/reporting/internal.json', 'common/fixtures/dashboard/coam/external.json', diff --git a/bc_obps/common/migrations/0022_update_dashboard_data.py b/bc_obps/common/migrations/0022_update_dashboard_data.py index e8c5150080..92a8ca2ef2 100644 --- a/bc_obps/common/migrations/0022_update_dashboard_data.py +++ b/bc_obps/common/migrations/0022_update_dashboard_data.py @@ -13,7 +13,6 @@ def load_dashboard_fixtures(apps, schema_editor): 'common/fixtures/dashboard/administration/internal.json', 'common/fixtures/dashboard/operators/internal.json', 'common/fixtures/dashboard/registration/external.json', - 'common/fixtures/dashboard/registration/internal.json', 'common/fixtures/dashboard/reporting/external.json', 'common/fixtures/dashboard/reporting/internal.json', 'common/fixtures/dashboard/coam/external.json', diff --git a/bc_obps/common/permissions.py b/bc_obps/common/permissions.py index fadfb83678..d205865855 100644 --- a/bc_obps/common/permissions.py +++ b/bc_obps/common/permissions.py @@ -124,6 +124,11 @@ def get_permission_configs(permission: str) -> Optional[Union[Dict[str, List[str 'authorized_app_roles': ["cas_admin", "cas_analyst", "industry_user"], 'authorized_user_operator_roles': ["admin"], }, + "cas_analyst": { + 'authorized_app_roles': list( + AppRole.objects.filter(role_name="cas_analyst").values_list("role_name", flat=True) + ), + }, } cache.set(PERMISSION_CONFIGS_CACHE_KEY, permission_configs, timeout=3600) # 1 hour return permission_configs.get(permission) @@ -155,6 +160,7 @@ def authorize( "authorized_irc_user_and_industry_admin_user", "v1_authorized_irc_user_write", "v1_authorized_irc_user_and_industry_admin_user_write", + "cas_analyst", ] ) -> Callable[[HttpRequest], bool]: """ diff --git a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py index 31f34eab7a..0164562e43 100644 --- a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py +++ b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py @@ -344,6 +344,9 @@ class TestEndpointPermissions(TestCase): "kwargs": {"user_operator_id": mock_uuid}, }, ], + "cas_analyst": [ + {"method": "post", "endpoint_name": "create_transfer_event"}, + ], } @classmethod diff --git a/bc_obps/common/utils.py b/bc_obps/common/utils.py index 26c5c423f7..a333955f5b 100644 --- a/bc_obps/common/utils.py +++ b/bc_obps/common/utils.py @@ -13,7 +13,6 @@ def reset_dashboard_data() -> None: 'common/fixtures/dashboard/administration/internal.json', 'common/fixtures/dashboard/operators/internal.json', 'common/fixtures/dashboard/registration/external.json', - 'common/fixtures/dashboard/registration/internal.json', 'common/fixtures/dashboard/reporting/external.json', 'common/fixtures/dashboard/reporting/internal.json', 'common/fixtures/dashboard/coam/external.json', diff --git a/bc_obps/registration/admin.py b/bc_obps/registration/admin.py index 208396bf52..1d2c6b6e5d 100644 --- a/bc_obps/registration/admin.py +++ b/bc_obps/registration/admin.py @@ -34,7 +34,6 @@ admin.site.register(BcObpsRegulatedOperation) admin.site.register(ClosureEvent) admin.site.register(TemporaryShutdownEvent) -admin.site.register(TransferEvent) admin.site.register(RestartEvent) @@ -87,6 +86,7 @@ class FacilityDesignatedOperationTimelineAdmin(admin.ModelAdmin): class OperationDesignatedOperatorTimelineAdmin(admin.ModelAdmin): list_display = ( 'id', + 'status', 'operation', 'operator', 'start_date', @@ -125,3 +125,37 @@ def user_full_name(obj: UserOperator) -> str: @staticmethod def operator_legal_name(obj: UserOperator) -> str: return obj.operator.legal_name + + +@admin.register(TransferEvent) +class TransferEventAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'status', + 'from_operator_name', + 'to_operation_name', + 'operation_name', + 'from_operation_name', + 'to_operator_name', + 'effective_date', + ) + + @staticmethod + def to_operator_name(obj: TransferEvent) -> str: + return obj.to_operator.legal_name + + @staticmethod + def from_operator_name(obj: TransferEvent) -> str: + return obj.from_operator.legal_name + + @staticmethod + def operation_name(obj: TransferEvent) -> str: + return obj.operation.name if obj.operation else 'N/A' + + @staticmethod + def from_operation_name(obj: TransferEvent) -> str: + return obj.from_operation.name if obj.from_operation else 'N/A' + + @staticmethod + def to_operation_name(obj: TransferEvent) -> str: + return obj.to_operation.name if obj.to_operation else 'N/A' diff --git a/bc_obps/registration/api/v2/__init__.py b/bc_obps/registration/api/v2/__init__.py index 9f38b3123b..ea67310b73 100644 --- a/bc_obps/registration/api/v2/__init__.py +++ b/bc_obps/registration/api/v2/__init__.py @@ -21,14 +21,13 @@ facilities, ) from ._operations import current as operations_current -from ._operations._operation_id import boro_id, bcghg_id as operation_bcghg_id +from ._operations._operation_id import boro_id, bcghg_id as operation_bcghg_id, operation_representatives from ._operations._operation_id.facilities import list_facilities_by_operation_id from ._facilities._facility_id import bcghg_id as facility_bcghg_id from ._facilities import facility_id -from ._operators import search +from ._operators import search, operator_id from .transfer_events import list_transfer_events from ._operators._operator_id import has_admin, request_access, request_admin_access, access_declined, confirm -from ._operators import operator_id from ._user_operators._current import ( operator, access_requests, @@ -38,9 +37,8 @@ operator_users, ) from ._user_operators._user_operator_id import update_status - from .user import user_profile, user_app_role from ._user_operators import user_operator_id, pending, current from ._contacts import contact_id from ._users import user_id -from ._operations._operation_id import operation_representatives +from . import transfer_events diff --git a/bc_obps/registration/api/v2/_operations/_operation_id/facilities.py b/bc_obps/registration/api/v2/_operations/_operation_id/facilities.py index 1140404a67..d8fe5c157b 100644 --- a/bc_obps/registration/api/v2/_operations/_operation_id/facilities.py +++ b/bc_obps/registration/api/v2/_operations/_operation_id/facilities.py @@ -1,5 +1,6 @@ from uuid import UUID from registration.models.facility_designated_operation_timeline import FacilityDesignatedOperationTimeline +from registration.utils import CustomPagination from service.facility_designated_operation_timeline_service import FacilityDesignatedOperationTimelineService from registration.schema.v1.facility_designated_operation_timeline import ( FacilityDesignatedOperationTimelineFilterSchema, @@ -13,7 +14,7 @@ from common.api.utils import get_current_user_guid from registration.constants import FACILITY_TAGS from registration.decorators import handle_http_errors -from ninja.pagination import paginate, PageNumberPagination +from ninja.pagination import paginate from registration.schema.generic import Message from service.error_service.custom_codes_4xx import custom_codes_4xx from ninja import Query @@ -28,13 +29,14 @@ auth=authorize("approved_authorized_roles"), ) @handle_http_errors() -@paginate(PageNumberPagination) +@paginate(CustomPagination) def list_facilities_by_operation_id( request: HttpRequest, operation_id: UUID, filters: FacilityDesignatedOperationTimelineFilterSchema = Query(...), sort_field: Optional[str] = "facility__created_at", sort_order: Optional[Literal["desc", "asc"]] = "desc", + paginate_result: bool = Query(True, description="Whether to paginate the results"), ) -> QuerySet[FacilityDesignatedOperationTimeline]: # NOTE: PageNumberPagination raises an error if we pass the response as a tuple (like 200, ...) return FacilityDesignatedOperationTimelineService.list_timeline_by_operation_id( diff --git a/bc_obps/registration/api/v2/_operators/__init__.py b/bc_obps/registration/api/v2/_operators/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bc_obps/registration/api/v2/transfer_events.py b/bc_obps/registration/api/v2/transfer_events.py index 41789e1648..47d3faec8c 100644 --- a/bc_obps/registration/api/v2/transfer_events.py +++ b/bc_obps/registration/api/v2/transfer_events.py @@ -1,6 +1,6 @@ -from typing import List, Literal, Optional +from typing import List, Literal, Optional, Tuple from registration.models.event.transfer_event import TransferEvent -from registration.schema.v1.transfer_event import TransferEventFilterSchema, TransferEventListOut +from registration.schema.v2.transfer_event import TransferEventFilterSchema, TransferEventListOut from service.transfer_event_service import TransferEventService from common.permissions import authorize from django.http import HttpRequest @@ -13,6 +13,8 @@ from ninja import Query from registration.schema.generic import Message from django.db.models import QuerySet +from common.api.utils import get_current_user_guid +from registration.schema.v2.transfer_event import TransferEventCreateIn, TransferEventOut @router.get( @@ -34,3 +36,18 @@ def list_transfer_events( ) -> QuerySet[TransferEvent]: # NOTE: PageNumberPagination raises an error if we pass the response as a tuple (like 200, ...) return TransferEventService.list_transfer_events(sort_field, sort_order, filters) + + +@router.post( + "/transfer-events", + response={201: TransferEventOut, custom_codes_4xx: Message}, + tags=TRANSFER_EVENT_TAGS, + description="""Creates a new transfer event.""", + auth=authorize("cas_analyst"), +) +@handle_http_errors() +def create_transfer_event( + request: HttpRequest, + payload: TransferEventCreateIn, +) -> Tuple[Literal[201], TransferEvent]: + return 201, TransferEventService.create_transfer_event(get_current_user_guid(request), payload) diff --git a/bc_obps/registration/constants.py b/bc_obps/registration/constants.py index 0856a17e61..c006c06dc1 100644 --- a/bc_obps/registration/constants.py +++ b/bc_obps/registration/constants.py @@ -31,3 +31,4 @@ CONTACT_TAGS = ["Contact V1"] TRANSFER_EVENT_TAGS = ["Transfer Event V1"] V2 = ["V2"] +OPERATOR_TAGS_V2 = ["Operator V2"] diff --git a/bc_obps/registration/fixtures/mock/operation.json b/bc_obps/registration/fixtures/mock/operation.json index 4ee1022206..b5a36aa253 100644 --- a/bc_obps/registration/fixtures/mock/operation.json +++ b/bc_obps/registration/fixtures/mock/operation.json @@ -125,6 +125,7 @@ }, { "model": "registration.operation", + "pk": "436dd99a-cb41-4494-91c9-98ab149b557d", "fields": { "operator": "685d581b-5698-411f-ae00-de1d97334a71", "documents": [], @@ -144,6 +145,7 @@ }, { "model": "registration.operation", + "pk": "a47b5fb6-1e10-401a-b70e-574bd925db99", "fields": { "operator": "685d581b-5698-411f-ae00-de1d97334a71", "documents": [], @@ -163,6 +165,7 @@ }, { "model": "registration.operation", + "pk": "21e70498-c4b0-4525-8443-86faa96206e3", "fields": { "operator": "685d581b-5698-411f-ae00-de1d97334a71", "documents": [], @@ -182,6 +185,7 @@ }, { "model": "registration.operation", + "pk": "17550cd8-3e73-4e52-aa91-ab90cb3b62b0", "fields": { "operator": "685d581b-5698-411f-ae00-de1d97334a71", "documents": [], @@ -201,6 +205,7 @@ }, { "model": "registration.operation", + "pk": "7d3fc7d1-0504-4ee4-a9c5-447f4b324b57", "fields": { "operator": "685d581b-5698-411f-ae00-de1d97334a71", "documents": [], @@ -220,6 +225,7 @@ }, { "model": "registration.operation", + "pk": "acf5811e-d521-43f7-a5c7-a6d6dd47bb31", "fields": { "operator": "685d581b-5698-411f-ae00-de1d97334a71", "documents": [], @@ -239,6 +245,7 @@ }, { "model": "registration.operation", + "pk": "8563da83-0762-4d29-9b22-da5b52ef0f24", "fields": { "operator": "685d581b-5698-411f-ae00-de1d97334a71", "documents": [], diff --git a/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json b/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json index 451c2d951c..97631998fa 100644 --- a/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json +++ b/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json @@ -1,23 +1,156 @@ [ + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:08:53.565Z", + "operation": "002d5a9e-32a6-4191-938c-2c02bfec592d", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:09:07Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:11:26.265Z", + "operation": "02a3ab84-26c6-4a79-bf89-72f877ceef8e", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:11:51Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:11:45.702Z", + "operation": "0ac72fa9-2636-4f54-b378-af6b1a070787", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:11:37Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:12:18.766Z", + "operation": "17550cd8-3e73-4e52-aa91-ab90cb3b62b0", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:12:13Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:12:42.300Z", + "operation": "17f13f4d-29b4-45f4-b025-b21f2e126771", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:12:40Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:13:06.052Z", + "operation": "1bd04128-d070-4d3a-940a-0874c4956181", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:13:03Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:13:41.375Z", + "operation": "21e70498-c4b0-4525-8443-86faa96206e3", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:15:00Z", + "status": "Active" + } + }, { "model": "registration.operationdesignatedoperatortimeline", "fields": { "created_by": "4da70f32-65fd-4137-87c1-111f2daba3dd", - "created_at": "2024-06-05T23:20:41.567Z", - "operation": "e1300fd7-2dee-47d1-b655-2ad3fd10f052", - "operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "start_date": "2024-06-05T23:20:40Z" + "created_at": "2024-12-06T21:14:06.087Z", + "operation": "3b5b95ea-2a1a-450d-8e2e-2e15feed96c9", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:14:04Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:14:23.612Z", + "operation": "436dd99a-cb41-4494-91c9-98ab149b557d", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:14:48Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:14:41.273Z", + "operation": "59d95661-c752-489b-9fd1-0c3fa3454dda", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:14:40Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "created_at": "2024-12-06T21:15:23.053Z", + "operation": "6d07d02a-1ad2-46ed-ad56-2f84313e98bf", + "operator": "685d581b-5698-411f-ae00-de1d97334a71", + "start_date": "2024-12-06T21:15:22Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "00000000-0000-0000-0000-000000000023", + "created_at": "2024-12-06T21:21:42.102Z", + "operation": "7d3fc7d1-0504-4ee4-a9c5-447f4b324b57", + "operator": "4a792f0f-cf9d-48c8-9a95-f504c5f84b12", + "start_date": "2024-12-06T21:21:40Z", + "status": "Active" + } + }, + { + "model": "registration.operationdesignatedoperatortimeline", + "fields": { + "created_by": "00000000-0000-0000-0000-000000000023", + "created_at": "2024-12-06T21:22:04.647Z", + "operation": "8563da83-0762-4d29-9b22-da5b52ef0f24", + "operator": "4a792f0f-cf9d-48c8-9a95-f504c5f84b12", + "start_date": "2024-12-06T21:22:03Z", + "status": "Active" } }, { "model": "registration.operationdesignatedoperatortimeline", "fields": { - "created_by": "00000000-0000-0000-0000-000000000004", - "created_at": "2024-06-05T23:21:02.353Z", - "operation": "e1300fd7-2dee-47d1-b655-2ad3fd10f052", - "operator": "4c1010c1-55ca-485d-84bd-6d975fd0af90", - "start_date": "2024-06-05T23:20:57Z", - "end_date": "2024-06-28T23:21:01Z" + "created_by": "00000000-0000-0000-0000-000000000023", + "created_at": "2024-12-06T21:22:24.993Z", + "operation": "954c0382-ff61-4e87-a8a0-873586534b54", + "operator": "4a792f0f-cf9d-48c8-9a95-f504c5f84b12", + "start_date": "2024-12-06T21:22:22Z", + "status": "Active" } } ] diff --git a/bc_obps/registration/fixtures/mock/transfer_event.json b/bc_obps/registration/fixtures/mock/transfer_event.json index d352d38761..1587fecdf1 100644 --- a/bc_obps/registration/fixtures/mock/transfer_event.json +++ b/bc_obps/registration/fixtures/mock/transfer_event.json @@ -2,46 +2,46 @@ { "model": "registration.transferevent", "fields": { - "created_by": "ba2ba62a121842e0942aab9e92ce8822", + "created_by": "00000000-0000-0000-0000-000000000028", "created_at": "2024-07-05T23:25:37.892Z", - "updated_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", - "updated_at": "2024-08-20T17:00:54.543Z", "operation": "3b5b95ea-2a1a-450d-8e2e-2e15feed96c9", + "from_operator": "4242ea9d-b917-4129-93c2-db00b7451051", + "to_operator": "4a792f0f-cf9d-48c8-9a95-f504c5f84b12", "effective_date": "2025-02-01T09:00:00Z", - "description": "Sold my operation to the other operator so that I could move to Hawaii. Don't tell my employees they don't know.", - "status": "Transferred", - "other_operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "other_operator_contact": 13 + "status": "To be transferred" } }, { "model": "registration.transferevent", "fields": { - "created_by": "ba2ba62a121842e0942aab9e92ce8822", + "created_by": "00000000-0000-0000-0000-000000000028", "created_at": "2024-07-05T23:25:37.892Z", + "updated_by": "00000000-0000-0000-0000-000000000028", + "updated_at": "2024-08-20T17:00:54.543Z", "operation": "d99725a7-1c3a-47cb-a59b-e2388ce0fa18", + "from_operator": "4242ea9d-b917-4129-93c2-db00b7451051", + "to_operator": "4a792f0f-cf9d-48c8-9a95-f504c5f84b12", "effective_date": "2024-08-21T09:00:00Z", - "description": "I was the designated operator but I'm being relieved of my duties because my other operators don't trust me anymore. Transfer this operation over to another one of the operators in our multiple-operators relationship. See if you can do better Mia!", - "other_operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "other_operator_contact": 15 + "status": "Transferred" } }, { "model": "registration.transferevent", "fields": { - "created_by": "00000000-0000-0000-0000-000000000022", + "created_by": "00000000-0000-0000-0000-000000000028", "created_at": "2024-07-05T23:25:37.892Z", - "updated_by": "ba2ba62a-1218-42e0-942a-ab9e92ce8822", + "updated_by": "00000000-0000-0000-0000-000000000028", "updated_at": "2024-08-20T17:00:54.543Z", "facilities": [ "459b80f9-b5f3-48aa-9727-90c30eaf3a58", - "f486f2fb-62ed-438d-bb3e-0819b51e3aeb" + "f486f2fb-62ed-438d-bb3e-0819b51e3aed" ], - "status": "Completed", - "effective_date": "2024-12-25T09:00:00Z", - "description": "I don't know what I'm doing but this seems like the right thing to do.", - "other_operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "other_operator_contact": 15 + "from_operator": "4242ea9d-b917-4129-93c2-db00b7451051", + "to_operator": "685d581b-5698-411f-ae00-de1d97334a71", + "from_operation": "002d5a9e-32a6-4191-938c-2c02bfec592d", + "to_operation": "e1300fd7-2dee-47d1-b655-2ad3fd10f052", + "status": "Complete", + "effective_date": "2024-12-25T09:00:00Z" } } ] diff --git a/bc_obps/registration/fixtures/mock/user.json b/bc_obps/registration/fixtures/mock/user.json index 1b464be469..1381433eb4 100644 --- a/bc_obps/registration/fixtures/mock/user.json +++ b/bc_obps/registration/fixtures/mock/user.json @@ -446,5 +446,19 @@ "phone_number": "+16044015432", "app_role": "cas_admin" } + }, + { + "model": "registration.user", + "fields": { + "user_guid": "00000000-0000-0000-0000-000000000028", + "business_guid": "00000000-0000-0000-0000-000000000000", + "bceid_business_name": "BCGOV", + "first_name": "CAS", + "last_name": "ANALYST", + "position_title": "Analyst", + "email": "test2@email.com", + "phone_number": "+16044015432", + "app_role": "cas_analyst" + } } ] diff --git a/bc_obps/registration/fixtures/mock/user_operator.json b/bc_obps/registration/fixtures/mock/user_operator.json index a20801f15d..6ddf11a83e 100644 --- a/bc_obps/registration/fixtures/mock/user_operator.json +++ b/bc_obps/registration/fixtures/mock/user_operator.json @@ -304,10 +304,10 @@ "fields": { "user": "00000000-0000-0000-0000-000000000023", "operator": "4242ea9d-b917-4129-93c2-db00b7451051", - "role": "pending", - "status": "Pending", - "verified_at": null, - "verified_by": null, + "role": "admin", + "status": "Approved", + "verified_at": "2024-02-26 06:24:57.293242-08", + "verified_by": "58f255ed-8d46-44ee-b2fe-9f8d3d92c684", "user_friendly_id": 26 } }, diff --git a/bc_obps/registration/migrations/0063_remove_historicaltransferevent_description_and_more.py b/bc_obps/registration/migrations/0063_remove_historicaltransferevent_description_and_more.py new file mode 100644 index 0000000000..554e7fcb01 --- /dev/null +++ b/bc_obps/registration/migrations/0063_remove_historicaltransferevent_description_and_more.py @@ -0,0 +1,168 @@ +# Generated by Django 5.0.9 on 2024-12-13 22:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0062_V1_16_0'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicaltransferevent', + name='description', + ), + migrations.RemoveField( + model_name='historicaltransferevent', + name='other_operator', + ), + migrations.RemoveField( + model_name='historicaltransferevent', + name='other_operator_contact', + ), + migrations.RemoveField( + model_name='transferevent', + name='description', + ), + migrations.RemoveField( + model_name='transferevent', + name='other_operator', + ), + migrations.RemoveField( + model_name='transferevent', + name='other_operator_contact', + ), + migrations.AddField( + model_name='historicaloperationdesignatedoperatortimeline', + name='status', + field=models.CharField( + choices=[ + ('Active', 'Active'), + ('Transferred', 'Transferred'), + ('Closed', 'Closed'), + ('Temporarily Shutdown', 'Temporarily Shutdown'), + ], + db_comment='The status of an operation in relation to a specific operator (e.g. if an operation has transferred ownership, it may have a status of Transferred with its old operator and Active with its new one)', + default='Active', + max_length=1000, + ), + ), + migrations.AddField( + model_name='historicaltransferevent', + name='from_operation', + field=models.ForeignKey( + blank=True, + db_comment='The operation that facilities are being transferred from.', + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='+', + to='registration.operation', + ), + ), + migrations.AddField( + model_name='historicaltransferevent', + name='from_operator', + field=models.ForeignKey( + blank=True, + db_comment='The operator who is transferring the operation or facility.', + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='+', + to='registration.operator', + ), + ), + migrations.AddField( + model_name='historicaltransferevent', + name='to_operation', + field=models.ForeignKey( + blank=True, + db_comment='The operation that facilities are being transferred to.', + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='+', + to='registration.operation', + ), + ), + migrations.AddField( + model_name='historicaltransferevent', + name='to_operator', + field=models.ForeignKey( + blank=True, + db_comment='The operator who is receiving the operation or facility.', + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='+', + to='registration.operator', + ), + ), + migrations.AddField( + model_name='operationdesignatedoperatortimeline', + name='status', + field=models.CharField( + choices=[ + ('Active', 'Active'), + ('Transferred', 'Transferred'), + ('Closed', 'Closed'), + ('Temporarily Shutdown', 'Temporarily Shutdown'), + ], + db_comment='The status of an operation in relation to a specific operator (e.g. if an operation has transferred ownership, it may have a status of Transferred with its old operator and Active with its new one)', + default='Active', + max_length=1000, + ), + ), + migrations.AddField( + model_name='transferevent', + name='from_operation', + field=models.ForeignKey( + blank=True, + db_comment='The operation that facilities are being transferred from.', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='transfer_events_from', + to='registration.operation', + ), + ), + migrations.AddField( + model_name='transferevent', + name='from_operator', + field=models.ForeignKey( + db_comment='The operator who is transferring the operation or facility.', + default=None, + on_delete=django.db.models.deletion.PROTECT, + related_name='transfer_events_from', + to='registration.operator', + ), + preserve_default=False, + ), + migrations.AddField( + model_name='transferevent', + name='to_operation', + field=models.ForeignKey( + blank=True, + db_comment='The operation that facilities are being transferred to.', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='transfer_events_to', + to='registration.operation', + ), + ), + migrations.AddField( + model_name='transferevent', + name='to_operator', + field=models.ForeignKey( + db_comment='The operator who is receiving the operation or facility.', + default=None, + on_delete=django.db.models.deletion.PROTECT, + related_name='transfer_events_to', + to='registration.operator', + ), + preserve_default=False, + ), + ] diff --git a/bc_obps/registration/models/event/transfer_event.py b/bc_obps/registration/models/event/transfer_event.py index ffd6a7bef1..260a4298f0 100644 --- a/bc_obps/registration/models/event/transfer_event.py +++ b/bc_obps/registration/models/event/transfer_event.py @@ -1,33 +1,47 @@ -from registration.models import Contact, Operator +from registration.models import Operator, Operation from django.db import models from registration.models.event.event_base_model import EventBaseModel from simple_history.models import HistoricalRecords class TransferEvent(EventBaseModel): - # ok to display the db ones in the grid class Statuses(models.TextChoices): COMPLETE = "Complete" TO_BE_TRANSFERRED = "To be transferred" TRANSFERRED = "Transferred" - description = models.TextField(db_comment="Description of the transfer or change in designated operator.") - other_operator = models.ForeignKey( + status = models.CharField( + max_length=100, + choices=Statuses.choices, + default=Statuses.TO_BE_TRANSFERRED, + ) + from_operator = models.ForeignKey( Operator, on_delete=models.PROTECT, - related_name="transfer_events", - db_comment="The second operator who is involved in the transfer but is not reporting the event.", + related_name="transfer_events_from", + db_comment="The operator who is transferring the operation or facility.", ) - other_operator_contact = models.ForeignKey( - Contact, + to_operator = models.ForeignKey( + Operator, on_delete=models.PROTECT, - related_name="transfer_events", - db_comment="Contact information for the other operator.", + related_name="transfer_events_to", + db_comment="The operator who is receiving the operation or facility.", ) - status = models.CharField( - max_length=100, - choices=Statuses.choices, - default=Statuses.TO_BE_TRANSFERRED, + from_operation = models.ForeignKey( + Operation, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="transfer_events_from", + db_comment="The operation that facilities are being transferred from.", + ) + to_operation = models.ForeignKey( + Operation, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="transfer_events_to", + db_comment="The operation that facilities are being transferred to.", ) history = HistoricalRecords( table_name='erc_history"."transfer_event_history', diff --git a/bc_obps/registration/models/operation.py b/bc_obps/registration/models/operation.py index 0aa521527b..88fd4a17f8 100644 --- a/bc_obps/registration/models/operation.py +++ b/bc_obps/registration/models/operation.py @@ -304,3 +304,6 @@ def is_regulated_operation(self) -> bool: Operation.Purposes.NEW_ENTRANT_OPERATION, Operation.Purposes.OPTED_IN_OPERATION, ] + + def __str__(self) -> str: + return f"{self.name} ({self.id})" diff --git a/bc_obps/registration/models/operation_designated_operator_timeline.py b/bc_obps/registration/models/operation_designated_operator_timeline.py index 52427dc4d0..11e9df2c2f 100644 --- a/bc_obps/registration/models/operation_designated_operator_timeline.py +++ b/bc_obps/registration/models/operation_designated_operator_timeline.py @@ -8,6 +8,12 @@ class OperationDesignatedOperatorTimeline(TimeStampedModel): + class Statuses(models.TextChoices): + ACTIVE = "Active" + TRANSFERRED = "Transferred" + CLOSED = "Closed" + TEMPORARILY_SHUTDOWN = "Temporarily Shutdown" + operation = models.ForeignKey(Operation, on_delete=models.PROTECT, related_name="designated_operators") operator = models.ForeignKey(Operator, on_delete=models.PROTECT, related_name="operation_designated_operators") start_date = models.DateTimeField( @@ -18,6 +24,12 @@ class OperationDesignatedOperatorTimeline(TimeStampedModel): end_date = models.DateTimeField( blank=True, null=True, db_comment="The date the operator ended being the designated operator of the operation" ) + status = models.CharField( + max_length=1000, + choices=Statuses.choices, + default=Statuses.ACTIVE, + db_comment="The status of an operation in relation to a specific operator (e.g. if an operation has transferred ownership, it may have a status of Transferred with its old operator and Active with its new one)", + ) history = HistoricalRecords( table_name='erc_history"."operation_designated_operator_timeline_history', history_user_id_field=models.UUIDField(null=True, blank=True), diff --git a/bc_obps/registration/models/operator.py b/bc_obps/registration/models/operator.py index 96f344f426..8bfc1fe83a 100644 --- a/bc_obps/registration/models/operator.py +++ b/bc_obps/registration/models/operator.py @@ -120,3 +120,6 @@ class Meta: ] db_table_comment = "Table containing operator information. An operator is the person who owns and/or controls and directs industrial operations. An operator can own multiple operations. For more information see definitions in the Greenhouse Gas Industrial Reporting and Control Act: https://www.bclaws.gov.bc.ca/civix/document/id/complete/statreg/14029_01#section1: https://www.bclaws.gov.bc.ca/civix/document/id/complete/statreg/14029_01#section1" db_table = 'erc"."operator' + + def __str__(self) -> str: + return f"{self.legal_name} ({self.id})" diff --git a/bc_obps/registration/models/user.py b/bc_obps/registration/models/user.py index 78ca72d989..b00a214646 100644 --- a/bc_obps/registration/models/user.py +++ b/bc_obps/registration/models/user.py @@ -52,6 +52,12 @@ def is_industry_user(self) -> bool: """ return self.app_role.role_name == "industry_user" + def is_cas_analyst(self) -> bool: + """ + Return whether the user is a CAS analyst. + """ + return self.app_role.role_name == "cas_analyst" + @typing.no_type_check def save(self, *args, **kwargs) -> None: """ @@ -60,3 +66,6 @@ def save(self, *args, **kwargs) -> None: cache_key = f"{USER_CACHE_PREFIX}{self.user_guid}" cache.delete(cache_key) super().save(*args, **kwargs) + + def __str__(self) -> str: + return f"{self.user_guid} - {self.email} - {self.app_role.role_name}" diff --git a/bc_obps/registration/schema/v1/facility_designated_operation_timeline.py b/bc_obps/registration/schema/v1/facility_designated_operation_timeline.py index 9bf9afeaf8..721af255c2 100644 --- a/bc_obps/registration/schema/v1/facility_designated_operation_timeline.py +++ b/bc_obps/registration/schema/v1/facility_designated_operation_timeline.py @@ -9,6 +9,13 @@ class FacilityDesignatedOperationTimelineOut(ModelSchema): facility__type: str = Field(..., alias="facility.type") facility__bcghg_id__id: Optional[str] = Field(None, alias="facility.bcghg_id.id") facility__id: UUID = Field(..., alias="facility.id") + # Using two below fields for rendering a list of facilities along with their locations for transfer event + facility__latitude_of_largest_emissions: Optional[float] = Field( + None, alias="facility.latitude_of_largest_emissions" + ) + facility__longitude_of_largest_emissions: Optional[float] = Field( + None, alias="facility.longitude_of_largest_emissions" + ) class Meta: model = FacilityDesignatedOperationTimeline @@ -23,3 +30,4 @@ class FacilityDesignatedOperationTimelineFilterSchema(FilterSchema): facility__name: Optional[str] = Field(None, json_schema_extra={'q': 'facility__name__icontains'}) facility__type: Optional[str] = Field(None, json_schema_extra={'q': 'facility__type__icontains'}) status: Optional[str] = Field(None, json_schema_extra={'q': 'status__icontains'}) + end_date: Optional[bool] = Field(None, json_schema_extra={'q': 'end_date__isnull'}) diff --git a/bc_obps/registration/schema/v1/transfer_event.py b/bc_obps/registration/schema/v2/transfer_event.py similarity index 64% rename from bc_obps/registration/schema/v1/transfer_event.py rename to bc_obps/registration/schema/v2/transfer_event.py index b11801472e..83873feeec 100644 --- a/bc_obps/registration/schema/v1/transfer_event.py +++ b/bc_obps/registration/schema/v2/transfer_event.py @@ -1,11 +1,10 @@ -from typing import Optional +import uuid +from typing import Optional, List, Literal from uuid import UUID - -from registration.models.event.transfer_event import TransferEvent from ninja import ModelSchema, Field, FilterSchema +from registration.models import TransferEvent from django.db.models import Q import re -from typing import Dict, Any class TransferEventListOut(ModelSchema): @@ -13,17 +12,7 @@ class TransferEventListOut(ModelSchema): operation__name: Optional[str] = Field(None, alias="operation__name") facilities__name: Optional[str] = Field(None, alias="facilities__name") facility__id: Optional[UUID] = Field(None, alias="facilities__id") - id: UUID - - @staticmethod - def resolve_id(obj: Dict[str, Any]) -> UUID: - operation_id = obj.get('operation__id', None) - facility_id = obj.get('facilities__id', None) - - record_id = operation_id if operation_id else facility_id - if not isinstance(record_id, UUID): - raise Exception('Missing valid UUID') - return record_id + id: UUID = Field(default_factory=uuid.uuid4) class Meta: model = TransferEvent @@ -50,3 +39,37 @@ def filter_operation__name(self, value: str) -> Q: def filter_facilities__name(self, value: str) -> Q: return self.filtering_including_not_applicable('facilities__name', value) + + +class TransferEventCreateIn(ModelSchema): + transfer_entity: Literal["Operation", "Facility"] + from_operator: UUID + to_operator: UUID + operation: Optional[UUID] = None + from_operation: Optional[UUID] = None + to_operation: Optional[UUID] = None + facilities: Optional[List] = None + + class Meta: + model = TransferEvent + fields = ['effective_date'] + + +class TransferEventOut(ModelSchema): + transfer_entity: str + + class Meta: + model = TransferEvent + fields = [ + 'from_operator', + 'to_operator', + 'effective_date', + 'operation', + 'from_operation', + 'to_operation', + 'facilities', + ] + + @staticmethod + def resolve_transfer_entity(obj: TransferEvent) -> str: + return "Facility" if obj.facilities.exists() else "Operation" diff --git a/bc_obps/registration/tests/endpoints/v2/_operations/_operation_id/test_facilities.py b/bc_obps/registration/tests/endpoints/v2/_operations/_operation_id/test_facilities.py index 69b57800f8..cdce4432ba 100644 --- a/bc_obps/registration/tests/endpoints/v2/_operations/_operation_id/test_facilities.py +++ b/bc_obps/registration/tests/endpoints/v2/_operations/_operation_id/test_facilities.py @@ -26,6 +26,17 @@ def test_facilities_endpoint_list_facilities_paginated(self): page_1_response_id = response_items_1[0].get('id') assert len(response_items_1) == NINJA_PAGINATION_PER_PAGE assert response_count_1 == 45 # total count of facilities + # make sure key fields are present + assert response_items_1[0].keys() == { + 'id', + 'status', + 'facility__name', + 'facility__type', + 'facility__bcghg_id__id', + 'facility__id', + 'facility__latitude_of_largest_emissions', + 'facility__longitude_of_largest_emissions', + } # Get the page 2 response response = TestUtils.mock_get_with_auth_role( diff --git a/bc_obps/registration/tests/endpoints/v2/test_transfer_events.py b/bc_obps/registration/tests/endpoints/v2/test_transfer_events.py index c598ccd5ad..4b8b80f043 100644 --- a/bc_obps/registration/tests/endpoints/v2/test_transfer_events.py +++ b/bc_obps/registration/tests/endpoints/v2/test_transfer_events.py @@ -1,9 +1,11 @@ from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock +from uuid import uuid4 from django.utils import timezone from bc_obps.settings import NINJA_PAGINATION_PER_PAGE from model_bakery import baker +from registration.schema.v2.transfer_event import TransferEventCreateIn from registration.tests.utils.helpers import CommonTestSetup, TestUtils - from registration.utils import custom_reverse_lazy @@ -136,3 +138,55 @@ def test_transfer_events_endpoint_list_transfer_events_with_filter(self): assert response.json().get('count') == 1 assert response_items_2[0].get('facilities__name') == facilities__name_to_filter assert response_items_2[0].get('status') == status_to_filter + + # POST + @patch("service.transfer_event_service.TransferEventService.create_transfer_event") + def test_user_can_post_transfer_event_success(self, mock_create_transfer_event: MagicMock): + mock_payload = { + "transfer_entity": "Operation", + "from_operator": uuid4(), + "to_operator": uuid4(), + "effective_date": "2023-10-13T15:27:00.000Z", + "operation": uuid4(), + } + + mock_transfer_event = baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) + mock_create_transfer_event.return_value = mock_transfer_event + response = TestUtils.mock_post_with_auth_role( + self, + "cas_analyst", + self.content_type, + mock_payload, + custom_reverse_lazy("create_transfer_event"), + ) + + mock_create_transfer_event.assert_called_once_with( + self.user.user_guid, + TransferEventCreateIn.model_validate(mock_payload), + ) + + assert response.status_code == 201 + response_json = response.json() + assert set(response_json.keys()) == { + 'transfer_entity', + 'from_operator', + 'to_operator', + 'effective_date', + 'operation', + 'from_operation', + 'to_operation', + 'facilities', + } + assert response_json['transfer_entity'] == "Operation" + assert response_json['from_operator'] == str(mock_transfer_event.from_operator.id) + assert response_json['to_operator'] == str(mock_transfer_event.to_operator.id) + # modify the effective date to match the format of the response + response_effective_date = datetime.strptime(response_json['effective_date'], "%Y-%m-%dT%H:%M:%S.%fZ") + mock_transfer_event_effective_date = datetime.fromisoformat(str(mock_transfer_event.effective_date)) + assert response_effective_date.replace(microsecond=0) == mock_transfer_event_effective_date.replace( + microsecond=0, tzinfo=None + ) + assert response_json['operation'] == str(mock_transfer_event.operation.id) + assert response_json['from_operation'] is None + assert response_json['to_operation'] is None + assert response_json['facilities'] == [] diff --git a/bc_obps/registration/tests/models/event/event_base_model_mixin.py b/bc_obps/registration/tests/models/event/event_base_model_mixin.py index 0537a4affa..c46f7bda99 100644 --- a/bc_obps/registration/tests/models/event/event_base_model_mixin.py +++ b/bc_obps/registration/tests/models/event/event_base_model_mixin.py @@ -1,7 +1,7 @@ +from model_bakery import baker from common.tests.utils.helpers import BaseTestCase from django.core.exceptions import ValidationError from registration.models import Operation, Facility -from registration.tests.utils.bakers import facility_baker, operation_baker from datetime import datetime from zoneinfo import ZoneInfo @@ -9,9 +9,9 @@ class EventBaseModelMixin(BaseTestCase): @classmethod def setUpTestData(cls): - cls.operation: Operation = operation_baker() - cls.facility1: Facility = facility_baker() - cls.facility2: Facility = facility_baker() + cls.operation: Operation = baker.make_recipe('utils.operation') + cls.facility1: Facility = baker.make_recipe('utils.facility') + cls.facility2: Facility = baker.make_recipe('utils.facility') def create_event_with_operation_only(self, *args, **kwargs): event = self.model.objects.create( @@ -29,7 +29,7 @@ def create_event_with_operation_and_adding_facilities_raises_error(self, *args, ValidationError, msg="An event must have either an operation or facilities, but not both." ): event = self.model.objects.create( - operation=self.operation, effective_date=datetime.now(ZoneInfo("UTC")), *args, **kwargs + effective_date=datetime.now(ZoneInfo("UTC")), operation=self.operation, *args, **kwargs ) event.facilities.set([self.facility1]) diff --git a/bc_obps/registration/tests/models/event/test_transfer.py b/bc_obps/registration/tests/models/event/test_transfer.py index 149dc53693..78fced2526 100644 --- a/bc_obps/registration/tests/models/event/test_transfer.py +++ b/bc_obps/registration/tests/models/event/test_transfer.py @@ -1,4 +1,6 @@ -from registration.models import TransferEvent +from model_bakery import baker + +from registration.models import TransferEvent, Operator, Operation from registration.tests.constants import ( TIMESTAMP_COMMON_FIELDS, ADDRESS_FIXTURE, @@ -12,7 +14,6 @@ TRANSFER_EVENT_FIXTURE, ) from registration.tests.models.event.event_base_model_mixin import EventBaseModelMixin -from registration.tests.utils.bakers import contact_baker, operator_baker class TransferEventModelTest(EventBaseModelMixin): @@ -37,30 +38,43 @@ def setUpTestData(cls): ("id", "id", None, None), ("effective_date", "effective date", None, None), ("status", "status", 100, None), - ("description", "description", None, None), ("operation", "operation", None, None), ("facilities", "facilities", None, None), - ("other_operator", "other operator", None, None), - ("other_operator_contact", "other operator contact", None, None), + ("from_operator", "from operator", None, None), + ("to_operator", "to operator", None, None), + ("from_operation", "from operation", None, None), + ("to_operation", "to operation", None, None), ] + cls.from_operator: Operator = baker.make_recipe('utils.operator') + cls.to_operator: Operator = baker.make_recipe('utils.operator') + cls.from_operation: Operation = baker.make_recipe('utils.operation') + cls.to_operation: Operation = baker.make_recipe('utils.operation') super().setUpTestData() def test_event_with_operation_only(self): self.create_event_with_operation_only( - description="Why the transfer is happening", - other_operator=operator_baker(), - other_operator_contact=contact_baker(), + from_operator=self.from_operator, + to_operator=self.to_operator, ) def test_event_with_facilities_only(self): self.create_event_with_facilities_only( - description="Why the transfer is happening returns", - other_operator=operator_baker(), - other_operator_contact=contact_baker(), + from_operator=self.from_operator, + to_operator=self.to_operator, + from_operation=self.from_operation, + to_operation=self.to_operation, ) def test_event_with_operation_and_adding_facilities_raises_error(self): - self.create_event_with_operation_and_adding_facilities_raises_error() + self.create_event_with_operation_and_adding_facilities_raises_error( + from_operator=self.from_operator, + to_operator=self.to_operator, + ) def test_event_with_facilities_and_adding_operation_raises_error(self): - self.create_event_with_facilities_and_adding_operation_raises_error() + self.create_event_with_facilities_and_adding_operation_raises_error( + from_operator=self.from_operator, + to_operator=self.to_operator, + from_operation=self.from_operation, + to_operation=self.to_operation, + ) diff --git a/bc_obps/registration/tests/models/test_contact.py b/bc_obps/registration/tests/models/test_contact.py index 14eadea551..0aba0733e0 100644 --- a/bc_obps/registration/tests/models/test_contact.py +++ b/bc_obps/registration/tests/models/test_contact.py @@ -37,6 +37,5 @@ def setUpTestData(cls): ("documents", "documents", None, None), ("operators", "operator", None, None), ("operations", "operation", None, None), - ("transfer_events", "transfer event", None, None), ("operations_contacts", "operation", None, None), ] diff --git a/bc_obps/registration/tests/models/test_operation.py b/bc_obps/registration/tests/models/test_operation.py index d273dbc318..5f842fa1b6 100644 --- a/bc_obps/registration/tests/models/test_operation.py +++ b/bc_obps/registration/tests/models/test_operation.py @@ -96,11 +96,13 @@ def setUpTestData(cls): ("registration_purpose", "registration purpose", 1000, None), ("opted_in_operation", "opted in operation", None, None), ("transfer_events", "transfer event", None, None), + ("transfer_events", "transfer event", None, None), ("restart_events", "restart event", None, None), ("closure_events", "closure event", None, None), ("temporary_shutdown_events", "temporary shutdown event", None, None), ("contacts", "contacts", None, None), ("date_of_first_shipment", "date of first shipment", 1000, None), + ("status", "status", 1000, None), ] def test_unique_boro_id_per_operation(self): diff --git a/bc_obps/registration/tests/models/test_operator.py b/bc_obps/registration/tests/models/test_operator.py index d58f90b20b..36984bb029 100644 --- a/bc_obps/registration/tests/models/test_operator.py +++ b/bc_obps/registration/tests/models/test_operator.py @@ -83,7 +83,8 @@ def setUpTestData(cls): ("report", "report", None, None), ("parent_operators", "parent operator", None, None), ("partner_operators", "partner operator", None, None), - ("transfer_events", "transfer event", None, None), + ("transfer_events_from", "transfer event", None, None), + ("transfer_events_to", "transfer event", None, None), ] def test_check_cra_business_number_format(self): diff --git a/bc_obps/registration/tests/utils/baker_recipes.py b/bc_obps/registration/tests/utils/baker_recipes.py index 941359d87c..958d40ab44 100644 --- a/bc_obps/registration/tests/utils/baker_recipes.py +++ b/bc_obps/registration/tests/utils/baker_recipes.py @@ -1,6 +1,8 @@ from datetime import datetime from itertools import cycle from zoneinfo import ZoneInfo + +from registration.models import OperationDesignatedOperatorTimeline from registration.models.bc_obps_regulated_operation import BcObpsRegulatedOperation from registration.models.bc_greenhouse_gas_id import BcGreenhouseGasId from registration.models.facility_designated_operation_timeline import FacilityDesignatedOperationTimeline @@ -78,8 +80,8 @@ industry_operator_user = Recipe(User, app_role=AppRole.objects.get(role_name="industry_user")) - -irc_user = Recipe(User, app_role=AppRole.objects.get(role_name="cas_admin")) +cas_admin = Recipe(User, app_role=AppRole.objects.get(role_name="cas_admin")) +cas_analyst = Recipe(User, app_role=AppRole.objects.get(role_name="cas_analyst")) operation = Recipe( Operation, @@ -126,21 +128,10 @@ contact = Recipe(Contact, business_role=BusinessRole.objects.first(), address=foreign_key(address)) -# transfer event bakers -contact_for_transfer_event = Recipe(Contact, business_role=BusinessRole.objects.first()) - -other_operator_for_transfer_event = Recipe( - Operator, - bc_corporate_registry_number=generate_random_bc_corporate_registry_number, - business_structure=BusinessStructure.objects.first(), - mailing_address=foreign_key(address), - cra_business_number=generate_random_cra_business_number, -) - transfer_event = Recipe( TransferEvent, - other_operator=foreign_key(other_operator_for_transfer_event), - other_operator_contact=foreign_key(contact_for_transfer_event), + from_operator=foreign_key(operator), + to_operator=foreign_key(operator), ) facility = Recipe(Facility, address=foreign_key(address), name=seq('Facility 0')) @@ -153,5 +144,14 @@ end_date=datetime.now(ZoneInfo("UTC")), ) +operation_designated_operator_timeline = Recipe( + OperationDesignatedOperatorTimeline, + operation=foreign_key(operation), + operator=foreign_key(operator), + status=cycle([status for status in OperationDesignatedOperatorTimeline.Statuses]), + start_date=datetime.now(ZoneInfo("UTC")), + end_date=datetime.now(ZoneInfo("UTC")), +) + regulated_product = Recipe(RegulatedProduct) diff --git a/bc_obps/registration/utils.py b/bc_obps/registration/utils.py index 66ce2be407..47e97d6ce0 100644 --- a/bc_obps/registration/utils.py +++ b/bc_obps/registration/utils.py @@ -71,6 +71,8 @@ def generate_useful_error(error: ValidationError) -> Optional[str]: NOTE: this only returns the first error message until we can figure out a better way to handle multiple errors in the client side. """ for key, value in error.message_dict.items(): + if key == '__all__': # ignore adding formatted key for general error message like constraints + return value[0] # Return the general error message directly formatted_key = ' '.join(word.capitalize() for word in key.split('_')) return f"{formatted_key}: {value[0]}" return None diff --git a/bc_obps/service/data_access_service/operation_designated_operator_timeline_service.py b/bc_obps/service/data_access_service/operation_designated_operator_timeline_service.py new file mode 100644 index 0000000000..210dbc3580 --- /dev/null +++ b/bc_obps/service/data_access_service/operation_designated_operator_timeline_service.py @@ -0,0 +1,18 @@ +from uuid import UUID +from registration.models import OperationDesignatedOperatorTimeline +from ninja.types import DictStrAny + + +class OperationDesignatedOperatorTimelineDataAccessService: + @classmethod + def create_operation_designated_operator_timeline( + cls, + user_guid: UUID, + operation_designated_operator_timeline_data: DictStrAny, + ) -> OperationDesignatedOperatorTimeline: + operation_designated_operator_timeline = OperationDesignatedOperatorTimeline.objects.create( + **operation_designated_operator_timeline_data, + created_by_id=user_guid, + ) + operation_designated_operator_timeline.set_create_or_update(user_guid) + return operation_designated_operator_timeline diff --git a/bc_obps/service/data_access_service/transfer_event_service.py b/bc_obps/service/data_access_service/transfer_event_service.py new file mode 100644 index 0000000000..2b5542e625 --- /dev/null +++ b/bc_obps/service/data_access_service/transfer_event_service.py @@ -0,0 +1,13 @@ +from uuid import UUID +from registration.models import TransferEvent +from ninja.types import DictStrAny + + +class TransferEventDataAccessService: + @classmethod + def create_transfer_event( + cls, + user_guid: UUID, + transfer_event_data: DictStrAny, + ) -> TransferEvent: + return TransferEvent.objects.create(**transfer_event_data, created_by_id=user_guid) diff --git a/bc_obps/service/facility_designated_operation_timeline_service.py b/bc_obps/service/facility_designated_operation_timeline_service.py index 34ab98b0a6..e6cbef011b 100644 --- a/bc_obps/service/facility_designated_operation_timeline_service.py +++ b/bc_obps/service/facility_designated_operation_timeline_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from django.db.models import QuerySet from uuid import UUID @@ -39,3 +40,25 @@ def list_timeline_by_operation_id( sort_by = f"{sort_direction}{sort_field}" base_qs = cls.get_timeline_by_operation_id(user, operation_id) return filters.filter(base_qs).order_by(sort_by) + + @classmethod + def get_current_timeline( + cls, operation_id: UUID, facility_id: UUID + ) -> Optional[FacilityDesignatedOperationTimeline]: + return FacilityDesignatedOperationTimeline.objects.filter( + operation_id=operation_id, facility_id=facility_id, end_date__isnull=True + ).first() + + @classmethod + def set_timeline_status_and_end_date( + cls, + user_guid: UUID, + timeline: FacilityDesignatedOperationTimeline, + status: FacilityDesignatedOperationTimeline.Statuses, + end_date: datetime, + ) -> FacilityDesignatedOperationTimeline: + timeline.status = status + timeline.end_date = end_date + timeline.save(update_fields=["status", "end_date"]) + timeline.set_create_or_update(user_guid) + return timeline diff --git a/bc_obps/service/operation_designated_operator_timeline_service.py b/bc_obps/service/operation_designated_operator_timeline_service.py new file mode 100644 index 0000000000..9215eb6cd8 --- /dev/null +++ b/bc_obps/service/operation_designated_operator_timeline_service.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from registration.models import OperationDesignatedOperatorTimeline + + +class OperationDesignatedOperatorTimelineService: + @classmethod + def get_current_timeline( + cls, operator_id: UUID, operation_id: UUID + ) -> Optional[OperationDesignatedOperatorTimeline]: + return OperationDesignatedOperatorTimeline.objects.filter( + operator_id=operator_id, operation_id=operation_id, end_date__isnull=True + ).first() + + @classmethod + def set_timeline_status_and_end_date( + cls, + user_guid: UUID, + timeline: OperationDesignatedOperatorTimeline, + status: OperationDesignatedOperatorTimeline.Statuses, + end_date: datetime, + ) -> OperationDesignatedOperatorTimeline: + timeline.status = status + timeline.end_date = end_date + timeline.save(update_fields=["status", "end_date"]) + timeline.set_create_or_update(user_guid) + return timeline diff --git a/bc_obps/service/operation_service_v2.py b/bc_obps/service/operation_service_v2.py index 34efca7334..33c9e45fc2 100644 --- a/bc_obps/service/operation_service_v2.py +++ b/bc_obps/service/operation_service_v2.py @@ -443,3 +443,19 @@ def generate_bcghg_id(cls, user_guid: UUID, operation_id: UUID) -> BcGreenhouseG operation.save(update_fields=['bcghg_id']) return operation.bcghg_id + + @classmethod + @transaction.atomic() + def update_operator(cls, user_guid: UUID, operation: Operation, operator_id: UUID) -> Operation: + """ + Update the operator for the operation + At the time of implementation, this is only used for transferring operations between operators and, + is only available to cas_analyst users + """ + user = UserDataAccessService.get_by_guid(user_guid) + if not user.is_cas_analyst(): + raise Exception(UNAUTHORIZED_MESSAGE) + operation.operator_id = operator_id + operation.save(update_fields=["operator_id"]) + operation.set_create_or_update(user_guid) + return operation diff --git a/bc_obps/service/tests/data_access_service/test_data_access_contact_service.py b/bc_obps/service/tests/data_access_service/test_data_access_contact_service.py index 4fddf42f9e..e8e2ebbb6c 100644 --- a/bc_obps/service/tests/data_access_service/test_data_access_contact_service.py +++ b/bc_obps/service/tests/data_access_service/test_data_access_contact_service.py @@ -18,8 +18,8 @@ class TestDataAccessContactService: @staticmethod def test_list_contacts_for_irc_user(): contact_baker(_quantity=10) - irc_user = user_baker({'app_role': AppRole.objects.get(role_name='cas_admin')}) - assert ContactDataAccessService.get_all_contacts_for_user(irc_user).count() == 10 + cas_admin = user_baker({'app_role': AppRole.objects.get(role_name='cas_admin')}) + assert ContactDataAccessService.get_all_contacts_for_user(cas_admin).count() == 10 @staticmethod def test_list_contacts_for_industry_user(): diff --git a/bc_obps/service/tests/data_access_service/test_data_access_operation_service_v2.py b/bc_obps/service/tests/data_access_service/test_data_access_operation_service_v2.py index 2a5bf78dce..45d9c5e9c7 100644 --- a/bc_obps/service/tests/data_access_service/test_data_access_operation_service_v2.py +++ b/bc_obps/service/tests/data_access_service/test_data_access_operation_service_v2.py @@ -12,8 +12,8 @@ class TestDataAccessOperationServiceV2: @staticmethod def test_get_all_operations_for_irc_user(): operation_baker(_quantity=10) - irc_user = user_baker({'app_role': AppRole.objects.get(role_name='cas_admin')}) - assert OperationDataAccessServiceV2.get_all_operations_for_user(irc_user).count() == 10 + cas_admin = user_baker({'app_role': AppRole.objects.get(role_name='cas_admin')}) + assert OperationDataAccessServiceV2.get_all_operations_for_user(cas_admin).count() == 10 @staticmethod def test_get_all_operations_for_industry_user(): diff --git a/bc_obps/service/tests/test_facility_designated_operations_timeline_service.py b/bc_obps/service/tests/test_facility_designated_operation_timeline_service.py similarity index 62% rename from bc_obps/service/tests/test_facility_designated_operations_timeline_service.py rename to bc_obps/service/tests/test_facility_designated_operation_timeline_service.py index ace20be9dd..e33ec9817b 100644 --- a/bc_obps/service/tests/test_facility_designated_operations_timeline_service.py +++ b/bc_obps/service/tests/test_facility_designated_operation_timeline_service.py @@ -1,3 +1,7 @@ +from datetime import datetime +from unittest.mock import MagicMock, patch +from uuid import uuid4 +from zoneinfo import ZoneInfo from registration.models import FacilityDesignatedOperationTimeline from registration.schema.v1.facility_designated_operation_timeline import ( FacilityDesignatedOperationTimelineFilterSchema, @@ -12,7 +16,7 @@ class TestGetTimeline: @staticmethod def test_get_timeline_by_operation_id_for_irc_user(): - irc_user = baker.make_recipe('utils.irc_user') + cas_admin = baker.make_recipe('utils.cas_admin') timeline_for_selected_operation = baker.make_recipe( 'utils.facility_designated_operation_timeline', _quantity=10 @@ -21,7 +25,7 @@ def test_get_timeline_by_operation_id_for_irc_user(): baker.make_recipe('utils.facility_designated_operation_timeline', _quantity=10) expected_facilities = FacilityDesignatedOperationTimelineService.get_timeline_by_operation_id( - irc_user, timeline_for_selected_operation[0].operation.id + cas_admin, timeline_for_selected_operation[0].operation.id ) assert expected_facilities.count() == 10 @@ -67,7 +71,7 @@ def test_get_timeline_by_operation_id_industry_user(): class TestListTimeline: @staticmethod def test_list_timeline_sort(): - irc_user = baker.make_recipe('utils.irc_user') + cas_admin = baker.make_recipe('utils.cas_admin') facilities = baker.make_recipe('utils.facility', _quantity=10) operation = baker.make_recipe('utils.operation') @@ -75,7 +79,7 @@ def test_list_timeline_sort(): baker.make_recipe('utils.facility_designated_operation_timeline', facility=facility, operation=operation) facilities_list = FacilityDesignatedOperationTimelineService.list_timeline_by_operation_id( - irc_user.user_guid, + cas_admin.user_guid, operation.id, 'facility__name', 'asc', @@ -88,7 +92,7 @@ def test_list_timeline_sort(): @staticmethod def test_list_timeline_filter(): - irc_user = baker.make_recipe('utils.irc_user') + cas_admin = baker.make_recipe('utils.cas_admin') facilities = baker.make_recipe('utils.facility', _quantity=10) operation = baker.make_recipe('utils.operation') @@ -96,7 +100,7 @@ def test_list_timeline_filter(): baker.make_recipe('utils.facility_designated_operation_timeline', facility=facility, operation=operation) facilities_list = FacilityDesignatedOperationTimelineService.list_timeline_by_operation_id( - irc_user.user_guid, + cas_admin.user_guid, operation.id, "facility__created_at", # default value "desc", # default value @@ -106,3 +110,49 @@ def test_list_timeline_filter(): ) assert facilities_list.count() == 1 assert facilities_list.first().facility.name == 'Facility 08' + + +class TestFacilityDesignatedOperationTimelineService: + @staticmethod + def test_get_current_timeline(): + timeline_with_no_end_date = baker.make_recipe('utils.facility_designated_operation_timeline', end_date=None) + # another timeline for the same facility to make sure it is not returned + baker.make_recipe('utils.facility_designated_operation_timeline', facility=timeline_with_no_end_date.facility) + result_found = FacilityDesignatedOperationTimelineService.get_current_timeline( + timeline_with_no_end_date.operation_id, timeline_with_no_end_date.facility_id + ) + assert result_found == timeline_with_no_end_date + timeline_with_end_date = baker.make_recipe( + 'utils.facility_designated_operation_timeline', end_date=datetime.now(ZoneInfo("UTC")) + ) + result_not_found = FacilityDesignatedOperationTimelineService.get_current_timeline( + timeline_with_end_date.operation_id, timeline_with_end_date.facility_id + ) + assert result_not_found is None + + @staticmethod + @patch("registration.models.FacilityDesignatedOperationTimeline.set_create_or_update") + def test_set_timeline_status_and_end_date(mock_set_create_or_update: MagicMock): + timeline = baker.make_recipe( + 'utils.facility_designated_operation_timeline', status=FacilityDesignatedOperationTimeline.Statuses.ACTIVE + ) + new_status = FacilityDesignatedOperationTimeline.Statuses.CLOSED + end_date = datetime.now(ZoneInfo("UTC")) + user_guid = uuid4() + + updated_timeline = FacilityDesignatedOperationTimelineService.set_timeline_status_and_end_date( + user_guid, timeline, new_status, end_date + ) + + assert updated_timeline.status == new_status + assert updated_timeline.end_date == end_date + assert updated_timeline.facility_id == timeline.facility_id + assert updated_timeline.operation_id == timeline.operation_id + + # Verify set_create_or_update is called with the correct user_guid + mock_set_create_or_update.assert_called_once_with(user_guid) + + # Verify the changes are saved in the database + timeline.refresh_from_db() + assert timeline.status == new_status + assert timeline.end_date == end_date diff --git a/bc_obps/service/tests/test_operation_designated_operator_timeline_service.py b/bc_obps/service/tests/test_operation_designated_operator_timeline_service.py new file mode 100644 index 0000000000..30a4575139 --- /dev/null +++ b/bc_obps/service/tests/test_operation_designated_operator_timeline_service.py @@ -0,0 +1,55 @@ +from unittest.mock import patch, MagicMock +from zoneinfo import ZoneInfo +import pytest +from datetime import datetime +from uuid import uuid4 +from model_bakery import baker +from service.operation_designated_operator_timeline_service import OperationDesignatedOperatorTimelineService +from registration.models import OperationDesignatedOperatorTimeline + +pytestmark = pytest.mark.django_db + + +class TestOperationDesignatedOperatorTimelineService: + @staticmethod + def test_get_current_timeline(): + timeline_with_no_end_date = baker.make_recipe('utils.operation_designated_operator_timeline', end_date=None) + # another timeline for the same operation to make sure it is not returned + baker.make_recipe('utils.operation_designated_operator_timeline', operation=timeline_with_no_end_date.operation) + result_found = OperationDesignatedOperatorTimelineService.get_current_timeline( + timeline_with_no_end_date.operator_id, timeline_with_no_end_date.operation_id + ) + assert result_found == timeline_with_no_end_date + + timeline_with_end_date = baker.make_recipe('utils.operation_designated_operator_timeline') + result_not_found = OperationDesignatedOperatorTimelineService.get_current_timeline( + timeline_with_end_date.operator_id, timeline_with_end_date.operation_id + ) + assert result_not_found is None + + @staticmethod + @patch("registration.models.OperationDesignatedOperatorTimeline.set_create_or_update") + def test_set_timeline_status_and_end_date(mock_set_create_or_update: MagicMock): + timeline = baker.make_recipe( + 'utils.operation_designated_operator_timeline', status=OperationDesignatedOperatorTimeline.Statuses.ACTIVE + ) + new_status = OperationDesignatedOperatorTimeline.Statuses.TRANSFERRED + end_date = datetime.now(ZoneInfo("UTC")) + user_guid = uuid4() + + updated_timeline = OperationDesignatedOperatorTimelineService.set_timeline_status_and_end_date( + user_guid, timeline, new_status, end_date + ) + + assert updated_timeline.status == new_status + assert updated_timeline.end_date == end_date + assert updated_timeline.operator_id == timeline.operator_id + assert updated_timeline.operation_id == timeline.operation_id + + # Verify set_create_or_update is called with the correct user_guid + mock_set_create_or_update.assert_called_once_with(user_guid) + + # Verify the changes are saved in the database + timeline.refresh_from_db() + assert timeline.status == new_status + assert timeline.end_date == end_date diff --git a/bc_obps/service/tests/test_operation_service_v2.py b/bc_obps/service/tests/test_operation_service_v2.py index 14e79bdbd4..7cc811e1e0 100644 --- a/bc_obps/service/tests/test_operation_service_v2.py +++ b/bc_obps/service/tests/test_operation_service_v2.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock +from uuid import uuid4 from zoneinfo import ZoneInfo from registration.models.contact import Contact @@ -883,3 +885,27 @@ def test_removes_operation_representative(): assert operation.updated_by == approved_user_operator.user # confirm the contact was only removed from the operation, not removed from the db assert Contact.objects.filter(id=2).exists() + + +class TestUpdateOperationsOperator: + @staticmethod + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_unauthorized_user_cannot_update_operations_operator(mock_get_by_guid): + cas_admin = baker.make_recipe('utils.cas_admin') + mock_get_by_guid.return_value = cas_admin + operation = MagicMock() + operator_id = uuid4() + with pytest.raises(Exception, match="Unauthorized."): + OperationServiceV2.update_operator(cas_admin.user_guid, operation, operator_id) + + @staticmethod + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + @patch("registration.models.Operation.set_create_or_update") + def test_update_operations_operator_success(mock_get_by_guid, mock_set_create_or_update): + cas_analyst = baker.make_recipe('utils.cas_analyst') + mock_get_by_guid.return_value = cas_analyst + operation = baker.make_recipe('utils.operation') + operator = baker.make_recipe('utils.operator') + OperationServiceV2.update_operator(cas_analyst.user_guid, operation, operator.id) + mock_set_create_or_update.assert_called_once() + assert operation.operator == operator diff --git a/bc_obps/service/tests/test_transfer_event_service.py b/bc_obps/service/tests/test_transfer_event_service.py index 607456f5c6..a8580ad267 100644 --- a/bc_obps/service/tests/test_transfer_event_service.py +++ b/bc_obps/service/tests/test_transfer_event_service.py @@ -1,4 +1,9 @@ -from registration.schema.v1.transfer_event import TransferEventFilterSchema +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock +from uuid import uuid4 +from zoneinfo import ZoneInfo +from registration.models import TransferEvent, FacilityDesignatedOperationTimeline, OperationDesignatedOperatorTimeline +from registration.schema.v2.transfer_event import TransferEventFilterSchema, TransferEventCreateIn from service.transfer_event_service import TransferEventService import pytest from model_bakery import baker @@ -20,3 +25,521 @@ def test_list_transfer_events(): TransferEventFilterSchema(effective_date=None, operation__name=None, facilities__name=None, status=None), ) assert result.count() == 7 + + @staticmethod + def test_validate_no_overlapping_transfer_events(): + # Scenario 1: No overlapping operation or facility + new_operation = baker.make_recipe('utils.operation') + new_facilities = baker.make_recipe('utils.facility', _quantity=2) + try: + TransferEventService.validate_no_overlapping_transfer_events( + operation_id=new_operation.id, facility_ids=[facility.id for facility in new_facilities] + ) + except Exception as e: + pytest.fail(f"Unexpected exception raised: {e}") + + # Scenario 2: Overlapping operation + operation = baker.make_recipe('utils.operation') + baker.make_recipe( + 'utils.transfer_event', + operation=operation, + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + with pytest.raises(Exception, match="An active transfer event already exists for the selected operation."): + TransferEventService.validate_no_overlapping_transfer_events(operation_id=operation.id) + + # Scenario 3: Overlapping facilities + facilities = baker.make_recipe('utils.facility', _quantity=2) + baker.make_recipe( + 'utils.transfer_event', + facilities=facilities, + status=TransferEvent.Statuses.COMPLETE, + ) + with pytest.raises( + Exception, + match="One or more facilities in this transfer event are already part of an active transfer event.", + ): + TransferEventService.validate_no_overlapping_transfer_events( + facility_ids=[facility.id for facility in facilities] + ) + + @staticmethod + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_create_transfer_event_unauthorized_user(mock_get_by_guid): + cas_admin = baker.make_recipe('utils.cas_admin') + mock_get_by_guid.return_value = cas_admin + # Mock user to not be a CAS analyst + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = False + mock_get_by_guid.return_value = mock_user + + with pytest.raises(Exception, match="User is not authorized to create transfer events."): + TransferEventService.create_transfer_event(cas_admin.user_guid, {}) + + @classmethod + def _get_transfer_event_payload_for_operation(cls): + from_operator = baker.make_recipe('utils.operator') + to_operator = baker.make_recipe('utils.operator') + operation = baker.make_recipe('utils.operation') + return TransferEventCreateIn.model_construct( + transfer_entity="Operation", + from_operator=from_operator.id, + to_operator=to_operator.id, + effective_date=datetime.now(ZoneInfo("UTC")), + operation=operation.id, + ) + + @classmethod + @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_create_transfer_event_operation_missing_operation(cls, mock_get_by_guid, mock_validate_no_overlap): + cas_analyst = baker.make_recipe("utils.cas_analyst") + payload = cls._get_transfer_event_payload_for_operation() + payload.operation = None + + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = True + mock_get_by_guid.return_value = cas_analyst + mock_validate_no_overlap.return_value = None + + with pytest.raises(Exception, match="Operation is required for operation transfer events."): + TransferEventService.create_transfer_event(cas_analyst.user_guid, payload) + + @classmethod + @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_create_transfer_event_operation_using_the_same_operator(cls, mock_get_by_guid, mock_validate_no_overlap): + cas_analyst = baker.make_recipe("utils.cas_analyst") + payload_with_same_from_operator_and_to_operator = cls._get_transfer_event_payload_for_operation() + payload_with_same_from_operator_and_to_operator.to_operator = ( + payload_with_same_from_operator_and_to_operator.from_operator + ) + + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = True + mock_get_by_guid.return_value = cas_analyst + mock_validate_no_overlap.return_value = None + + with pytest.raises(Exception, match="Operations cannot be transferred within the same operator."): + TransferEventService.create_transfer_event( + cas_analyst.user_guid, payload_with_same_from_operator_and_to_operator + ) + + @classmethod + @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + @patch("service.data_access_service.transfer_event_service.TransferEventDataAccessService.create_transfer_event") + def test_create_transfer_event_operation( + cls, mock_create_transfer_event, mock_get_by_guid, mock_validate_no_overlap + ): + cas_analyst = baker.make_recipe('utils.cas_analyst') + payload = cls._get_transfer_event_payload_for_operation() + + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = True + mock_get_by_guid.return_value = cas_analyst + + # Mock transfer event creation + mock_transfer_event = MagicMock() + mock_create_transfer_event.return_value = mock_transfer_event + + result = TransferEventService.create_transfer_event(cas_analyst.user_guid, payload) + + mock_get_by_guid.assert_called_once_with(cas_analyst.user_guid) + mock_validate_no_overlap.assert_called_once_with(operation_id=payload.operation) + mock_create_transfer_event.assert_called_once_with( + cas_analyst.user_guid, + { + "from_operator_id": payload.from_operator, + "to_operator_id": payload.to_operator, + "effective_date": payload.effective_date, + "operation_id": payload.operation, + }, + ) + assert result == mock_transfer_event + + @classmethod + def _get_transfer_event_payload_for_facility(cls): + from_operator = baker.make_recipe('utils.operator') + to_operator = baker.make_recipe('utils.operator') + from_operation = baker.make_recipe('utils.operation') + to_operation = baker.make_recipe('utils.operation') + facilities = baker.make_recipe('utils.facility', _quantity=2) + return TransferEventCreateIn.model_construct( + transfer_entity="Facility", + from_operator=from_operator.id, + to_operator=to_operator.id, + effective_date=datetime.now(ZoneInfo("UTC")), + from_operation=from_operation.id, + to_operation=to_operation.id, + facilities=[facility.id for facility in facilities], + ) + + @classmethod + @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_create_transfer_event_facility_missing_required_fields(cls, mock_get_by_guid, mock_validate_no_overlap): + cas_analyst = baker.make_recipe("utils.cas_analyst") + payload_without_facility = cls._get_transfer_event_payload_for_facility() + payload_without_facility.facilities = None + + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = True + mock_get_by_guid.return_value = cas_analyst + mock_validate_no_overlap.return_value = None + + with pytest.raises( + Exception, match="Facilities, from_operation, and to_operation are required for facility transfer events." + ): + TransferEventService.create_transfer_event(cas_analyst.user_guid, payload_without_facility) + + payload_without_from_operation = cls._get_transfer_event_payload_for_facility() + payload_without_from_operation.from_operation = None + with pytest.raises( + Exception, match="Facilities, from_operation, and to_operation are required for facility transfer events." + ): + TransferEventService.create_transfer_event(cas_analyst.user_guid, payload_without_from_operation) + + payload_without_to_operation = cls._get_transfer_event_payload_for_facility() + payload_without_to_operation.to_operation = None + with pytest.raises( + Exception, match="Facilities, from_operation, and to_operation are required for facility transfer events." + ): + TransferEventService.create_transfer_event(cas_analyst.user_guid, payload_without_to_operation) + + @classmethod + @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_create_transfer_event_facility_between_the_same_operation(cls, mock_get_by_guid, mock_validate_no_overlap): + cas_analyst = baker.make_recipe("utils.cas_analyst") + payload_with_same_from_and_to_operation = cls._get_transfer_event_payload_for_facility() + payload_with_same_from_and_to_operation.to_operation = payload_with_same_from_and_to_operation.from_operation + + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = True + mock_get_by_guid.return_value = cas_analyst + mock_validate_no_overlap.return_value = None + + with pytest.raises(Exception, match="Facilities cannot be transferred within the same operation."): + TransferEventService.create_transfer_event(cas_analyst.user_guid, payload_with_same_from_and_to_operation) + + @classmethod + @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + @patch("service.data_access_service.transfer_event_service.TransferEventDataAccessService.create_transfer_event") + def test_create_transfer_event_facility( + cls, mock_create_transfer_event, mock_get_by_guid, mock_validate_no_overlap + ): + cas_analyst = baker.make_recipe("utils.cas_analyst") + payload = cls._get_transfer_event_payload_for_facility() + + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = True + mock_get_by_guid.return_value = cas_analyst + + mock_transfer_event = MagicMock() + mock_create_transfer_event.return_value = mock_transfer_event + + result = TransferEventService.create_transfer_event(cas_analyst.user_guid, payload) + + mock_get_by_guid.assert_called_once_with(cas_analyst.user_guid) + mock_validate_no_overlap.assert_called_once_with(facility_ids=payload.facilities) + mock_create_transfer_event.assert_called_once_with( + cas_analyst.user_guid, + { + "from_operator_id": payload.from_operator, + "to_operator_id": payload.to_operator, + "effective_date": payload.effective_date, + "from_operation_id": payload.from_operation, + "to_operation_id": payload.to_operation, + }, + ) + mock_transfer_event.facilities.set.assert_called_once_with(payload.facilities) + assert result == mock_transfer_event + + @classmethod + @patch("service.transfer_event_service.TransferEventService._process_single_event") + @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + @patch("service.data_access_service.transfer_event_service.TransferEventDataAccessService.create_transfer_event") + def test_process_event_on_effective_date( + cls, mock_create_transfer_event, mock_get_by_guid, mock_validate_no_overlap, mock_process_event + ): + cas_analyst = baker.make_recipe("utils.cas_analyst") + + # Use an effective date that is yesterday + payload = cls._get_transfer_event_payload_for_operation() + payload.effective_date = datetime.now(ZoneInfo("UTC")) - timedelta(days=1) + + mock_user = MagicMock() + mock_user.is_cas_analyst.return_value = True + mock_get_by_guid.return_value = cas_analyst + mock_validate_no_overlap.return_value = None + + # Mock transfer event creation + mock_transfer_event = MagicMock() + mock_create_transfer_event.return_value = mock_transfer_event + + result = TransferEventService.create_transfer_event(cas_analyst.user_guid, payload) + + mock_process_event.assert_called_once_with(mock_transfer_event, cas_analyst.user_guid) + assert result == mock_transfer_event + + @staticmethod + @patch("service.transfer_event_service.TransferEventService._process_single_event") + @patch("service.transfer_event_service.logger") + def test_process_due_transfer_events(mock_logger: MagicMock, mock_process_single_event: MagicMock): + # Setup test data: Three transfer events, two of which are due today and one is due in the future + today = datetime.now(ZoneInfo("UTC")) + due_event_1 = baker.make_recipe( + "utils.transfer_event", effective_date=today, status=TransferEvent.Statuses.TO_BE_TRANSFERRED + ) + due_event_2 = baker.make_recipe( + "utils.transfer_event", + effective_date=today - timedelta(days=1), + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + # future_event to ensure it is not processed + future_event = baker.make_recipe( + "utils.transfer_event", + effective_date=today + timedelta(days=1), + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + + # Simulate processing behavior + mock_process_single_event.side_effect = [None, Exception("Processing failed")] + + # Call the function + TransferEventService.process_due_transfer_events() + + # Verify that process_single_event is called for each due event + mock_process_single_event.assert_any_call(due_event_1) + mock_process_single_event.assert_any_call(due_event_2) + assert mock_process_single_event.call_count == 2 + + # Ensure the future event is not processed + processed_events = [call[0][0] for call in mock_process_single_event.call_args_list] + assert future_event not in processed_events + + # Verify logger calls + mock_logger.info.assert_any_call("Successfully processed 1 transfer events.") + mock_logger.info.assert_any_call(f"Event IDs: {[due_event_1.id]}") + mock_logger.error.assert_called_once_with(f"Failed to process event {due_event_2.id}: Processing failed") + + @staticmethod + @patch("registration.models.TransferEvent.set_create_or_update") + @patch("service.transfer_event_service.TransferEventService._process_facilities_transfer") + @patch("service.transfer_event_service.TransferEventService._process_operation_transfer") + def test_process_single_event_success( + mock_process_operation_transfer: MagicMock, + mock_process_facilities_transfer: MagicMock, + mock_set_create_or_update: MagicMock, + ): + user_guid = uuid4() + # Scenario 1: Transfer event with facilities + transfer_event_facilities = baker.make_recipe( + "utils.transfer_event", + facilities=baker.make_recipe("utils.facility", _quantity=3), + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + + # Call the function for facilities + TransferEventService._process_single_event(transfer_event_facilities, user_guid) + + # Verify facilities transfer processing + mock_process_facilities_transfer.assert_called_once_with(transfer_event_facilities, user_guid) + mock_process_operation_transfer.assert_not_called() + + # Verify transfer event is marked as transferred + transfer_event_facilities.refresh_from_db() + assert transfer_event_facilities.status == TransferEvent.Statuses.TRANSFERRED + mock_set_create_or_update.assert_called_once() + + # Scenario 2: Transfer event with an operation + transfer_event_operation = baker.make_recipe( + "utils.transfer_event", + operation=baker.make_recipe("utils.operation"), + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + created_by=baker.make_recipe("utils.cas_analyst"), + ) + + # Reset the mock for the next scenario(otherwise we will get a call count 1 from the previous scenario) + mock_process_facilities_transfer.reset_mock() + + # Call the function for operations + TransferEventService._process_single_event(transfer_event_operation, None) + + # Verify operation transfer processing + mock_process_operation_transfer.assert_called_once_with( + transfer_event_operation, transfer_event_operation.created_by.pk + ) + mock_process_facilities_transfer.assert_not_called() + + # Verify transfer event is marked as transferred + transfer_event_operation.refresh_from_db() + assert transfer_event_operation.status == TransferEvent.Statuses.TRANSFERRED + + @staticmethod + @patch("service.transfer_event_service.TransferEventService._process_facilities_transfer") + @patch("service.transfer_event_service.TransferEventService._process_operation_transfer") + def test_process_single_event_failure(mock_process_operation: MagicMock, mock_process_facilities: MagicMock): + user_guid = uuid4() + operation_event = baker.make_recipe( + "utils.transfer_event", + operation=baker.make_recipe("utils.operation"), + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + facility_event = baker.make_recipe( + "utils.transfer_event", + facilities=[baker.make_recipe("utils.facility")], + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + + # Simulate failure in processing functions + mock_process_operation.side_effect = Exception("Operation processing failed") + mock_process_facilities.side_effect = Exception("Facilities processing failed") + + # Test operation transfer failure + with pytest.raises(Exception, match="Operation processing failed"): + TransferEventService._process_single_event(operation_event, user_guid) + operation_event.refresh_from_db() + # Make sure the status is still TO_BE_TRANSFERRED + assert operation_event.status == TransferEvent.Statuses.TO_BE_TRANSFERRED + + # Test facilities transfer failure + with pytest.raises(Exception, match="Facilities processing failed"): + TransferEventService._process_single_event(facility_event, user_guid) + facility_event.refresh_from_db() + # Make sure the status is still TO_BE_TRANSFERRED + assert facility_event.status == TransferEvent.Statuses.TO_BE_TRANSFERRED + + @staticmethod + @patch("service.transfer_event_service.FacilityDesignatedOperationTimelineService.get_current_timeline") + @patch("service.transfer_event_service.FacilityDesignatedOperationTimelineService.set_timeline_status_and_end_date") + @patch( + "service.transfer_event_service.FacilityDesignatedOperationTimelineDataAccessService.create_facility_designated_operation_timeline" + ) + def test_process_facilities_transfer( + mock_create_timeline: MagicMock, mock_set_timeline: MagicMock, mock_get_current_timeline: MagicMock + ): + facility_1 = baker.make_recipe("utils.facility") + facility_2 = baker.make_recipe("utils.facility") + transfer_event = baker.make_recipe( + "utils.transfer_event", + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + facilities=[facility_1, facility_2], + from_operation=baker.make_recipe("utils.operation"), + to_operation=baker.make_recipe("utils.operation"), + ) + + user_guid = uuid4() + + # Mock the behavior of get_current_timeline for facility 1 and 2 + timeline_1 = MagicMock() # Simulate an existing timeline for facility_1 + mock_get_current_timeline.side_effect = [timeline_1, None] # First call returns timeline_1, second returns None + + # Simulate the behavior of setting the timeline status and creating a new timeline + mock_set_timeline.return_value = None + mock_create_timeline.return_value = None + + # Call the method under test + TransferEventService._process_facilities_transfer(transfer_event, user_guid) + + # Verify that get_current_timeline was called for each facility + mock_get_current_timeline.assert_any_call(transfer_event.from_operation.id, facility_1.id) + mock_get_current_timeline.assert_any_call(transfer_event.from_operation.id, facility_2.id) + + # Verify that set_timeline_status_and_end_date was called for facility_1 (existing timeline) + mock_set_timeline.assert_called_once_with( + user_guid, + timeline_1, + FacilityDesignatedOperationTimeline.Statuses.TRANSFERRED, + transfer_event.effective_date, + ) + + # Verify that create_facility_designated_operation_timeline was called twice, once for each facility + mock_create_timeline.assert_any_call( + user_guid=user_guid, + facility_designated_operation_timeline_data={ + "facility": facility_2, + "operation": transfer_event.to_operation, + "start_date": transfer_event.effective_date, + "status": FacilityDesignatedOperationTimeline.Statuses.ACTIVE, + }, + ) + + mock_create_timeline.assert_any_call( + user_guid=user_guid, + facility_designated_operation_timeline_data={ + "facility": facility_1, + "operation": transfer_event.to_operation, + "start_date": transfer_event.effective_date, + "status": FacilityDesignatedOperationTimeline.Statuses.ACTIVE, + }, + ) + + @patch("service.transfer_event_service.OperationDesignatedOperatorTimelineService.get_current_timeline") + @patch("service.transfer_event_service.OperationDesignatedOperatorTimelineService.set_timeline_status_and_end_date") + @patch( + "service.transfer_event_service.OperationDesignatedOperatorTimelineDataAccessService.create_operation_designated_operator_timeline" + ) + @patch("service.operation_service_v2.OperationServiceV2.update_operator") + def test_process_operation_transfer( + self, + mock_update_operator, + mock_create_timeline, + mock_set_timeline, + mock_get_current_timeline, + ): + transfer_event = baker.make_recipe( + "utils.transfer_event", + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + operation=baker.make_recipe("utils.operation"), + ) + + user_guid = uuid4() # Simulating the user GUID + + # Scenario 1: Current timeline exists + mock_get_current_timeline.return_value = MagicMock() + + # Call the method under test for the first scenario (with existing timeline) + TransferEventService._process_operation_transfer(transfer_event, user_guid) + + # Verify that get_current_timeline was called for the operation and operator + mock_get_current_timeline.assert_called_once_with(transfer_event.from_operator.id, transfer_event.operation.id) + + # Verify that set_timeline_status_and_end_date was called since the timeline exists + mock_set_timeline.assert_called_once_with( + user_guid, + mock_get_current_timeline.return_value, + OperationDesignatedOperatorTimeline.Statuses.TRANSFERRED, + transfer_event.effective_date, + ) + + # Scenario 2: No current timeline + mock_get_current_timeline.return_value = None + mock_set_timeline.reset_mock() # Reset mock for the next call + mock_create_timeline.reset_mock() # Reset mock for the next call + mock_update_operator.reset_mock() # Reset mock for the next call + + # Call the method under test for the second scenario (no existing timeline) + TransferEventService._process_operation_transfer(transfer_event, user_guid) + + # Verify that create_operation_designated_operator_timeline was called since the timeline does not exist + mock_create_timeline.assert_called_once_with( + user_guid=user_guid, + operation_designated_operator_timeline_data={ + "operation": transfer_event.operation, + "operator": transfer_event.to_operator, + "start_date": transfer_event.effective_date, + "status": OperationDesignatedOperatorTimeline.Statuses.ACTIVE, + }, + ) + + # Verify that set_timeline_status_and_end_date was not called, since the timeline did not exist + mock_set_timeline.assert_not_called() + mock_update_operator.assert_called_once_with( + user_guid, + transfer_event.operation, + transfer_event.to_operator.id, + ) diff --git a/bc_obps/service/tests/test_user_operator_service_v2.py b/bc_obps/service/tests/test_user_operator_service_v2.py index e065be3da4..25947d91ab 100644 --- a/bc_obps/service/tests/test_user_operator_service_v2.py +++ b/bc_obps/service/tests/test_user_operator_service_v2.py @@ -102,9 +102,9 @@ def test_list_user_operators_v2(): user__bceid_business_name="", operator__legal_name="", ) - irc_user = baker.make_recipe('utils.irc_user') + cas_admin = baker.make_recipe('utils.cas_admin') user_operators_with_admin_access_status = UserOperatorServiceV2.list_user_operators_v2( - user_guid=irc_user.user_guid, filters=filters_2, sort_field="created_at", sort_order="asc" + user_guid=cas_admin.user_guid, filters=filters_2, sort_field="created_at", sort_order="asc" ) assert user_operators_with_admin_access_status.count() == 5 assert user_operators_with_admin_access_status.filter(status=UserOperator.Statuses.APPROVED).count() == 5 @@ -114,28 +114,28 @@ def test_list_user_operators_v2(): update={"status": ""} ) # making a copy of filters_2 and updating status to empty string user_operators_sorted_by_created_at = UserOperatorServiceV2.list_user_operators_v2( - user_guid=irc_user.user_guid, filters=filters_3, sort_field="created_at", sort_order="asc" + user_guid=cas_admin.user_guid, filters=filters_3, sort_field="created_at", sort_order="asc" ) assert ( user_operators_sorted_by_created_at.first().created_at < user_operators_sorted_by_created_at.last().created_at ) user_operators_sorted_by_created_at_desc = UserOperatorServiceV2.list_user_operators_v2( - user_guid=irc_user.user_guid, filters=filters_3, sort_field="created_at", sort_order="desc" + user_guid=cas_admin.user_guid, filters=filters_3, sort_field="created_at", sort_order="desc" ) assert ( user_operators_sorted_by_created_at_desc.first().created_at > user_operators_sorted_by_created_at_desc.last().created_at ) user_operators_sorted_by_user_friendly_id = UserOperatorServiceV2.list_user_operators_v2( - user_guid=irc_user.user_guid, filters=filters_3, sort_field="user_friendly_id", sort_order="asc" + user_guid=cas_admin.user_guid, filters=filters_3, sort_field="user_friendly_id", sort_order="asc" ) assert ( user_operators_sorted_by_user_friendly_id.first().user_friendly_id < user_operators_sorted_by_user_friendly_id.last().user_friendly_id ) user_operators_sorted_by_status = UserOperatorServiceV2.list_user_operators_v2( - user_guid=irc_user.user_guid, filters=filters_3, sort_field="status", sort_order="asc" + user_guid=cas_admin.user_guid, filters=filters_3, sort_field="status", sort_order="asc" ) assert user_operators_sorted_by_status.first().status == UserOperator.Statuses.APPROVED assert user_operators_sorted_by_status.last().status == UserOperator.Statuses.PENDING diff --git a/bc_obps/service/transfer_event_service.py b/bc_obps/service/transfer_event_service.py index 70680dc531..2dbc5fd3da 100644 --- a/bc_obps/service/transfer_event_service.py +++ b/bc_obps/service/transfer_event_service.py @@ -1,9 +1,28 @@ -from typing import cast +import logging +from datetime import datetime +from typing import cast, List +from uuid import UUID +from zoneinfo import ZoneInfo +from django.db import transaction from django.db.models import QuerySet +from registration.models import FacilityDesignatedOperationTimeline, OperationDesignatedOperatorTimeline from registration.models.event.transfer_event import TransferEvent from typing import Optional -from registration.schema.v1.transfer_event import TransferEventFilterSchema from ninja import Query +from registration.schema.v2.transfer_event import TransferEventCreateIn, TransferEventFilterSchema +from service.data_access_service.facility_designated_operation_timeline_service import ( + FacilityDesignatedOperationTimelineDataAccessService, +) +from service.data_access_service.operation_designated_operator_timeline_service import ( + OperationDesignatedOperatorTimelineDataAccessService, +) +from service.data_access_service.transfer_event_service import TransferEventDataAccessService +from service.data_access_service.user_service import UserDataAccessService +from service.facility_designated_operation_timeline_service import FacilityDesignatedOperationTimelineService +from service.operation_designated_operator_timeline_service import OperationDesignatedOperatorTimelineService +from service.operation_service_v2 import OperationServiceV2 + +logger = logging.getLogger(__name__) class TransferEventService: @@ -30,3 +49,214 @@ def list_transfer_events( .distinct() ) return cast(QuerySet[TransferEvent], queryset) + + @classmethod + def validate_no_overlapping_transfer_events( + cls, operation_id: Optional[UUID] = None, facility_ids: Optional[List[UUID]] = None + ) -> None: + """ + Validates that there are no overlapping active transfer events for the given operation or facilities. + """ + if operation_id: + # Check for overlapping transfer events with the operation + overlapping_operation = TransferEvent.objects.filter( + operation_id=operation_id, + status__in=[ + TransferEvent.Statuses.TO_BE_TRANSFERRED, + TransferEvent.Statuses.COMPLETE, + ], + ) + if overlapping_operation.exists(): + raise Exception("An active transfer event already exists for the selected operation.") + + if facility_ids: + # Check for overlapping transfer events with the facilities + overlapping_facilities = TransferEvent.objects.filter( + facilities__id__in=facility_ids, + status__in=[ + TransferEvent.Statuses.TO_BE_TRANSFERRED, + TransferEvent.Statuses.COMPLETE, + ], + ).distinct() + if overlapping_facilities.exists(): + raise Exception( + "One or more facilities in this transfer event are already part of an active transfer event." + ) + + @classmethod + def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) -> TransferEvent: + user = UserDataAccessService.get_by_guid(user_guid) + + if not user.is_cas_analyst(): + raise Exception("User is not authorized to create transfer events.") + + # Validate against overlapping transfer events + if payload.transfer_entity == "Operation": + cls.validate_no_overlapping_transfer_events(operation_id=payload.operation) + elif payload.transfer_entity == "Facility": + cls.validate_no_overlapping_transfer_events(facility_ids=payload.facilities) + + # Prepare the payload for the data access service + prepared_payload = { + "from_operator_id": payload.from_operator, + "to_operator_id": payload.to_operator, + "effective_date": payload.effective_date, # type: ignore[attr-defined] # mypy not aware of model schema field + } + + transfer_event = None + if payload.transfer_entity == "Operation": + if not payload.operation: + raise Exception("Operation is required for operation transfer events.") + + # make sure that the from_operator and to_operator are different(we can't transfer operations within the same operator) + if payload.from_operator == payload.to_operator: + raise Exception("Operations cannot be transferred within the same operator.") + + prepared_payload.update( + { + "operation_id": payload.operation, + } + ) + transfer_event = TransferEventDataAccessService.create_transfer_event(user_guid, prepared_payload) + + elif payload.transfer_entity == "Facility": + if not all([payload.facilities, payload.from_operation, payload.to_operation]): + raise Exception( + "Facilities, from_operation, and to_operation are required for facility transfer events." + ) + + # make sure that the from_operation and to_operation are different(we can't transfer facilities within the same operation) + if payload.from_operation == payload.to_operation: + raise Exception("Facilities cannot be transferred within the same operation.") + + prepared_payload.update( + { + "from_operation_id": payload.from_operation, + "to_operation_id": payload.to_operation, + } + ) + transfer_event = TransferEventDataAccessService.create_transfer_event(user_guid, prepared_payload) + + # doing a check just to make mypy happy + payload_facilities = payload.facilities + if payload_facilities: + transfer_event.facilities.set(payload_facilities) + + # Check if the effective date is today or in the past and process the event + now = datetime.now(ZoneInfo("UTC")) + if payload.effective_date <= now: # type: ignore[attr-defined] # mypy not aware of model schema field + cls._process_single_event(transfer_event, user_guid) + + return transfer_event + + @classmethod + def process_due_transfer_events(cls) -> None: + """ + Process all due transfer events (effective date <= today and status = 'To be transferred'). + Updates timelines and marks the events as 'Transferred'. + """ + today = datetime.now(ZoneInfo("UTC")).date() + + # Fetch all due transfer events with related fields to optimize queries + transfer_events = TransferEvent.objects.filter( + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + effective_date__date__lte=today, + ) + + if not transfer_events: + return + + processed_events = [] + + for event in transfer_events: + try: + cls._process_single_event(event) + processed_events.append(event.id) + except Exception as e: + logger.error(f"Failed to process event {event.id}: {e}") + + logger.info(f"Successfully processed {len(processed_events)} transfer events.") + if processed_events: + logger.info(f"Event IDs: {processed_events}") + + @classmethod + @transaction.atomic + def _process_single_event(cls, event: TransferEvent, user_guid: Optional[UUID] = None) -> None: + """ + Processes a single transfer event, delegating based on its type. + """ + # If the timeline update is user-triggered (via a transfer event with a past effective date), use the user_guid. + # Otherwise, for cronjob updates, use created_by_id from the event. + processed_by_id: UUID = user_guid if user_guid else event.created_by.pk # type: ignore # we are sure that created_by is not None + + if event.facilities.exists(): + cls._process_facilities_transfer(event, processed_by_id) + elif event.operation: + cls._process_operation_transfer(event, processed_by_id) + + # Mark the transfer event as 'Transferred' + event.status = TransferEvent.Statuses.TRANSFERRED + event.save(update_fields=["status"]) + event.set_create_or_update(processed_by_id) + + @classmethod + def _process_facilities_transfer(cls, event: TransferEvent, user_guid: UUID) -> None: + """ + Process a facility transfer event. Updates the timelines for all associated facilities. + """ + for facility in event.facilities.all(): + # get the current timeline for the facility and operation + current_timeline = FacilityDesignatedOperationTimelineService.get_current_timeline( + event.from_operation.id, facility.id # type: ignore # we are sure that from_operation is not None + ) + + if current_timeline: + FacilityDesignatedOperationTimelineService.set_timeline_status_and_end_date( + user_guid, + current_timeline, + FacilityDesignatedOperationTimeline.Statuses.TRANSFERRED, + event.effective_date, + ) + + # Create a new timeline + FacilityDesignatedOperationTimelineDataAccessService.create_facility_designated_operation_timeline( + user_guid=user_guid, + facility_designated_operation_timeline_data={ + "facility": facility, + "operation": event.to_operation, + "start_date": event.effective_date, + "status": FacilityDesignatedOperationTimeline.Statuses.ACTIVE, + }, + ) + + @classmethod + def _process_operation_transfer(cls, event: TransferEvent, user_guid: UUID) -> None: + """ + Process an operation transfer event. Updates the timelines for the associated operation. + """ + # get the current timeline for the operation and operator + current_timeline = OperationDesignatedOperatorTimelineService.get_current_timeline( + event.from_operator.id, event.operation.id # type: ignore # we are sure that operation is not None + ) + + if current_timeline: + OperationDesignatedOperatorTimelineService.set_timeline_status_and_end_date( + user_guid, + current_timeline, + OperationDesignatedOperatorTimeline.Statuses.TRANSFERRED, + event.effective_date, + ) + + # Create a new timeline + OperationDesignatedOperatorTimelineDataAccessService.create_operation_designated_operator_timeline( + user_guid=user_guid, + operation_designated_operator_timeline_data={ + "operation": event.operation, + "operator": event.to_operator, + "start_date": event.effective_date, + "status": OperationDesignatedOperatorTimeline.Statuses.ACTIVE, + }, + ) + + # update the operation's operator + OperationServiceV2.update_operator(user_guid, event.operation, event.to_operator.id) # type: ignore # we are sure that operation is not None diff --git a/bciers/apps/administration/app/components/facilities/types.ts b/bciers/apps/administration/app/components/facilities/types.ts index 8752490d9d..5abc7d1b2b 100644 --- a/bciers/apps/administration/app/components/facilities/types.ts +++ b/bciers/apps/administration/app/components/facilities/types.ts @@ -5,6 +5,8 @@ export interface FacilityRow { facility__type: string; facility__id: number; status: string; + facility__latitude_of_largest_emissions: string; + facility__longitude_of_largest_emissions: string; } export interface FacilityInitialData { @@ -13,7 +15,7 @@ export interface FacilityInitialData { } export interface FacilitiesSearchParams { - [key: string]: string | number | undefined; + [key: string]: string | number | undefined | boolean; operations_title?: string; bcghg_id?: string; type?: string; @@ -22,4 +24,6 @@ export interface FacilitiesSearchParams { page?: number; sort_field?: string; sort_order?: string; + paginate_results?: boolean; + end_date?: boolean; } diff --git a/bciers/apps/administration/app/components/operations/OperationDataGrid.tsx b/bciers/apps/administration/app/components/operations/OperationDataGrid.tsx index e40513be1d..113ba1ea5e 100644 --- a/bciers/apps/administration/app/components/operations/OperationDataGrid.tsx +++ b/bciers/apps/administration/app/components/operations/OperationDataGrid.tsx @@ -8,7 +8,7 @@ import OperationFacilitiesActionCell from "apps/administration/app/components/op import operationColumns from "../datagrid/models/operations/operationColumns"; import operationGroupColumns from "../datagrid/models/operations/operationGroupColumns"; import { OperationRow } from "./types"; -import fetchOperationsPageData from "./fetchOperationsPageData"; +import fetchOperationsPageData from "@bciers/actions/api/fetchOperationsPageData"; const OperationDataGrid = ({ initialData, diff --git a/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx b/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx index 2c91e8630d..cfda53363c 100644 --- a/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx +++ b/bciers/apps/administration/app/components/operations/OperationDataGridPage.tsx @@ -1,6 +1,6 @@ import OperationDataGrid from "./OperationDataGrid"; import { OperationRow, OperationsSearchParams } from "./types"; -import fetchOperationsPageData from "./fetchOperationsPageData"; +import fetchOperationsPageData from "@bciers/actions/api/fetchOperationsPageData"; import { Suspense } from "react"; import Loading from "@bciers/components/loading/SkeletonGrid"; import { getSessionRole } from "@bciers/utils/src/sessionUtils"; diff --git a/bciers/apps/administration/app/components/operations/types.ts b/bciers/apps/administration/app/components/operations/types.ts index ac8e93cec9..ef52752f0d 100644 --- a/bciers/apps/administration/app/components/operations/types.ts +++ b/bciers/apps/administration/app/components/operations/types.ts @@ -1,7 +1,8 @@ import { OptedInOperationFormData } from "@/registration/app/components/operations/registration/types"; +import { UUID } from "crypto"; export interface OperationRow { - id: number; + id: UUID; bcghg_id: string; name: string; operator: string; @@ -9,7 +10,7 @@ export interface OperationRow { } export interface OperationsSearchParams { - [key: string]: string | number | undefined; + [key: string]: string | number | undefined | boolean; bcghg_id?: string; name?: string; operator?: string; @@ -17,7 +18,7 @@ export interface OperationsSearchParams { sort_field?: string; sort_order?: string; type?: string; - operator_id?: number; + operator_id?: string; } export interface OperationInformationFormData { diff --git a/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx b/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx index 569047c578..14137e5783 100644 --- a/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx +++ b/bciers/apps/administration/app/components/operators/OperatorDataGridPage.tsx @@ -15,7 +15,7 @@ export default async function Operators({ rows: OperatorRow[]; row_count: number; } = await fetchOperatorsPageData(searchParams); - if (!operators || "error" in operators) + if (!operators || "error" in operators || !operators.rows) throw new Error("Failed to retrieve operators"); // Render the DataGrid component diff --git a/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.tsx b/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts similarity index 90% rename from bciers/apps/administration/app/components/operators/fetchOperatorsPageData.tsx rename to bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts index 490ca8dee7..527011d4c5 100644 --- a/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.tsx +++ b/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts @@ -15,8 +15,8 @@ export default async function fetchOperatorsPageData( "", ); return { - rows: pageData.items, - row_count: pageData.count, + rows: pageData?.items, + row_count: pageData?.count, }; } catch (error) { throw error; diff --git a/bciers/apps/administration/app/data/jsonSchema/operationInformation/registrationInformation.ts b/bciers/apps/administration/app/data/jsonSchema/operationInformation/registrationInformation.ts deleted file mode 100644 index f6b4f00d3d..0000000000 --- a/bciers/apps/administration/app/data/jsonSchema/operationInformation/registrationInformation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import SectionFieldTemplate from "@bciers/components/form/fields/SectionFieldTemplate"; -import { RJSFSchema, UiSchema } from "@rjsf/utils"; -import { - getRegistrationPurposes, - getRegulatedProducts, -} from "@bciers/actions/api"; -import { RegistrationPurposes } from "apps/registration/app/components/operations/registration/enums"; - -export const createRegistrationInformationSchema = - async (): Promise => { - // fetch db values that are dropdown options - const regulatedProducts: { id: number; name: string }[] = - await getRegulatedProducts(); - const registrationPurposes = await getRegistrationPurposes(); - - // create the schema with the fetched values - const registrationInformationSchema: RJSFSchema = { - title: "Registration Information", - type: "object", - required: ["registration_purpose", "operation"], - properties: { - registration_purpose: { - type: "string", - title: "The purpose of this registration is to register as a:", - anyOf: registrationPurposes.map((purpose: string) => ({ - const: purpose, - title: purpose, - })), - }, - }, - - dependencies: { - registration_purpose: { - oneOf: registrationPurposes.map((purpose: string) => { - return { - required: ["regulated_products"], - properties: { - registration_purpose: { - type: "string", - const: purpose, - }, - ...(purpose !== - RegistrationPurposes.ELECTRICITY_IMPORT_OPERATION && - purpose !== - RegistrationPurposes.POTENTIAL_REPORTING_OPERATION && { - regulated_products: { - title: "Regulated Product Name(s)", - type: "array", - minItems: 1, - items: { - enum: regulatedProducts.map((product) => product.id), - enumNames: regulatedProducts.map( - (product) => product.name, - ), - }, - }, - }), - }, - }; - }), - }, - }, - }; - return registrationInformationSchema; - }; - -export const registrationInformationUiSchema: UiSchema = { - "ui:order": [ - "registration_purpose", - "regulated_operation", - "new_entrant_operation", - "regulated_products", - ], - "ui:FieldTemplate": SectionFieldTemplate, - regulated_products: { - "ui:widget": "MultiSelectWidget", - "ui:placeholder": "Select Regulated Product", - }, -}; diff --git a/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx b/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx index 3f1ac94279..c76f95ec2b 100644 --- a/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx +++ b/bciers/apps/administration/tests/components/operations/OperationDataGridPage.test.tsx @@ -1,11 +1,8 @@ import { render, screen } from "@testing-library/react"; -import { - fetchOperationsPageData, - useRouter, - useSearchParams, -} from "@bciers/testConfig/mocks"; +import { useRouter, useSearchParams } from "@bciers/testConfig/mocks"; import Operations from "@/administration/app/components/operations/OperationDataGridPage"; import { auth } from "@bciers/testConfig/mocks"; +import { fetchOperationsPageData } from "@/administration/tests/components/operations/mocks"; useRouter.mockReturnValue({ query: {}, @@ -16,13 +13,6 @@ useSearchParams.mockReturnValue({ get: vi.fn(), }); -vi.mock( - "apps/administration/app/components/operations/fetchOperationsPageData", - () => ({ - default: fetchOperationsPageData, - }), -); - const mockResponse = { data: [ { diff --git a/bciers/apps/administration/tests/components/operations/OperationLayouts.test.tsx b/bciers/apps/administration/tests/components/operations/OperationLayouts.test.tsx index a0351ffe2f..7f48fbee85 100644 --- a/bciers/apps/administration/tests/components/operations/OperationLayouts.test.tsx +++ b/bciers/apps/administration/tests/components/operations/OperationLayouts.test.tsx @@ -1,9 +1,5 @@ import { render, screen } from "@testing-library/react"; -import { - fetchOperationsPageData, - useRouter, - useSearchParams, -} from "@bciers/testConfig/mocks"; +import { useRouter, useSearchParams } from "@bciers/testConfig/mocks"; import { ExternalUserOperationDataGridLayout, InternalUserOperationDataGridLayout, @@ -18,13 +14,6 @@ useSearchParams.mockReturnValue({ get: vi.fn(), }); -vi.mock( - "apps/administration/app/components/operations/fetchOperationsPageData", - () => ({ - default: fetchOperationsPageData, - }), -); - describe("OperationLayouts component", () => { beforeEach(async () => { vi.clearAllMocks(); diff --git a/bciers/apps/administration/tests/components/operations/mocks.ts b/bciers/apps/administration/tests/components/operations/mocks.ts index 28a706f09d..f6f1e45ab6 100644 --- a/bciers/apps/administration/tests/components/operations/mocks.ts +++ b/bciers/apps/administration/tests/components/operations/mocks.ts @@ -5,6 +5,7 @@ const getReportingActivities = vi.fn(); const getRegulatedProducts = vi.fn(); const getRegistrationPurposes = vi.fn(); const getBusinessStructures = vi.fn(); +const fetchOperationsPageData = vi.fn(); vi.mock("libs/actions/src/api/getOperation", () => ({ default: getOperation, @@ -34,6 +35,10 @@ vi.mock("libs/actions/src/api/getBusinessStructures", () => ({ default: getBusinessStructures, })); +vi.mock("libs/actions/src/api/fetchOperationsPageData", () => ({ + default: fetchOperationsPageData, +})); + export { getOperation, getOperationWithDocuments, @@ -42,4 +47,5 @@ export { getRegulatedProducts, getRegistrationPurposes, getBusinessStructures, + fetchOperationsPageData, }; diff --git a/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx b/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx index e1ce46058d..b2062e2273 100644 --- a/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx +++ b/bciers/apps/administration/tests/components/operators/OperatorDataGridPage.test.tsx @@ -1,10 +1,7 @@ import { render, screen } from "@testing-library/react"; -import { - fetchOperatorsPageData, - useRouter, - useSearchParams, -} from "@bciers/testConfig/mocks"; +import { useRouter, useSearchParams } from "@bciers/testConfig/mocks"; import Operators from "apps/administration/app/components/operators/OperatorDataGridPage"; +import { fetchOperatorsPageData } from "@/administration/tests/components/operators/mocks"; useRouter.mockReturnValue({ query: {}, @@ -42,20 +39,16 @@ const mockResponse = { row_count: 2, }; -vi.mock( - "apps/administration/app/components/operators/fetchOperatorsPageData", - () => ({ - default: fetchOperatorsPageData, - }), -); - describe("OperatorDataGridPage component", () => { beforeEach(async () => { vi.clearAllMocks(); }); it("throws an error when there's a problem fetching data", async () => { - fetchOperatorsPageData.mockReturnValueOnce(undefined); + fetchOperatorsPageData.mockReturnValueOnce({ + rows: undefined, + row_count: undefined, + }); await expect(async () => { render(await Operators({ searchParams: {} })); }).rejects.toThrow("Failed to retrieve operators"); diff --git a/bciers/apps/administration/tests/components/operators/mocks.ts b/bciers/apps/administration/tests/components/operators/mocks.ts index c127714f30..f974391acb 100644 --- a/bciers/apps/administration/tests/components/operators/mocks.ts +++ b/bciers/apps/administration/tests/components/operators/mocks.ts @@ -3,6 +3,7 @@ const getOperator = vi.fn(); const getOperatorHasAdmin = vi.fn(); const getOperatorConfirmationInfo = vi.fn(); const getOperatorAccessDeclined = vi.fn(); +const fetchOperatorsPageData = vi.fn(); vi.mock( "apps/administration/app/components/operators/getCurrentOperator", @@ -34,10 +35,18 @@ vi.mock( }), ); +vi.mock( + "@/administration/app/components/operators/fetchOperatorsPageData", + () => ({ + default: fetchOperatorsPageData, + }), +); + export { getCurrentOperator, getOperator, getOperatorHasAdmin, getOperatorConfirmationInfo, getOperatorAccessDeclined, + fetchOperatorsPageData, }; diff --git a/bciers/apps/dashboard/tests/routes/dashboard/page.test.tsx b/bciers/apps/dashboard/tests/routes/dashboard/page.test.tsx index cd0102877e..afcbfd5896 100644 --- a/bciers/apps/dashboard/tests/routes/dashboard/page.test.tsx +++ b/bciers/apps/dashboard/tests/routes/dashboard/page.test.tsx @@ -1,7 +1,12 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, vi } from "vitest"; import DashboardPage from "apps/dashboard/app/dashboard/page"; -import { auth } from "@bciers/testConfig/mocks"; +import { getSessionRole } from "@bciers/utils/src/sessionUtils"; + +vi.mock("@bciers/utils/src/sessionUtils", () => ({ + getSessionRole: vi.fn(), +})); + const roles = [ "cas_admin", "cas_analyst", @@ -109,9 +114,7 @@ describe("Registration dashboard page", () => { }); it("renders the dashboard page with the correct tiles", async () => { - auth.mockReturnValueOnce({ - user: { app_role: "cas_admin" }, - }); + (getSessionRole as ReturnType).mockResolvedValue("cas_admin"); render(await DashboardPage()); @@ -126,9 +129,7 @@ describe("Registration dashboard page", () => { }); it("renders the correct links for each tile", async () => { - auth.mockReturnValueOnce({ - user: { app_role: "cas_admin" }, - }); + (getSessionRole as ReturnType).mockResolvedValue("cas_admin"); render(await DashboardPage()); @@ -204,9 +205,9 @@ describe("Registration dashboard page", () => { }); it("renders the Note component for industry_admin role", async () => { - auth.mockReturnValueOnce({ - user: { app_role: "industry_admin" }, - }); + (getSessionRole as ReturnType).mockResolvedValue( + "industry_admin", + ); render(await DashboardPage()); @@ -216,9 +217,9 @@ describe("Registration dashboard page", () => { }); it("renders the Note component for industry_user role", async () => { - auth.mockReturnValueOnce({ - user: { app_role: "industry_user" }, - }); + (getSessionRole as ReturnType).mockResolvedValue( + "industry_user", + ); render(await DashboardPage()); @@ -228,9 +229,7 @@ describe("Registration dashboard page", () => { }); it("does not render the Note component for cas_admin", async () => { - auth.mockReturnValueOnce({ - user: { app_role: "cas_admin" }, - }); + (getSessionRole as ReturnType).mockResolvedValue("cas_admin"); render(await DashboardPage()); @@ -238,9 +237,9 @@ describe("Registration dashboard page", () => { }); it("does not render the Note component for cas_analyst", async () => { - auth.mockReturnValueOnce({ - user: { app_role: "cas_analyst" }, - }); + (getSessionRole as ReturnType).mockResolvedValue( + "cas_analyst", + ); render(await DashboardPage()); @@ -248,9 +247,9 @@ describe("Registration dashboard page", () => { }); it("renders the dashboard-pending-message card for cas_pending role", async () => { - auth.mockReturnValueOnce({ - user: { app_role: "cas_pending" }, - }); + (getSessionRole as ReturnType).mockResolvedValue( + "cas_pending", + ); render(await DashboardPage()); @@ -262,9 +261,7 @@ describe("Registration dashboard page", () => { it.each(roles)( "does not render the dashboard-pending-message card for role: %s", async (role) => { - auth.mockReturnValueOnce({ - user: { app_role: role }, - }); + (getSessionRole as ReturnType).mockResolvedValue(role); render(await DashboardPage()); diff --git a/bciers/apps/registration/app/components/transfers/TransferForm.tsx b/bciers/apps/registration/app/components/transfers/TransferForm.tsx new file mode 100644 index 0000000000..f10f54f491 --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useEffect, useState } from "react"; +import FormBase from "@bciers/components/form/FormBase"; +import { Alert, Button } from "@mui/material"; +import SubmitButton from "@bciers/components/button/SubmitButton"; +import { useRouter } from "next/navigation"; +import { IChangeEvent } from "@rjsf/core"; +import { TransferFormData } from "@/registration/app/components/transfers/types"; +import { OperatorRow } from "@/administration/app/components/operators/types"; +import { + createTransferSchema, + transferUISchema, +} from "@/registration/app/data/jsonSchema/transfer/transfer"; +import fetchFacilitiesPageData from "@/administration/app/components/facilities/fetchFacilitiesPageData"; +import { FacilityRow } from "@/administration/app/components/facilities/types"; +import TaskList from "@bciers/components/form/components/TaskList"; +import { actionHandler } from "@bciers/actions"; +import TransferSuccess from "@/registration/app/components/transfers/TransferSuccess"; +import fetchOperationsPageData from "@bciers/actions/api/fetchOperationsPageData"; +import { OperationRow } from "@/administration/app/components/operations/types"; + +interface TransferFormProps { + formData: TransferFormData; + operators: OperatorRow[]; +} + +export default function TransferForm({ + formData, + operators, +}: Readonly) { + const router = useRouter(); + + const [formState, setFormState] = useState(formData); + const [key, setKey] = useState(Math.random()); // NOSONAR + const [error, setError] = useState(undefined); + const [schema, setSchema] = useState(createTransferSchema(operators)); + const [uiSchema, setUiSchema] = useState(transferUISchema); + const [fromOperatorOperations, setFromOperatorOperations] = useState( + [] as any, + ); + const [toOperatorOperations, setToOperatorOperations] = useState([] as any); + const [isSubmitted, setIsSubmitted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [disabled, setDisabled] = useState(true); + + // Check if the form is valid + const formIsValid = (data: TransferFormData): boolean => { + let requiredFields = [ + "from_operator", + "to_operator", + "transfer_entity", + "effective_date", + ]; + if (data?.transfer_entity === "Operation") + requiredFields = requiredFields.concat(["operation"]); + if (data?.transfer_entity === "Facility") { + requiredFields = requiredFields.concat([ + "from_operation", + "facilities", // array of facilities + "to_operation", + ]); + } + return requiredFields.every((field) => { + if (field === "facilities") return !!data[field]?.length; + return !!data[field]; + }); + }; + + const updateUiSchemaWithError = (): void => + // Handling the error when the same operator is selected for both from and to operators when transferring an operation + setUiSchema({ + ...uiSchema, + operation: { + ...uiSchema.operation, + "ui:options": { + ...uiSchema.operation["ui:options"], + disabled: true, + }, + "ui:help": ( + + Note: Cannot transfer an operation to the same operator + + ), + }, + }); + + const resetUiSchema = () => setUiSchema(transferUISchema); + + const sameOperatorSelectedForOperationEntity = (): boolean => + !!formState?.from_operator && + !!formState?.to_operator && + formState?.from_operator === formState?.to_operator && + formState?.transfer_entity === "Operation"; + + const fetchOperatorOperations = async (operatorId?: string) => { + if (!operatorId) return []; + const response: { + rows: OperationRow[]; + row_count: number; + } = await fetchOperationsPageData({ + paginate_results: false, + operator_id: operatorId, + }); + if (!response || "error" in response || !response.rows) { + setError("Failed to fetch operations data!" as any); + return []; + } + return response.rows; + }; + + const handleOperatorChange = async () => { + // Reset error state + setError(undefined); + // Handle the error when the same operator is selected for both from and to operators when transferring an operation + if (sameOperatorSelectedForOperationEntity()) updateUiSchemaWithError(); + else resetUiSchema(); + + const getFromOperatorOperations = await fetchOperatorOperations( + formState?.from_operator, + ); + const getByOperatorOperations = await fetchOperatorOperations( + formState?.to_operator, + ); + + if (!error) { + setFromOperatorOperations(getFromOperatorOperations); + setToOperatorOperations(getByOperatorOperations); + setSchema( + createTransferSchema( + operators, + getFromOperatorOperations, + getByOperatorOperations, + ), + ); + + // reset selected operation, from_operation, to_operation and facilities when changing the operator + // Not doing this will cause the form to keep the old values when changing the operator + setFormState({ + ...formState, + operation: "", + from_operation: "", + to_operation: "", + facilities: [], + }); + } + }; + + const fetchFacilities = async (operationId?: string) => { + if (!operationId) return []; + const response: { + rows: FacilityRow[]; + row_count: number; + } = await fetchFacilitiesPageData(operationId, { + paginate_results: false, + end_date: true, // this indicates that the end_date is not null, + status: "Active", // only fetch active facilities + }); + if (!response || "error" in response || !response.rows) { + setError("Failed to fetch facilities data!" as any); + return []; + } + return response.rows; + }; + + const handleFromOperationChange = async () => { + // Reset error state + setError(undefined); + const facilitiesByOperation = await fetchFacilities( + formState?.from_operation, + ); + + if (!error) { + // Filter out the current from_operation from toOperatorOperations(we can't transfer facilities to the same operation) + const filteredToOperatorOperations = toOperatorOperations.filter( + (operation: OperationRow) => operation.id !== formState?.from_operation, + ); + + setSchema( + createTransferSchema( + operators, + fromOperatorOperations, + filteredToOperatorOperations, + facilitiesByOperation, + ), + ); + // reset selected facilities when changing the from_operation + setFormState({ + ...formState, + facilities: [], + }); + // force re-render + setKey(Math.random()); // NOSONAR + } + }; + + /* + Using multiple useEffects to listen to changes in the form state and update the schema accordingly + */ + useEffect(() => { + handleOperatorChange(); + }, [ + formState?.from_operator, + formState?.to_operator, + formState?.transfer_entity, + ]); + + useEffect(() => { + handleFromOperationChange(); + }, [formState?.from_operation]); + + const submitHandler = async (e: IChangeEvent) => { + const updatedFormData = e.formData; + // we can't transfer facilities to the same operation + if ( + updatedFormData.transfer_entity === "Facility" && + updatedFormData.from_operation === updatedFormData.to_operation + ) { + setError("Cannot transfer facilities to the same operation!" as any); + setIsSubmitting(false); + return; + } + + setIsSubmitting(true); + // Update the form state with the new data so we don't use stale data on edit + setFormState(updatedFormData); + const endpoint = "registration/transfer-events"; + const response = await actionHandler(endpoint, "POST", "", { + body: JSON.stringify({ + ...updatedFormData, + }), + }); + setIsSubmitting(false); + if (!response || response?.error) { + setDisabled(false); + setError(response.error as any); + return; + } else { + setDisabled(true); + setIsSubmitted(true); + } + }; + + return ( + <> + {isSubmitted ? ( + + ) : ( +
+ +
+ { + const updatedFormData = e.formData; + setFormState(updatedFormData); + setDisabled(!formIsValid(updatedFormData)); + }} + onSubmit={submitHandler} + omitExtraData={true} + > +
+ {error && {error}} +
+
+ + + Transfer Entity + +
+
+
+
+ )} + + ); +} diff --git a/bciers/apps/registration/app/components/transfers/TransferPage.tsx b/bciers/apps/registration/app/components/transfers/TransferPage.tsx new file mode 100644 index 0000000000..9ca19a2b7f --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransferPage.tsx @@ -0,0 +1,22 @@ +import TransferForm from "@/registration/app/components/transfers/TransferForm"; +import fetchOperatorsPageData from "@/administration/app/components/operators/fetchOperatorsPageData"; +import { OperatorRow } from "@/administration/app/components/operators/types"; +import { TransferFormData } from "@/registration/app/components/transfers/types"; + +// 🧩 Main component +export default async function TransferPage() { + const operators: { + rows: OperatorRow[]; + row_count: number; + } = await fetchOperatorsPageData({ paginate_result: "False" }); + + if (!operators || "error" in operators || !operators.rows) + throw new Error("Failed to fetch operators data"); + + return ( + + ); +} diff --git a/bciers/apps/registration/app/components/transfers/TransferSuccess.tsx b/bciers/apps/registration/app/components/transfers/TransferSuccess.tsx new file mode 100644 index 0000000000..b411ed02fe --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransferSuccess.tsx @@ -0,0 +1,78 @@ +import Check from "@bciers/components/icons/Check"; +import { Button } from "@mui/material"; +import { useRouter } from "next/navigation"; +import { OperatorRow } from "@/administration/app/components/operators/types"; +import formatTimestamp from "@bciers/utils/src/formatTimestamp"; + +interface TransferSuccessProps { + fromOperatorId: string; + toOperatorId: string; + operators: OperatorRow[]; + effectiveDate: string; + transferEntity: string; +} + +const TransferSuccess = ({ + fromOperatorId, + toOperatorId, + operators, + effectiveDate, + transferEntity, +}: TransferSuccessProps) => { + const router = useRouter(); + + // Get the operator names from the operator IDs + const { fromOperator, toOperator } = operators.reduce( + (acc, operator) => { + if (operator.id === fromOperatorId) + acc.fromOperator = operator.legal_name; + if (operator.id === toOperatorId) acc.toOperator = operator.legal_name; + return acc; + }, + { fromOperator: null, toOperator: null } as { + fromOperator: string | null; + toOperator: string | null; + }, + ); + + // if the effective date is in the past, the entity has been transferred + const entityTransferred = new Date(effectiveDate) < new Date(); + return ( + <> +

Transfer Entity

+
+
+ {Check} + {entityTransferred ? ( +
+

Transferred

+

+ {transferEntity} has been transferred from {fromOperator} to{" "} + {toOperator}. +

+

+ {transferEntity} is now in the account of {toOperator} +

+
+ ) : ( +

+ {transferEntity} will be transferred from {fromOperator} to{" "} + {toOperator} on {formatTimestamp(effectiveDate)}. +

+ )} +
+
+ +
+
+ + ); +}; + +export default TransferSuccess; diff --git a/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx index 95879d78cb..c6731fbeb6 100644 --- a/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx @@ -5,10 +5,10 @@ import DataGrid from "@bciers/components/datagrid/DataGrid"; import { TransferRow } from "./types"; import HeaderSearchCell from "@bciers/components/datagrid/cells/HeaderSearchCell"; import ActionCellFactory from "@bciers/components/datagrid/cells/ActionCellFactory"; -import fetchTransfersPageData from "./fetchTransferEventsPageData"; import { GridRenderCellParams } from "@mui/x-data-grid"; import transferColumns from "@/registration/app/components/datagrid/models/transfers/transferColumns"; import transferGroupColumns from "@/registration/app/components/datagrid/models/transfers/transferGroupColumns"; +import fetchTransferEventsPageData from "@/registration/app/components/transfers/fetchTransferEventsPageData"; const TransfersActionCell = ActionCellFactory({ generateHref: (params: GridRenderCellParams) => { @@ -45,7 +45,7 @@ const TransfersDataGrid = ({ diff --git a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx index 10db935b36..1feecb969d 100644 --- a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx @@ -3,6 +3,10 @@ import { Suspense } from "react"; import Loading from "@bciers/components/loading/SkeletonGrid"; import { TransferRow, TransfersSearchParams } from "./types"; import fetchTransferEventsPageData from "./fetchTransferEventsPageData"; +import Link from "next/link"; +import { Button } from "@mui/material"; +import { FrontEndRoles } from "@bciers/utils/src/enums"; +import { getSessionRole } from "@bciers/utils/src/sessionUtils"; // 🧩 Main component export default async function TransfersDataGridPage({ @@ -18,10 +22,25 @@ export default async function TransfersDataGridPage({ if (!transfers || "error" in transfers) throw new Error("Failed to retrieve transfers"); + // To get the user's role from the session + const role = await getSessionRole(); + const isCasAnalyst = role === FrontEndRoles.CAS_ANALYST; + // Render the DataGrid component return ( }>
+

Transfers

+ {isCasAnalyst && ( +
+ + {/* textTransform to remove uppercase text */} + + +
+ )}
diff --git a/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.tsx b/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.ts similarity index 100% rename from bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.tsx rename to bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.ts diff --git a/bciers/apps/registration/app/components/transfers/types.ts b/bciers/apps/registration/app/components/transfers/types.ts index 4f91c7784e..2e801a9a3b 100644 --- a/bciers/apps/registration/app/components/transfers/types.ts +++ b/bciers/apps/registration/app/components/transfers/types.ts @@ -13,3 +13,15 @@ export interface TransfersSearchParams { facilities?: string; status?: string; } + +export interface TransferFormData { + [key: string]: string | number | undefined | string[]; + from_operator: string; + to_operator: string; + transfer_entity: string; + operation?: string; + from_operation?: string; + facilities?: string[]; + to_operation?: string; + effective_date: string; +} diff --git a/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts new file mode 100644 index 0000000000..4b3da43708 --- /dev/null +++ b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts @@ -0,0 +1,219 @@ +import { RJSFSchema, UiSchema } from "@rjsf/utils"; +import FieldTemplate from "@bciers/components/form/fields/FieldTemplate"; +import SectionFieldTemplate from "@bciers/components/form/fields/SectionFieldTemplate"; +import { OperatorRow } from "@/administration/app/components/operators/types"; +import { FacilityRow } from "@/administration/app/components/facilities/types"; +import { OperationRow } from "@/administration/app/components/operations/types"; + +export const createTransferSchema = ( + operatorOptions: OperatorRow[], + operationOptions: OperationRow[] = [], // fromOperationOptions and operationOptions are the same + toOperationOptions: OperationRow[] = [], + facilityOptions: FacilityRow[] = [], +) => { + const transferSchema: RJSFSchema = { + type: "object", + title: "Transfer Entity", + required: ["from_operator", "to_operator", "transfer_entity"], + properties: { + // We don't need the sectioning here, but it's just to make the form look like the wireframes + transfer_header: { + //Not an actual field in the db - this is just to make the form look like the wireframes + type: "object", + readOnly: true, + title: "Transfer Entity", + }, + transfer_preface: { + //Not an actual field in the db - this is just to make the form look like the wireframes + type: "object", + readOnly: true, + title: "Select the operators involved", + }, + from_operator: { + type: "string", + title: "Current Operator", + anyOf: operatorOptions?.map((operator: OperatorRow) => ({ + const: operator.id, + title: operator.legal_name, + })), + }, + to_operator: { + type: "string", + title: "Select the new operator", + anyOf: operatorOptions?.map((operator: OperatorRow) => ({ + const: operator.id, + title: operator.legal_name, + })), + }, + transfer_entity: { + type: "string", + title: "What is being transferred?", + oneOf: [ + { + const: "Operation", + title: "Operation", + }, + { + const: "Facility", + title: "Facility", + }, + ], + }, + }, + dependencies: { + transfer_entity: { + allOf: [ + { + if: { + properties: { + transfer_entity: { + const: "Operation", + }, + }, + }, + then: { + properties: { + operation: { + type: "string", + title: "Operation", + }, + effective_date: { + type: "string", + title: "Effective date of transfer", + }, + }, + required: ["operation", "effective_date"], + }, + }, + { + if: { + properties: { + transfer_entity: { + const: "Facility", + }, + }, + }, + then: { + properties: { + from_operation: { + type: "string", + title: + "Select the operation that the facility(s) currently belongs to", + }, + facilities: { + type: "array", + title: "Facilities", + items: { + type: "string", + }, + }, + to_operation: { + type: "string", + title: + "Select the new operation the facility(s) will be allocated to", + }, + effective_date: { + type: "string", + title: "Effective date of transfer", + }, + }, + required: [ + "from_operation", + "facilities", + "to_operation", + "effective_date", + ], + }, + }, + ], + }, + }, + }; + + const transferSchemaCopy = JSON.parse(JSON.stringify(transferSchema)); + + if (operationOptions.length > 0) { + const operationOptionsAnyOf = operationOptions.map( + (operation: OperationRow) => ({ + const: operation.id, + title: operation.name, + }), + ); + // Add the operation options to the operation field + transferSchemaCopy.dependencies.transfer_entity.allOf[0].then.properties.operation.anyOf = + operationOptionsAnyOf; + // Add the operation options to the from_operation field + transferSchemaCopy.dependencies.transfer_entity.allOf[1].then.properties.from_operation.anyOf = + operationOptionsAnyOf; + } + + if (toOperationOptions.length > 0) { + // Add the operation options to the to_operation field + transferSchemaCopy.dependencies.transfer_entity.allOf[1].then.properties.to_operation.anyOf = + toOperationOptions.map((operation: OperationRow) => ({ + const: operation.id, + title: operation.name, + })); + } + + if (facilityOptions.length > 0) { + // Add the facility options to the facilities field + transferSchemaCopy.dependencies.transfer_entity.allOf[1].then.properties.facilities.items.enum = + facilityOptions.map((facility) => facility.facility__id); + transferSchemaCopy.dependencies.transfer_entity.allOf[1].then.properties.facilities.items.enumNames = + facilityOptions.map( + (facility) => + `${facility.facility__name} - (${facility.facility__latitude_of_largest_emissions}, ${facility.facility__longitude_of_largest_emissions})`, + ); + } + + return transferSchemaCopy; +}; + +export const transferUISchema: UiSchema = { + "ui:FieldTemplate": SectionFieldTemplate, + "ui:options": { + label: false, + }, + transfer_header: { + "ui:FieldTemplate": FieldTemplate, + "ui:classNames": "form-heading mb-8", + }, + transfer_preface: { + "ui:classNames": "text-bc-bg-blue text-md", + }, + from_operator: { + "ui:widget": "ComboBox", + "ui:placeholder": "Select the current operator", + }, + to_operator: { + "ui:widget": "ComboBox", + "ui:placeholder": "Select the new operator", + }, + transfer_entity: { + "ui:widget": "RadioWidget", + "ui:classNames": "md:gap-20", + "ui:options": { + inline: true, + }, + }, + operation: { + "ui:widget": "ComboBox", + "ui:placeholder": "Select the operation", + }, + effective_date: { + "ui:widget": "DateWidget", + }, + from_operation: { + "ui:widget": "ComboBox", + "ui:placeholder": "Select the operation", + }, + facilities: { + "ui:widget": "MultiSelectWidget", + "ui:placeholder": "Select facilities", + }, + to_operation: { + "ui:widget": "ComboBox", + "ui:placeholder": "Select the operation", + }, +}; diff --git a/bciers/apps/registration/app/idir/cas_analyst/transfers/transfer-entity/page.tsx b/bciers/apps/registration/app/idir/cas_analyst/transfers/transfer-entity/page.tsx new file mode 100644 index 0000000000..09e86993ed --- /dev/null +++ b/bciers/apps/registration/app/idir/cas_analyst/transfers/transfer-entity/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react"; +import Loading from "@bciers/components/loading/SkeletonForm"; +import TransferPage from "@/registration/app/components/transfers/TransferPage"; + +export default async function Page() { + return ( + }> + + + ); +} diff --git a/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx b/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx new file mode 100644 index 0000000000..a60aa68a51 --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx @@ -0,0 +1,255 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { UUID } from "crypto"; +import { expect } from "vitest"; +import expectButton from "@bciers/testConfig/helpers/expectButton"; +import expectRadio from "@bciers/testConfig/helpers/expectRadio"; +import { actionHandler } from "@bciers/testConfig/mocks"; +import { fetchOperationsPageData } from "@/administration/tests/components/operations/mocks"; +import { fetchFacilitiesPageData } from "@/administration/tests/components/facilities/mocks"; +import TransferForm from "@/registration/app/components/transfers/TransferForm"; + +const mockOperators = [ + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063" as UUID, + legal_name: "Operator 1", + business_structure: "Corporation", + cra_business_number: "123456789", + bc_corporate_registry_number: "123456789", + }, + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea064" as UUID, + legal_name: "Operator 2", + business_structure: "Corporation", + cra_business_number: "123456789", + bc_corporate_registry_number: "123456789", + }, +]; + +const mockOperations = { + rows: [ + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea065" as UUID, + name: "Operation 1", + }, + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea066" as UUID, + name: "Operation 2", + }, + ], + row_count: 2, +}; + +const renderTransferForm = () => { + render(); +}; + +const checkComboBoxExists = (label: RegExp) => { + expect(screen.getByLabelText(label)).toBeVisible(); + expect(screen.getByRole("combobox", { name: label })).toBeVisible(); +}; + +const selectOperator = (label: RegExp, operatorName: string) => { + fireEvent.change(screen.getByLabelText(label), { + target: { value: "Operator" }, + }); + fireEvent.click(screen.getByRole("option", { name: operatorName })); +}; + +const selectEntityAndAssertFields = async (entity: string) => { + fireEvent.click(screen.getByLabelText(entity)); + if (entity === "Operation") { + // expect(screen.findByLabelText(/operation\*/i)).resolves.toBeVisible(); + expect(screen.getByLabelText(/operation\*/i)).toBeVisible(); + // await waitFor(() => { + // }); + expect( + screen.getByLabelText(/effective date of transfer\*/i), + ).toBeVisible(); + } else if (entity === "Facility") { + // check field labels + expect( + screen.getByLabelText( + /select the operation that the facility\(s\) currently belongs to\*/i, + ), + ).toBeVisible(); + expect(screen.getByLabelText(/facilities\*/i)).toBeVisible(); + expect( + screen.getByLabelText( + /select the new operation the facility\(s\) will be allocated to\*/i, + ), + ).toBeVisible(); + expect( + screen.getByLabelText(/effective date of transfer\*/i), + ).toBeVisible(); + + // check field types + expect( + screen.getByRole("combobox", { + name: /select the operation/i, + }), + ).toBeVisible(); + expect( + screen.getByRole("combobox", { + name: /facilities\*/i, + }), + ).toBeVisible(); + expect( + screen.getByLabelText( + /select the new operation the facility\(s\) will be allocated to\*/i, + ), + ).toBeVisible(); + } +}; + +const selectOperation = async (label: RegExp, operationName: string) => { + await waitFor(() => { + fireEvent.change(screen.getByLabelText(label), { + target: { value: "Operation" }, + }); + expect( + screen.getByRole("option", { name: operationName }), + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("option", { name: operationName })); + await waitFor(() => + expect(screen.getByLabelText(label)).toHaveValue(operationName), + ); +}; + +const selectDateOfTransfer = (date: string) => { + const dateOfTransfer = screen.getByLabelText(/effective date of transfer\*/i); + expect(dateOfTransfer).toBeVisible(); + fireEvent.change(dateOfTransfer, { target: { value: date } }); + expect(dateOfTransfer).toHaveValue(date); +}; + +describe("The TransferForm component", () => { + beforeEach(async () => { + vi.clearAllMocks(); + fetchOperationsPageData.mockResolvedValue(mockOperations); + }); + + it("should render the TransferForm component", async () => { + renderTransferForm(); + expect(screen.getByTestId("field-template-label")).toHaveTextContent( + "Transfer Entity", + ); + expect(screen.getByText(/select the operators involved/i)).toBeVisible(); + checkComboBoxExists(/current operator/i); + checkComboBoxExists(/select the new operator/i); + expect(screen.getByText(/what is being transferred?/i)).toBeVisible(); + expectRadio(/operation/i); + expectRadio(/facility/i); + expectButton("Transfer Entity", false); + expectButton("Back"); + }); + + it("should enable the submit button when the form is valid", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + selectOperator(/select the new operator\*/i, "Operator 2"); + await selectEntityAndAssertFields("Operation"); + expect(screen.getByLabelText(/operation\*/i)).toBeVisible(); + expect( + screen.getByLabelText(/effective date of transfer\*/i), + ).toBeVisible(); + await selectOperation(/operation\*/i, "Operation 1"); + selectDateOfTransfer("2022-12-31"); + expectButton("Transfer Entity"); + }); + + it("displays error when same operator is selected", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + selectOperator(/select the new operator\*/i, "Operator 1"); + await selectEntityAndAssertFields("Operation"); + // make sure the operation field is disabled and the error message is displayed + expect( + screen.getByText(/cannot transfer an operation to the same operator/i), + ).toBeVisible(); + expect(screen.getByRole("combobox", { name: /operation/i })).toBeDisabled(); + }); + + it("calls fetchOperationsPageData with new operator id when operator changes", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + expect(fetchOperationsPageData).toHaveBeenCalledTimes(1); + expect(fetchOperationsPageData).toHaveBeenCalledWith({ + operator_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063", + paginate_results: false, + }); + selectOperator(/current operator\*/i, "Operator 2"); + expect(fetchOperationsPageData).toHaveBeenCalledTimes(2); + expect(fetchOperationsPageData).toHaveBeenCalledWith({ + operator_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea064", + paginate_results: false, + }); + }); + + it("displays fields related to Facility entity", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + selectOperator(/select the new operator\*/i, "Operator 2"); + await selectEntityAndAssertFields("Facility"); + }); + + it("fetches facilities when operation changes", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + selectOperator(/select the new operator\*/i, "Operator 2"); + await selectEntityAndAssertFields("Facility"); + await selectOperation( + /select the operation that the facility\(s\) currently belongs to\*/i, + "Operation 1", + ); + expect(fetchFacilitiesPageData).toHaveBeenCalledWith( + "8be4c7aa-6ab3-4aad-9206-0ef914fea065", + { paginate_results: false, end_date: true, status: "Active" }, + ); + }); + + it("submits the form and shows success screen", async () => { + actionHandler.mockResolvedValueOnce({}); + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + selectOperator(/select the new operator\*/i, "Operator 2"); + await selectEntityAndAssertFields("Operation"); + await selectOperation(/operation\*/i, "Operation 1"); + selectDateOfTransfer("2022-12-31"); + // submit the form + const submitButton = screen.getByRole("button", { + name: /transfer entity/i, + }); + expect(submitButton).toBeEnabled(); + fireEvent.click( + screen.getByRole("button", { + name: /transfer entity/i, + }), + ); + expect(actionHandler).toHaveBeenCalledWith( + "registration/transfer-events", + "POST", + "", + { + body: JSON.stringify({ + from_operator: "8be4c7aa-6ab3-4aad-9206-0ef914fea063", + to_operator: "8be4c7aa-6ab3-4aad-9206-0ef914fea064", + transfer_entity: "Operation", + operation: "8be4c7aa-6ab3-4aad-9206-0ef914fea065", + effective_date: "2022-12-31T09:00:00.000Z", + }), + }, + ); + // make sure the success page is displayed + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /transferred/i }), + ).toBeVisible(); + expect( + screen.getByRole("button", { + name: /return to transfer requests table/i, + }), + ).toBeVisible(); + }); + }); +}); diff --git a/bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx b/bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx new file mode 100644 index 0000000000..e913b4091c --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { fetchOperatorsPageData } from "apps/administration/tests/components/operators/mocks"; +import TransferPage from "@/registration/app/components/transfers/TransferPage"; + +describe("Transfer page", () => { + beforeEach(async () => { + vi.clearAllMocks(); + }); + + it("throws an error when there's a problem fetching operators data", async () => { + fetchOperatorsPageData.mockReturnValueOnce({ + rows: undefined, + row_count: undefined, + }); + await expect(async () => { + render(await TransferPage()); + }).rejects.toThrow("Failed to fetch operators data"); + }); + + it("renders the TransferPage", async () => { + fetchOperatorsPageData.mockReturnValueOnce({ + rows: [ + { + id: "1", + }, + ], + row_count: 1, + }); + render(await TransferPage()); + expect(screen.getByTestId("field-template-label")).toHaveTextContent( + "Transfer Entity", + ); + expect( + screen.getByText(/select the operators involved/i), + ).toBeInTheDocument(); + }); +}); diff --git a/bciers/apps/registration/tests/components/transfers/TransferSuccess.test.tsx b/bciers/apps/registration/tests/components/transfers/TransferSuccess.test.tsx new file mode 100644 index 0000000000..3ee56e31d9 --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/TransferSuccess.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { UUID } from "crypto"; +import { expect, vi } from "vitest"; +import TransferSuccess from "@/registration/app/components/transfers/TransferSuccess"; +import { useRouter } from "@bciers/testConfig/mocks"; + +const mockOperators = [ + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063" as UUID, + legal_name: "Operator 1", + business_structure: "Corporation", + cra_business_number: "123456789", + bc_corporate_registry_number: "123456789", + }, + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea064" as UUID, + legal_name: "Operator 2", + business_structure: "Corporation", + cra_business_number: "123456789", + bc_corporate_registry_number: "123456789", + }, +]; +const defaultProps = { + fromOperatorId: "8be4c7aa-6ab3-4aad-9206-0ef914fea063" as UUID, + toOperatorId: "8be4c7aa-6ab3-4aad-9206-0ef914fea064" as UUID, + operators: mockOperators, + effectiveDate: "2022-01-01", + transferEntity: "Operation", +}; + +const mockPush = vi.fn(); +useRouter.mockReturnValue({ + query: {}, + push: mockPush, +}); + +describe("The TransferSuccess component", () => { + beforeEach(async () => { + vi.clearAllMocks(); + }); + + it("renders transferred message when effective date is in the past", () => { + render(); + + expect( + screen.getByRole("heading", { name: /Transfer Entity/i }), + ).toBeVisible(); + expect(screen.getByRole("heading", { name: /transferred/i })).toBeVisible(); + expect( + screen.getByText( + /Operation has been transferred from Operator 1 to Operator 2/i, + ), + ).toBeVisible(); + expect( + screen.getByText(/Operation is now in the account of Operator 2/i), + ).toBeVisible(); + + expect( + screen.getByRole("button", { + name: /Return to Transfer Requests Table/i, + }), + ).toBeVisible(); + }); + + it("renders pending transfer message when effective date is in the future", () => { + const futureDate = "2099-10-10:09:00:00Z"; + const updatedProps = { ...defaultProps, effectiveDate: futureDate }; + + render(); + + expect( + screen.getByText( + /Operation will be transferred from Operator 1 to Operator 2 on Oct 10, 2099/i, + ), + ).toBeVisible(); + }); + + it("navigates to transfers page when the button is clicked", () => { + render(); + + const button = screen.getByRole("button", { + name: /Return to Transfer Requests Table/i, + }); + expect(button).toBeVisible(); + fireEvent.click(button); + expect(mockPush).toHaveBeenCalledWith("/transfers"); + }); +}); diff --git a/bciers/apps/registration/tests/components/transfers/TransfersDataGridPage.test.tsx b/bciers/apps/registration/tests/components/transfers/TransfersDataGridPage.test.tsx index 997c4e6765..f07a6ddb76 100644 --- a/bciers/apps/registration/tests/components/transfers/TransfersDataGridPage.test.tsx +++ b/bciers/apps/registration/tests/components/transfers/TransfersDataGridPage.test.tsx @@ -1,10 +1,12 @@ import { render, screen } from "@testing-library/react"; import { + auth, fetchTransferEventsPageData, useRouter, useSearchParams, } from "@bciers/testConfig/mocks"; import TransfersDataGridPage from "@/registration/app/components/transfers/TransfersDataGridPage"; +import { FrontEndRoles } from "@bciers/utils/src/enums"; useRouter.mockReturnValue({ query: {}, @@ -22,6 +24,10 @@ vi.mock( }), ); +auth.mockReturnValueOnce({ + user: { app_role: FrontEndRoles.CAS_DIRECTOR }, +}); + const mockResponse = { items: [ { @@ -68,7 +74,7 @@ const mockResponse = { row_count: 4, }; -describe("Transfers component", () => { +describe("TransfersDataGrid page", () => { beforeEach(async () => { vi.clearAllMocks(); }); @@ -88,5 +94,15 @@ describe("Transfers component", () => { expect( screen.queryByText(/No transfers data in database./i), ).not.toBeInTheDocument(); + // make sure the `Make a Transfer` button is not visible + expect(screen.queryByText(/Make a Transfer/i)).not.toBeInTheDocument(); + }); + it("only shows the 'Make a Transfer' button to CAS_ANALYST users", async () => { + auth.mockReturnValueOnce({ + user: { app_role: FrontEndRoles.CAS_ANALYST }, + }); + fetchTransferEventsPageData.mockReturnValueOnce(mockResponse); + render(await TransfersDataGridPage({ searchParams: {} })); + expect(screen.getByText(/make a transfer/i)).toBeVisible(); }); }); diff --git a/bciers/apps/administration/app/components/operations/fetchOperationsPageData.tsx b/bciers/libs/actions/src/api/fetchOperationsPageData.ts similarity index 86% rename from bciers/apps/administration/app/components/operations/fetchOperationsPageData.tsx rename to bciers/libs/actions/src/api/fetchOperationsPageData.ts index ea01e2f15e..4095f4293f 100644 --- a/bciers/apps/administration/app/components/operations/fetchOperationsPageData.tsx +++ b/bciers/libs/actions/src/api/fetchOperationsPageData.ts @@ -1,5 +1,5 @@ import buildQueryParams from "@bciers/utils/src/buildQueryParams"; -import { OperationsSearchParams } from "./types"; +import { OperationsSearchParams } from "@/administration/app/components/operations/types"; import { actionHandler } from "@bciers/actions"; // 🛠️ Function to fetch operations diff --git a/bciers/libs/actions/src/api/index.ts b/bciers/libs/actions/src/api/index.ts index 679b1924ed..dd2e656e83 100644 --- a/bciers/libs/actions/src/api/index.ts +++ b/bciers/libs/actions/src/api/index.ts @@ -14,5 +14,5 @@ export { default as getContact } from "./getContact"; export { default as getContacts } from "./getContacts"; export { default as getOperationRepresentatives } from "./getOperationRepresentatives"; export { default as getProductionData } from "./getProductionData"; - export { default as postProductionData } from "./postProductionData"; +export { default as fetchOperationsPageData } from "./fetchOperationsPageData"; diff --git a/bciers/libs/testConfig/src/helpers/expectRadio.ts b/bciers/libs/testConfig/src/helpers/expectRadio.ts new file mode 100644 index 0000000000..4aade7ae96 --- /dev/null +++ b/bciers/libs/testConfig/src/helpers/expectRadio.ts @@ -0,0 +1,10 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { screen } from "@testing-library/react"; +import { expect } from "vitest"; + +export const expectRadio = (label: RegExp) => { + const radio = screen.getByRole("radio", { name: label }); + expect(radio).toBeInTheDocument(); // Verify that the radio button is visible +}; + +export default expectRadio; diff --git a/bciers/libs/testConfig/src/mocks.ts b/bciers/libs/testConfig/src/mocks.ts index a1e1f221ec..a842ec9ee6 100644 --- a/bciers/libs/testConfig/src/mocks.ts +++ b/bciers/libs/testConfig/src/mocks.ts @@ -33,8 +33,6 @@ const useSearchParams = vi.fn(); const notFound = vi.fn(); const useSession = vi.fn(); const auth = vi.fn(); -const fetchOperationsPageData = vi.fn(); -const fetchOperatorsPageData = vi.fn(); const fetchTransferEventsPageData = vi.fn(); const getUserOperatorsPageData = vi.fn(); @@ -48,8 +46,6 @@ export { usePathname, useSearchParams, useSession, - fetchOperationsPageData, - fetchOperatorsPageData, getUserOperatorsPageData, notFound, fetchTransferEventsPageData, diff --git a/bciers/libs/types/src/tiles.ts b/bciers/libs/types/src/tiles.ts index f04f5550b7..8f314a3c82 100644 --- a/bciers/libs/types/src/tiles.ts +++ b/bciers/libs/types/src/tiles.ts @@ -11,6 +11,7 @@ export type LinkItem = { href: string; // URL the link points to title: string; // The title of the link conditions?: Condition[]; // Optional array of conditions to display the link + allowedRoles?: string[]; // Optional array of roles that can see the tile }; // Main ContentItem type, including optional conditions and links diff --git a/bciers/libs/utils/src/buildQueryParams.ts b/bciers/libs/utils/src/buildQueryParams.ts index f436595b24..53389eee14 100644 --- a/bciers/libs/utils/src/buildQueryParams.ts +++ b/bciers/libs/utils/src/buildQueryParams.ts @@ -2,7 +2,7 @@ // eg: buildQueryParams({ page: "1", search: "search" }) => "?page=1&search=search" const buildQueryParams = (params: { - [key: string]: string | number | undefined; + [key: string]: string | number | undefined | boolean; }) => { if (!params) return ""; const query = Object.entries(params) diff --git a/bciers/libs/utils/src/evalDashboardRules.ts b/bciers/libs/utils/src/evalDashboardRules.ts index 50dcf386f2..d5b56a825a 100644 --- a/bciers/libs/utils/src/evalDashboardRules.ts +++ b/bciers/libs/utils/src/evalDashboardRules.ts @@ -1,5 +1,7 @@ import { ContentItem, LinkItem } from "@bciers/types/tiles"; import { actionHandler } from "@bciers/actions"; +import { getSessionRole } from "@bciers/utils/src/sessionUtils"; +import { FrontEndRoles } from "@bciers/utils/src/enums"; /** * Evaluates conditions for dashboard data items and their links. @@ -13,9 +15,11 @@ import { actionHandler } from "@bciers/actions"; if (!Array.isArray(items)) { items = []; } + const userRole: FrontEndRoles = await getSessionRole(); const result = await Promise.all( items.map(async (item) => { + // If item has conditions, evaluate them if (item.conditions && Array.isArray(item.conditions)) { const allConditionsMet = await evaluateAllConditions(item.conditions); if (!allConditionsMet) return null; @@ -30,9 +34,14 @@ import { actionHandler } from "@bciers/actions"; ); return allLinkConditionsMet ? link : null; } + // If item has allowedRoles, evaluate them + if (link.allowedRoles && Array.isArray(link.allowedRoles)) { + if (!link.allowedRoles.includes(userRole)) return null; + } return link; }), ); + item.links = filteredLinks.filter( (link): link is LinkItem => link !== null, ); diff --git a/bciers/libs/utils/src/sessionUtils.ts b/bciers/libs/utils/src/sessionUtils.ts index 480d3dfe6b..2ad261d041 100644 --- a/bciers/libs/utils/src/sessionUtils.ts +++ b/bciers/libs/utils/src/sessionUtils.ts @@ -1,5 +1,6 @@ import { auth } from "@/dashboard/auth"; import { useSession } from "next-auth/react"; +import { FrontEndRoles } from "@bciers/utils/src/enums"; // use getSessionRole in server components const getSessionRole = async () => { @@ -8,16 +9,16 @@ const getSessionRole = async () => { throw new Error("Failed to retrieve session role"); } - return session.user.app_role; + return session.user.app_role as FrontEndRoles; }; -// useSessionFole is for client components +// useSessionRole is for client components const useSessionRole = () => { const session = useSession(); if (!session?.data?.user?.app_role) { throw new Error("Failed to retrieve session role"); } - return session.data.user.app_role; + return session.data.user.app_role as FrontEndRoles; }; export { getSessionRole, useSessionRole }; diff --git a/docs/backend/events/transfer.md b/docs/backend/events/transfer.md new file mode 100644 index 0000000000..34884bc2d5 --- /dev/null +++ b/docs/backend/events/transfer.md @@ -0,0 +1,103 @@ +# Transfer Events + +This document explains how transfer events are processed in the system, including the interaction between models, +statuses, and dates. There are two types of transfer events: **Facility transfers** and **Operation transfers**. + +--- + +## What are Transfer Events? + +Transfer events manage the movement of facilities between operations or the transfer of an entire operation to a new +operator. These events can be initiated for a specific future date or take effect immediately. + +When an internal user creates a transfer, it is stored in the **TransferEvent** model. The event's status and effective +date control when and how the transfer is processed. + +--- + +## How do Transfer Events Work? + +### The TransferEvent Model and Statuses + +- **TO_BE_TRANSFERRED:** Set when the event is created and pending processing. This status indicates the event is not + yet finalized. +- **TRANSFERRED:** Used when the event has been processed and its associated timeline updates are complete. (See notes + below on when this is used.) +- **COMPLETE:** Marks the event as fully finalized in certain contexts. + +### Effective Date Behavior + +- **Past or Today:** If the event's effective date is today or earlier, the system processes it immediately: + - The event's timelines are updated to reflect the transfer. + - The event's status is updated (see the “Processing a Transfer Event” section). +- **Future:** If the effective date is in the future, no immediate action is taken. A **cron job** later processes the + event when the effective date arrives. + +### Interaction with Timeline Models + +Timeline models record the historical and current relationships between facilities, operations, and operators. The +system ensures there is always a single active timeline record with no end date for each entity. + +--- + +## Types of Transfer Events + +### Facility Transfers + +Facility transfers involve: + +- Moving facilities from one operation to another. +- Updating both the originating and receiving operations’ timelines. + +### Operation Transfers + +Operation transfers involve: + +- Moving an operation from one operator to another. +- Updating both the originating and receiving operators’ timelines. + +--- + +## Processing Transfer Events + +### General Workflow + +1. **Validation:** The system ensures no overlapping active transfer events for the same entities. +2. **Timeline Updates:** + - For facilities: The current timeline's status is updated to `TRANSFERRED`, and its end date is set. + - For operations: The same update is applied to the operation's timeline. + - New timelines are created for the receiving operation/operator, with a status of `ACTIVE` and no end date. +3. **Status Update:** The transfer event's status is set to `TRANSFERRED` once the timeline updates are complete. + +### Immediate vs. Scheduled Processing + +- **Immediate:** For past or present effective dates, timelines are updated immediately. +- **Scheduled:** For future effective dates, a background job periodically processes due events. + +### Timeline Integrity + +- At any given time, the timeline record with no `end_date` represents the current, active state of the entity. +- The `ACTIVE` status in timeline records reinforces this, but null `end_date` is the definitive check for activeness. + +--- + +### Detailed Example: Facility Transfers + +1. **Validation:** Ensures no overlapping transfer events for the facility or operation. +2. **Timeline Update:** + - The facility's current timeline (linking it to its current operation) is updated with: + - `end_date`: The transfer's effective date. + - `status`: `TRANSFERRED`. + - A new timeline record is created with: + - `start_date`: The transfer's effective date. + - `status`: `ACTIVE`. + - The new operation association. +3. **TransferEvent Status:** Updated to `TRANSFERRED` once processing is complete. + +### Detailed Example: Operation Transfers + +1. **Validation:** Ensures no overlapping transfer events for the operation or operator. +2. **Timeline Update:** + - The operation's current timeline (linking it to its current operator) is updated similarly to facilities. + - A new timeline record is created for the new operator. +3. **TransferEvent Status:** Updated to `TRANSFERRED`. diff --git a/docs/frontend/developer-guide.md b/docs/frontend/developer-guide.md index dd4dc6fd09..d3f298ae96 100644 --- a/docs/frontend/developer-guide.md +++ b/docs/frontend/developer-guide.md @@ -157,7 +157,7 @@ The .json file then sets the dashboard tile links' href property as per the proj "href": "/administration/select-operator", "conditions": [ { - "api": "registration/v2/user-operators/current/operator", + "api": "registration/user-operators/current/operator", "field": "error", "operator": "exists", "value": true