From c2cb573bfaf2e91504f64c4ec75a22cfea4b1590 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:53:39 -0800 Subject: [PATCH 01/53] chore: add new user role to permissions Signed-off-by: SeSo --- bc_obps/common/permissions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bc_obps/common/permissions.py b/bc_obps/common/permissions.py index fadfb83678..5aaef751ae 100644 --- a/bc_obps/common/permissions.py +++ b/bc_obps/common/permissions.py @@ -123,6 +123,9 @@ def get_permission_configs(permission: str) -> Optional[Union[Dict[str, List[str "v1_authorized_irc_user_and_industry_admin_user_write": { 'authorized_app_roles': ["cas_admin", "cas_analyst", "industry_user"], 'authorized_user_operator_roles': ["admin"], + "cas_analyst": { + 'authorized_app_roles': ["cas_analyst"], + }, }, } cache.set(PERMISSION_CONFIGS_CACHE_KEY, permission_configs, timeout=3600) # 1 hour @@ -155,6 +158,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]: """ From 6e624459a4f73eb7bf10b4c0e1803a39751ce3a5 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:54:40 -0800 Subject: [PATCH 02/53] chore: admin panel tweaks Signed-off-by: SeSo --- bc_obps/registration/admin.py | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) 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' From a8361b5da260e6cebf5ba89040c818b2f9363439 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:55:00 -0800 Subject: [PATCH 03/53] chore: fix type hints Signed-off-by: SeSo --- bciers/libs/utils/src/buildQueryParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From c4431504d76f56fe7426688974e6a8f4dac74e9c Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:55:16 -0800 Subject: [PATCH 04/53] chore: add new api endpoint tag Signed-off-by: SeSo --- bc_obps/registration/constants.py | 1 + 1 file changed, 1 insertion(+) 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"] From 3d59799fb94e421aed219731fee017334facaec8 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:55:57 -0800 Subject: [PATCH 05/53] chore: exclude `all` key from generic errors Signed-off-by: SeSo --- bc_obps/registration/utils.py | 2 ++ 1 file changed, 2 insertions(+) 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 From ad4ced0ba636ae45387d42fedf939179d9a29a2d Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:57:52 -0800 Subject: [PATCH 06/53] chore: operation and designated operator fixture tweaks Signed-off-by: SeSo --- .../registration/fixtures/mock/operation.json | 15 +- ...peration_designated_operator_timeline.json | 155 ++++++++++++++++-- 2 files changed, 155 insertions(+), 15 deletions(-) diff --git a/bc_obps/registration/fixtures/mock/operation.json b/bc_obps/registration/fixtures/mock/operation.json index 4ee1022206..fa7d285c55 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": [], @@ -289,7 +296,7 @@ "opt_in": false, "bcghg_id": "23219990014", "regulated_products": [], - "status": "Closed", + "status": "Registered", "created_at": "2024-1-19T15:27:00.000Z", "activities": [1, 5], "registration_purpose": "OBPS Regulated Operation" @@ -309,7 +316,7 @@ "opt_in": false, "bcghg_id": "23219990015", "regulated_products": [], - "status": "Temporarily Shutdown", + "status": "Registered", "created_at": "2024-1-18T15:27:00.000Z", "activities": [1, 5], "registration_purpose": "Potential Reporting Operation" @@ -329,7 +336,7 @@ "opt_in": false, "bcghg_id": "23219990016", "regulated_products": [], - "status": "Changes Requested", + "status": "Registered", "created_at": "2024-1-17T15:27:00.000Z", "activities": [1, 5], "registration_purpose": "OBPS Regulated Operation" @@ -349,7 +356,7 @@ "opt_in": false, "bcghg_id": "23219990017", "regulated_products": [], - "status": "Declined", + "status": "Registered", "created_at": "2024-1-16T15:27:00.000Z", "activities": [1, 5] } 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..514f60d708 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-000000000026", + "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-000000000026", + "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-000000000026", + "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" } } -] +] \ No newline at end of file From d62b410e58a925db3d6acc89b240bc7ec51e8c2f Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:58:35 -0800 Subject: [PATCH 07/53] chore: new fixture for cas analyst user Signed-off-by: SeSo --- bc_obps/registration/fixtures/mock/user.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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" + } } ] From b4a908d0e6acb201312cb477e221fcac44ef7fa8 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 14:59:09 -0800 Subject: [PATCH 08/53] chore: one more approved industry user admin Signed-off-by: SeSo --- bc_obps/registration/fixtures/mock/user_operator.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bc_obps/registration/fixtures/mock/user_operator.json b/bc_obps/registration/fixtures/mock/user_operator.json index a20801f15d..2154d25fb3 100644 --- a/bc_obps/registration/fixtures/mock/user_operator.json +++ b/bc_obps/registration/fixtures/mock/user_operator.json @@ -340,10 +340,10 @@ "fields": { "user": "00000000-0000-0000-0000-000000000026", "operator": "4a792f0f-cf9d-48c8-9a95-f504c5f84b12", - "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": 29 } } From 6b99ddcb35ebe5d0124d2105fef4f7c3428aa3b0 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:00:35 -0800 Subject: [PATCH 09/53] chore: more data model methods Signed-off-by: SeSo --- bc_obps/registration/models/operation.py | 3 +++ bc_obps/registration/models/operator.py | 3 +++ bc_obps/registration/models/user.py | 9 +++++++++ 3 files changed, 15 insertions(+) diff --git a/bc_obps/registration/models/operation.py b/bc_obps/registration/models/operation.py index 0aa521527b..77789e197b 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/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}" From 6db5193bd922f5b7c6c9dcc68184523f24e7cece Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:01:23 -0800 Subject: [PATCH 10/53] chore: remove unused jsonschema file Signed-off-by: SeSo --- .../registrationInformation.ts | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 bciers/apps/administration/app/data/jsonSchema/operationInformation/registrationInformation.ts 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", - }, -}; From d1b51f73b1297947de006e6cb10655c971d51c39 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:05:05 -0800 Subject: [PATCH 11/53] chore: tweak endpoint, schema and types to fetch non paginated data and added end_date as a new filter Signed-off-by: SeSo --- .../api/v2/_operations/_operation_id/facilities.py | 6 ++++-- .../v1/facility_designated_operation_timeline.py | 8 ++++++++ .../administration/app/components/facilities/types.ts | 6 +++++- .../libs/actions/src/api/getOperationsByOperatorId.ts | 11 +++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 bciers/libs/actions/src/api/getOperationsByOperatorId.ts 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/schema/v1/facility_designated_operation_timeline.py b/bc_obps/registration/schema/v1/facility_designated_operation_timeline.py index 9bf9afeaf8..b188ff11b5 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 alogn 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/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/libs/actions/src/api/getOperationsByOperatorId.ts b/bciers/libs/actions/src/api/getOperationsByOperatorId.ts new file mode 100644 index 0000000000..4910c0a491 --- /dev/null +++ b/bciers/libs/actions/src/api/getOperationsByOperatorId.ts @@ -0,0 +1,11 @@ +import { actionHandler } from "@bciers/actions"; + +async function getOperationsByOperatorId(operatorId: string) { + return actionHandler( + `registration/v2/operators/${operatorId}/operations`, + "GET", + "", + ); +} + +export default getOperationsByOperatorId; From e11ba88df9c304d4ca405769c9a771730b8a2809 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:06:08 -0800 Subject: [PATCH 12/53] chore: fix dashboard tile href Signed-off-by: SeSo --- bc_obps/common/fixtures/dashboard/bciers/internal.json | 2 +- bc_obps/registration/models/operation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bc_obps/common/fixtures/dashboard/bciers/internal.json b/bc_obps/common/fixtures/dashboard/bciers/internal.json index a4e569e7ca..22254e3834 100644 --- a/bc_obps/common/fixtures/dashboard/bciers/internal.json +++ b/bc_obps/common/fixtures/dashboard/bciers/internal.json @@ -48,7 +48,7 @@ }, { "title": "Transfer an operation or facility", - "href": "/registration/transfer", + "href": "/registration/transfers/transfer-entity", "conditions": [ { "allowedRoles": ["cas_analyst"], diff --git a/bc_obps/registration/models/operation.py b/bc_obps/registration/models/operation.py index 77789e197b..88fd4a17f8 100644 --- a/bc_obps/registration/models/operation.py +++ b/bc_obps/registration/models/operation.py @@ -304,6 +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})" From e584d846aa04cc06d8b360e575cc0237a50593a1 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:06:52 -0800 Subject: [PATCH 13/53] chore: renaming - using ts instead of tsx Signed-off-by: SeSo --- .../{fetchOperatorsPageData.tsx => fetchOperatorsPageData.ts} | 0 ...hTransferEventsPageData.tsx => fetchTransferEventsPageData.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename bciers/apps/administration/app/components/operators/{fetchOperatorsPageData.tsx => fetchOperatorsPageData.ts} (100%) rename bciers/apps/registration/app/components/transfers/{fetchTransferEventsPageData.tsx => fetchTransferEventsPageData.ts} (100%) diff --git a/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.tsx b/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts similarity index 100% rename from bciers/apps/administration/app/components/operators/fetchOperatorsPageData.tsx rename to bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts 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 From 4599f9082c76418d9f96a6baaf937d500fdd52d0 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:07:26 -0800 Subject: [PATCH 14/53] chore: update transfer event fixtures Signed-off-by: SeSo --- .../fixtures/mock/transfer_event.json | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) 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" } } ] From 58ce5fa4ead085732d88a527494b165f4cb3c9f9 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:07:54 -0800 Subject: [PATCH 15/53] chore: update transfer event data model Signed-off-by: SeSo --- ...ricaltransferevent_description_and_more.py | 138 ++++++++++++++++++ .../models/event/transfer_event.py | 42 ++++-- 2 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 bc_obps/registration/migrations/0057_remove_historicaltransferevent_description_and_more.py diff --git a/bc_obps/registration/migrations/0057_remove_historicaltransferevent_description_and_more.py b/bc_obps/registration/migrations/0057_remove_historicaltransferevent_description_and_more.py new file mode 100644 index 0000000000..aa9ae81e52 --- /dev/null +++ b/bc_obps/registration/migrations/0057_remove_historicaltransferevent_description_and_more.py @@ -0,0 +1,138 @@ +# Generated by Django 5.0.9 on 2024-12-05 22:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0056_remove_historicaltransferevent_future_designated_operator_and_more'), + ] + + 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='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='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', From 062942a4eac4f92db6ad5f66d1b567bdf4510213 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:11:04 -0800 Subject: [PATCH 16/53] chore: add status to operation designated operator timeline table Signed-off-by: SeSo --- ...ignatedoperatortimeline_status_and_more.py | 43 +++++++++++++++++++ .../operation_designated_operator_timeline.py | 12 ++++++ 2 files changed, 55 insertions(+) create mode 100644 bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py diff --git a/bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py b/bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py new file mode 100644 index 0000000000..4b4cf0fabf --- /dev/null +++ b/bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.9 on 2024-12-06 17:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0057_remove_historicaltransferevent_description_and_more'), + ] + + operations = [ + 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='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, + ), + ), + ] 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), From 2816b5db84ceb6a3c501d2b74902a0ede4d21e8a Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:14:19 -0800 Subject: [PATCH 17/53] feat: add transfer event feature Signed-off-by: SeSo --- .../app/components/transfers/TransferForm.tsx | 292 ++++++++++++++++++ .../app/components/transfers/TransferPage.tsx | 16 + .../components/transfers/TransferSuccess.tsx | 78 +++++ .../transfers/TransfersDataGridPage.tsx | 20 ++ .../app/components/transfers/types.ts | 17 + .../app/data/jsonSchema/transfer/transfer.ts | 219 +++++++++++++ .../transfers/transfer-entity/page.tsx | 11 + 7 files changed, 653 insertions(+) create mode 100644 bciers/apps/registration/app/components/transfers/TransferForm.tsx create mode 100644 bciers/apps/registration/app/components/transfers/TransferPage.tsx create mode 100644 bciers/apps/registration/app/components/transfers/TransferSuccess.tsx create mode 100644 bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts create mode 100644 bciers/apps/registration/app/idir/cas_analyst/transfers/transfer-entity/page.tsx 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..508a355e7c --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -0,0 +1,292 @@ +"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 { + Operation, + 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 { getOperationsByOperatorId } from "@bciers/actions/api"; +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"; + +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()); + const [error, setError] = useState(undefined); + const [schema, setSchema] = useState(createTransferSchema(operators)); + const [uiSchema, setUiSchema] = useState(transferUISchema); + const [fromOperatorOperations, setFromOperatorOperations] = useState([]); + const [toOperatorOperations, setToOperatorOperations] = useState([]); + 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 = await getOperationsByOperatorId(operatorId); + if ("error" in response) { + setError("Failed to fetch operations data!" as any); + return []; + } + return response; + }; + + 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 as Operation[], + getByOperatorOperations as Operation[], + ), + ); + + // 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 ("error" in response) { + 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: Operation) => operation.id !== formState?.from_operation, + ); + + setSchema( + createTransferSchema( + operators, + fromOperatorOperations, + filteredToOperatorOperations, + facilitiesByOperation, + ), + ); + // reset selected facilities when changing the from_operation + setFormState({ + ...formState, + facilities: [], + }); + setKey(Math.random()); // force re-render + } + }; + + /* + 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..836b4894e3 --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransferPage.tsx @@ -0,0 +1,16 @@ +import TransferForm from "@/registration/app/components/transfers/TransferForm"; +import fetchOperatorsPageData from "@/administration/app/components/operators/fetchOperatorsPageData"; +import { OperatorRow } from "@/administration/app/components/operators/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/TransfersDataGridPage.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx index 10db935b36..88215bc022 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 { auth } from "@/dashboard/auth"; +import { FrontEndRoles } from "@bciers/utils/src/enums"; // 🧩 Main component export default async function TransfersDataGridPage({ @@ -18,10 +22,26 @@ 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 session = await auth(); + const role = session?.user?.app_role ?? ""; + 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/types.ts b/bciers/apps/registration/app/components/transfers/types.ts index 4f91c7784e..6bad326a84 100644 --- a/bciers/apps/registration/app/components/transfers/types.ts +++ b/bciers/apps/registration/app/components/transfers/types.ts @@ -13,3 +13,20 @@ 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; +} + +export interface Operation { + id: string; + name: 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..4ce135793d --- /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 { Operation } from "@/registration/app/components/transfers/types"; +import { FacilityRow } from "@/administration/app/components/facilities/types"; + +export const createTransferSchema = ( + operatorOptions: OperatorRow[], + operationOptions: Operation[] = [], // fromOperationOptions and operationOptions are the same + toOperationOptions: Operation[] = [], + 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: "Select your 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: Operation) => ({ + 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: Operation) => ({ + 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 ( + }> + + + ); +} From afd622180bc47bc18ea00f4a2126875f62a4ae7e Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:16:43 -0800 Subject: [PATCH 18/53] chore: add transfer event endpoint and schema Signed-off-by: SeSo --- bc_obps/registration/api/v2/__init__.py | 7 +- .../api/v2/_operators/__init__.py | 2 + .../v2/_operators/_operator_id/operations.py | 29 +++ .../registration/api/v2/transfer_events.py | 19 +- .../v2/operation_designated_operator.py | 7 + .../registration/schema/v2/transfer_event.py | 39 ++++ ...on_designated_operator_timeline_service.py | 18 ++ .../transfer_event_service.py | 13 ++ ...y_designated_operation_timeline_service.py | 21 ++ ...on_designated_operator_timeline_service.py | 37 +++ bc_obps/service/transfer_event_service.py | 213 +++++++++++++++++- 11 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 bc_obps/registration/api/v2/_operators/__init__.py create mode 100644 bc_obps/registration/api/v2/_operators/_operator_id/operations.py create mode 100644 bc_obps/registration/schema/v2/operation_designated_operator.py create mode 100644 bc_obps/registration/schema/v2/transfer_event.py create mode 100644 bc_obps/service/data_access_service/operation_designated_operator_timeline_service.py create mode 100644 bc_obps/service/data_access_service/transfer_event_service.py create mode 100644 bc_obps/service/operation_designated_operator_timeline_service.py diff --git a/bc_obps/registration/api/v2/__init__.py b/bc_obps/registration/api/v2/__init__.py index 9f38b3123b..4e2a069542 100644 --- a/bc_obps/registration/api/v2/__init__.py +++ b/bc_obps/registration/api/v2/__init__.py @@ -21,11 +21,11 @@ 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 @@ -38,9 +38,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/_operators/__init__.py b/bc_obps/registration/api/v2/_operators/__init__.py new file mode 100644 index 0000000000..033726f001 --- /dev/null +++ b/bc_obps/registration/api/v2/_operators/__init__.py @@ -0,0 +1,2 @@ +# ruff: noqa: F401 +from ._operator_id.operations import list_operations_by_operator_id diff --git a/bc_obps/registration/api/v2/_operators/_operator_id/operations.py b/bc_obps/registration/api/v2/_operators/_operator_id/operations.py new file mode 100644 index 0000000000..70325e42cc --- /dev/null +++ b/bc_obps/registration/api/v2/_operators/_operator_id/operations.py @@ -0,0 +1,29 @@ +from typing import Literal, Tuple, List +from uuid import UUID +from django.db.models import QuerySet +from common.permissions import authorize +from django.http import HttpRequest +from registration.constants import OPERATOR_TAGS_V2 +from registration.schema.v2.operation_designated_operator import OperationDesignatedOperatorTimelineOut +from service.error_service.custom_codes_4xx import custom_codes_4xx +from registration.decorators import handle_http_errors +from registration.api.router import router +from registration.models import OperationDesignatedOperatorTimeline +from registration.schema.generic import Message +from service.operation_designated_operator_timeline_service import OperationDesignatedOperatorTimelineService + + +##### GET ##### +@router.get( + "/v2/operators/{uuid:operator_id}/operations", + response={200: List[OperationDesignatedOperatorTimelineOut], custom_codes_4xx: Message}, + tags=OPERATOR_TAGS_V2, + description="""Retrieves a list of operations associated with the specified operator. + This endpoint is not paginated because it is being used for a dropdown list in the UI.""", + auth=authorize("cas_analyst"), +) +@handle_http_errors() +def list_operations_by_operator_id( + request: HttpRequest, operator_id: UUID +) -> Tuple[Literal[200], QuerySet[OperationDesignatedOperatorTimeline]]: + return 200, OperationDesignatedOperatorTimelineService.list_timeline_by_operator_id(operator_id) diff --git a/bc_obps/registration/api/v2/transfer_events.py b/bc_obps/registration/api/v2/transfer_events.py index 41789e1648..615a5208a3 100644 --- a/bc_obps/registration/api/v2/transfer_events.py +++ b/bc_obps/registration/api/v2/transfer_events.py @@ -1,4 +1,4 @@ -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 service.transfer_event_service import TransferEventService @@ -13,7 +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( "/transfer-events", @@ -34,3 +35,17 @@ 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/schema/v2/operation_designated_operator.py b/bc_obps/registration/schema/v2/operation_designated_operator.py new file mode 100644 index 0000000000..b76277b130 --- /dev/null +++ b/bc_obps/registration/schema/v2/operation_designated_operator.py @@ -0,0 +1,7 @@ +from uuid import UUID +from ninja import Field, Schema + + +class OperationDesignatedOperatorTimelineOut(Schema): + id: UUID = Field(..., alias="operation.id") + name: str = Field(..., alias="operation.name") diff --git a/bc_obps/registration/schema/v2/transfer_event.py b/bc_obps/registration/schema/v2/transfer_event.py new file mode 100644 index 0000000000..ab0c115a23 --- /dev/null +++ b/bc_obps/registration/schema/v2/transfer_event.py @@ -0,0 +1,39 @@ +from typing import Optional, List, Literal +from uuid import UUID + +from ninja import ModelSchema +from registration.models import TransferEvent + + +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/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..77d5afbbf6 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,23 @@ 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_by_facility_id(cls, facility_id: UUID) -> Optional[FacilityDesignatedOperationTimeline]: + return FacilityDesignatedOperationTimeline.objects.filter( + 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..17bdd71c85 --- /dev/null +++ b/bc_obps/service/operation_designated_operator_timeline_service.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from django.db.models import QuerySet +from registration.models import OperationDesignatedOperatorTimeline + + +class OperationDesignatedOperatorTimelineService: + @classmethod + def list_timeline_by_operator_id( + cls, + operator_id: UUID, + ) -> QuerySet[OperationDesignatedOperatorTimeline]: + """ """ + return OperationDesignatedOperatorTimeline.objects.filter( + operator_id=operator_id, end_date__isnull=True + ).distinct() + + @classmethod + def get_current_timeline_by_operation_id(cls, operation_id: UUID) -> Optional[OperationDesignatedOperatorTimeline]: + return OperationDesignatedOperatorTimeline.objects.filter( + 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/transfer_event_service.py b/bc_obps/service/transfer_event_service.py index 70680dc531..ac69636833 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 +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 + +logger = logging.getLogger(__name__) class TransferEventService: @@ -30,3 +49,195 @@ 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": + prepared_payload.update( + { + "operation_id": payload.operation, + } + ) + transfer_event = TransferEventDataAccessService.create_transfer_event(user_guid, prepared_payload) + + elif payload.transfer_entity == "Facility": + 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: + logger.info("No due transfer events found.") + return + + logger.info(f"Found {len(transfer_events)} transfer events to process.") + 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.id # 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(): + current_timeline = FacilityDesignatedOperationTimelineService.get_current_timeline_by_facility_id( + facility.id + ) + + 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. + """ + current_timeline = OperationDesignatedOperatorTimelineService.get_current_timeline_by_operation_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, + }, + ) From 49a8a56a09cf144d044ab8d2ee0b697c3130e94c Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:18:03 -0800 Subject: [PATCH 19/53] chore: handle server errors when rows is undefined Signed-off-by: SeSo --- .../app/components/operators/OperatorDataGridPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 47c7222adaa344f680cecd2fd5781fc0095e37f0 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:21:09 -0800 Subject: [PATCH 20/53] chore: resolve a new uuid when we have similar transfer events - caused by the same facility uuid for different transfers Signed-off-by: SeSo --- bc_obps/registration/schema/v1/transfer_event.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/bc_obps/registration/schema/v1/transfer_event.py b/bc_obps/registration/schema/v1/transfer_event.py index b11801472e..4d07c8ddea 100644 --- a/bc_obps/registration/schema/v1/transfer_event.py +++ b/bc_obps/registration/schema/v1/transfer_event.py @@ -1,11 +1,10 @@ +import uuid from typing import Optional from uuid import UUID - from registration.models.event.transfer_event import TransferEvent from ninja import ModelSchema, Field, FilterSchema 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 From 0e9fb443daf58eff6e9858aa18f57e465b166ab6 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:21:40 -0800 Subject: [PATCH 21/53] chore: add the new fetch api to the index file Signed-off-by: SeSo --- bciers/libs/actions/src/api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bciers/libs/actions/src/api/index.ts b/bciers/libs/actions/src/api/index.ts index 679b1924ed..1f7f6c00a1 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 getOperationsByOperatorId } from "./getOperationsByOperatorId"; From e9474f49bdb40778dfb0bbabb2be949b09561e92 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:22:53 -0800 Subject: [PATCH 22/53] chore: fix import Signed-off-by: SeSo --- .../app/components/transfers/TransfersDataGrid.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 = ({ From 5196f687e2fa828eee27040f677cc500cdd5c9c5 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 6 Dec 2024 15:52:54 -0800 Subject: [PATCH 23/53] chore: post-rebase migration fix --- ...ignatedoperatortimeline_status_and_more.py | 43 ------------------- ...icaltransferevent_description_and_more.py} | 34 ++++++++++++++- 2 files changed, 32 insertions(+), 45 deletions(-) delete mode 100644 bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py rename bc_obps/registration/migrations/{0057_remove_historicaltransferevent_description_and_more.py => 0062_remove_historicaltransferevent_description_and_more.py} (75%) diff --git a/bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py b/bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py deleted file mode 100644 index 4b4cf0fabf..0000000000 --- a/bc_obps/registration/migrations/0058_historicaloperationdesignatedoperatortimeline_status_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.0.9 on 2024-12-06 17:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registration', '0057_remove_historicaltransferevent_description_and_more'), - ] - - operations = [ - 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='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, - ), - ), - ] diff --git a/bc_obps/registration/migrations/0057_remove_historicaltransferevent_description_and_more.py b/bc_obps/registration/migrations/0062_remove_historicaltransferevent_description_and_more.py similarity index 75% rename from bc_obps/registration/migrations/0057_remove_historicaltransferevent_description_and_more.py rename to bc_obps/registration/migrations/0062_remove_historicaltransferevent_description_and_more.py index aa9ae81e52..aa60788ddb 100644 --- a/bc_obps/registration/migrations/0057_remove_historicaltransferevent_description_and_more.py +++ b/bc_obps/registration/migrations/0062_remove_historicaltransferevent_description_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.9 on 2024-12-05 22:27 +# Generated by Django 5.0.9 on 2024-12-06 23:50 import django.db.models.deletion from django.db import migrations, models @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('registration', '0056_remove_historicaltransferevent_future_designated_operator_and_more'), + ('registration', '0061_remove_naics_code'), ] operations = [ @@ -35,6 +35,21 @@ class Migration(migrations.Migration): 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', @@ -87,6 +102,21 @@ class Migration(migrations.Migration): 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', From dc7d1e0e47ef03cee88da0c91d7a37766bc5f44a Mon Sep 17 00:00:00 2001 From: SeSo Date: Sat, 7 Dec 2024 21:52:55 -0800 Subject: [PATCH 24/53] chore: fix tests after latest transfer event modifications Signed-off-by: SeSo --- .../models/event/event_base_model_mixin.py | 10 ++--- .../tests/models/event/test_transfer.py | 40 +++++++++++++------ .../registration/tests/models/test_contact.py | 1 - .../tests/models/test_operation.py | 2 + .../tests/models/test_operator.py | 3 +- .../registration/tests/utils/baker_recipes.py | 15 +------ 6 files changed, 38 insertions(+), 33 deletions(-) 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..c9adcd0860 100644 --- a/bc_obps/registration/tests/utils/baker_recipes.py +++ b/bc_obps/registration/tests/utils/baker_recipes.py @@ -126,21 +126,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')) From f0b06bb643d44511ca5ca03a3f6eeedbbe72a786 Mon Sep 17 00:00:00 2001 From: SeSo Date: Sat, 7 Dec 2024 21:54:02 -0800 Subject: [PATCH 25/53] chore: remove v2 ref after endpoint refactor Signed-off-by: SeSo --- .../registration/api/v2/_operators/_operator_id/operations.py | 2 +- bc_obps/registration/api/v2/transfer_events.py | 4 +++- bc_obps/service/tests/test_transfer_event_service.py | 2 +- bciers/libs/actions/src/api/getOperationsByOperatorId.ts | 2 +- docs/frontend/developer-guide.md | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bc_obps/registration/api/v2/_operators/_operator_id/operations.py b/bc_obps/registration/api/v2/_operators/_operator_id/operations.py index 70325e42cc..d05f686e93 100644 --- a/bc_obps/registration/api/v2/_operators/_operator_id/operations.py +++ b/bc_obps/registration/api/v2/_operators/_operator_id/operations.py @@ -15,7 +15,7 @@ ##### GET ##### @router.get( - "/v2/operators/{uuid:operator_id}/operations", + "/operators/{uuid:operator_id}/operations", response={200: List[OperationDesignatedOperatorTimelineOut], custom_codes_4xx: Message}, tags=OPERATOR_TAGS_V2, description="""Retrieves a list of operations associated with the specified operator. diff --git a/bc_obps/registration/api/v2/transfer_events.py b/bc_obps/registration/api/v2/transfer_events.py index 615a5208a3..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, 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 @@ -16,6 +16,7 @@ from common.api.utils import get_current_user_guid from registration.schema.v2.transfer_event import TransferEventCreateIn, TransferEventOut + @router.get( "/transfer-events", response={200: List[TransferEventListOut], custom_codes_4xx: Message}, @@ -36,6 +37,7 @@ def list_transfer_events( # 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}, diff --git a/bc_obps/service/tests/test_transfer_event_service.py b/bc_obps/service/tests/test_transfer_event_service.py index 607456f5c6..26ef1922ca 100644 --- a/bc_obps/service/tests/test_transfer_event_service.py +++ b/bc_obps/service/tests/test_transfer_event_service.py @@ -1,4 +1,4 @@ -from registration.schema.v1.transfer_event import TransferEventFilterSchema +from registration.schema.v2.transfer_event import TransferEventFilterSchema from service.transfer_event_service import TransferEventService import pytest from model_bakery import baker diff --git a/bciers/libs/actions/src/api/getOperationsByOperatorId.ts b/bciers/libs/actions/src/api/getOperationsByOperatorId.ts index 4910c0a491..ccd11a75b8 100644 --- a/bciers/libs/actions/src/api/getOperationsByOperatorId.ts +++ b/bciers/libs/actions/src/api/getOperationsByOperatorId.ts @@ -2,7 +2,7 @@ import { actionHandler } from "@bciers/actions"; async function getOperationsByOperatorId(operatorId: string) { return actionHandler( - `registration/v2/operators/${operatorId}/operations`, + `registration/operators/${operatorId}/operations`, "GET", "", ); 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 From 43b2aae79b141cff0d10f4e9bcd616d5e897fa8a Mon Sep 17 00:00:00 2001 From: SeSo Date: Sat, 7 Dec 2024 21:56:04 -0800 Subject: [PATCH 26/53] chore: transfer event is a v2 feature Signed-off-by: SeSo --- .../registration/schema/v1/transfer_event.py | 41 ------------------- .../registration/schema/v2/transfer_event.py | 40 +++++++++++++++++- 2 files changed, 38 insertions(+), 43 deletions(-) delete mode 100644 bc_obps/registration/schema/v1/transfer_event.py diff --git a/bc_obps/registration/schema/v1/transfer_event.py b/bc_obps/registration/schema/v1/transfer_event.py deleted file mode 100644 index 4d07c8ddea..0000000000 --- a/bc_obps/registration/schema/v1/transfer_event.py +++ /dev/null @@ -1,41 +0,0 @@ -import uuid -from typing import Optional -from uuid import UUID -from registration.models.event.transfer_event import TransferEvent -from ninja import ModelSchema, Field, FilterSchema -from django.db.models import Q -import re - - -class TransferEventListOut(ModelSchema): - operation__id: Optional[UUID] = None - 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 = Field(default_factory=uuid.uuid4) - - class Meta: - model = TransferEvent - fields = ['effective_date', 'status', 'created_at'] - - -class TransferEventFilterSchema(FilterSchema): - # NOTE: we could simply use the `q` parameter to filter by related fields but, - # due to this issue: https://github.com/vitalik/django-ninja/issues/1037 mypy is unhappy so I'm using the `json_schema_extra` parameter - # If we want to achieve more by using the `q` parameter, we should use it and ignore the mypy error - effective_date: Optional[str] = Field(None, json_schema_extra={'q': 'effective_date__icontains'}) - operation__name: Optional[str] = None - facilities__name: Optional[str] = Field(None, json_schema_extra={'q': 'facilities__name__icontains'}) - status: Optional[str] = Field(None, json_schema_extra={'q': 'status__icontains'}) - - @staticmethod - def filtering_including_not_applicable(field: str, value: str) -> Q: - if value and re.search(value, 'n/a', re.IGNORECASE): - return Q(**{f"{field}__icontains": value}) | Q(**{f"{field}__isnull": True}) - return Q(**{f"{field}__icontains": value}) if value else Q() - - def filter_operation__name(self, value: str) -> Q: - return self.filtering_including_not_applicable('operation__name', value) - - def filter_facilities__name(self, value: str) -> Q: - return self.filtering_including_not_applicable('facilities__name', value) diff --git a/bc_obps/registration/schema/v2/transfer_event.py b/bc_obps/registration/schema/v2/transfer_event.py index ab0c115a23..83873feeec 100644 --- a/bc_obps/registration/schema/v2/transfer_event.py +++ b/bc_obps/registration/schema/v2/transfer_event.py @@ -1,8 +1,44 @@ +import uuid from typing import Optional, List, Literal from uuid import UUID - -from ninja import ModelSchema +from ninja import ModelSchema, Field, FilterSchema from registration.models import TransferEvent +from django.db.models import Q +import re + + +class TransferEventListOut(ModelSchema): + operation__id: Optional[UUID] = None + 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 = Field(default_factory=uuid.uuid4) + + class Meta: + model = TransferEvent + fields = ['effective_date', 'status', 'created_at'] + + +class TransferEventFilterSchema(FilterSchema): + # NOTE: we could simply use the `q` parameter to filter by related fields but, + # due to this issue: https://github.com/vitalik/django-ninja/issues/1037 mypy is unhappy so I'm using the `json_schema_extra` parameter + # If we want to achieve more by using the `q` parameter, we should use it and ignore the mypy error + effective_date: Optional[str] = Field(None, json_schema_extra={'q': 'effective_date__icontains'}) + operation__name: Optional[str] = None + facilities__name: Optional[str] = Field(None, json_schema_extra={'q': 'facilities__name__icontains'}) + status: Optional[str] = Field(None, json_schema_extra={'q': 'status__icontains'}) + + @staticmethod + def filtering_including_not_applicable(field: str, value: str) -> Q: + if value and re.search(value, 'n/a', re.IGNORECASE): + return Q(**{f"{field}__icontains": value}) | Q(**{f"{field}__isnull": True}) + return Q(**{f"{field}__icontains": value}) if value else Q() + + def filter_operation__name(self, value: str) -> Q: + return self.filtering_including_not_applicable('operation__name', value) + + def filter_facilities__name(self, value: str) -> Q: + return self.filtering_including_not_applicable('facilities__name', value) class TransferEventCreateIn(ModelSchema): From 5e854adf8d8a0303fa9a2943c7fc73268473f5d4 Mon Sep 17 00:00:00 2001 From: SeSo Date: Sat, 7 Dec 2024 21:57:05 -0800 Subject: [PATCH 27/53] chore: post-rebase fix Signed-off-by: SeSo --- bc_obps/common/permissions.py | 6 ++++-- bc_obps/registration/api/v2/__init__.py | 1 - .../mock/operation_designated_operator_timeline.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bc_obps/common/permissions.py b/bc_obps/common/permissions.py index 5aaef751ae..d205865855 100644 --- a/bc_obps/common/permissions.py +++ b/bc_obps/common/permissions.py @@ -123,9 +123,11 @@ def get_permission_configs(permission: str) -> Optional[Union[Dict[str, List[str "v1_authorized_irc_user_and_industry_admin_user_write": { 'authorized_app_roles': ["cas_admin", "cas_analyst", "industry_user"], 'authorized_user_operator_roles': ["admin"], - "cas_analyst": { - 'authorized_app_roles': ["cas_analyst"], }, + "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 diff --git a/bc_obps/registration/api/v2/__init__.py b/bc_obps/registration/api/v2/__init__.py index 4e2a069542..ea67310b73 100644 --- a/bc_obps/registration/api/v2/__init__.py +++ b/bc_obps/registration/api/v2/__init__.py @@ -28,7 +28,6 @@ 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, 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 514f60d708..debb77b65b 100644 --- a/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json +++ b/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json @@ -153,4 +153,4 @@ "status": "Active" } } -] \ No newline at end of file +] From 8ef2f995b8fb4dab66caa9654871e64623936568 Mon Sep 17 00:00:00 2001 From: SeSo Date: Sat, 7 Dec 2024 21:57:31 -0800 Subject: [PATCH 28/53] chore: add cas analyst to endpoint tests Signed-off-by: SeSo --- .../common/tests/endpoints/auth/test_endpoint_permissions.py | 4 ++++ 1 file changed, 4 insertions(+) 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..ffd99e27c6 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,10 @@ class TestEndpointPermissions(TestCase): "kwargs": {"user_operator_id": mock_uuid}, }, ], + "cas_analyst": [ + {"method": "get", "endpoint_name": "list_operations_by_operator_id", "kwargs": {"operator_id": mock_uuid}}, + {"method": "post", "endpoint_name": "create_transfer_event"}, + ], } @classmethod From aba9f4fa5a4fe6035f5dcbf4de1a0d816e47a935 Mon Sep 17 00:00:00 2001 From: SeSo Date: Sat, 7 Dec 2024 21:58:05 -0800 Subject: [PATCH 29/53] chore: add extra check to prevent creating wrong transfer event Signed-off-by: SeSo --- bc_obps/service/transfer_event_service.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bc_obps/service/transfer_event_service.py b/bc_obps/service/transfer_event_service.py index ac69636833..5f29e9982e 100644 --- a/bc_obps/service/transfer_event_service.py +++ b/bc_obps/service/transfer_event_service.py @@ -8,9 +8,8 @@ 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 +from registration.schema.v2.transfer_event import TransferEventCreateIn, TransferEventFilterSchema from service.data_access_service.facility_designated_operation_timeline_service import ( FacilityDesignatedOperationTimelineDataAccessService, ) @@ -105,6 +104,9 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) transfer_event = None if payload.transfer_entity == "Operation": + if not payload.operation: + raise Exception("Operation is required for operation transfer events.") + prepared_payload.update( { "operation_id": payload.operation, @@ -113,6 +115,9 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) transfer_event = TransferEventDataAccessService.create_transfer_event(user_guid, prepared_payload) elif payload.transfer_entity == "Facility": + if not payload.facilities: + raise Exception("Facility is required for facility transfer events.") + prepared_payload.update( { "from_operation_id": payload.from_operation, From 35bc6df6cb38a3627ac193deae210ff11bbb9667 Mon Sep 17 00:00:00 2001 From: SeSo Date: Sat, 7 Dec 2024 21:58:27 -0800 Subject: [PATCH 30/53] chore: make eslint happy Signed-off-by: SeSo --- .../app/components/transfers/TransferForm.tsx | 2 +- .../app/components/transfers/TransferPage.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bciers/apps/registration/app/components/transfers/TransferForm.tsx b/bciers/apps/registration/app/components/transfers/TransferForm.tsx index 508a355e7c..57f940c3dd 100644 --- a/bciers/apps/registration/app/components/transfers/TransferForm.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -23,7 +23,7 @@ import { actionHandler } from "@bciers/actions"; import TransferSuccess from "@/registration/app/components/transfers/TransferSuccess"; interface TransferFormProps { - formData: TransferFormData | {}; + formData: TransferFormData; operators: OperatorRow[]; } diff --git a/bciers/apps/registration/app/components/transfers/TransferPage.tsx b/bciers/apps/registration/app/components/transfers/TransferPage.tsx index 836b4894e3..9ca19a2b7f 100644 --- a/bciers/apps/registration/app/components/transfers/TransferPage.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferPage.tsx @@ -1,6 +1,7 @@ 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() { @@ -12,5 +13,10 @@ export default async function TransferPage() { if (!operators || "error" in operators || !operators.rows) throw new Error("Failed to fetch operators data"); - return ; + return ( + + ); } From 0780dce684778d6200adb46c282b23a6de2dfeb4 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:13:21 -0800 Subject: [PATCH 31/53] chore: add more baker recipes Signed-off-by: SeSo --- bc_obps/registration/tests/utils/baker_recipes.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bc_obps/registration/tests/utils/baker_recipes.py b/bc_obps/registration/tests/utils/baker_recipes.py index c9adcd0860..4b5961d801 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, @@ -142,5 +144,13 @@ 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")), +) + regulated_product = Recipe(RegulatedProduct) From 93826a200321ad2c77c8dc9b4d65c953ae15034a Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:14:34 -0800 Subject: [PATCH 32/53] chore: make test name singular Signed-off-by: SeSo --- ..._designated_operation_timeline_service.py} | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) rename bc_obps/service/tests/{test_facility_designated_operations_timeline_service.py => test_facility_designated_operation_timeline_service.py} (64%) 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 64% 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..3810a5de08 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,47 @@ 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) + 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 From 29f4edc05b535a705e84cc7a46cc03726463d502 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:15:27 -0800 Subject: [PATCH 33/53] chore: fix schema name Signed-off-by: SeSo --- .../apps/registration/app/data/jsonSchema/transfer/transfer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts index 4ce135793d..c8245f9220 100644 --- a/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts +++ b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts @@ -75,7 +75,7 @@ export const createTransferSchema = ( properties: { operation: { type: "string", - title: "Select your operation:", + title: "Operation", }, effective_date: { type: "string", From 0f2b1c4269c057f48a433140b82dfe5cc6afa307 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:16:06 -0800 Subject: [PATCH 34/53] chore: renaming tweaks Signed-off-by: SeSo --- .../facility_designated_operation_timeline_service.py | 6 ++++-- .../operation_designated_operator_timeline_service.py | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/bc_obps/service/facility_designated_operation_timeline_service.py b/bc_obps/service/facility_designated_operation_timeline_service.py index 77d5afbbf6..e6cbef011b 100644 --- a/bc_obps/service/facility_designated_operation_timeline_service.py +++ b/bc_obps/service/facility_designated_operation_timeline_service.py @@ -42,9 +42,11 @@ def list_timeline_by_operation_id( return filters.filter(base_qs).order_by(sort_by) @classmethod - def get_current_timeline_by_facility_id(cls, facility_id: UUID) -> Optional[FacilityDesignatedOperationTimeline]: + def get_current_timeline( + cls, operation_id: UUID, facility_id: UUID + ) -> Optional[FacilityDesignatedOperationTimeline]: return FacilityDesignatedOperationTimeline.objects.filter( - facility_id=facility_id, end_date__isnull=True + operation_id=operation_id, facility_id=facility_id, end_date__isnull=True ).first() @classmethod diff --git a/bc_obps/service/operation_designated_operator_timeline_service.py b/bc_obps/service/operation_designated_operator_timeline_service.py index 17bdd71c85..711bb0d735 100644 --- a/bc_obps/service/operation_designated_operator_timeline_service.py +++ b/bc_obps/service/operation_designated_operator_timeline_service.py @@ -11,15 +11,19 @@ def list_timeline_by_operator_id( cls, operator_id: UUID, ) -> QuerySet[OperationDesignatedOperatorTimeline]: - """ """ + """ + List active timelines belonging to a specific operator. + """ return OperationDesignatedOperatorTimeline.objects.filter( operator_id=operator_id, end_date__isnull=True ).distinct() @classmethod - def get_current_timeline_by_operation_id(cls, operation_id: UUID) -> Optional[OperationDesignatedOperatorTimeline]: + def get_current_timeline( + cls, operator_id: UUID, operation_id: UUID + ) -> Optional[OperationDesignatedOperatorTimeline]: return OperationDesignatedOperatorTimeline.objects.filter( - operation_id=operation_id, end_date__isnull=True + operator_id=operator_id, operation_id=operation_id, end_date__isnull=True ).first() @classmethod From 2f22c7504174a49139218c16fb6f495375c74526 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:17:10 -0800 Subject: [PATCH 35/53] test: add backend tests Signed-off-by: SeSo --- .../_operation_id/test_facilities.py | 11 + .../_operator_id/test_operations.py | 31 ++ .../endpoints/v2/test_transfer_events.py | 56 +- .../test_data_access_contact_service.py | 4 +- .../test_data_access_operation_service_v2.py | 4 +- ...on_designated_operator_timeline_service.py | 76 +++ .../tests/test_transfer_event_service.py | 482 +++++++++++++++++- .../tests/test_user_operator_service_v2.py | 12 +- 8 files changed, 664 insertions(+), 12 deletions(-) create mode 100644 bc_obps/registration/tests/endpoints/v2/_operators/_operator_id/test_operations.py create mode 100644 bc_obps/service/tests/test_operation_designated_operator_timeline_service.py 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/_operators/_operator_id/test_operations.py b/bc_obps/registration/tests/endpoints/v2/_operators/_operator_id/test_operations.py new file mode 100644 index 0000000000..d6acdeb619 --- /dev/null +++ b/bc_obps/registration/tests/endpoints/v2/_operators/_operator_id/test_operations.py @@ -0,0 +1,31 @@ +from itertools import cycle +from unittest.mock import patch, MagicMock +from uuid import uuid4 +from model_bakery import baker +from registration.tests.utils.helpers import CommonTestSetup, TestUtils +from registration.utils import custom_reverse_lazy + + +class TestOperatorIdOperations(CommonTestSetup): + @patch( + 'service.operation_designated_operator_timeline_service.OperationDesignatedOperatorTimelineService.list_timeline_by_operator_id' + ) + def test_list_timeline_by_operator_id(self, mock_list_timeline_by_operator_id: MagicMock): + operator_id = uuid4() + operation_designated_operator_timelines = baker.make_recipe( + 'utils.operation_designated_operator_timeline', + operation=cycle(baker.make_recipe('utils.operation', _quantity=2)), + _quantity=2, + ) + mock_list_timeline_by_operator_id.return_value = operation_designated_operator_timelines + response = TestUtils.mock_get_with_auth_role( + self, + "cas_analyst", + custom_reverse_lazy("list_operations_by_operator_id", kwargs={"operator_id": operator_id}), + ) + + mock_list_timeline_by_operator_id.assert_called_once_with(operator_id) + assert response.status_code == 200 + response_json = response.json() + assert len(response_json) == 2 + assert response_json[0].keys() == {'id', 'name'} 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/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_operation_designated_operator_timeline_service.py b/bc_obps/service/tests/test_operation_designated_operator_timeline_service.py new file mode 100644 index 0000000000..54dbe76f62 --- /dev/null +++ b/bc_obps/service/tests/test_operation_designated_operator_timeline_service.py @@ -0,0 +1,76 @@ +from itertools import cycle +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_list_timeline_by_operator_id(): + operator = baker.make_recipe('utils.operator') + # timeline records without end date + baker.make_recipe( + 'utils.operation_designated_operator_timeline', + operator=operator, + operation=cycle(baker.make_recipe('utils.operation', _quantity=2)), + _quantity=2, + ) + # timeline records with end date + baker.make_recipe( + 'utils.operation_designated_operator_timeline', operator=operator, end_date=datetime.now(ZoneInfo("UTC")) + ) + + timelines = OperationDesignatedOperatorTimelineService.list_timeline_by_operator_id(operator.id) + assert timelines.count() == 2 + assert all(timeline.end_date is None for timeline in timelines) + assert all(timeline.operator_id == operator.id for timeline in timelines) + + @staticmethod + def test_get_current_timeline(): + timeline_with_no_end_date = baker.make_recipe('utils.operation_designated_operator_timeline') + 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', end_date=datetime.now(ZoneInfo("UTC")) + ) + 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_transfer_event_service.py b/bc_obps/service/tests/test_transfer_event_service.py index 26ef1922ca..0ad5346030 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.v2.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,478 @@ 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(): + operation = baker.make_recipe('utils.operation') + baker.make_recipe( + 'utils.transfer_event', + operation=operation, + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + facilities = baker.make_recipe('utils.facility', _quantity=2) + baker.make_recipe( + 'utils.transfer_event', + facilities=facilities, + status=TransferEvent.Statuses.COMPLETE, + ) + + # 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 + 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 + 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") + @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") + @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" + ) + def test_process_operation_transfer( + self, + 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 + + # 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() 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 From 72de6803f4426052d4ab4779948ed54609a0d78b Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:19:06 -0800 Subject: [PATCH 36/53] test: add frontend tests Signed-off-by: SeSo --- .../operators/OperatorDataGridPage.test.tsx | 19 +- .../tests/components/operators/mocks.ts | 9 + .../transfers/TransfersDataGridPage.tsx | 7 +- .../transfers/TransferForm.test.tsx | 251 ++++++++++++++++++ .../transfers/TransferPage.test.tsx | 37 +++ .../transfers/TransferSuccess.test.tsx | 88 ++++++ .../transfers/TransfersDataGridPage.test.tsx | 18 +- .../testConfig/src/helpers/expectRadio.ts | 10 + bciers/libs/testConfig/src/mocks.ts | 4 +- 9 files changed, 424 insertions(+), 19 deletions(-) create mode 100644 bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx create mode 100644 bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx create mode 100644 bciers/apps/registration/tests/components/transfers/TransferSuccess.test.tsx create mode 100644 bciers/libs/testConfig/src/helpers/expectRadio.ts 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/registration/app/components/transfers/TransfersDataGridPage.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx index 88215bc022..835efd6fc4 100644 --- a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx @@ -5,8 +5,8 @@ import { TransferRow, TransfersSearchParams } from "./types"; import fetchTransferEventsPageData from "./fetchTransferEventsPageData"; import Link from "next/link"; import { Button } from "@mui/material"; -import { auth } from "@/dashboard/auth"; import { FrontEndRoles } from "@bciers/utils/src/enums"; +import { getSessionRole } from "@bciers/utils/src/sessionUtils"; // 🧩 Main component export default async function TransfersDataGridPage({ @@ -23,10 +23,11 @@ export default async function TransfersDataGridPage({ throw new Error("Failed to retrieve transfers"); // To get the user's role from the session - const session = await auth(); - const role = session?.user?.app_role ?? ""; + const role = await getSessionRole(); const isCasAnalyst = role === FrontEndRoles.CAS_ANALYST; + console.log({ role }); + // Render the DataGrid component 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..24d2130268 --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx @@ -0,0 +1,251 @@ +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, + getOperationsByOperatorId, +} from "@bciers/testConfig/mocks"; +import { fetchFacilitiesPageData } from "@/administration/tests/components/facilities/mocks"; +import TransferForm from "@/registration/app/components/transfers/TransferForm"; + +vi.mock("@bciers/actions/api/getOperationsByOperatorId", () => ({ + default: getOperationsByOperatorId, +})); + +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 = [ + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea065" as UUID, + name: "Operation 1", + }, + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea066" as UUID, + name: "Operation 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 selectEntity = async (entity: string) => { + fireEvent.click(screen.getByLabelText(entity)); + if (entity === "Operation") { + await waitFor(() => + expect(screen.getByLabelText(/operation\*/i)).toBeVisible(), + ); + 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(); + getOperationsByOperatorId.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 selectEntity("Operation"); + 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 selectEntity("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 getOperationsByOperatorId with new operator id when operator changes", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + expect(getOperationsByOperatorId).toHaveBeenCalledTimes(1); + expect(getOperationsByOperatorId).toHaveBeenCalledWith( + "8be4c7aa-6ab3-4aad-9206-0ef914fea063", + ); + selectOperator(/current operator\*/i, "Operator 2"); + expect(getOperationsByOperatorId).toHaveBeenCalledTimes(2); + expect(getOperationsByOperatorId).toHaveBeenCalledWith( + "8be4c7aa-6ab3-4aad-9206-0ef914fea064", + ); + }); + + it("displays fields related to Facility entity", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + selectOperator(/select the new operator\*/i, "Operator 2"); + await selectEntity("Facility"); + }); + + it("fetches facilities when operation changes", async () => { + renderTransferForm(); + selectOperator(/current operator\*/i, "Operator 1"); + selectOperator(/select the new operator\*/i, "Operator 2"); + await selectEntity("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 selectEntity("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/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..dc93cc0d3a 100644 --- a/bciers/libs/testConfig/src/mocks.ts +++ b/bciers/libs/testConfig/src/mocks.ts @@ -34,9 +34,9 @@ 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(); +const getOperationsByOperatorId = vi.fn(); export { actionHandler, @@ -49,8 +49,8 @@ export { useSearchParams, useSession, fetchOperationsPageData, - fetchOperatorsPageData, getUserOperatorsPageData, notFound, fetchTransferEventsPageData, + getOperationsByOperatorId, }; From 2d15e580c21b5bfffc158346388dd321ce4e0d82 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:19:54 -0800 Subject: [PATCH 37/53] test: raise error if response is undefined Signed-off-by: SeSo --- .../registration/app/components/transfers/TransferForm.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bciers/apps/registration/app/components/transfers/TransferForm.tsx b/bciers/apps/registration/app/components/transfers/TransferForm.tsx index 57f940c3dd..d00b93b6e1 100644 --- a/bciers/apps/registration/app/components/transfers/TransferForm.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -96,7 +96,7 @@ export default function TransferForm({ const fetchOperatorOperations = async (operatorId?: string) => { if (!operatorId) return []; const response = await getOperationsByOperatorId(operatorId); - if ("error" in response) { + if (!response || "error" in response) { setError("Failed to fetch operations data!" as any); return []; } @@ -106,7 +106,6 @@ export default function TransferForm({ 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(); @@ -151,7 +150,7 @@ export default function TransferForm({ end_date: true, // this indicates that the end_date is not null, status: "Active", // only fetch active facilities }); - if ("error" in response) { + if (!response || "error" in response || !response.rows) { setError("Failed to fetch facilities data!" as any); return []; } From f1ad03d34a06ea396191ab653c6c3a017f0877ca Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:20:33 -0800 Subject: [PATCH 38/53] chore: renaming tweaks and cover edge cases Signed-off-by: SeSo --- bc_obps/service/transfer_event_service.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bc_obps/service/transfer_event_service.py b/bc_obps/service/transfer_event_service.py index 5f29e9982e..8a74185d31 100644 --- a/bc_obps/service/transfer_event_service.py +++ b/bc_obps/service/transfer_event_service.py @@ -115,8 +115,10 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) transfer_event = TransferEventDataAccessService.create_transfer_event(user_guid, prepared_payload) elif payload.transfer_entity == "Facility": - if not payload.facilities: - raise Exception("Facility is required for facility transfer events.") + 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." + ) prepared_payload.update( { @@ -153,10 +155,8 @@ def process_due_transfer_events(cls) -> None: ) if not transfer_events: - logger.info("No due transfer events found.") return - logger.info(f"Found {len(transfer_events)} transfer events to process.") processed_events = [] for event in transfer_events: @@ -178,7 +178,7 @@ def _process_single_event(cls, event: TransferEvent, user_guid: Optional[UUID] = """ # 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.id # type: ignore # we are sure that created_by is not None + 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) @@ -196,8 +196,9 @@ def _process_facilities_transfer(cls, event: TransferEvent, user_guid: UUID) -> Process a facility transfer event. Updates the timelines for all associated facilities. """ for facility in event.facilities.all(): - current_timeline = FacilityDesignatedOperationTimelineService.get_current_timeline_by_facility_id( - facility.id + # 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: @@ -224,8 +225,9 @@ def _process_operation_transfer(cls, event: TransferEvent, user_guid: UUID) -> N """ Process an operation transfer event. Updates the timelines for the associated operation. """ - current_timeline = OperationDesignatedOperatorTimelineService.get_current_timeline_by_operation_id( - event.operation.id # type: ignore # we are sure that operation is not None + # 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: From ddf86763b20d6f26747fe21d297f15eec28cd217 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 14:21:06 -0800 Subject: [PATCH 39/53] chore: return undefined if pageData is undefined Signed-off-by: SeSo --- .../app/components/operators/fetchOperatorsPageData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts b/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts index 490ca8dee7..527011d4c5 100644 --- a/bciers/apps/administration/app/components/operators/fetchOperatorsPageData.ts +++ 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; From 8c95f9325af58e3e9c4f82c0e5a7de5ea9dfd78a Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 16:01:21 -0800 Subject: [PATCH 40/53] chore: fix dashboard tile allowedRules issue Signed-off-by: SeSo --- bc_obps/common/fixtures/dashboard/bciers/internal.json | 7 +------ bciers/libs/types/src/tiles.ts | 1 + bciers/libs/utils/src/evalDashboardRules.ts | 9 +++++++++ bciers/libs/utils/src/sessionUtils.ts | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/bc_obps/common/fixtures/dashboard/bciers/internal.json b/bc_obps/common/fixtures/dashboard/bciers/internal.json index 22254e3834..2bfbf1d710 100644 --- a/bc_obps/common/fixtures/dashboard/bciers/internal.json +++ b/bc_obps/common/fixtures/dashboard/bciers/internal.json @@ -49,12 +49,7 @@ { "title": "Transfer an operation or facility", "href": "/registration/transfers/transfer-entity", - "conditions": [ - { - "allowedRoles": ["cas_analyst"], - "value": true - } - ] + "allowedRoles": ["cas_analyst"] } ] }, 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/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..67fb037bb4 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,7 +9,7 @@ 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 From 4134e7f6851451a8ae686f9a8accb9ac4bbced04 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 16:01:46 -0800 Subject: [PATCH 41/53] chore: remove leftover Signed-off-by: SeSo --- .../app/components/transfers/TransfersDataGridPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx index 835efd6fc4..1feecb969d 100644 --- a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx @@ -26,8 +26,6 @@ export default async function TransfersDataGridPage({ const role = await getSessionRole(); const isCasAnalyst = role === FrontEndRoles.CAS_ANALYST; - console.log({ role }); - // Render the DataGrid component return ( }> From b0ee8732bded6a0476ddf668e60ff622d8d40ee9 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 16:57:55 -0800 Subject: [PATCH 42/53] chore: fix vitest tests after using the session util Signed-off-by: SeSo --- .../tests/routes/dashboard/page.test.tsx | 47 +++++++++---------- bciers/libs/utils/src/sessionUtils.ts | 4 +- 2 files changed, 24 insertions(+), 27 deletions(-) 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/libs/utils/src/sessionUtils.ts b/bciers/libs/utils/src/sessionUtils.ts index 67fb037bb4..2ad261d041 100644 --- a/bciers/libs/utils/src/sessionUtils.ts +++ b/bciers/libs/utils/src/sessionUtils.ts @@ -12,13 +12,13 @@ const getSessionRole = async () => { 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 }; From 2668fbf9f0c7edc2711ab29d6779c0cc0436f126 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 11 Dec 2024 17:38:41 -0800 Subject: [PATCH 43/53] docs: add docs about transfer events Signed-off-by: SeSo --- docs/backend/events/transfer.md | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/backend/events/transfer.md diff --git a/docs/backend/events/transfer.md b/docs/backend/events/transfer.md new file mode 100644 index 0000000000..54c61a80aa --- /dev/null +++ b/docs/backend/events/transfer.md @@ -0,0 +1,67 @@ +# Transfer Events + +This document explains how transfer events are processed in the system. There are two types of transfer events: Facility +transfers and Operation transfers. + +## What are Transfer Events? + +Transfer events are used to 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 date in the future, or they can be set to take effect +immediately. + +## How do Transfer Events Work? + +The system categorizes transfer events based on the entity being transferred: Facility or Operation. + +### Transferring Facilities + +* The facility's current operation (the one it's assigned to before the transfer). +* The new operation (the one the facility will be assigned to after the transfer). +* The effective date of the transfer. + +### Transferring Operations + +* The Operation's current operator (the one it's assigned to before the transfer). +* The operation being transferred. +* The new operator that will take over the operation. +* The effective date of the transfer. + +## Processing Transfer Events + +The system processes transfer events differently depending on their effective date: + +* **Today or Past Effective Date:** If a transfer event's effective date is today or in the past, the transfer is + processed immediately upon creation. +* **Future Effective Date:** Transfer events with a future effective date are handled by a scheduled background job ( + cron job). This job runs periodically and utilizes the `process_due_transfer_events` service to identify and process + any transfer events that have become due (effective date has arrived). + +## Processing a Transfer Event + +Once a transfer event is identified for processing (either immediately or by the cron job), the system performs the +following actions specific to the transfer type: + +### Facility Transfer + +1. **For each facility in the transfer:** + * Identify the current timeline record linking the facility to its original operation using the + `FacilityDesignatedOperationTimelineService.get_current_timeline` service. + * If a current timeline exists, update its end date and status to reflect the transfer using the + `FacilityDesignatedOperationTimelineService.set_timeline_status_and_end_date` service. The new status will be set + to `TRANSFERRED` and the end date will be set to the transfer's effective date. + * Create a new timeline record using the + `FacilityDesignatedOperationTimelineDataAccessService.create_facility_designated_operation_timeline` service. This + new record links the facility to the new operation, sets the start date to the transfer's effective date, and sets + the status to `ACTIVE`. + +### Operation Transfer + +1. Identify the current timeline record linking the operation to its original operator using the + `OperationDesignatedOperatorTimelineService.get_current_timeline` service. +2. If a current timeline exists, update its end date and status to reflect the transfer using the + `OperationDesignatedOperatorTimelineService.set_timeline_status_and_end_date` service. The new status will be set to + `TRANSFERRED` and the end date will be set to the transfer's effective date. +3. Create a new timeline record using the + `OperationDesignatedOperatorTimelineDataAccessService.create_operation_designated_operator_timeline` service. This + new record links the operation to the new operator, sets the start date to the transfer's effective date, and sets + the status to `ACTIVE`. \ No newline at end of file From acbf3dd197e83e17da33a1dfd62458e66c1b7693 Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 12 Dec 2024 09:25:06 -0800 Subject: [PATCH 44/53] chore: make pre-commit happy Signed-off-by: SeSo --- docs/backend/events/transfer.md | 38 ++++++++++++++++----------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/backend/events/transfer.md b/docs/backend/events/transfer.md index 54c61a80aa..5beccf3124 100644 --- a/docs/backend/events/transfer.md +++ b/docs/backend/events/transfer.md @@ -15,24 +15,24 @@ The system categorizes transfer events based on the entity being transferred: Fa ### Transferring Facilities -* The facility's current operation (the one it's assigned to before the transfer). -* The new operation (the one the facility will be assigned to after the transfer). -* The effective date of the transfer. +- The facility's current operation (the one it's assigned to before the transfer). +- The new operation (the one the facility will be assigned to after the transfer). +- The effective date of the transfer. ### Transferring Operations -* The Operation's current operator (the one it's assigned to before the transfer). -* The operation being transferred. -* The new operator that will take over the operation. -* The effective date of the transfer. +- The Operation's current operator (the one it's assigned to before the transfer). +- The operation being transferred. +- The new operator that will take over the operation. +- The effective date of the transfer. ## Processing Transfer Events The system processes transfer events differently depending on their effective date: -* **Today or Past Effective Date:** If a transfer event's effective date is today or in the past, the transfer is +- **Today or Past Effective Date:** If a transfer event's effective date is today or in the past, the transfer is processed immediately upon creation. -* **Future Effective Date:** Transfer events with a future effective date are handled by a scheduled background job ( +- **Future Effective Date:** Transfer events with a future effective date are handled by a scheduled background job ( cron job). This job runs periodically and utilizes the `process_due_transfer_events` service to identify and process any transfer events that have become due (effective date has arrived). @@ -44,15 +44,15 @@ following actions specific to the transfer type: ### Facility Transfer 1. **For each facility in the transfer:** - * Identify the current timeline record linking the facility to its original operation using the - `FacilityDesignatedOperationTimelineService.get_current_timeline` service. - * If a current timeline exists, update its end date and status to reflect the transfer using the - `FacilityDesignatedOperationTimelineService.set_timeline_status_and_end_date` service. The new status will be set - to `TRANSFERRED` and the end date will be set to the transfer's effective date. - * Create a new timeline record using the - `FacilityDesignatedOperationTimelineDataAccessService.create_facility_designated_operation_timeline` service. This - new record links the facility to the new operation, sets the start date to the transfer's effective date, and sets - the status to `ACTIVE`. + - Identify the current timeline record linking the facility to its original operation using the + `FacilityDesignatedOperationTimelineService.get_current_timeline` service. + - If a current timeline exists, update its end date and status to reflect the transfer using the + `FacilityDesignatedOperationTimelineService.set_timeline_status_and_end_date` service. The new status will be set + to `TRANSFERRED` and the end date will be set to the transfer's effective date. + - Create a new timeline record using the + `FacilityDesignatedOperationTimelineDataAccessService.create_facility_designated_operation_timeline` service. This + new record links the facility to the new operation, sets the start date to the transfer's effective date, and sets + the status to `ACTIVE`. ### Operation Transfer @@ -64,4 +64,4 @@ following actions specific to the transfer type: 3. Create a new timeline record using the `OperationDesignatedOperatorTimelineDataAccessService.create_operation_designated_operator_timeline` service. This new record links the operation to the new operator, sets the start date to the transfer's effective date, and sets - the status to `ACTIVE`. \ No newline at end of file + the status to `ACTIVE`. From 1f9a07a5d231245612154d87acf6eb0f5722f561 Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 12 Dec 2024 09:25:23 -0800 Subject: [PATCH 45/53] chore: make reg1 e2e tests happy Signed-off-by: SeSo --- .../registration/fixtures/mock/operation.json | 8 ++++---- .../operation_designated_operator_timeline.json | 6 +++--- .../fixtures/mock/user_operator.json | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bc_obps/registration/fixtures/mock/operation.json b/bc_obps/registration/fixtures/mock/operation.json index fa7d285c55..b5a36aa253 100644 --- a/bc_obps/registration/fixtures/mock/operation.json +++ b/bc_obps/registration/fixtures/mock/operation.json @@ -296,7 +296,7 @@ "opt_in": false, "bcghg_id": "23219990014", "regulated_products": [], - "status": "Registered", + "status": "Closed", "created_at": "2024-1-19T15:27:00.000Z", "activities": [1, 5], "registration_purpose": "OBPS Regulated Operation" @@ -316,7 +316,7 @@ "opt_in": false, "bcghg_id": "23219990015", "regulated_products": [], - "status": "Registered", + "status": "Temporarily Shutdown", "created_at": "2024-1-18T15:27:00.000Z", "activities": [1, 5], "registration_purpose": "Potential Reporting Operation" @@ -336,7 +336,7 @@ "opt_in": false, "bcghg_id": "23219990016", "regulated_products": [], - "status": "Registered", + "status": "Changes Requested", "created_at": "2024-1-17T15:27:00.000Z", "activities": [1, 5], "registration_purpose": "OBPS Regulated Operation" @@ -356,7 +356,7 @@ "opt_in": false, "bcghg_id": "23219990017", "regulated_products": [], - "status": "Registered", + "status": "Declined", "created_at": "2024-1-16T15:27:00.000Z", "activities": [1, 5] } 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 debb77b65b..97631998fa 100644 --- a/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json +++ b/bc_obps/registration/fixtures/mock/operation_designated_operator_timeline.json @@ -123,7 +123,7 @@ { "model": "registration.operationdesignatedoperatortimeline", "fields": { - "created_by": "00000000-0000-0000-0000-000000000026", + "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", @@ -134,7 +134,7 @@ { "model": "registration.operationdesignatedoperatortimeline", "fields": { - "created_by": "00000000-0000-0000-0000-000000000026", + "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", @@ -145,7 +145,7 @@ { "model": "registration.operationdesignatedoperatortimeline", "fields": { - "created_by": "00000000-0000-0000-0000-000000000026", + "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", diff --git a/bc_obps/registration/fixtures/mock/user_operator.json b/bc_obps/registration/fixtures/mock/user_operator.json index 2154d25fb3..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 } }, @@ -340,10 +340,10 @@ "fields": { "user": "00000000-0000-0000-0000-000000000026", "operator": "4a792f0f-cf9d-48c8-9a95-f504c5f84b12", - "role": "admin", - "status": "Approved", - "verified_at": "2024-02-26 06:24:57.293242-08", - "verified_by": "58f255ed-8d46-44ee-b2fe-9f8d3d92c684", + "role": "pending", + "status": "Pending", + "verified_at": null, + "verified_by": null, "user_friendly_id": 29 } } From 8c20d4d0bcb82efd5630f87aef99792162df87db Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 12 Dec 2024 09:31:57 -0800 Subject: [PATCH 46/53] chore: make sonarcloud happy Signed-off-by: SeSo --- .../registration/app/components/transfers/TransferForm.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bciers/apps/registration/app/components/transfers/TransferForm.tsx b/bciers/apps/registration/app/components/transfers/TransferForm.tsx index d00b93b6e1..b7feb0c501 100644 --- a/bciers/apps/registration/app/components/transfers/TransferForm.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -34,7 +34,7 @@ export default function TransferForm({ const router = useRouter(); const [formState, setFormState] = useState(formData); - const [key, setKey] = useState(Math.random()); + const [key, setKey] = useState(Math.random()); // NOSONAR const [error, setError] = useState(undefined); const [schema, setSchema] = useState(createTransferSchema(operators)); const [uiSchema, setUiSchema] = useState(transferUISchema); @@ -183,7 +183,8 @@ export default function TransferForm({ ...formState, facilities: [], }); - setKey(Math.random()); // force re-render + // force re-render + setKey(Math.random()); // NOSONAR } }; From 20306cd3599f7d561d46994e6946ac958df95c22 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 13 Dec 2024 14:31:06 -0800 Subject: [PATCH 47/53] chore: remove transfer sub-dashboard for internal users Signed-off-by: SeSo --- .../fixtures/dashboard/bciers/internal.json | 1 - .../dashboard/registration/internal.json | 25 ------------------- .../common/migrations/0010_dashboarddata.py | 1 - .../migrations/0012_update_dashboard_data.py | 1 - .../migrations/0016_update_dashboard_data.py | 1 - .../migrations/0017_update_dashboard_data.py | 1 - .../migrations/0020_update_dashboard_data.py | 1 - .../migrations/0022_update_dashboard_data.py | 1 - 8 files changed, 32 deletions(-) delete mode 100644 bc_obps/common/fixtures/dashboard/registration/internal.json diff --git a/bc_obps/common/fixtures/dashboard/bciers/internal.json b/bc_obps/common/fixtures/dashboard/bciers/internal.json index 2bfbf1d710..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": [ 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', From fa1a69011d57899d910e17ba12e22eaae8b00d11 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 13 Dec 2024 14:31:40 -0800 Subject: [PATCH 48/53] chore: remove transfer sub-dashboard for internal users Signed-off-by: SeSo --- bc_obps/common/utils.py | 1 - docs/backend/events/transfer.md | 140 ++++++++++++++++++++------------ 2 files changed, 88 insertions(+), 53 deletions(-) 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/docs/backend/events/transfer.md b/docs/backend/events/transfer.md index 5beccf3124..34884bc2d5 100644 --- a/docs/backend/events/transfer.md +++ b/docs/backend/events/transfer.md @@ -1,67 +1,103 @@ # Transfer Events -This document explains how transfer events are processed in the system. There are two types of transfer events: Facility -transfers and Operation transfers. +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 are used to 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 date in the future, or they can be set to take effect -immediately. +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 system categorizes transfer events based on the entity being transferred: Facility or Operation. +### 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: -### Transferring Facilities +- Moving facilities from one operation to another. +- Updating both the originating and receiving operations’ timelines. -- The facility's current operation (the one it's assigned to before the transfer). -- The new operation (the one the facility will be assigned to after the transfer). -- The effective date of the transfer. +### Operation Transfers -### Transferring Operations +Operation transfers involve: -- The Operation's current operator (the one it's assigned to before the transfer). -- The operation being transferred. -- The new operator that will take over the operation. -- The effective date of the transfer. +- Moving an operation from one operator to another. +- Updating both the originating and receiving operators’ timelines. + +--- ## Processing Transfer Events -The system processes transfer events differently depending on their effective date: - -- **Today or Past Effective Date:** If a transfer event's effective date is today or in the past, the transfer is - processed immediately upon creation. -- **Future Effective Date:** Transfer events with a future effective date are handled by a scheduled background job ( - cron job). This job runs periodically and utilizes the `process_due_transfer_events` service to identify and process - any transfer events that have become due (effective date has arrived). - -## Processing a Transfer Event - -Once a transfer event is identified for processing (either immediately or by the cron job), the system performs the -following actions specific to the transfer type: - -### Facility Transfer - -1. **For each facility in the transfer:** - - Identify the current timeline record linking the facility to its original operation using the - `FacilityDesignatedOperationTimelineService.get_current_timeline` service. - - If a current timeline exists, update its end date and status to reflect the transfer using the - `FacilityDesignatedOperationTimelineService.set_timeline_status_and_end_date` service. The new status will be set - to `TRANSFERRED` and the end date will be set to the transfer's effective date. - - Create a new timeline record using the - `FacilityDesignatedOperationTimelineDataAccessService.create_facility_designated_operation_timeline` service. This - new record links the facility to the new operation, sets the start date to the transfer's effective date, and sets - the status to `ACTIVE`. - -### Operation Transfer - -1. Identify the current timeline record linking the operation to its original operator using the - `OperationDesignatedOperatorTimelineService.get_current_timeline` service. -2. If a current timeline exists, update its end date and status to reflect the transfer using the - `OperationDesignatedOperatorTimelineService.set_timeline_status_and_end_date` service. The new status will be set to - `TRANSFERRED` and the end date will be set to the transfer's effective date. -3. Create a new timeline record using the - `OperationDesignatedOperatorTimelineDataAccessService.create_operation_designated_operator_timeline` service. This - new record links the operation to the new operator, sets the start date to the transfer's effective date, and sets - the status to `ACTIVE`. +### 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`. From 21bc2e958343a87d22567c85d10d86767aeb2e25 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 13 Dec 2024 14:34:10 -0800 Subject: [PATCH 49/53] chore: implement review suggestions, remove redundant endpoints and update tests Signed-off-by: SeSo --- .../auth/test_endpoint_permissions.py | 1 - .../api/v2/_operators/__init__.py | 2 - .../v2/_operators/_operator_id/operations.py | 29 -------- .../facility_designated_operation_timeline.py | 2 +- .../v2/operation_designated_operator.py | 7 -- .../_operator_id/test_operations.py | 31 --------- .../registration/tests/utils/baker_recipes.py | 1 + ...on_designated_operator_timeline_service.py | 13 ---- bc_obps/service/operation_service_v2.py | 16 +++++ ...y_designated_operation_timeline_service.py | 2 + ...on_designated_operator_timeline_service.py | 29 ++------ .../tests/test_operation_service_v2.py | 26 +++++++ .../tests/test_transfer_event_service.py | 69 +++++++++++++++---- bc_obps/service/transfer_event_service.py | 12 ++++ .../app/components/operations/types.ts | 7 +- .../app/components/transfers/TransferForm.tsx | 32 +++++---- .../app/components/transfers/types.ts | 5 -- .../app/data/jsonSchema/transfer/transfer.ts | 10 +-- .../src/api/getOperationsByOperatorId.ts | 11 --- bciers/libs/actions/src/api/index.ts | 1 - bciers/libs/testConfig/src/mocks.ts | 2 - 21 files changed, 146 insertions(+), 162 deletions(-) delete mode 100644 bc_obps/registration/api/v2/_operators/_operator_id/operations.py delete mode 100644 bc_obps/registration/schema/v2/operation_designated_operator.py delete mode 100644 bc_obps/registration/tests/endpoints/v2/_operators/_operator_id/test_operations.py delete mode 100644 bciers/libs/actions/src/api/getOperationsByOperatorId.ts 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 ffd99e27c6..0164562e43 100644 --- a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py +++ b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py @@ -345,7 +345,6 @@ class TestEndpointPermissions(TestCase): }, ], "cas_analyst": [ - {"method": "get", "endpoint_name": "list_operations_by_operator_id", "kwargs": {"operator_id": mock_uuid}}, {"method": "post", "endpoint_name": "create_transfer_event"}, ], } diff --git a/bc_obps/registration/api/v2/_operators/__init__.py b/bc_obps/registration/api/v2/_operators/__init__.py index 033726f001..e69de29bb2 100644 --- a/bc_obps/registration/api/v2/_operators/__init__.py +++ b/bc_obps/registration/api/v2/_operators/__init__.py @@ -1,2 +0,0 @@ -# ruff: noqa: F401 -from ._operator_id.operations import list_operations_by_operator_id diff --git a/bc_obps/registration/api/v2/_operators/_operator_id/operations.py b/bc_obps/registration/api/v2/_operators/_operator_id/operations.py deleted file mode 100644 index d05f686e93..0000000000 --- a/bc_obps/registration/api/v2/_operators/_operator_id/operations.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Literal, Tuple, List -from uuid import UUID -from django.db.models import QuerySet -from common.permissions import authorize -from django.http import HttpRequest -from registration.constants import OPERATOR_TAGS_V2 -from registration.schema.v2.operation_designated_operator import OperationDesignatedOperatorTimelineOut -from service.error_service.custom_codes_4xx import custom_codes_4xx -from registration.decorators import handle_http_errors -from registration.api.router import router -from registration.models import OperationDesignatedOperatorTimeline -from registration.schema.generic import Message -from service.operation_designated_operator_timeline_service import OperationDesignatedOperatorTimelineService - - -##### GET ##### -@router.get( - "/operators/{uuid:operator_id}/operations", - response={200: List[OperationDesignatedOperatorTimelineOut], custom_codes_4xx: Message}, - tags=OPERATOR_TAGS_V2, - description="""Retrieves a list of operations associated with the specified operator. - This endpoint is not paginated because it is being used for a dropdown list in the UI.""", - auth=authorize("cas_analyst"), -) -@handle_http_errors() -def list_operations_by_operator_id( - request: HttpRequest, operator_id: UUID -) -> Tuple[Literal[200], QuerySet[OperationDesignatedOperatorTimeline]]: - return 200, OperationDesignatedOperatorTimelineService.list_timeline_by_operator_id(operator_id) 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 b188ff11b5..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,7 +9,7 @@ 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 alogn with their locations for transfer event + # 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" ) diff --git a/bc_obps/registration/schema/v2/operation_designated_operator.py b/bc_obps/registration/schema/v2/operation_designated_operator.py deleted file mode 100644 index b76277b130..0000000000 --- a/bc_obps/registration/schema/v2/operation_designated_operator.py +++ /dev/null @@ -1,7 +0,0 @@ -from uuid import UUID -from ninja import Field, Schema - - -class OperationDesignatedOperatorTimelineOut(Schema): - id: UUID = Field(..., alias="operation.id") - name: str = Field(..., alias="operation.name") diff --git a/bc_obps/registration/tests/endpoints/v2/_operators/_operator_id/test_operations.py b/bc_obps/registration/tests/endpoints/v2/_operators/_operator_id/test_operations.py deleted file mode 100644 index d6acdeb619..0000000000 --- a/bc_obps/registration/tests/endpoints/v2/_operators/_operator_id/test_operations.py +++ /dev/null @@ -1,31 +0,0 @@ -from itertools import cycle -from unittest.mock import patch, MagicMock -from uuid import uuid4 -from model_bakery import baker -from registration.tests.utils.helpers import CommonTestSetup, TestUtils -from registration.utils import custom_reverse_lazy - - -class TestOperatorIdOperations(CommonTestSetup): - @patch( - 'service.operation_designated_operator_timeline_service.OperationDesignatedOperatorTimelineService.list_timeline_by_operator_id' - ) - def test_list_timeline_by_operator_id(self, mock_list_timeline_by_operator_id: MagicMock): - operator_id = uuid4() - operation_designated_operator_timelines = baker.make_recipe( - 'utils.operation_designated_operator_timeline', - operation=cycle(baker.make_recipe('utils.operation', _quantity=2)), - _quantity=2, - ) - mock_list_timeline_by_operator_id.return_value = operation_designated_operator_timelines - response = TestUtils.mock_get_with_auth_role( - self, - "cas_analyst", - custom_reverse_lazy("list_operations_by_operator_id", kwargs={"operator_id": operator_id}), - ) - - mock_list_timeline_by_operator_id.assert_called_once_with(operator_id) - assert response.status_code == 200 - response_json = response.json() - assert len(response_json) == 2 - assert response_json[0].keys() == {'id', 'name'} diff --git a/bc_obps/registration/tests/utils/baker_recipes.py b/bc_obps/registration/tests/utils/baker_recipes.py index 4b5961d801..958d40ab44 100644 --- a/bc_obps/registration/tests/utils/baker_recipes.py +++ b/bc_obps/registration/tests/utils/baker_recipes.py @@ -150,6 +150,7 @@ operator=foreign_key(operator), status=cycle([status for status in OperationDesignatedOperatorTimeline.Statuses]), start_date=datetime.now(ZoneInfo("UTC")), + end_date=datetime.now(ZoneInfo("UTC")), ) diff --git a/bc_obps/service/operation_designated_operator_timeline_service.py b/bc_obps/service/operation_designated_operator_timeline_service.py index 711bb0d735..9215eb6cd8 100644 --- a/bc_obps/service/operation_designated_operator_timeline_service.py +++ b/bc_obps/service/operation_designated_operator_timeline_service.py @@ -1,23 +1,10 @@ from datetime import datetime from typing import Optional from uuid import UUID -from django.db.models import QuerySet from registration.models import OperationDesignatedOperatorTimeline class OperationDesignatedOperatorTimelineService: - @classmethod - def list_timeline_by_operator_id( - cls, - operator_id: UUID, - ) -> QuerySet[OperationDesignatedOperatorTimeline]: - """ - List active timelines belonging to a specific operator. - """ - return OperationDesignatedOperatorTimeline.objects.filter( - operator_id=operator_id, end_date__isnull=True - ).distinct() - @classmethod def get_current_timeline( cls, operator_id: UUID, operation_id: UUID 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/test_facility_designated_operation_timeline_service.py b/bc_obps/service/tests/test_facility_designated_operation_timeline_service.py index 3810a5de08..e33ec9817b 100644 --- a/bc_obps/service/tests/test_facility_designated_operation_timeline_service.py +++ b/bc_obps/service/tests/test_facility_designated_operation_timeline_service.py @@ -116,6 +116,8 @@ 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 ) 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 index 54dbe76f62..30a4575139 100644 --- a/bc_obps/service/tests/test_operation_designated_operator_timeline_service.py +++ b/bc_obps/service/tests/test_operation_designated_operator_timeline_service.py @@ -1,4 +1,3 @@ -from itertools import cycle from unittest.mock import patch, MagicMock from zoneinfo import ZoneInfo import pytest @@ -12,37 +11,17 @@ class TestOperationDesignatedOperatorTimelineService: - @staticmethod - def test_list_timeline_by_operator_id(): - operator = baker.make_recipe('utils.operator') - # timeline records without end date - baker.make_recipe( - 'utils.operation_designated_operator_timeline', - operator=operator, - operation=cycle(baker.make_recipe('utils.operation', _quantity=2)), - _quantity=2, - ) - # timeline records with end date - baker.make_recipe( - 'utils.operation_designated_operator_timeline', operator=operator, end_date=datetime.now(ZoneInfo("UTC")) - ) - - timelines = OperationDesignatedOperatorTimelineService.list_timeline_by_operator_id(operator.id) - assert timelines.count() == 2 - assert all(timeline.end_date is None for timeline in timelines) - assert all(timeline.operator_id == operator.id for timeline in timelines) - @staticmethod def test_get_current_timeline(): - timeline_with_no_end_date = baker.make_recipe('utils.operation_designated_operator_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', end_date=datetime.now(ZoneInfo("UTC")) - ) + 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 ) 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 0ad5346030..a8580ad267 100644 --- a/bc_obps/service/tests/test_transfer_event_service.py +++ b/bc_obps/service/tests/test_transfer_event_service.py @@ -28,19 +28,6 @@ def test_list_transfer_events(): @staticmethod def test_validate_no_overlapping_transfer_events(): - operation = baker.make_recipe('utils.operation') - baker.make_recipe( - 'utils.transfer_event', - operation=operation, - status=TransferEvent.Statuses.TO_BE_TRANSFERRED, - ) - facilities = baker.make_recipe('utils.facility', _quantity=2) - baker.make_recipe( - 'utils.transfer_event', - facilities=facilities, - status=TransferEvent.Statuses.COMPLETE, - ) - # Scenario 1: No overlapping operation or facility new_operation = baker.make_recipe('utils.operation') new_facilities = baker.make_recipe('utils.facility', _quantity=2) @@ -52,10 +39,22 @@ def test_validate_no_overlapping_transfer_events(): 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.", @@ -106,6 +105,26 @@ def test_create_transfer_event_operation_missing_operation(cls, mock_get_by_guid 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") @@ -188,6 +207,22 @@ def test_create_transfer_event_facility_missing_required_fields(cls, mock_get_by ): 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") @@ -448,8 +483,10 @@ def test_process_facilities_transfer( @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, @@ -483,6 +520,7 @@ def test_process_operation_transfer( 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) @@ -500,3 +538,8 @@ def test_process_operation_transfer( # 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/transfer_event_service.py b/bc_obps/service/transfer_event_service.py index 8a74185d31..2dbc5fd3da 100644 --- a/bc_obps/service/transfer_event_service.py +++ b/bc_obps/service/transfer_event_service.py @@ -20,6 +20,7 @@ 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__) @@ -107,6 +108,10 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) 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, @@ -120,6 +125,10 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) "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, @@ -248,3 +257,6 @@ def _process_operation_transfer(cls, event: TransferEvent, user_guid: UUID) -> N "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/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/registration/app/components/transfers/TransferForm.tsx b/bciers/apps/registration/app/components/transfers/TransferForm.tsx index b7feb0c501..57cdf0d362 100644 --- a/bciers/apps/registration/app/components/transfers/TransferForm.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -6,21 +6,19 @@ import { Alert, Button } from "@mui/material"; import SubmitButton from "@bciers/components/button/SubmitButton"; import { useRouter } from "next/navigation"; import { IChangeEvent } from "@rjsf/core"; -import { - Operation, - TransferFormData, -} from "@/registration/app/components/transfers/types"; +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 { getOperationsByOperatorId } from "@bciers/actions/api"; 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 "@/administration/app/components/operations/fetchOperationsPageData"; +import { OperationRow } from "@/administration/app/components/operations/types"; interface TransferFormProps { formData: TransferFormData; @@ -38,8 +36,10 @@ export default function TransferForm({ const [error, setError] = useState(undefined); const [schema, setSchema] = useState(createTransferSchema(operators)); const [uiSchema, setUiSchema] = useState(transferUISchema); - const [fromOperatorOperations, setFromOperatorOperations] = useState([]); - const [toOperatorOperations, setToOperatorOperations] = useState([]); + 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); @@ -95,12 +95,18 @@ export default function TransferForm({ const fetchOperatorOperations = async (operatorId?: string) => { if (!operatorId) return []; - const response = await getOperationsByOperatorId(operatorId); - if (!response || "error" in response) { + 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; + return response.rows; }; const handleOperatorChange = async () => { @@ -123,8 +129,8 @@ export default function TransferForm({ setSchema( createTransferSchema( operators, - getFromOperatorOperations as Operation[], - getByOperatorOperations as Operation[], + getFromOperatorOperations, + getByOperatorOperations, ), ); @@ -167,7 +173,7 @@ export default function TransferForm({ if (!error) { // Filter out the current from_operation from toOperatorOperations(we can't transfer facilities to the same operation) const filteredToOperatorOperations = toOperatorOperations.filter( - (operation: Operation) => operation.id !== formState?.from_operation, + (operation: OperationRow) => operation.id !== formState?.from_operation, ); setSchema( diff --git a/bciers/apps/registration/app/components/transfers/types.ts b/bciers/apps/registration/app/components/transfers/types.ts index 6bad326a84..2e801a9a3b 100644 --- a/bciers/apps/registration/app/components/transfers/types.ts +++ b/bciers/apps/registration/app/components/transfers/types.ts @@ -25,8 +25,3 @@ export interface TransferFormData { to_operation?: string; effective_date: string; } - -export interface Operation { - id: string; - name: string; -} diff --git a/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts index c8245f9220..4b3da43708 100644 --- a/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts +++ b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts @@ -2,13 +2,13 @@ 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 { Operation } from "@/registration/app/components/transfers/types"; import { FacilityRow } from "@/administration/app/components/facilities/types"; +import { OperationRow } from "@/administration/app/components/operations/types"; export const createTransferSchema = ( operatorOptions: OperatorRow[], - operationOptions: Operation[] = [], // fromOperationOptions and operationOptions are the same - toOperationOptions: Operation[] = [], + operationOptions: OperationRow[] = [], // fromOperationOptions and operationOptions are the same + toOperationOptions: OperationRow[] = [], facilityOptions: FacilityRow[] = [], ) => { const transferSchema: RJSFSchema = { @@ -134,7 +134,7 @@ export const createTransferSchema = ( if (operationOptions.length > 0) { const operationOptionsAnyOf = operationOptions.map( - (operation: Operation) => ({ + (operation: OperationRow) => ({ const: operation.id, title: operation.name, }), @@ -150,7 +150,7 @@ export const createTransferSchema = ( 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: Operation) => ({ + toOperationOptions.map((operation: OperationRow) => ({ const: operation.id, title: operation.name, })); diff --git a/bciers/libs/actions/src/api/getOperationsByOperatorId.ts b/bciers/libs/actions/src/api/getOperationsByOperatorId.ts deleted file mode 100644 index ccd11a75b8..0000000000 --- a/bciers/libs/actions/src/api/getOperationsByOperatorId.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { actionHandler } from "@bciers/actions"; - -async function getOperationsByOperatorId(operatorId: string) { - return actionHandler( - `registration/operators/${operatorId}/operations`, - "GET", - "", - ); -} - -export default getOperationsByOperatorId; diff --git a/bciers/libs/actions/src/api/index.ts b/bciers/libs/actions/src/api/index.ts index 1f7f6c00a1..d22de7f441 100644 --- a/bciers/libs/actions/src/api/index.ts +++ b/bciers/libs/actions/src/api/index.ts @@ -15,4 +15,3 @@ 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 getOperationsByOperatorId } from "./getOperationsByOperatorId"; diff --git a/bciers/libs/testConfig/src/mocks.ts b/bciers/libs/testConfig/src/mocks.ts index dc93cc0d3a..a361c47208 100644 --- a/bciers/libs/testConfig/src/mocks.ts +++ b/bciers/libs/testConfig/src/mocks.ts @@ -36,7 +36,6 @@ const auth = vi.fn(); const fetchOperationsPageData = vi.fn(); const fetchTransferEventsPageData = vi.fn(); const getUserOperatorsPageData = vi.fn(); -const getOperationsByOperatorId = vi.fn(); export { actionHandler, @@ -52,5 +51,4 @@ export { getUserOperatorsPageData, notFound, fetchTransferEventsPageData, - getOperationsByOperatorId, }; From f59d3f21df09e0952aee9fab53e6c0bc32e614d6 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 13 Dec 2024 14:34:32 -0800 Subject: [PATCH 50/53] chore: using ts instead of tsx Signed-off-by: SeSo --- .../{fetchOperationsPageData.tsx => fetchOperationsPageData.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename bciers/apps/administration/app/components/operations/{fetchOperationsPageData.tsx => fetchOperationsPageData.ts} (100%) diff --git a/bciers/apps/administration/app/components/operations/fetchOperationsPageData.tsx b/bciers/apps/administration/app/components/operations/fetchOperationsPageData.ts similarity index 100% rename from bciers/apps/administration/app/components/operations/fetchOperationsPageData.tsx rename to bciers/apps/administration/app/components/operations/fetchOperationsPageData.ts From eabb344f4429916d46ccf967563c21dc5eae1938 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 13 Dec 2024 14:35:00 -0800 Subject: [PATCH 51/53] chore: remove unused mock Signed-off-by: SeSo --- .../components/operations/OperationLayouts.test.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) 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(); From 2439d7658c12cbcc265c47474038be3c11bdc6c9 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 13 Dec 2024 14:45:47 -0800 Subject: [PATCH 52/53] chore: fix post-rebase migration issue Signed-off-by: SeSo --- ...63_remove_historicaltransferevent_description_and_more.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename bc_obps/registration/migrations/{0062_remove_historicaltransferevent_description_and_more.py => 0063_remove_historicaltransferevent_description_and_more.py} (98%) diff --git a/bc_obps/registration/migrations/0062_remove_historicaltransferevent_description_and_more.py b/bc_obps/registration/migrations/0063_remove_historicaltransferevent_description_and_more.py similarity index 98% rename from bc_obps/registration/migrations/0062_remove_historicaltransferevent_description_and_more.py rename to bc_obps/registration/migrations/0063_remove_historicaltransferevent_description_and_more.py index aa60788ddb..554e7fcb01 100644 --- a/bc_obps/registration/migrations/0062_remove_historicaltransferevent_description_and_more.py +++ b/bc_obps/registration/migrations/0063_remove_historicaltransferevent_description_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.9 on 2024-12-06 23:50 +# Generated by Django 5.0.9 on 2024-12-13 22:41 import django.db.models.deletion from django.db import migrations, models @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('registration', '0061_remove_naics_code'), + ('registration', '0062_V1_16_0'), ] operations = [ From 0a4940949fe10569b8bf3c9d4cb53444c419f3a1 Mon Sep 17 00:00:00 2001 From: SeSo Date: Fri, 13 Dec 2024 16:26:20 -0800 Subject: [PATCH 53/53] chore: fix vitest Signed-off-by: SeSo --- .../operations/OperationDataGrid.tsx | 2 +- .../operations/OperationDataGridPage.tsx | 2 +- .../operations/OperationDataGridPage.test.tsx | 14 +--- .../tests/components/operations/mocks.ts | 6 ++ .../app/components/transfers/TransferForm.tsx | 2 +- .../transfers/TransferForm.test.tsx | 78 ++++++++++--------- .../src/api}/fetchOperationsPageData.ts | 2 +- bciers/libs/actions/src/api/index.ts | 1 + bciers/libs/testConfig/src/mocks.ts | 2 - 9 files changed, 54 insertions(+), 55 deletions(-) rename bciers/{apps/administration/app/components/operations => libs/actions/src/api}/fetchOperationsPageData.ts (86%) 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/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/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/registration/app/components/transfers/TransferForm.tsx b/bciers/apps/registration/app/components/transfers/TransferForm.tsx index 57cdf0d362..f10f54f491 100644 --- a/bciers/apps/registration/app/components/transfers/TransferForm.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -17,7 +17,7 @@ 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 "@/administration/app/components/operations/fetchOperationsPageData"; +import fetchOperationsPageData from "@bciers/actions/api/fetchOperationsPageData"; import { OperationRow } from "@/administration/app/components/operations/types"; interface TransferFormProps { diff --git a/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx b/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx index 24d2130268..a60aa68a51 100644 --- a/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx +++ b/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx @@ -3,17 +3,11 @@ import { UUID } from "crypto"; import { expect } from "vitest"; import expectButton from "@bciers/testConfig/helpers/expectButton"; import expectRadio from "@bciers/testConfig/helpers/expectRadio"; -import { - actionHandler, - getOperationsByOperatorId, -} from "@bciers/testConfig/mocks"; +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"; -vi.mock("@bciers/actions/api/getOperationsByOperatorId", () => ({ - default: getOperationsByOperatorId, -})); - const mockOperators = [ { id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063" as UUID, @@ -31,16 +25,19 @@ const mockOperators = [ }, ]; -const mockOperations = [ - { - id: "8be4c7aa-6ab3-4aad-9206-0ef914fea065" as UUID, - name: "Operation 1", - }, - { - id: "8be4c7aa-6ab3-4aad-9206-0ef914fea066" as UUID, - name: "Operation 2", - }, -]; +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(); @@ -58,12 +55,13 @@ const selectOperator = (label: RegExp, operatorName: string) => { fireEvent.click(screen.getByRole("option", { name: operatorName })); }; -const selectEntity = async (entity: string) => { +const selectEntityAndAssertFields = async (entity: string) => { fireEvent.click(screen.getByLabelText(entity)); if (entity === "Operation") { - await waitFor(() => - expect(screen.getByLabelText(/operation\*/i)).toBeVisible(), - ); + // expect(screen.findByLabelText(/operation\*/i)).resolves.toBeVisible(); + expect(screen.getByLabelText(/operation\*/i)).toBeVisible(); + // await waitFor(() => { + // }); expect( screen.getByLabelText(/effective date of transfer\*/i), ).toBeVisible(); @@ -128,7 +126,7 @@ const selectDateOfTransfer = (date: string) => { describe("The TransferForm component", () => { beforeEach(async () => { vi.clearAllMocks(); - getOperationsByOperatorId.mockResolvedValue(mockOperations); + fetchOperationsPageData.mockResolvedValue(mockOperations); }); it("should render the TransferForm component", async () => { @@ -150,7 +148,11 @@ describe("The TransferForm component", () => { renderTransferForm(); selectOperator(/current operator\*/i, "Operator 1"); selectOperator(/select the new operator\*/i, "Operator 2"); - await selectEntity("Operation"); + 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"); @@ -160,7 +162,7 @@ describe("The TransferForm component", () => { renderTransferForm(); selectOperator(/current operator\*/i, "Operator 1"); selectOperator(/select the new operator\*/i, "Operator 1"); - await selectEntity("Operation"); + 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), @@ -168,32 +170,34 @@ describe("The TransferForm component", () => { expect(screen.getByRole("combobox", { name: /operation/i })).toBeDisabled(); }); - it("calls getOperationsByOperatorId with new operator id when operator changes", async () => { + it("calls fetchOperationsPageData with new operator id when operator changes", async () => { renderTransferForm(); selectOperator(/current operator\*/i, "Operator 1"); - expect(getOperationsByOperatorId).toHaveBeenCalledTimes(1); - expect(getOperationsByOperatorId).toHaveBeenCalledWith( - "8be4c7aa-6ab3-4aad-9206-0ef914fea063", - ); + expect(fetchOperationsPageData).toHaveBeenCalledTimes(1); + expect(fetchOperationsPageData).toHaveBeenCalledWith({ + operator_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063", + paginate_results: false, + }); selectOperator(/current operator\*/i, "Operator 2"); - expect(getOperationsByOperatorId).toHaveBeenCalledTimes(2); - expect(getOperationsByOperatorId).toHaveBeenCalledWith( - "8be4c7aa-6ab3-4aad-9206-0ef914fea064", - ); + 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 selectEntity("Facility"); + 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 selectEntity("Facility"); + await selectEntityAndAssertFields("Facility"); await selectOperation( /select the operation that the facility\(s\) currently belongs to\*/i, "Operation 1", @@ -209,7 +213,7 @@ describe("The TransferForm component", () => { renderTransferForm(); selectOperator(/current operator\*/i, "Operator 1"); selectOperator(/select the new operator\*/i, "Operator 2"); - await selectEntity("Operation"); + await selectEntityAndAssertFields("Operation"); await selectOperation(/operation\*/i, "Operation 1"); selectDateOfTransfer("2022-12-31"); // submit the form diff --git a/bciers/apps/administration/app/components/operations/fetchOperationsPageData.ts b/bciers/libs/actions/src/api/fetchOperationsPageData.ts similarity index 86% rename from bciers/apps/administration/app/components/operations/fetchOperationsPageData.ts rename to bciers/libs/actions/src/api/fetchOperationsPageData.ts index ea01e2f15e..4095f4293f 100644 --- a/bciers/apps/administration/app/components/operations/fetchOperationsPageData.ts +++ 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 d22de7f441..dd2e656e83 100644 --- a/bciers/libs/actions/src/api/index.ts +++ b/bciers/libs/actions/src/api/index.ts @@ -15,3 +15,4 @@ 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/mocks.ts b/bciers/libs/testConfig/src/mocks.ts index a361c47208..a842ec9ee6 100644 --- a/bciers/libs/testConfig/src/mocks.ts +++ b/bciers/libs/testConfig/src/mocks.ts @@ -33,7 +33,6 @@ const useSearchParams = vi.fn(); const notFound = vi.fn(); const useSession = vi.fn(); const auth = vi.fn(); -const fetchOperationsPageData = vi.fn(); const fetchTransferEventsPageData = vi.fn(); const getUserOperatorsPageData = vi.fn(); @@ -47,7 +46,6 @@ export { usePathname, useSearchParams, useSession, - fetchOperationsPageData, getUserOperatorsPageData, notFound, fetchTransferEventsPageData,