diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4fa4f8fa..e94e544130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# [1.19.0](https://github.com/bcgov/cas-registration/compare/v1.18.0...v1.19.0) (2025-01-22) + +### Bug Fixes + +- Add previously missed default emissions to alumina source types ([9568534](https://github.com/bcgov/cas-registration/commit/9568534f11ecb4940c550ab80b4df5760ace6630)) +- Block start button from redirecting if async doesn't return operation number ([aa99553](https://github.com/bcgov/cas-registration/commit/aa99553c399ef3c6643aae5c4cad332ffed1da7b)) +- filter out the Facilities crumb from the breadcrumb component and update tests ([a920690](https://github.com/bcgov/cas-registration/commit/a9206904d92adf9f7e9a645871728c9b4cf68b4c)) + +### Features + +- api endpoint to change a report version's report type ([83ee6dd](https://github.com/bcgov/cas-registration/commit/83ee6ddf8cf7562caf99c112da061bb1336d1577)) +- create external transfers pages ([42f138e](https://github.com/bcgov/cas-registration/commit/42f138e98d170f62852ce84689d622dc6a44c622)) + # [1.18.0](https://github.com/bcgov/cas-registration/compare/v1.17.1...v1.18.0) (2025-01-14) ### Bug Fixes diff --git a/bc_obps/Makefile b/bc_obps/Makefile index 758a958137..110e60ccf5 100644 --- a/bc_obps/Makefile +++ b/bc_obps/Makefile @@ -132,6 +132,12 @@ pythontests_coverage: ## run Python tests with coverage pythontests_coverage: $(POETRY_RUN) pytest --cov=. --cov-config=.coveragerc --cov-report=term-missing --no-cov-on-fail + +.PHONY: pythontests_parallel +pythontests_parallel: ## run Python tests in parallel for faster execution +pythontests_parallel: + $(POETRY_RUN) pytest -n auto + .PHONY: clear_db clear_db: ## Clear all data in the datbase clear_db: diff --git a/bc_obps/bc_obps/settings.py b/bc_obps/bc_obps/settings.py index dbfc2686e9..491375d085 100644 --- a/bc_obps/bc_obps/settings.py +++ b/bc_obps/bc_obps/settings.py @@ -66,31 +66,37 @@ # Application definition INSTALLED_APPS = [ + # Django apps "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + # Third-party apps 'simple_history', "corsheaders", "localflavor", + # Local apps "registration.apps.RegistrationConfig", "reporting.apps.ReportingConfig", "common.apps.CommonConfig", + "rls.apps.RlsConfig", ] MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", - "registration.middleware.kubernetes_middleware.KubernetesHealthCheckMiddleware", + "registration.middleware.kubernetes_health_check.KubernetesHealthCheckMiddleware", "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "registration.middleware.current_user_middleware.CurrentUserMiddleware", + "registration.middleware.current_user.CurrentUserMiddleware", + # RlsMiddleware must be after CurrentUserMiddleware(it depends on current_user attribute) + "rls.middleware.rls.RlsMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", 'simple_history.middleware.HistoryRequestMiddleware', diff --git a/bc_obps/common/migrations/0031_V1_19_0.py b/bc_obps/common/migrations/0031_V1_19_0.py new file mode 100644 index 0000000000..cec0028458 --- /dev/null +++ b/bc_obps/common/migrations/0031_V1_19_0.py @@ -0,0 +1,12 @@ +# Generated by Django 5.0.11 on 2025-01-22 21:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0030_V1_18_0'), + ] + + operations = [] 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 59d83c3c1b..28fd26b349 100644 --- a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py +++ b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py @@ -416,6 +416,7 @@ class TestEndpointPermissions(TestCase): {"method": "get", "endpoint_name": "list_user_operators"}, {"method": "get", "endpoint_name": "list_user_operators"}, {"method": "get", "endpoint_name": "list_transfer_events"}, + {"method": "get", "endpoint_name": "get_transfer_event", "kwargs": {"transfer_id": mock_uuid}}, ], "approved_authorized_roles": [ { @@ -564,6 +565,8 @@ class TestEndpointPermissions(TestCase): ], "cas_analyst": [ {"method": "post", "endpoint_name": "create_transfer_event"}, + {"method": "patch", "endpoint_name": "update_transfer_event", "kwargs": {"transfer_id": mock_uuid}}, + {"method": "delete", "endpoint_name": "delete_transfer_event", "kwargs": {"transfer_id": mock_uuid}}, ], } diff --git a/bc_obps/common/utils.py b/bc_obps/common/utils.py index a333955f5b..f685f74d28 100644 --- a/bc_obps/common/utils.py +++ b/bc_obps/common/utils.py @@ -1,5 +1,8 @@ +from typing import Optional + from django.apps import apps from django.core.management import call_command +from decimal import Decimal def reset_dashboard_data() -> None: @@ -26,3 +29,17 @@ def reset_dashboard_data() -> None: # Load the fixtures for fixture in fixture_files: call_command('loaddata', fixture) + + +def format_decimal(value: Decimal, decimal_places: int = 2) -> Optional[Decimal]: + """ + Formats a Decimal or numeric value to the specified number of decimal places + without rounding (truncates the extra digits). + """ + if value is None: + return None + try: + quantize_value = Decimal(f"1.{'0' * decimal_places}") + return value.quantize(quantize_value) + except (ValueError, TypeError): + raise ValueError(f"Cannot format the provided value: {value}") diff --git a/bc_obps/mypy.ini b/bc_obps/mypy.ini index 2abf36d1cc..651439821f 100644 --- a/bc_obps/mypy.ini +++ b/bc_obps/mypy.ini @@ -4,7 +4,6 @@ exclude = (?x) tests/ | migrations/ | commands/ | - registration/middleware/ | # Exclude files ^.*apps\.py$ | ^.*manage\.py$ | diff --git a/bc_obps/registration/api/v2/__init__.py b/bc_obps/registration/api/v2/__init__.py index af40303636..01cfac1124 100644 --- a/bc_obps/registration/api/v2/__init__.py +++ b/bc_obps/registration/api/v2/__init__.py @@ -42,3 +42,4 @@ from .user import user_profile, user_app_role from ._users import user_id from . import transfer_events +from ._transfer_events import transfer_id diff --git a/bc_obps/registration/api/v2/_contacts/contact_id.py b/bc_obps/registration/api/v2/_contacts/contact_id.py index b229c8e5f3..d9cea7545d 100644 --- a/bc_obps/registration/api/v2/_contacts/contact_id.py +++ b/bc_obps/registration/api/v2/_contacts/contact_id.py @@ -4,17 +4,18 @@ from registration.constants import CONTACT_TAGS from registration.models.contact import Contact from registration.schema.v1.contact import ContactIn, ContactOut -from service.contact_service import ContactService, ContactWithPlacesAssigned from common.api.utils import get_current_user_guid from registration.decorators import handle_http_errors from registration.api.router import router from registration.schema.generic import Message +from service.contact_service_v2 import ContactServiceV2, ContactWithPlacesAssigned +from service.contact_service import ContactService from service.error_service.custom_codes_4xx import custom_codes_4xx @router.get( "/contacts/{contact_id}", - response={200: ContactOut, custom_codes_4xx: Message}, + response={200: ContactWithPlacesAssigned, custom_codes_4xx: Message}, tags=CONTACT_TAGS, description="""Retrieves the details of a specific contact by its ID. The endpoint checks if the current user is authorized to access the contact. Industry users can only access contacts that are associated with their own operator. If an unauthorized user attempts to access the contact, an error is raised.""", @@ -23,7 +24,7 @@ ) @handle_http_errors() def get_contact(request: HttpRequest, contact_id: int) -> Tuple[Literal[200], Optional[ContactWithPlacesAssigned]]: - return 200, ContactService.get_with_places_assigned(get_current_user_guid(request), contact_id) + return 200, ContactServiceV2.get_with_places_assigned_v2(contact_id) @router.put( diff --git a/bc_obps/registration/api/v2/_operations/operation_id.py b/bc_obps/registration/api/v2/_operations/operation_id.py index 4329d45f53..964f97219f 100644 --- a/bc_obps/registration/api/v2/_operations/operation_id.py +++ b/bc_obps/registration/api/v2/_operations/operation_id.py @@ -1,6 +1,10 @@ from typing import Literal, Tuple from uuid import UUID -from registration.schema.v2.operation import OperationOutV2, OperationInformationIn, OperationOutWithDocuments +from registration.schema.v2.operation import ( + OperationInformationInUpdate, + OperationOutV2, + OperationOutWithDocuments, +) from common.permissions import authorize from django.http import HttpRequest from registration.constants import OPERATION_TAGS @@ -57,6 +61,6 @@ def get_operation_with_documents(request: HttpRequest, operation_id: UUID) -> Tu ) @handle_http_errors() def update_operation( - request: HttpRequest, operation_id: UUID, payload: OperationInformationIn + request: HttpRequest, operation_id: UUID, payload: OperationInformationInUpdate ) -> Tuple[Literal[200], Operation]: return 200, OperationServiceV2.update_operation(get_current_user_guid(request), payload, operation_id) diff --git a/bc_obps/registration/api/v2/_transfer_events/transfer_id.py b/bc_obps/registration/api/v2/_transfer_events/transfer_id.py new file mode 100644 index 0000000000..dc97fdad23 --- /dev/null +++ b/bc_obps/registration/api/v2/_transfer_events/transfer_id.py @@ -0,0 +1,53 @@ +from typing import Literal, Tuple +from uuid import UUID +from django.http import HttpRequest +from ninja.types import DictStrAny +from registration.constants import TRANSFER_EVENT_TAGS +from registration.models import TransferEvent +from common.api.utils import get_current_user_guid +from common.permissions import authorize +from registration.decorators import handle_http_errors +from registration.api.router import router +from registration.schema.generic import Message +from registration.schema.v2.transfer_event import TransferEventOut, TransferEventUpdateIn +from service.error_service.custom_codes_4xx import custom_codes_4xx +from service.transfer_event_service import TransferEventService + + +@router.get( + "/transfer-events/{uuid:transfer_id}", + response={200: TransferEventOut, custom_codes_4xx: Message}, + tags=TRANSFER_EVENT_TAGS, + description="""Retrieves the details of a specific transfer event by its ID. The endpoint checks if the current user is authorized to access the transfer event.""", + auth=authorize("authorized_irc_user"), +) +@handle_http_errors() +def get_transfer_event(request: HttpRequest, transfer_id: UUID) -> Tuple[Literal[200], TransferEvent]: + return 200, TransferEventService.get_if_authorized(get_current_user_guid(request), transfer_id) + + +@router.delete( + "/transfer-events/{uuid:transfer_id}", + response={200: DictStrAny, custom_codes_4xx: Message}, + tags=TRANSFER_EVENT_TAGS, + description="""Deletes a transfer event by its ID.""", + auth=authorize("cas_analyst"), +) +@handle_http_errors() +def delete_transfer_event(request: HttpRequest, transfer_id: UUID) -> Tuple[Literal[200], DictStrAny]: + TransferEventService.delete_transfer_event(get_current_user_guid(request), transfer_id) + return 200, {"success": True} + + +@router.patch( + "/transfer-events/{uuid:transfer_id}", + response={200: TransferEventOut, custom_codes_4xx: Message}, + tags=TRANSFER_EVENT_TAGS, + description="""Updates the details of an existing transfer event by its ID.""", + auth=authorize("cas_analyst"), +) +@handle_http_errors() +def update_transfer_event( + request: HttpRequest, transfer_id: UUID, payload: TransferEventUpdateIn +) -> Tuple[Literal[200], TransferEvent]: + return 200, TransferEventService.update_transfer_event(get_current_user_guid(request), transfer_id, payload) diff --git a/bc_obps/registration/api/v2/_user_operators/_user_operator_id/update_status.py b/bc_obps/registration/api/v2/_user_operators/_user_operator_id/update_status.py index 73b1b85561..80464ac911 100644 --- a/bc_obps/registration/api/v2/_user_operators/_user_operator_id/update_status.py +++ b/bc_obps/registration/api/v2/_user_operators/_user_operator_id/update_status.py @@ -3,7 +3,6 @@ from django.http import HttpRequest from uuid import UUID from registration.constants import USER_OPERATOR_TAGS -from service.user_operator_service import UserOperatorService from common.api.utils import get_current_user_guid from registration.decorators import handle_http_errors from registration.schema.v1 import ( @@ -14,6 +13,7 @@ from registration.models import UserOperator from service.error_service.custom_codes_4xx import custom_codes_4xx from registration.api.router import router +from service.user_operator_service_v2 import UserOperatorServiceV2 @router.put( @@ -29,4 +29,6 @@ def update_user_operator_status( request: HttpRequest, user_operator_id: UUID, payload: UserOperatorStatusUpdate ) -> Tuple[Literal[200], UserOperator]: - return 200, UserOperatorService.update_status(user_operator_id, payload, get_current_user_guid(request)) + return 200, UserOperatorServiceV2.update_status_and_create_contact( + user_operator_id, payload, get_current_user_guid(request) + ) diff --git a/bc_obps/registration/api/v2/operations.py b/bc_obps/registration/api/v2/operations.py index e565eaf00d..62ed6da3d2 100644 --- a/bc_obps/registration/api/v2/operations.py +++ b/bc_obps/registration/api/v2/operations.py @@ -33,7 +33,7 @@ def list_operations( request: HttpRequest, filters: OperationTimelineFilterSchema = Query(...), - sort_field: Optional[str] = "created_at", + sort_field: Optional[str] = "operation__created_at", sort_order: Optional[Literal["desc", "asc"]] = "desc", paginate_result: bool = Query(True, description="Whether to paginate the results"), ) -> QuerySet[OperationDesignatedOperatorTimeline]: diff --git a/bc_obps/registration/fixtures/mock/address.json b/bc_obps/registration/fixtures/mock/address.json index df7dc4140b..e99c5227d1 100644 --- a/bc_obps/registration/fixtures/mock/address.json +++ b/bc_obps/registration/fixtures/mock/address.json @@ -208,5 +208,13 @@ "province": "ON", "postal_code": "N2L 3G1" } + }, + { + "model": "registration.address", + "pk": 21, + "fields": { + "street_address": "Incomplete address", + "municipality": "Grove" + } } ] diff --git a/bc_obps/registration/fixtures/mock/contact.json b/bc_obps/registration/fixtures/mock/contact.json index 01d2083fc1..37052f3a22 100644 --- a/bc_obps/registration/fixtures/mock/contact.json +++ b/bc_obps/registration/fixtures/mock/contact.json @@ -4,11 +4,11 @@ "pk": 1, "fields": { "business_role": "Operation Representative", - "first_name": "John", - "last_name": "Doe", + "first_name": "Alice", + "last_name": "Art", "position_title": "Manager", "address": 1, - "email": "john.doe@example.com", + "email": "alice.art@example.com", "phone_number": "+16044011234" } }, @@ -17,11 +17,11 @@ "pk": 2, "fields": { "business_role": "Operation Representative", - "first_name": "Jane", - "last_name": "Smith", + "first_name": "Althea", + "last_name": "Ark", "position_title": "Manager", "address": 2, - "email": "jane.smith@example.com", + "email": "althea.ark@example.com", "phone_number": "+16044011234" } }, @@ -30,11 +30,11 @@ "pk": 3, "fields": { "business_role": "Operation Representative", - "first_name": "Alice", - "last_name": "Johnson", + "first_name": "Bill", + "last_name": "Blue", "position_title": "Manager", "address": 3, - "email": "alice.johnson@example.com", + "email": "bill.blue@example.com", "phone_number": "+16044011235" } }, @@ -56,11 +56,10 @@ "pk": 5, "fields": { "business_role": "Operation Representative", - "first_name": "Carol", - "last_name": "Davis", + "first_name": "Blair", + "last_name": "Balloons - no address", "position_title": "Manager", - "address": 5, - "email": "carol.davis@example.com", + "email": "blair.balloons@example.com", "phone_number": "+16044011237" } }, @@ -69,11 +68,11 @@ "pk": 6, "fields": { "business_role": "Operation Representative", - "first_name": "Dave", - "last_name": "Evans", + "first_name": "Bart", + "last_name": "Banker - incomplete address", "position_title": "Manager", - "address": 6, - "email": "dave.evans@example.com", + "address": 21, + "email": "bart.banker@example.com", "phone_number": "+16044011238" } }, @@ -135,9 +134,8 @@ "fields": { "business_role": "Operation Representative", "first_name": "Ivy", - "last_name": "Jones", + "last_name": "Jones - no address", "position_title": "Manager", - "address": 11, "email": "ivy.jones@example.com", "phone_number": "+16044011243" } @@ -148,9 +146,9 @@ "fields": { "business_role": "Operation Representative", "first_name": "Jack", - "last_name": "King", + "last_name": "King - incomplete address", "position_title": "Manager", - "address": 12, + "address": 21, "email": "jack.king@example.com", "phone_number": "+16044011244" } diff --git a/bc_obps/registration/fixtures/mock/operation.json b/bc_obps/registration/fixtures/mock/operation.json index bf010d645a..ccc9e5074e 100644 --- a/bc_obps/registration/fixtures/mock/operation.json +++ b/bc_obps/registration/fixtures/mock/operation.json @@ -14,7 +14,8 @@ "bc_obps_regulated_operation": "24-0014", "created_at": "2024-2-01T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "Reporting Operation" + "registration_purpose": "Reporting Operation", + "contacts": [1, 2] } }, { @@ -34,8 +35,8 @@ "bc_obps_regulated_operation": "24-0015", "created_at": "2024-2-02T15:27:00.000Z", "activities": [1, 5], - "contacts": [10], - "registration_purpose": "OBPS Regulated Operation" + "registration_purpose": "OBPS Regulated Operation", + "contacts": [3, 4] } }, { @@ -53,7 +54,6 @@ "status": "Draft", "created_at": "2024-2-02T15:27:00.000Z", "activities": [1, 5], - "contacts": [10], "registration_purpose": "OBPS Regulated Operation" } }, @@ -75,8 +75,8 @@ "bc_obps_regulated_operation": "23-0001", "created_at": "2024-1-31T15:27:00.000Z", "activities": [1, 3], - "contacts": [14, 15], - "registration_purpose": "OBPS Regulated Operation" + "registration_purpose": "OBPS Regulated Operation", + "contacts": [3, 4] } }, { @@ -97,8 +97,8 @@ "bc_obps_regulated_operation": "23-0002", "created_at": "2024-1-30T15:27:00.000Z", "activities": [1, 5], - "contacts": [10, 11, 12], - "registration_purpose": "OBPS Regulated Operation" + "registration_purpose": "OBPS Regulated Operation", + "contacts": [1] } }, { @@ -120,7 +120,8 @@ "bc_obps_regulated_operation": "24-0003", "created_at": "2024-1-29T15:27:00.000Z", "activities": [1, 5], - "operation_has_multiple_operators": true + "operation_has_multiple_operators": true, + "contacts": [3] } }, { @@ -141,7 +142,8 @@ "bc_obps_regulated_operation": "24-0004", "created_at": "2024-1-28T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "Opted-in Operation" + "registration_purpose": "Opted-in Operation", + "contacts": [1] } }, { @@ -161,7 +163,8 @@ "bc_obps_regulated_operation": "24-0005", "created_at": "2024-1-27T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "Potential Reporting Operation" + "registration_purpose": "Potential Reporting Operation", + "contacts": [2] } }, { @@ -181,7 +184,8 @@ "bc_obps_regulated_operation": "24-0006", "created_at": "2024-1-26T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "Electricity Import Operation" + "registration_purpose": "Electricity Import Operation", + "contacts": [1] } }, { @@ -202,7 +206,8 @@ "bc_obps_regulated_operation": "24-0007", "created_at": "2024-1-25T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "New Entrant Operation" + "registration_purpose": "New Entrant Operation", + "contacts": [1] } }, { @@ -221,7 +226,8 @@ "bc_obps_regulated_operation": "24-0008", "created_at": "2024-1-24T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "Reporting Operation" + "registration_purpose": "Reporting Operation", + "contacts": [2] } }, { @@ -242,7 +248,8 @@ "bc_obps_regulated_operation": "24-0009", "created_at": "2024-1-23T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "OBPS Regulated Operation" + "registration_purpose": "OBPS Regulated Operation", + "contacts": [1, 2] } }, { @@ -262,7 +269,8 @@ "bc_obps_regulated_operation": "24-0010", "created_at": "2024-1-22T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "Reporting Operation" + "registration_purpose": "Reporting Operation", + "contacts": [1, 2] } }, { @@ -282,7 +290,8 @@ "bc_obps_regulated_operation": "24-0011", "created_at": "2024-1-21T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "OBPS Regulated Operation" + "registration_purpose": "OBPS Regulated Operation", + "contacts": [1] } }, { @@ -360,7 +369,8 @@ "bc_obps_regulated_operation": "24-0012", "created_at": "2024-1-17T15:27:00.000Z", "activities": [1, 5], - "registration_purpose": "OBPS Regulated Operation" + "registration_purpose": "OBPS Regulated Operation", + "contacts": [3] } }, { @@ -399,7 +409,8 @@ "bc_obps_regulated_operation": "24-0013", "registration_purpose": "New Entrant Operation", "created_at": "2024-1-15T15:27:00.000Z", - "activities": [1, 3] + "activities": [1, 3], + "contacts": [4] } }, { @@ -419,7 +430,8 @@ "bc_obps_regulated_operation": "24-0016", "created_at": "2024-1-14T15:27:00.000Z", "registration_purpose": "Reporting Operation", - "activities": [1, 5] + "activities": [1, 5], + "contacts": [3] } }, { @@ -438,7 +450,8 @@ "bc_obps_regulated_operation": "24-0017", "created_at": "2024-1-13T15:27:00.000Z", "registration_purpose": "Reporting Operation", - "activities": [1, 5] + "activities": [1, 5], + "contacts": [3] } }, { @@ -457,7 +470,8 @@ "bc_obps_regulated_operation": "24-0018", "created_at": "2024-1-12T15:27:00.000Z", "registration_purpose": "Reporting Operation", - "activities": [1, 5] + "activities": [1, 5], + "contacts": [4] } }, { diff --git a/bc_obps/registration/fixtures/mock/operator.json b/bc_obps/registration/fixtures/mock/operator.json index eb90f3358b..5da7318310 100644 --- a/bc_obps/registration/fixtures/mock/operator.json +++ b/bc_obps/registration/fixtures/mock/operator.json @@ -8,7 +8,8 @@ "bc_corporate_registry_number": "abc1234567", "business_structure": "Sole Proprietorship", "status": "Approved", - "is_new": false + "is_new": false, + "contacts": [1, 2] } }, { @@ -20,9 +21,9 @@ "bc_corporate_registry_number": "def1234567", "business_structure": "General Partnership", "mailing_address": 3, - "contacts": [10, 11, 12, 13, 14, 15], "status": "Approved", - "is_new": false + "is_new": false, + "contacts": [3, 4, 5, 6] } }, { @@ -34,7 +35,6 @@ "bc_corporate_registry_number": "ghi1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -48,7 +48,6 @@ "bc_corporate_registry_number": "jkl1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1, 2], "status": "Approved", "is_new": false } @@ -62,7 +61,6 @@ "bc_corporate_registry_number": "mno1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -76,7 +74,6 @@ "bc_corporate_registry_number": "pqr1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -90,7 +87,6 @@ "bc_corporate_registry_number": "stu1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -104,7 +100,6 @@ "bc_corporate_registry_number": "vwx1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -118,7 +113,6 @@ "bc_corporate_registry_number": "yza1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -132,7 +126,6 @@ "bc_corporate_registry_number": "bcd1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -146,7 +139,6 @@ "bc_corporate_registry_number": "efg1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -160,7 +152,6 @@ "bc_corporate_registry_number": "hij1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -174,7 +165,6 @@ "bc_corporate_registry_number": "klm1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -188,7 +178,6 @@ "bc_corporate_registry_number": "nop1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -202,7 +191,6 @@ "bc_corporate_registry_number": "qrs1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } @@ -216,7 +204,6 @@ "bc_corporate_registry_number": "qrs1234567", "business_structure": "BC Corporation", "mailing_address": 4, - "contacts": [1], "status": "Approved", "is_new": false } diff --git a/bc_obps/registration/fixtures/mock/transfer_event.json b/bc_obps/registration/fixtures/mock/transfer_event.json index 7bf7ab57c4..82c543b38e 100644 --- a/bc_obps/registration/fixtures/mock/transfer_event.json +++ b/bc_obps/registration/fixtures/mock/transfer_event.json @@ -43,5 +43,24 @@ "status": "Complete", "effective_date": "2024-12-25T09:00:00Z" } + }, + { + "model": "registration.transferevent", + "fields": { + "created_by": "00000000-0000-0000-0000-000000000028", + "created_at": "2025-01-10T23:42:44.039Z", + "updated_by": "00000000-0000-0000-0000-000000000028", + "updated_at": "2025-01-10T23:50:44.039Z", + "facilities": [ + "f486f2fb-62ed-438d-bb3e-0819b51e3af3", + "f486f2fb-62ed-438d-bb3e-0819b51e3af4" + ], + "from_operator": "4242ea9d-b917-4129-93c2-db00b7451051", + "to_operator": "4242ea9d-b917-4129-93c2-db00b7451051", + "from_operation": "002d5a9e-32a6-4191-938c-2c02bfec592d", + "to_operation": "59d95661-c752-489b-9fd1-0c3fa3454dda", + "status": "To be transferred", + "effective_date": "2026-03-01T09:00:00Z" + } } ] diff --git a/bc_obps/registration/fixtures/mock/user_operator.json b/bc_obps/registration/fixtures/mock/user_operator.json index e79119180a..d010857588 100644 --- a/bc_obps/registration/fixtures/mock/user_operator.json +++ b/bc_obps/registration/fixtures/mock/user_operator.json @@ -29,7 +29,7 @@ "user": "279c80cf57814c28872740a133d17c0d", "operator": "4242ea9d-b917-4129-93c2-db00b7451051", "role": "pending", - "status": "Declined", + "status": "Pending", "verified_at": "2024-02-27 06:24:57.293242-08", "verified_by": "58f255ed-8d46-44ee-b2fe-9f8d3d92c684", "user_friendly_id": 3 diff --git a/bc_obps/registration/middleware/current_user.py b/bc_obps/registration/middleware/current_user.py new file mode 100644 index 0000000000..bbec692d22 --- /dev/null +++ b/bc_obps/registration/middleware/current_user.py @@ -0,0 +1,59 @@ +import json +import logging +from typing import Callable, Optional +from uuid import UUID +from django.core.cache import cache +from django.http import JsonResponse, HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 +from registration.constants import USER_CACHE_PREFIX +from registration.models import User + +logger = logging.getLogger(__name__) + + +class CurrentUserMiddleware: + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + auth_header = request.headers.get("Authorization") + if auth_header: + try: + user_guid = self._extract_user_guid(auth_header) + user = self.get_or_cache_user(user_guid) + setattr(request, "current_user", user) + except ValueError as e: + logger.warning(f"Invalid Authorization header: {e}", exc_info=True) + return JsonResponse({"error": "Invalid Authorization header"}, status=400) + except Exception as e: + logger.error(f"Unexpected error in CurrentUserMiddleware: {e}", exc_info=True) + + return self.get_response(request) + + @staticmethod + def _extract_user_guid(auth_header: str) -> UUID: + """ + Extracts and validates the user GUID from the Authorization header. + """ + try: + user_data = json.loads(auth_header) + user_guid = user_data.get("user_guid") + if not user_guid: + raise ValueError("Missing user_guid in Authorization header") + return UUID(user_guid, version=4) + except (KeyError, ValueError, TypeError) as e: + raise ValueError(f"Failed to extract user GUID: {e}") + + @staticmethod + def get_or_cache_user(user_guid: UUID) -> User: + """ + Retrieves the full user object from the cache or database. + """ + cache_key = f"{USER_CACHE_PREFIX}{user_guid}" + user: Optional[User] = cache.get(cache_key) + + if not user: + user = get_object_or_404(User, user_guid=user_guid) + cache.set(cache_key, user, 300) # Cache for 5 minutes + + return user diff --git a/bc_obps/registration/middleware/current_user_middleware.py b/bc_obps/registration/middleware/current_user_middleware.py deleted file mode 100644 index a1600acd9e..0000000000 --- a/bc_obps/registration/middleware/current_user_middleware.py +++ /dev/null @@ -1,36 +0,0 @@ -import json -from uuid import UUID -from django.core.cache import cache -from django.shortcuts import get_object_or_404 -from registration.constants import USER_CACHE_PREFIX -from registration.models import User - - -class CurrentUserMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - auth_header = request.headers.get('Authorization') - if auth_header: - try: - user_guid = UUID(json.loads(auth_header).get('user_guid'), version=4) - - # Try to get the user from cache - cache_key = f"{USER_CACHE_PREFIX}{user_guid}" - user = cache.get(cache_key) - - if not user: - # If user is not in cache, fetch from database - user = get_object_or_404(User, user_guid=user_guid) - - # Cache the user for 5 minutes - cache.set(cache_key, user, 300) - - request.current_user = user # Attach user to request for access in API endpoints - - except Exception as e: - print(e) - - response = self.get_response(request) - return response diff --git a/bc_obps/registration/middleware/kubernetes_middleware.py b/bc_obps/registration/middleware/kubernetes_health_check.py similarity index 75% rename from bc_obps/registration/middleware/kubernetes_middleware.py rename to bc_obps/registration/middleware/kubernetes_health_check.py index 3edee7f647..95675b1211 100644 --- a/bc_obps/registration/middleware/kubernetes_middleware.py +++ b/bc_obps/registration/middleware/kubernetes_health_check.py @@ -1,30 +1,32 @@ import logging - -from django.http import HttpResponse, HttpResponseServerError +from typing import Callable +from django.http import HttpResponse, HttpResponseServerError, HttpRequest logger = logging.getLogger("liveness") class KubernetesHealthCheckMiddleware(object): - def __init__(self, get_response): + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): self.get_response = get_response # One-time configuration and initialization. - def __call__(self, request): + def __call__(self, request: HttpRequest) -> HttpResponse: if request.method == "GET": if request.path == "/readiness": - return self.readiness(request) + return self.readiness() elif request.path == "/liveness": - return self.liveness(request) + return self.liveness() return self.get_response(request) - def liveness(self, request): + @staticmethod + def liveness() -> HttpResponse: """ Returns that the server is alive. """ return HttpResponse("OK. Server is running.") - def readiness(self, request): + @staticmethod + def readiness() -> HttpResponse: # Connect to each database and do a generic standard SQL query # that doesn't write any data and doesn't depend on any tables # being present. @@ -49,8 +51,8 @@ def readiness(self, request): for cache in caches.all(): if isinstance(cache, BaseMemcachedCache): - stats = cache._cache.get_stats() - if len(stats) != len(cache._servers): + stats = cache._cache.get_stats() # type: ignore[attr-defined] + if len(stats) != len(cache._servers): # type: ignore[attr-defined] return HttpResponseServerError("cache: cannot connect to cache.") except Exception as e: logger.exception(e) diff --git a/bc_obps/registration/migrations/0069_V1_19_0.py b/bc_obps/registration/migrations/0069_V1_19_0.py new file mode 100644 index 0000000000..78867a1c8b --- /dev/null +++ b/bc_obps/registration/migrations/0069_V1_19_0.py @@ -0,0 +1,12 @@ +# Generated by Django 5.0.11 on 2025-01-22 21:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0068_V1_18_0'), + ] + + operations = [] diff --git a/bc_obps/registration/schema/v2/contact.py b/bc_obps/registration/schema/v2/contact.py index 5fe1d761d2..f2529960cc 100644 --- a/bc_obps/registration/schema/v2/contact.py +++ b/bc_obps/registration/schema/v2/contact.py @@ -2,6 +2,21 @@ from ninja import ModelSchema, Field from registration.models import Contact from ninja import FilterSchema +from uuid import UUID + + +from registration.schema.v1.contact import ContactOut +from ninja import Schema + + +class PlacesAssigned(Schema): + role_name: str + operation_name: str + operation_id: UUID + + +class ContactWithPlacesAssigned(ContactOut): + places_assigned: Optional[list[PlacesAssigned]] class ContactListOutV2(ModelSchema): diff --git a/bc_obps/registration/schema/v2/operation.py b/bc_obps/registration/schema/v2/operation.py index 92d07f420d..1025b50238 100644 --- a/bc_obps/registration/schema/v2/operation.py +++ b/bc_obps/registration/schema/v2/operation.py @@ -112,6 +112,10 @@ class Meta: fields = ["name", 'type'] +class OperationInformationInUpdate(OperationInformationIn): + operation_representatives: List[int] + + class OptedInOperationDetailOut(ModelSchema): class Meta: model = OptedInOperationDetail @@ -153,6 +157,11 @@ class OperationOutV2(ModelSchema): date_of_first_shipment: Optional[str] = None new_entrant_application: Optional[str] = None bcghg_id: Optional[str] = Field(None, alias="bcghg_id.id") + operation_representatives: Optional[List[int]] = [] + + @staticmethod + def resolve_operation_representatives(obj: Operation) -> List[int]: + return list(obj.contacts.filter(business_role='Operation Representative').values_list('id', flat=True)) @staticmethod def resolve_multiple_operators_array(obj: Operation) -> List[MultipleOperator]: @@ -176,7 +185,16 @@ def resolve_operator(obj: Operation, context: DictStrAny) -> Optional[Operator]: class Meta: model = Operation - fields = ["id", 'name', 'type', 'opt_in', 'regulated_products', 'status', 'activities', 'opted_in_operation'] + fields = [ + "id", + 'name', + 'type', + 'opt_in', + 'regulated_products', + 'status', + 'activities', + 'opted_in_operation', + ] from_attributes = True diff --git a/bc_obps/registration/schema/v2/operation_timeline.py b/bc_obps/registration/schema/v2/operation_timeline.py index a0672d3e5b..1b9d8b712d 100644 --- a/bc_obps/registration/schema/v2/operation_timeline.py +++ b/bc_obps/registration/schema/v2/operation_timeline.py @@ -39,3 +39,4 @@ class OperationTimelineFilterSchema(FilterSchema): ) operator__legal_name: Optional[str] = Field(None, json_schema_extra={'q': 'operator__legal_name__icontains'}) operator_id: Optional[UUID] = Field(None, json_schema_extra={'q': 'operator__id__exact'}) + end_date: Optional[bool] = Field(None, json_schema_extra={'q': 'end_date__isnull'}) diff --git a/bc_obps/registration/schema/v2/transfer_event.py b/bc_obps/registration/schema/v2/transfer_event.py index 83873feeec..782950da8b 100644 --- a/bc_obps/registration/schema/v2/transfer_event.py +++ b/bc_obps/registration/schema/v2/transfer_event.py @@ -1,8 +1,9 @@ -import uuid -from typing import Optional, List, Literal +from typing import Optional, List, Literal, Dict, Union from uuid import UUID from ninja import ModelSchema, Field, FilterSchema -from registration.models import TransferEvent + +from common.utils import format_decimal +from registration.models import TransferEvent, Facility from django.db.models import Q import re @@ -12,11 +13,10 @@ 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 = Field(default_factory=uuid.uuid4) class Meta: model = TransferEvent - fields = ['effective_date', 'status', 'created_at'] + fields = ['id', 'effective_date', 'status', 'created_at'] class TransferEventFilterSchema(FilterSchema): @@ -55,21 +55,56 @@ class Meta: fields = ['effective_date'] +FacilitiesNameType = List[Dict[str, Union[str, UUID]]] + + class TransferEventOut(ModelSchema): transfer_entity: str + from_operator: str = Field(alias="from_operator.legal_name") + from_operator_id: UUID = Field(alias="from_operator.id") + to_operator: str = Field(alias="to_operator.legal_name") + operation_name: Optional[str] = Field(None, alias="operation.name") + from_operation: Optional[str] = Field(None, alias="from_operation.name") + from_operation_id: Optional[UUID] = Field(None, alias="from_operation.id") + to_operation: Optional[str] = Field(None, alias="to_operation.name") + # This is only required to display the existing facilities in the frontend in the format "Facility (lat, long)" + existing_facilities: FacilitiesNameType class Meta: model = TransferEvent - fields = [ - 'from_operator', - 'to_operator', - 'effective_date', - 'operation', - 'from_operation', - 'to_operation', - 'facilities', - ] + fields = ['status', 'effective_date', 'operation', 'facilities'] @staticmethod def resolve_transfer_entity(obj: TransferEvent) -> str: return "Facility" if obj.facilities.exists() else "Operation" + + @staticmethod + def resolve_existing_facilities(obj: TransferEvent) -> FacilitiesNameType: + def format_facility_name(facility: Facility) -> str: + if ( + facility.latitude_of_largest_emissions is not None + and facility.longitude_of_largest_emissions is not None + ): + return f"{facility.name} ({format_decimal(facility.latitude_of_largest_emissions)}, {format_decimal(facility.longitude_of_largest_emissions)})" + return facility.name + + if not obj.facilities.exists(): + return [] + + return [ + { + "id": facility.id, + "name": format_facility_name(facility), + } + for facility in obj.facilities.all() + ] + + +class TransferEventUpdateIn(ModelSchema): + transfer_entity: Literal["Operation", "Facility"] + operation: Optional[UUID] = None + facilities: Optional[List[UUID]] = None + + class Meta: + model = TransferEvent + fields = ['effective_date'] diff --git a/bc_obps/registration/tests/endpoints/v1/user/test_user_profile.py b/bc_obps/registration/tests/endpoints/v1/user/test_user_profile.py index fc3ffd21e7..4168bf4f47 100644 --- a/bc_obps/registration/tests/endpoints/v1/user/test_user_profile.py +++ b/bc_obps/registration/tests/endpoints/v1/user/test_user_profile.py @@ -106,10 +106,10 @@ def test_create_user_profile_idir(self): # Assert assert response.status_code == 200 - # Additional Assertions (If BYPASS_ROLE_ASSIGNMENT is True, app_role should be cas_admin, otherwise cas_pending) + # Additional Assertions (If BYPASS_ROLE_ASSIGNMENT is True, app_role should be cas_analyst, otherwise cas_pending) assert ( - 'app_role' in content and content["app_role"]["role_name"] == 'cas_admin' + 'app_role' in content and content["app_role"]["role_name"] == 'cas_analyst' if settings.BYPASS_ROLE_ASSIGNMENT else 'cas_pending' ) diff --git a/bc_obps/registration/tests/endpoints/v2/_contacts/test_contact_id.py b/bc_obps/registration/tests/endpoints/v2/_contacts/test_contact_id.py index ad2cc52c2f..5833b94a0c 100644 --- a/bc_obps/registration/tests/endpoints/v2/_contacts/test_contact_id.py +++ b/bc_obps/registration/tests/endpoints/v2/_contacts/test_contact_id.py @@ -36,7 +36,7 @@ def test_industry_users_can_get_contacts_associated_with_their_operator(self): response = TestUtils.mock_get_with_auth_role( self, - endpoint=custom_reverse_lazy("v1_get_contact", kwargs={"contact_id": contact.id}), + endpoint=custom_reverse_lazy("get_contact", kwargs={"contact_id": contact.id}), role_name="industry_user", ) assert response.status_code == 200 @@ -44,7 +44,13 @@ def test_industry_users_can_get_contacts_associated_with_their_operator(self): assert response_json.get('first_name') == contact.first_name assert response_json.get('last_name') == contact.last_name assert response_json.get('email') == contact.email - assert response_json.get('places_assigned') == [f"Operation Representative - {operation.name}"] + assert response_json.get('places_assigned') == [ + { + 'role_name': 'Operation Representative', + 'operation_name': operation.name, + 'operation_id': str(operation.id), + } + ] def test_industry_users_cannot_get_contacts_not_associated_with_their_operator(self): contact = contact_baker() diff --git a/bc_obps/registration/tests/endpoints/v2/_operations/test_operation_id.py b/bc_obps/registration/tests/endpoints/v2/_operations/test_operation_id.py index c1e835ee02..f278055fe2 100644 --- a/bc_obps/registration/tests/endpoints/v2/_operations/test_operation_id.py +++ b/bc_obps/registration/tests/endpoints/v2/_operations/test_operation_id.py @@ -2,6 +2,7 @@ from registration.models import ( UserOperator, ) +from registration.models.operation import Operation from registration.tests.constants import MOCK_DATA_URL from registration.tests.utils.helpers import CommonTestSetup, TestUtils from registration.tests.utils.bakers import operation_baker, operator_baker @@ -106,7 +107,11 @@ def test_operations_with_documents_endpoint_get_success(self): def test_operations_endpoint_put_success(self): approved_user_operator = baker.make_recipe('utils.approved_user_operator', user=self.user) - operation = baker.make_recipe('utils.operation', operator=approved_user_operator.operator) + operation = baker.make_recipe( + 'utils.operation', operator=approved_user_operator.operator, status=Operation.Statuses.REGISTERED + ) + contact = baker.make_recipe('utils.contact') + self.test_payload["operation_representatives"] = [contact.id] response = TestUtils.mock_put_with_auth_role( self, "industry_user", diff --git a/bc_obps/registration/tests/endpoints/v2/_transfer_events/test_transfer_id.py b/bc_obps/registration/tests/endpoints/v2/_transfer_events/test_transfer_id.py new file mode 100644 index 0000000000..b2b25c5a5a --- /dev/null +++ b/bc_obps/registration/tests/endpoints/v2/_transfer_events/test_transfer_id.py @@ -0,0 +1,118 @@ +from unittest.mock import patch, MagicMock +from uuid import uuid4 +import pytest +from registration.models import AppRole, TransferEvent +from registration.schema.v2.transfer_event import TransferEventUpdateIn +from registration.tests.utils.helpers import CommonTestSetup, TestUtils +from registration.utils import custom_reverse_lazy +from model_bakery import baker +from datetime import datetime + + +class TestTransferIdEndpoint(CommonTestSetup): + @patch("service.transfer_event_service.TransferEventService.get_if_authorized") + def test_authorized_users_can_get_transfer_event(self, mock_get_if_authorized: MagicMock): + transfer_event = baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) + mock_get_if_authorized.return_value = transfer_event + for role in AppRole.get_authorized_irc_roles(): + response = TestUtils.mock_get_with_auth_role( + self, role, custom_reverse_lazy('get_transfer_event', kwargs={'transfer_id': transfer_event.id}) + ) + mock_get_if_authorized.assert_called_once_with(self.user.pk, transfer_event.id) + mock_get_if_authorized.reset_mock() + assert response.status_code == 200 + response_json = response.json() + assert set(response_json.keys()) == { + 'transfer_entity', + 'from_operator', + 'from_operator_id', + 'to_operator', + 'operation_name', + 'from_operation', + 'from_operation_id', + 'to_operation', + 'existing_facilities', + 'status', + 'effective_date', + 'operation', + 'facilities', + } + assert response_json['transfer_entity'] == "Operation" + assert response_json['from_operator'] == transfer_event.from_operator.legal_name + assert response_json['from_operator_id'] == str(transfer_event.from_operator.id) + assert response_json['to_operator'] == transfer_event.to_operator.legal_name + assert response_json['operation_name'] == transfer_event.operation.name + assert response_json['from_operation'] is None + assert response_json['from_operation_id'] is None + assert response_json['to_operation'] is None + assert response_json['existing_facilities'] == [] + # 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(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(transfer_event.operation.id) + assert response_json['facilities'] == [] + assert response_json['status'] == TransferEvent.Statuses.TO_BE_TRANSFERRED + + @patch("service.transfer_event_service.TransferEventService.delete_transfer_event") + def test_cas_analyst_can_delete_transfer_event(self, mock_delete_transfer_event: MagicMock): + random_transfer_event_id = uuid4() + mock_delete_transfer_event.return_value = None + response = TestUtils.mock_delete_with_auth_role( + self, + "cas_analyst", + custom_reverse_lazy('delete_transfer_event', kwargs={'transfer_id': random_transfer_event_id}), + ) + mock_delete_transfer_event.assert_called_once_with(self.user.pk, random_transfer_event_id) + assert response.status_code == 200 + response_json = response.json() + assert response_json == {"success": True} + + @patch("service.transfer_event_service.TransferEventService.update_transfer_event") + @pytest.mark.parametrize("entity", ["Operation", "Facility"]) + def test_cas_analyst_can_update_transfer_event(self, mock_update_transfer_event: MagicMock, entity): + transfer_event = baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) + mock_update_transfer_event.return_value = transfer_event + mock_payload = { + "transfer_entity": entity, + "effective_date": "2023-10-13", + } + if entity == "Facility": + mock_payload['facilities'] = [uuid4(), uuid4()] + elif entity == "Operation": + mock_payload['operation'] = uuid4() + + response = TestUtils.mock_patch_with_auth_role( + self, + "cas_analyst", + self.content_type, + endpoint=custom_reverse_lazy('update_transfer_event', kwargs={'transfer_id': transfer_event.id}), + data=mock_payload, + ) + mock_update_transfer_event.assert_called_once_with( + self.user.pk, + transfer_event.id, + # override the effective date to match the format of the response + TransferEventUpdateIn.model_construct( + **{**mock_payload, "effective_date": datetime.strptime(mock_payload['effective_date'], "%Y-%m-%d")} + ), + ) + assert response.status_code == 200 + response_json = response.json() + assert set(response_json.keys()) == { + 'transfer_entity', + 'from_operator', + 'from_operator_id', + 'to_operator', + 'operation_name', + 'from_operation', + 'from_operation_id', + 'to_operation', + 'existing_facilities', + 'status', + 'effective_date', + 'operation', + 'facilities', + } diff --git a/bc_obps/registration/tests/endpoints/v2/_user_operators/_user_operator_id/test_user_operator_update_status.py b/bc_obps/registration/tests/endpoints/v2/_user_operators/_user_operator_id/test_user_operator_update_status.py index e2287ff188..0ab3693886 100644 --- a/bc_obps/registration/tests/endpoints/v2/_user_operators/_user_operator_id/test_user_operator_update_status.py +++ b/bc_obps/registration/tests/endpoints/v2/_user_operators/_user_operator_id/test_user_operator_update_status.py @@ -1,8 +1,5 @@ -from registration.enums.enums import AccessRequestStates, AccessRequestTypes from model_bakery import baker from registration.models import ( - BusinessRole, - Contact, Operator, User, UserOperator, @@ -13,6 +10,7 @@ ) from registration.tests.utils.helpers import CommonTestSetup, TestUtils from registration.utils import custom_reverse_lazy +from registration.enums.enums import AccessRequestStates, AccessRequestTypes class TestUpdateUserOperatorStatusEndpoint(CommonTestSetup): @@ -21,7 +19,7 @@ def test_industry_user_approves_access_request(self, mocker): TestUtils.authorize_current_user_as_operator_user(self, operator=operator) subsequent_user_operator = baker.make(UserOperator, operator=operator) mock_send_operator_access_request_email = mocker.patch( - "service.user_operator_service.send_operator_access_request_email" + "service.user_operator_service_v2.send_operator_access_request_email" ) response = TestUtils.mock_put_with_auth_role( self, @@ -38,7 +36,7 @@ def test_industry_user_approves_access_request(self, mocker): }, ), ) - # Assert that the email notification was called + # # Assert that the email notification was called mock_send_operator_access_request_email.assert_called_once_with( AccessRequestStates.APPROVED, AccessRequestTypes.OPERATOR_WITH_ADMIN, @@ -73,10 +71,16 @@ def test_industry_user_cannot_approve_access_request_from_a_different_operator( assert response.status_code == 403 def test_cas_analyst_approves_access_request_with_existing_operator(self, mocker): - operator = operator_baker({"status": Operator.Statuses.APPROVED, "is_new": False}) - user_operator = user_operator_baker({"operator": operator}) + + approved_admin_user_operator = baker.make_recipe( + 'utils.approved_user_operator', role=UserOperator.Roles.ADMIN, user=self.user + ) + pending_user_operator = baker.make_recipe('utils.user_operator', operator=approved_admin_user_operator.operator) + + pending_user_operator.user.business_guid = approved_admin_user_operator.user.business_guid + mock_send_operator_access_request_email = mocker.patch( - "service.user_operator_service.send_operator_access_request_email" + "service.user_operator_service_v2.send_operator_access_request_email" ) response_2 = TestUtils.mock_put_with_auth_role( self, @@ -89,22 +93,22 @@ def test_cas_analyst_approves_access_request_with_existing_operator(self, mocker custom_reverse_lazy( "update_user_operator_status", kwargs={ - "user_operator_id": user_operator.id, + "user_operator_id": pending_user_operator.id, }, ), ) assert response_2.status_code == 200 - user_operator.refresh_from_db() # refresh the user_operator object to get the updated status - assert user_operator.status == UserOperator.Statuses.APPROVED - assert user_operator.role == UserOperator.Roles.ADMIN - assert user_operator.verified_by == self.user + pending_user_operator.refresh_from_db() # refresh the pending_user_operator object to get the updated status + assert pending_user_operator.status == UserOperator.Statuses.APPROVED + assert pending_user_operator.role == UserOperator.Roles.ADMIN + assert pending_user_operator.verified_by == self.user # Assert that the email notification was called mock_send_operator_access_request_email.assert_called_once_with( AccessRequestStates.APPROVED, AccessRequestTypes.ADMIN, - operator.legal_name, - user_operator.user.get_full_name(), - user_operator.user.email, + approved_admin_user_operator.operator.legal_name, + pending_user_operator.user.get_full_name(), + pending_user_operator.user.email, ) def test_cas_director_approves_admin_access_request_with_new_operator(self, mocker): @@ -113,7 +117,7 @@ def test_cas_director_approves_admin_access_request_with_new_operator(self, mock operator = operator_baker({'status': Operator.Statuses.APPROVED, 'is_new': False, 'created_by': self.user}) user_operator = user_operator_baker({'operator': operator, 'user': operator.created_by}) mock_send_operator_access_request_email = mocker.patch( - "service.user_operator_service.send_operator_access_request_email" + "service.user_operator_service_v2.send_operator_access_request_email" ) response_2 = TestUtils.mock_put_with_auth_role( self, @@ -154,16 +158,9 @@ def test_cas_analyst_declines_access_request(self, mocker): user_operator.user_id = user.user_guid user_operator.operator = operator user_operator.save(update_fields=["user_id", "operator_id"]) - # Assigning contacts to the operator of the user_operator - contacts = baker.make( - Contact, - _quantity=2, - created_by=user_operator.user, - business_role=BusinessRole.objects.get(role_name="Senior Officer"), - ) - user_operator.operator.contacts.set(contacts) + mock_send_operator_access_request_email = mocker.patch( - "service.user_operator_service.send_operator_access_request_email" + "service.user_operator_service_v2.send_operator_access_request_email" ) # Now decline the user_operator and make sure the contacts are deleted response = TestUtils.mock_put_with_auth_role( @@ -185,8 +182,6 @@ def test_cas_analyst_declines_access_request(self, mocker): assert user_operator.status == UserOperator.Statuses.DECLINED assert user_operator.role == UserOperator.Roles.PENDING assert user_operator.verified_by == self.user - assert user_operator.operator.contacts.count() == 0 - assert Contact.objects.count() == 0 # Assert that the email notification was sent mock_send_operator_access_request_email.assert_called_once_with( AccessRequestStates.DECLINED, 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 4b8b80f043..858e8ee7e0 100644 --- a/bc_obps/registration/tests/endpoints/v2/test_transfer_events.py +++ b/bc_obps/registration/tests/endpoints/v2/test_transfer_events.py @@ -4,6 +4,7 @@ from django.utils import timezone from bc_obps.settings import NINJA_PAGINATION_PER_PAGE from model_bakery import baker +from registration.models import TransferEvent from registration.schema.v2.transfer_event import TransferEventCreateIn from registration.tests.utils.helpers import CommonTestSetup, TestUtils from registration.utils import custom_reverse_lazy @@ -37,10 +38,12 @@ def test_list_transfer_events_unpaginated(self): } def test_list_transfer_events_paginated(self): - # transfer of an operation - baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) - # transfer of 50 facilities - baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=50)) + + for _ in range(20): + # transfer of an operation + baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) + # transfer of 50 facilities + baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=2)) # Get the default page 1 response response = TestUtils.mock_get_with_auth_role(self, "cas_admin", custom_reverse_lazy("list_transfer_events")) assert response.status_code == 200 @@ -50,7 +53,7 @@ def test_list_transfer_events_paginated(self): # save the id of the first paginated response item page_1_response_id = response_items_1[0].get('id') assert len(response_items_1) == NINJA_PAGINATION_PER_PAGE - assert response_count_1 == 51 # total count of transfers + assert response_count_1 == 60 # total count of transfers # Get the page 2 response response = TestUtils.mock_get_with_auth_role( self, @@ -150,7 +153,11 @@ def test_user_can_post_transfer_event_success(self, mock_create_transfer_event: "operation": uuid4(), } - mock_transfer_event = baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) + mock_transfer_event = baker.make_recipe( + 'utils.transfer_event', + operation=baker.make_recipe('utils.operation'), + status=TransferEvent.Statuses.TRANSFERRED, + ) mock_create_transfer_event.return_value = mock_transfer_event response = TestUtils.mock_post_with_auth_role( self, @@ -170,16 +177,27 @@ def test_user_can_post_transfer_event_success(self, mock_create_transfer_event: assert set(response_json.keys()) == { 'transfer_entity', 'from_operator', + 'from_operator_id', 'to_operator', - 'effective_date', - 'operation', + 'operation_name', 'from_operation', + 'from_operation_id', 'to_operation', + 'existing_facilities', + 'status', + 'effective_date', + '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) + assert response_json['from_operator'] == mock_transfer_event.from_operator.legal_name + assert response_json['from_operator_id'] == str(mock_transfer_event.from_operator.id) + assert response_json['to_operator'] == mock_transfer_event.to_operator.legal_name + assert response_json['operation_name'] == mock_transfer_event.operation.name + assert response_json['from_operation'] is None + assert response_json['from_operation_id'] is None + assert response_json['to_operation'] is None + assert response_json['existing_facilities'] == [] # 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)) @@ -187,6 +205,5 @@ def test_user_can_post_transfer_event_success(self, mock_create_transfer_event: 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'] == [] + assert response_json['status'] == TransferEvent.Statuses.TRANSFERRED diff --git a/bc_obps/registration/tests/endpoints/v2/user/test_user_profile.py b/bc_obps/registration/tests/endpoints/v2/user/test_user_profile.py index fc3ffd21e7..4168bf4f47 100644 --- a/bc_obps/registration/tests/endpoints/v2/user/test_user_profile.py +++ b/bc_obps/registration/tests/endpoints/v2/user/test_user_profile.py @@ -106,10 +106,10 @@ def test_create_user_profile_idir(self): # Assert assert response.status_code == 200 - # Additional Assertions (If BYPASS_ROLE_ASSIGNMENT is True, app_role should be cas_admin, otherwise cas_pending) + # Additional Assertions (If BYPASS_ROLE_ASSIGNMENT is True, app_role should be cas_analyst, otherwise cas_pending) assert ( - 'app_role' in content and content["app_role"]["role_name"] == 'cas_admin' + 'app_role' in content and content["app_role"]["role_name"] == 'cas_analyst' if settings.BYPASS_ROLE_ASSIGNMENT else 'cas_pending' ) diff --git a/bc_obps/registration/tests/middleware/test_current_user.py b/bc_obps/registration/tests/middleware/test_current_user.py new file mode 100644 index 0000000000..5faa024b5e --- /dev/null +++ b/bc_obps/registration/tests/middleware/test_current_user.py @@ -0,0 +1,63 @@ +import json +from unittest.mock import MagicMock +from django.test import TestCase, RequestFactory +from registration.middleware.current_user import CurrentUserMiddleware +from registration.models import User +from model_bakery import baker + + +class TestCurrentUserMiddleware(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.middleware = CurrentUserMiddleware(lambda request: MagicMock(status_code=200)) + + def test_no_authorization_header(self): + middleware = self.middleware + request = self.factory.get('/') + middleware(request) + assert not hasattr(request, 'current_user') + + def test_invalid_authorization_header(self): + middleware = self.middleware + request = self.factory.get('/', HTTP_AUTHORIZATION='invalid_token') + middleware(request) + assert not hasattr(request, 'current_user') + + def test_valid_authorization_header(self): + middleware = self.middleware + user = baker.make(User) + auth_header = {'user_guid': str(user.user_guid)} + request = self.factory.get('/', HTTP_AUTHORIZATION=json.dumps(auth_header)) + middleware(request) + assert hasattr(request, 'current_user') + assert request.current_user == user + + def test_user_does_not_exist(self): + middleware = self.middleware + auth_header = {'user_guid': 'non_existing_guid'} + request = self.factory.get('/', HTTP_AUTHORIZATION=json.dumps(auth_header)) + middleware(request) + assert not hasattr(request, 'current_user') + + def test_missing_user_guid_in_authorization_header(self): + middleware = self.middleware + auth_header = {} + request = self.factory.get('/', HTTP_AUTHORIZATION=json.dumps(auth_header)) + response = middleware(request) + assert response.status_code == 400 + self.assertEqual(json.loads(response.content.decode())["error"], "Invalid Authorization header") + + def test_malformed_json_in_authorization_header(self): + middleware = self.middleware + request = self.factory.get('/', HTTP_AUTHORIZATION="invalid_json") + response = middleware(request) + assert response.status_code == 400 + self.assertEqual(json.loads(response.content.decode())["error"], "Invalid Authorization header") + + def test_invalid_uuid_format_in_authorization_header(self): + middleware = self.middleware + auth_header = {"user_guid": "invalid_uuid"} + request = self.factory.get('/', HTTP_AUTHORIZATION=json.dumps(auth_header)) + response = middleware(request) + assert response.status_code == 400 + self.assertEqual(json.loads(response.content.decode())["error"], "Invalid Authorization header") diff --git a/bc_obps/registration/tests/middleware/test_kubernetes_health_check.py b/bc_obps/registration/tests/middleware/test_kubernetes_health_check.py new file mode 100644 index 0000000000..419a542daa --- /dev/null +++ b/bc_obps/registration/tests/middleware/test_kubernetes_health_check.py @@ -0,0 +1,21 @@ +from unittest.mock import MagicMock +from django.test import TestCase, RequestFactory +from registration.middleware.kubernetes_health_check import KubernetesHealthCheckMiddleware + + +class TestKubernetesHealthCheckMiddleware(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.middleware = KubernetesHealthCheckMiddleware(lambda request: MagicMock(status_code=200)) + + def test_liveness_endpoint(self): + request = self.factory.get('/liveness') + response = self.middleware(request) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK. Server is running.') + + def test_readiness_endpoint(self): + request = self.factory.get('/readiness') + response = self.middleware(request) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK. Server is ready.') diff --git a/bc_obps/registration/tests/test_middleware.py b/bc_obps/registration/tests/test_middleware.py deleted file mode 100644 index 3c0ce1cf2c..0000000000 --- a/bc_obps/registration/tests/test_middleware.py +++ /dev/null @@ -1,79 +0,0 @@ -import json -import pytest -from django.http import HttpRequest, HttpResponse -from django.test import TestCase, RequestFactory -from registration.middleware.current_user_middleware import CurrentUserMiddleware -from registration.middleware.kubernetes_middleware import KubernetesHealthCheckMiddleware -from registration.models import User -from model_bakery import baker -from localflavor.ca.models import CAPostalCodeField -from registration.tests.utils.helpers import TestUtils - - -pytestmark = pytest.mark.django_db - -baker.generators.add(CAPostalCodeField, TestUtils.mock_postal_code) - - -class MiddlewareTestCase(TestCase): - def test_liveness_endpoint(self): - middleware = KubernetesHealthCheckMiddleware(None) - request = HttpRequest() - request.method = "GET" - request.path = "/liveness" - response = middleware(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'OK. Server is running.') - - def test_readiness_endpoint(self): - middleware = KubernetesHealthCheckMiddleware(None) - request = HttpRequest() - request.method = "GET" - request.path = "/readiness" - response = middleware(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'OK. Server is ready.') - - -class MockGetResponse: - def __call__(self, request): - return HttpResponse() - - -class TestCurrentUserMiddleware: - def _get_middleware(self): - mock_get_response = MockGetResponse() - return CurrentUserMiddleware(mock_get_response) - - def _get_request(self, **kwargs): - return RequestFactory().get('/', **kwargs) - - def test_no_authorization_header(self): - middleware = self._get_middleware() - request = self._get_request() - middleware(request) - assert not hasattr(request, 'current_user') - - def test_invalid_authorization_header(self): - middleware = self._get_middleware() - request = self._get_request(HTTP_AUTHORIZATION='invalid_token') - middleware(request) - assert not hasattr(request, 'current_user') - - def test_valid_authorization_header(self): - middleware = self._get_middleware() - user = baker.make(User) - auth_header = {'user_guid': str(user.user_guid)} - request = self._get_request(HTTP_AUTHORIZATION=json.dumps(auth_header)) - middleware(request) - assert hasattr(request, 'current_user') - assert request.current_user == user - - def test_user_does_not_exist(self): - middleware = self._get_middleware() - auth_header = {'user_guid': 'non_existing_guid'} - request = self._get_request(HTTP_AUTHORIZATION=json.dumps(auth_header)) - middleware(request) - assert not hasattr(request, 'current_user') diff --git a/bc_obps/registration/tests/utils/baker_recipes.py b/bc_obps/registration/tests/utils/baker_recipes.py index 5776cbf4e3..3bbc6038ad 100644 --- a/bc_obps/registration/tests/utils/baker_recipes.py +++ b/bc_obps/registration/tests/utils/baker_recipes.py @@ -128,7 +128,11 @@ meets_notification_to_director_on_criteria_change=False, ) contact = Recipe( - Contact, business_role=BusinessRole.objects.first(), address=foreign_key(address), first_name=seq('Firstname 0') + Contact, + business_role=BusinessRole.objects.first(), + address=foreign_key(address), + first_name=seq('Firstname 0'), + last_name=seq('Lastname 0'), ) diff --git a/bc_obps/registration/tests/utils/helpers.py b/bc_obps/registration/tests/utils/helpers.py index ce63717923..20821503ba 100644 --- a/bc_obps/registration/tests/utils/helpers.py +++ b/bc_obps/registration/tests/utils/helpers.py @@ -76,6 +76,10 @@ def authorize_current_user_as_operator_user(self, operator): role=UserOperator.Roles.ADMIN, ) + def mock_delete_with_auth_role(self, role_name, endpoint=None): + TestUtils.save_app_role(self, role_name) + return TestUtils.client.delete(endpoint or self.endpoint, HTTP_AUTHORIZATION=self.auth_header_dumps) + def create_operator_and_operation(self): """ Creates operator and operation instance for testing purposes. diff --git a/bc_obps/rls/apps.py b/bc_obps/rls/apps.py new file mode 100644 index 0000000000..bc06844e2b --- /dev/null +++ b/bc_obps/rls/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RlsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'rls' diff --git a/bc_obps/rls/enums.py b/bc_obps/rls/enums.py new file mode 100644 index 0000000000..9eca94b1a6 --- /dev/null +++ b/bc_obps/rls/enums.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class RlsRoles(Enum): + INDUSTRY_USER = "industry_user" + CAS_DIRECTOR = "cas_director" + CAS_ADMIN = "cas_admin" + CAS_ANALYST = "cas_analyst" + CAS_PENDING = "cas_pending" + CAS_VIEW_ONLY = "cas_view_only" + ALL_ROLES = "industry_user, cas_director, cas_admin, cas_analyst, cas_pending, cas_view_only" + + +class RlsOperations(Enum): + SELECT = "select" + UPDATE = "update" + INSERT = "insert" + DELETE = "delete" diff --git a/bc_obps/rls/middleware/__init__.py b/bc_obps/rls/middleware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bc_obps/rls/middleware/rls.py b/bc_obps/rls/middleware/rls.py new file mode 100644 index 0000000000..7d28e17525 --- /dev/null +++ b/bc_obps/rls/middleware/rls.py @@ -0,0 +1,45 @@ +import logging +from typing import Callable, Optional +from uuid import UUID +from django.db import connection +from django.http import HttpRequest, HttpResponse +from bc_obps import settings +from registration.models import User + +logger = logging.getLogger(__name__) + + +class RlsMiddleware: + """ + Middleware to set the user context for Row Level Security (RLS) in PostgreSQL. + NOTE: We need to use this middleware after CurrentUserMiddleware to be able to access the current_user attribute + """ + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + # Check middleware order during initialization + current_user_middleware = "registration.middleware.current_user.CurrentUserMiddleware" + if current_user_middleware in settings.MIDDLEWARE: + rls_index = settings.MIDDLEWARE.index("rls.middleware.rls.RlsMiddleware") + current_user_index = settings.MIDDLEWARE.index(current_user_middleware) + if current_user_index > rls_index: + raise RuntimeError("CurrentUserMiddleware must be placed before RlsMiddleware in MIDDLEWARE settings.") + + def __call__(self, request: HttpRequest) -> HttpResponse: + user: Optional[User] = getattr(request, 'current_user', None) + # Note from Dylan for later: + # when we get to actually setting roles, we'll want to still set a role here. We might need an "Unauthenticated" role to set in this case. + if user: + self._set_user_context(user.user_guid) + else: + logger.info("Anonymous user detected, skipping user context setup", exc_info=True) + return self.get_response(request) + + @staticmethod + def _set_user_context(user_guid: UUID) -> None: + try: + with connection.cursor() as cursor: + cursor.execute('set my.guid = %s', [str(user_guid)]) + except Exception as e: + logger.error(f"Failed to set user context: {e}", exc_info=True) diff --git a/bc_obps/rls/migrations/__init__.py b/bc_obps/rls/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bc_obps/rls/tests/__init__.py b/bc_obps/rls/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bc_obps/rls/tests/test_rls.py b/bc_obps/rls/tests/test_rls.py new file mode 100644 index 0000000000..2463b4412c --- /dev/null +++ b/bc_obps/rls/tests/test_rls.py @@ -0,0 +1,82 @@ +from unittest.mock import patch, MagicMock +from uuid import uuid4 +from django.test import TestCase, RequestFactory +from django.http import HttpResponse +from registration.models import User +from rls.middleware.rls import RlsMiddleware + + +class TestRlsMiddleware(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.get_response_mock = MagicMock(return_value=HttpResponse("OK")) + self.middleware = RlsMiddleware(self.get_response_mock) + + @patch( + "rls.middleware.rls.settings.MIDDLEWARE", + [ + "registration.middleware.current_user.CurrentUserMiddleware", + "rls.middleware.rls.RlsMiddleware", + ], + ) + def test_middleware_order_correct(self): + # No exception should be raised when the middleware order is correct + RlsMiddleware(self.get_response_mock) + + @patch( + "rls.middleware.rls.settings.MIDDLEWARE", + [ + "rls.middleware.rls.RlsMiddleware", + "registration.middleware.current_user.CurrentUserMiddleware", + ], + ) + def test_middleware_order_incorrect(self): + with self.assertRaises(RuntimeError): + RlsMiddleware(self.get_response_mock) + + @patch("rls.middleware.rls.RlsMiddleware._set_user_context") + def test_user_context_set_for_authenticated_user(self, mock_set_user_context): + user_guid = uuid4() + user = User(user_guid=user_guid) + request = self.factory.get("/") + request.current_user = user + + response = self.middleware(request) + + # Assert _set_user_context was called with the correct user GUID + mock_set_user_context.assert_called_once_with(user_guid) + # Assert response is passed correctly + self.assertEqual(response.content, b"OK") + + @patch("rls.middleware.rls.RlsMiddleware._set_user_context") + def test_user_context_not_set_for_anonymous_user(self, mock_set_user_context): + request = self.factory.get("/") + request.current_user = None + + response = self.middleware(request) + + # Assert _set_user_context was not called + mock_set_user_context.assert_not_called() + # Assert response is passed correctly + self.assertEqual(response.content, b"OK") + + @patch("rls.middleware.rls.connection.cursor") + def test_set_user_context_executes_set_query(self, mock_cursor): + user_guid = uuid4() + + self.middleware._set_user_context(user_guid) + + # Assert the database query was executed with the correct parameters + mock_cursor.assert_called_once() + mock_cursor().__enter__().execute.assert_called_once_with("set my.guid = %s", [str(user_guid)]) + + @patch("rls.middleware.rls.connection.cursor") + def test_set_user_context_logs_error_on_failure(self, mock_cursor): + mock_cursor.side_effect = Exception("Database error") + user_guid = uuid4() + + with self.assertLogs("rls.middleware.rls", level="ERROR") as log: + self.middleware._set_user_context(user_guid) + + # Assert the error was logged + self.assertIn("Failed to set user context: Database error", log.output[0]) diff --git a/bc_obps/service/contact_service_v2.py b/bc_obps/service/contact_service_v2.py index 391efc2ac0..4981d2e609 100644 --- a/bc_obps/service/contact_service_v2.py +++ b/bc_obps/service/contact_service_v2.py @@ -4,7 +4,7 @@ from registration.models.contact import Contact -from registration.schema.v2.contact import ContactFilterSchemaV2 +from registration.schema.v2.contact import ContactFilterSchemaV2, ContactWithPlacesAssigned, PlacesAssigned from service.data_access_service.contact_service import ContactDataAccessService from service.data_access_service.user_service import UserDataAccessService from ninja import Query @@ -31,3 +31,34 @@ def list_contacts_v2( .distinct() ) return cast(QuerySet[Contact], queryset) + + @classmethod + def get_with_places_assigned_v2(cls, contact_id: int) -> Optional[ContactWithPlacesAssigned]: + contact = ContactDataAccessService.get_by_id(contact_id) + places_assigned = [] + if contact: + role_name = contact.business_role.role_name + for operation in contact.operations_contacts.all(): + place = PlacesAssigned( + role_name=role_name, + operation_name=operation.name, + operation_id=operation.id, + ) + places_assigned.append(place) + result = cast(ContactWithPlacesAssigned, contact) + if places_assigned: + result.places_assigned = places_assigned + return result + return None + + @classmethod + def raise_exception_if_contact_missing_address_information(cls, contact_id: int) -> None: + """This function checks that a contact has a complete address record (contact.address exists and all fields in the address model have a value). In general in the app, address is not mandatory, but in certain cases (e.g., when a contact is assigned to an operation as the Operation Representative), the business area requires the contact to have an address.""" + contact = ContactDataAccessService.get_by_id(contact_id) + address = contact.address + if not address or any( + not getattr(address, field, None) for field in ['street_address', 'municipality', 'province', 'postal_code'] + ): + raise Exception( + f'The contact {contact.first_name} {contact.last_name} is missing address information. Please return to Contacts and fill in their address information before assigning them as an Operation Representative here.' + ) diff --git a/bc_obps/service/data_access_service/transfer_event_service.py b/bc_obps/service/data_access_service/transfer_event_service.py index 2b5542e625..716c61611a 100644 --- a/bc_obps/service/data_access_service/transfer_event_service.py +++ b/bc_obps/service/data_access_service/transfer_event_service.py @@ -11,3 +11,21 @@ def create_transfer_event( transfer_event_data: DictStrAny, ) -> TransferEvent: return TransferEvent.objects.create(**transfer_event_data, created_by_id=user_guid) + + @classmethod + def get_by_id(cls, transfer_id: UUID) -> TransferEvent: + return TransferEvent.objects.get(id=transfer_id) + + @classmethod + def update_transfer_event( + cls, + user_guid: UUID, + transfer_id: UUID, + transfer_event_data: DictStrAny, + ) -> TransferEvent: + transfer_event = cls.get_by_id(transfer_id) + for key, value in transfer_event_data.items(): + setattr(transfer_event, key, value) + transfer_event.save(update_fields=transfer_event_data.keys()) + transfer_event.set_create_or_update(user_guid) + return transfer_event diff --git a/bc_obps/service/operation_service.py b/bc_obps/service/operation_service.py index b9fad77d18..bcb043583e 100644 --- a/bc_obps/service/operation_service.py +++ b/bc_obps/service/operation_service.py @@ -218,9 +218,11 @@ def list_operations( base_qs = OperationDataAccessService.get_all_operations_for_user(user) list_of_filters = [ Q(bcghg_id__id__icontains=bcghg_id) if bcghg_id else Q(), - Q(bc_obps_regulated_operation__id__icontains=bc_obps_regulated_operation) - if bc_obps_regulated_operation - else Q(), + ( + Q(bc_obps_regulated_operation__id__icontains=bc_obps_regulated_operation) + if bc_obps_regulated_operation + else Q() + ), Q(name__icontains=name) if name else Q(), Q(operator__legal_name__icontains=operator) if operator else Q(), Q(status__icontains=status) if status else Q(), diff --git a/bc_obps/service/operation_service_v2.py b/bc_obps/service/operation_service_v2.py index 8a96f70d3d..d5bdd921ac 100644 --- a/bc_obps/service/operation_service_v2.py +++ b/bc_obps/service/operation_service_v2.py @@ -1,6 +1,7 @@ -from typing import Optional, Tuple, Callable, Generator +from typing import Optional, Tuple, Callable, Generator, Union from django.db.models import QuerySet from registration.schema.v2.operation_timeline import OperationTimelineFilterSchema +from service.contact_service_v2 import ContactServiceV2 from service.data_access_service.operation_designated_operator_timeline_service import ( OperationDesignatedOperatorTimelineDataAccessService, ) @@ -29,6 +30,7 @@ from service.operation_service import OperationService from registration.schema.v2.operation import ( OperationInformationIn, + OperationInformationInUpdate, OperationRepresentativeRemove, OptedInOperationDetailIn, OperationNewEntrantApplicationIn, @@ -194,7 +196,7 @@ def remove_opted_in_operation_detail(cls, user_guid: UUID, operation_id: UUID) - def create_or_update_operation_v2( cls, user_guid: UUID, - payload: OperationInformationIn, + payload: Union[OperationInformationIn, OperationInformationInUpdate], operation_id: UUID | None = None, ) -> Operation: user_operator: UserOperator = UserDataAccessService.get_user_operator_by_user(user_guid) @@ -224,9 +226,18 @@ def create_or_update_operation_v2( # set m2m relationships operation.activities.set(payload.activities) if payload.activities else operation.activities.clear() - operation.regulated_products.set( - payload.regulated_products - ) if payload.regulated_products else operation.regulated_products.clear() + + ( + operation.regulated_products.set(payload.regulated_products) + if payload.regulated_products + else operation.regulated_products.clear() + ) + + if isinstance(payload, OperationInformationInUpdate): + for contact_id in payload.operation_representatives: + ContactServiceV2.raise_exception_if_contact_missing_address_information(contact_id) + + operation.contacts.set(payload.operation_representatives) # create or replace documents operation_documents = [ @@ -362,20 +373,20 @@ def update_operation( payload: OperationInformationIn, operation_id: UUID, ) -> Operation: - OperationService.get_if_authorized(user_guid, operation_id) + """ + This service is used for updating an operation after it's been registered. During registration, we use the endpoints in cas-registration/bc_obps/registration/api/v2/_operations/_operation_id/_registration + """ + operation = OperationService.get_if_authorized(user_guid, operation_id) - operation: Operation = cls.create_or_update_operation_v2( + if not operation.status == Operation.Statuses.REGISTERED: + raise Exception('Operation must be registered') + + updated_operation: Operation = cls.create_or_update_operation_v2( user_guid, payload, operation_id, ) - - if payload.regulated_products: - # We should add a conditional to check registration_purpose type here - # At the time of implementation there are some changes to registration_purpose coming from the business area - operation.regulated_products.set(payload.regulated_products) - operation.set_create_or_update(user_guid) - return operation + return updated_operation @classmethod def is_operation_opt_in_information_complete(cls, operation: Operation) -> bool: diff --git a/bc_obps/service/tests/test_contact_service_v2.py b/bc_obps/service/tests/test_contact_service_v2.py index bfd1bdf340..ac18f8e239 100644 --- a/bc_obps/service/tests/test_contact_service_v2.py +++ b/bc_obps/service/tests/test_contact_service_v2.py @@ -1,7 +1,8 @@ from registration.schema.v1.contact import ContactFilterSchema import pytest -from service.contact_service_v2 import ContactServiceV2 +from service.contact_service_v2 import ContactServiceV2, PlacesAssigned from model_bakery import baker +from registration.models.business_role import BusinessRole pytestmark = pytest.mark.django_db @@ -31,3 +32,69 @@ def test_list_contacts(): ).count() == 3 ) + + +class TestContactService: + @staticmethod + def test_get_with_places_assigned_with_contacts(): + contact = baker.make_recipe( + 'utils.contact', business_role=BusinessRole.objects.get(role_name='Operation Representative') + ) + approved_user_operator = baker.make_recipe('utils.approved_user_operator') + # add contact to operator (they have to be associated with the operator or will throw unauthorized) + approved_user_operator.operator.contacts.set([contact]) + # add contact to operation + operation = baker.make_recipe('utils.operation', operator=approved_user_operator.operator) + operation.contacts.set([contact]) + + result = ContactServiceV2.get_with_places_assigned_v2(contact.id) + assert result.places_assigned == [ + PlacesAssigned( + role_name=contact.business_role.role_name, operation_name=operation.name, operation_id=operation.id + ) + ] + + @staticmethod + def test_get_with_places_assigned_with_no_contacts(): + contact = baker.make_recipe( + 'utils.contact', business_role=BusinessRole.objects.get(role_name='Operation Representative') + ) + approved_user_operator = baker.make_recipe('utils.approved_user_operator') + # add contact to operator (they have to be associated with the operator or will throw unauthorized) + approved_user_operator.operator.contacts.set([contact]) + + result = ContactServiceV2.get_with_places_assigned_v2(contact.id) + assert not hasattr(result, 'places_assigned') + + @staticmethod + def test_raises_exception_if_contact_missing_address(): + contact = baker.make_recipe('utils.contact', address=None) + + with pytest.raises( + Exception, + match=f'The contact {contact.first_name} {contact.last_name} is missing address information. Please return to Contacts and fill in their address information before assigning them as an Operation Representative here.', + ): + ContactServiceV2.raise_exception_if_contact_missing_address_information(contact.id) + + @staticmethod + def test_raises_exception_if_operation_rep_missing_required_fields(): + contacts = baker.make_recipe( + 'utils.contact', business_role=BusinessRole.objects.get(role_name='Operation Representative'), _quantity=5 + ) + contacts[0].address = None + contacts[0].save() + contacts[1].address.street_address = None + contacts[1].address.save() + contacts[2].address.municipality = None + contacts[2].address.save() + contacts[3].address.province = None + contacts[3].address.save() + contacts[4].address.postal_code = None + contacts[4].address.save() + + for contact in contacts: + with pytest.raises( + Exception, + match=f'The contact {contact.first_name} {contact.last_name} is missing address information. Please return to Contacts and fill in their address information before assigning them as an Operation Representative here.', + ): + ContactServiceV2.raise_exception_if_contact_missing_address_information(contact.id) diff --git a/bc_obps/service/tests/test_operation_service_v2.py b/bc_obps/service/tests/test_operation_service_v2.py index 6a972d014d..55f3c06811 100644 --- a/bc_obps/service/tests/test_operation_service_v2.py +++ b/bc_obps/service/tests/test_operation_service_v2.py @@ -16,6 +16,7 @@ from registration.constants import UNAUTHORIZED_MESSAGE from registration.models.address import Address from registration.schema.v2.operation import ( + OperationInformationInUpdate, OperationRepresentativeIn, OperationNewEntrantApplicationIn, OperationRepresentativeRemove, @@ -618,12 +619,71 @@ def test_update_operation_archive_multiple_operators(): assert operation.updated_by == approved_user_operator.user assert operation.updated_at is not None + @staticmethod + def test_update_operation_with_operation_representatives_with_address(): + approved_user_operator = baker.make_recipe('utils.approved_user_operator') + existing_operation = baker.make_recipe('utils.operation', operator=approved_user_operator.operator) + contacts = baker.make_recipe( + 'utils.contact', business_role=BusinessRole.objects.get(role_name='Operation Representative'), _quantity=3 + ) + + payload = OperationInformationInUpdate( + registration_purpose='Reporting Operation', + regulated_products=[1], + name="I am updated", + type="SFO", + naics_code_id=1, + secondary_naics_code_id=2, + tertiary_naics_code_id=3, + activities=[1], + process_flow_diagram=MOCK_DATA_URL, + boundary_map=MOCK_DATA_URL, + operation_representatives=[contact.id for contact in contacts], + ) + + operation = OperationServiceV2.create_or_update_operation_v2( + approved_user_operator.user.user_guid, + payload, + existing_operation.id, + ) + operation.refresh_from_db() + assert Operation.objects.count() == 1 + assert operation.contacts.count() == 3 + assert operation.updated_by == approved_user_operator.user + assert operation.updated_at is not None + class TestOperationServiceV2UpdateOperation: + def test_update_operation_fails_if_operation_not_registered(self): + approved_user_operator = baker.make_recipe('utils.approved_user_operator') + existing_operation = baker.make_recipe( + 'utils.operation', + operator=approved_user_operator.operator, + created_by=approved_user_operator.user, + status=Operation.Statuses.DRAFT, + ) + payload = OperationInformationIn( + registration_purpose='Potential Reporting Operation', + regulated_products=[1], + name="Test Update Operation Name", + type="SFO", + naics_code_id=1, + secondary_naics_code_id=1, + tertiary_naics_code_id=2, + activities=[2], + process_flow_diagram=MOCK_DATA_URL, + boundary_map=MOCK_DATA_URL, + ) + with pytest.raises(Exception, match='Operation must be registered'): + OperationServiceV2.update_operation(approved_user_operator.user.user_guid, payload, existing_operation.id) + def test_update_operation(self): approved_user_operator = baker.make_recipe('utils.approved_user_operator') existing_operation = baker.make_recipe( - 'utils.operation', operator=approved_user_operator.operator, created_by=approved_user_operator.user + 'utils.operation', + operator=approved_user_operator.operator, + created_by=approved_user_operator.user, + status=Operation.Statuses.REGISTERED, ) payload = OperationInformationIn( registration_purpose='Potential Reporting Operation', @@ -651,7 +711,10 @@ def test_update_operation(self): def test_update_operation_with_no_regulated_products(self): approved_user_operator = baker.make_recipe('utils.approved_user_operator') existing_operation = baker.make_recipe( - 'utils.operation', operator=approved_user_operator.operator, created_by=approved_user_operator.user + 'utils.operation', + operator=approved_user_operator.operator, + created_by=approved_user_operator.user, + status=Operation.Statuses.REGISTERED, ) payload = OperationInformationIn( registration_purpose='OBPS Regulated Operation', @@ -684,6 +747,7 @@ def test_update_operation_with_new_entrant_application_data(self): operator=approved_user_operator.operator, created_by=approved_user_operator.user, date_of_first_shipment=Operation.DateOfFirstShipmentChoices.ON_OR_AFTER_APRIL_1_2024, + status=Operation.Statuses.REGISTERED, ) payload = OperationInformationIn( registration_purpose='New Entrant Operation', @@ -787,26 +851,6 @@ def test_raises_exception_if_no_operation_rep(): with pytest.raises(Exception, match="Operation must have an operation representative with an address."): OperationServiceV2.raise_exception_if_operation_missing_registration_information(operation) - @staticmethod - def test_raises_exception_if_operation_rep_missing_address(): - operation = set_up_valid_mock_operation(Operation.Purposes.OPTED_IN_OPERATION) - op_rep = operation.contacts.first() - op_rep.address = None - op_rep.save() - - with pytest.raises(Exception, match="Operation must have an operation representative with an address."): - OperationServiceV2.raise_exception_if_operation_missing_registration_information(operation) - - @staticmethod - def test_raises_exception_if_operation_rep_missing_required_fields(): - operation = set_up_valid_mock_operation(Operation.Purposes.OPTED_IN_OPERATION) - op_rep_address = operation.contacts.first().address - op_rep_address.street_address = None - op_rep_address.save() - - with pytest.raises(Exception, match="Operation must have an operation representative with an address."): - OperationServiceV2.raise_exception_if_operation_missing_registration_information(operation) - @staticmethod def test_raises_exception_if_no_facilities(): operation = set_up_valid_mock_operation(Operation.Purposes.OPTED_IN_OPERATION) diff --git a/bc_obps/service/tests/test_transfer_event_service.py b/bc_obps/service/tests/test_transfer_event_service.py index a8580ad267..07dd749e81 100644 --- a/bc_obps/service/tests/test_transfer_event_service.py +++ b/bc_obps/service/tests/test_transfer_event_service.py @@ -2,6 +2,8 @@ from unittest.mock import patch, MagicMock from uuid import uuid4 from zoneinfo import ZoneInfo + +from registration.constants import UNAUTHORIZED_MESSAGE from registration.models import TransferEvent, FacilityDesignatedOperationTimeline, OperationDesignatedOperatorTimeline from registration.schema.v2.transfer_event import TransferEventFilterSchema, TransferEventCreateIn from service.transfer_event_service import TransferEventService @@ -31,12 +33,9 @@ def test_validate_no_overlapping_transfer_events(): # Scenario 1: No overlapping operation or facility new_operation = baker.make_recipe('utils.operation') new_facilities = baker.make_recipe('utils.facility', _quantity=2) - try: - TransferEventService.validate_no_overlapping_transfer_events( - operation_id=new_operation.id, facility_ids=[facility.id for facility in new_facilities] - ) - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") + TransferEventService._validate_no_overlapping_transfer_events( + operation_id=new_operation.id, facility_ids=[facility.id for facility in new_facilities] + ) # Scenario 2: Overlapping operation operation = baker.make_recipe('utils.operation') @@ -46,7 +45,7 @@ def test_validate_no_overlapping_transfer_events(): 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) + TransferEventService._validate_no_overlapping_transfer_events(operation_id=operation.id) # Scenario 3: Overlapping facilities facilities = baker.make_recipe('utils.facility', _quantity=2) @@ -59,10 +58,34 @@ def test_validate_no_overlapping_transfer_events(): Exception, match="One or more facilities in this transfer event are already part of an active transfer event.", ): - TransferEventService.validate_no_overlapping_transfer_events( + TransferEventService._validate_no_overlapping_transfer_events( facility_ids=[facility.id for facility in facilities] ) + # Scenario 4: Overlapping operation but excluded by current_transfer_id + overlapping_operation = baker.make_recipe('utils.operation') + existing_transfer = baker.make_recipe( + 'utils.transfer_event', + operation=overlapping_operation, + status=TransferEvent.Statuses.TO_BE_TRANSFERRED, + ) + TransferEventService._validate_no_overlapping_transfer_events( + operation_id=overlapping_operation.id, + current_transfer_id=existing_transfer.id, + ) + + # Scenario 5: Overlapping facilities but excluded by current_transfer_id + overlapping_facilities = baker.make_recipe('utils.facility', _quantity=2) + existing_transfer = baker.make_recipe( + 'utils.transfer_event', + facilities=overlapping_facilities, + status=TransferEvent.Statuses.COMPLETE, + ) + TransferEventService._validate_no_overlapping_transfer_events( + facility_ids=[facility.id for facility in overlapping_facilities], + current_transfer_id=existing_transfer.id, + ) + @staticmethod @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") def test_create_transfer_event_unauthorized_user(mock_get_by_guid): @@ -90,7 +113,7 @@ def _get_transfer_event_payload_for_operation(cls): ) @classmethod - @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @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") @@ -106,7 +129,7 @@ def test_create_transfer_event_operation_missing_operation(cls, mock_get_by_guid TransferEventService.create_transfer_event(cas_analyst.user_guid, payload) @classmethod - @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @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") @@ -126,11 +149,12 @@ def test_create_transfer_event_operation_using_the_same_operator(cls, mock_get_b ) @classmethod - @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @patch("service.transfer_event_service.TransferEventService._process_event_if_effective") + @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 + cls, mock_create_transfer_event, mock_get_by_guid, mock_validate_no_overlap, mock_process_event_if_effective ): cas_analyst = baker.make_recipe('utils.cas_analyst') payload = cls._get_transfer_event_payload_for_operation() @@ -156,6 +180,7 @@ def test_create_transfer_event_operation( "operation_id": payload.operation, }, ) + mock_process_event_if_effective.assert_called_once_with(payload, mock_transfer_event, cas_analyst.user_guid) assert result == mock_transfer_event @classmethod @@ -176,7 +201,7 @@ def _get_transfer_event_payload_for_facility(cls): ) @classmethod - @patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events") + @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") @@ -208,7 +233,7 @@ 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.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") @@ -224,11 +249,12 @@ def test_create_transfer_event_facility_between_the_same_operation(cls, mock_get 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.transfer_event_service.TransferEventService._process_event_if_effective") + @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 + cls, mock_create_transfer_event, mock_get_by_guid, mock_validate_no_overlap, mock_process_event_if_effective ): cas_analyst = baker.make_recipe("utils.cas_analyst") payload = cls._get_transfer_event_payload_for_facility() @@ -255,11 +281,12 @@ def test_create_transfer_event_facility( }, ) mock_transfer_event.facilities.set.assert_called_once_with(payload.facilities) + mock_process_event_if_effective.assert_called_once_with(payload, mock_transfer_event, cas_analyst.user_guid) 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.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( @@ -543,3 +570,147 @@ def test_process_operation_transfer( transfer_event.operation, transfer_event.to_operator.id, ) + + @staticmethod + @patch("service.transfer_event_service.TransferEventDataAccessService.get_by_id") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_get_if_authorized(mock_get_by_guid, mock_get_by_id): + # Scenario 1: Unauthorized user + mock_get_by_id.return_value = transfer_event = MagicMock(id=uuid4()) + mock_get_by_guid.return_value = unauthorized_user = MagicMock(user_guid=uuid4()) + unauthorized_user.is_industry_user.return_value = True + + with pytest.raises(Exception, match=UNAUTHORIZED_MESSAGE): + TransferEventService.get_if_authorized(unauthorized_user.user_guid, transfer_event.id) + + # Scenario 2: Authorized user + mock_get_by_guid.return_value = cas_admin = baker.make_recipe('utils.cas_admin') + result = TransferEventService.get_if_authorized(cas_admin.user_guid, uuid4()) + assert result == transfer_event + + @staticmethod + @patch("service.transfer_event_service.TransferEventDataAccessService.get_by_id") + @patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid") + def test_get_and_validate_transfer_event_for_update(mock_get_by_guid, mock_get_by_id): + # Scenario 1: Unauthorized user + mock_get_by_id.return_value = transfer_event = MagicMock( + id=uuid4(), status=TransferEvent.Statuses.TO_BE_TRANSFERRED + ) + mock_get_by_guid.return_value = unauthorized_user = MagicMock(user_guid=uuid4()) + unauthorized_user.is_cas_analyst.return_value = False + + with pytest.raises(Exception, match=UNAUTHORIZED_MESSAGE): + TransferEventService._get_and_validate_transfer_event_for_update( + transfer_event.id, unauthorized_user.user_guid + ) + + # Scenario 2: Valid transfer event and authorized user + mock_get_by_guid.return_value = authorized_user = MagicMock() + authorized_user.is_cas_analyst.return_value = True + + result = TransferEventService._get_and_validate_transfer_event_for_update( + transfer_event.id, authorized_user.user_guid + ) + assert result == transfer_event + + # Scenario 3: Invalid transfer event status + mock_get_by_id.return_value = invalid_transfer = MagicMock(id=uuid4(), status=TransferEvent.Statuses.COMPLETE) + mock_get_by_guid.return_value = authorized_user + + with pytest.raises(Exception, match="Only transfer events with status 'To be transferred' can be modified."): + TransferEventService._get_and_validate_transfer_event_for_update( + invalid_transfer.id, authorized_user.user_guid + ) + + @staticmethod + @patch("service.transfer_event_service.TransferEventService._get_and_validate_transfer_event_for_update") + def test_delete_transfer_event(mock_get_and_validate_transfer_event_for_update): + transfer_event_mock = MagicMock(id=uuid4()) + mock_get_and_validate_transfer_event_for_update.return_value = transfer_event_mock + user_guid = uuid4() + TransferEventService.delete_transfer_event(user_guid=user_guid, transfer_id=transfer_event_mock.id) + mock_get_and_validate_transfer_event_for_update.assert_called_once_with(transfer_event_mock.id, user_guid) + transfer_event_mock.delete.assert_called_once() + + @staticmethod + @patch("service.transfer_event_service.TransferEventService._validate_no_overlapping_transfer_events") + @patch("service.transfer_event_service.TransferEventDataAccessService.update_transfer_event") + def test_update_operation_transfer_event(mock_update_transfer_event, mock_validate_no_overlapping): + user_guid = uuid4() + transfer_id = uuid4() + operation_id = uuid4() + payload = MagicMock(operation=operation_id, effective_date=datetime.now(ZoneInfo("UTC"))) + + # Test with valid operation ID + TransferEventService._update_operation_transfer_event(user_guid, transfer_id, payload) + + mock_validate_no_overlapping.assert_called_once_with(operation_id=operation_id, current_transfer_id=transfer_id) + mock_update_transfer_event.assert_called_once_with( + user_guid, transfer_id, {"operation_id": operation_id, "effective_date": payload.effective_date} + ) + + # Test with missing operation ID + payload.operation = None + with pytest.raises(Exception, match="Operation is required for operation transfer events."): + TransferEventService._update_operation_transfer_event(user_guid, transfer_id, payload) + + @staticmethod + @patch("service.transfer_event_service.TransferEventService._validate_no_overlapping_transfer_events") + @patch("service.transfer_event_service.TransferEventDataAccessService.update_transfer_event") + def test_update_facility_transfer_event(mock_update_transfer_event, mock_validate_no_overlapping): + user_guid = uuid4() + transfer_id = uuid4() + facility_ids = [uuid4(), uuid4()] + payload = MagicMock(facilities=facility_ids, effective_date=datetime.now(ZoneInfo("UTC"))) + + updated_transfer_event_mock = MagicMock() + mock_update_transfer_event.return_value = updated_transfer_event_mock + + # Test with valid facility IDs + TransferEventService._update_facility_transfer_event(user_guid, transfer_id, payload) + + mock_validate_no_overlapping.assert_called_once_with(facility_ids=facility_ids, current_transfer_id=transfer_id) + mock_update_transfer_event.assert_called_once_with( + user_guid, transfer_id, payload.dict(include=["effective_date"]) + ) + updated_transfer_event_mock.facilities.set.assert_called_once_with(facility_ids) + + # Test with missing facility IDs + payload.facilities = [] + with pytest.raises(Exception, match="Facilities are required for facility transfer events."): + TransferEventService._update_facility_transfer_event(user_guid, transfer_id, payload) + + @staticmethod + @patch("service.transfer_event_service.TransferEventService._get_and_validate_transfer_event_for_update") + @patch("service.transfer_event_service.TransferEventService._update_operation_transfer_event") + @patch("service.transfer_event_service.TransferEventService._update_facility_transfer_event") + @patch("service.transfer_event_service.TransferEventService._process_event_if_effective") + def test_update_transfer_event( + mock_process_event_if_effective, + mock_update_facility_transfer_event, + mock_update_operation_transfer_event, + mock_get_and_validate_transfer_event_for_update, + ): + user_guid = uuid4() + transfer_id = uuid4() + transfer_event_mock = MagicMock() + mock_get_and_validate_transfer_event_for_update.return_value = transfer_event_mock + + # Scenario 1: Updating an operation transfer event + payload_operation = MagicMock(transfer_entity="Operation", operation=uuid4(), effective_date="2025-01-20") + TransferEventService.update_transfer_event(user_guid, transfer_id, payload_operation) + mock_update_operation_transfer_event.assert_called_once_with(user_guid, transfer_id, payload_operation) + mock_process_event_if_effective.assert_called_once_with(payload_operation, transfer_event_mock, user_guid) + + # Scenario 2: Updating a facility transfer event + payload_facility = MagicMock( + transfer_entity="Facility", facilities=[uuid4(), uuid4()], effective_date="2025-01-20" + ) + TransferEventService.update_transfer_event(user_guid, transfer_id, payload_facility) + mock_update_facility_transfer_event.assert_called_once_with(user_guid, transfer_id, payload_facility) + mock_process_event_if_effective.assert_called_with(payload_facility, transfer_event_mock, user_guid) + + # Scenario 3: Invalid transfer entity + payload_invalid = MagicMock(transfer_entity="Invalid", effective_date="2025-01-20") + with pytest.raises(KeyError): + TransferEventService.update_transfer_event(user_guid, transfer_id, payload_invalid) 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 25947d91ab..b9fed00596 100644 --- a/bc_obps/service/tests/test_user_operator_service_v2.py +++ b/bc_obps/service/tests/test_user_operator_service_v2.py @@ -4,7 +4,8 @@ from model_bakery import baker from registration.constants import UNAUTHORIZED_MESSAGE -from registration.models import Operator, User, UserOperator +from registration.models import Operator, User, UserOperator, Contact +from registration.schema.v1.user_operator import UserOperatorStatusUpdate from registration.schema.v2.operator import OperatorIn from registration.schema.v2.user_operator import UserOperatorFilterSchema from service.user_operator_service_v2 import UserOperatorServiceV2 @@ -158,3 +159,91 @@ def test_create_operator_and_user_operator_v2(): assert Operator.objects.count() == 1 assert Operator.objects.first().status == "Approved" assert Operator.objects.first().is_new is False + + +class TestUpdateStatusAndCreateContact: + def test_industry_user_cannot_approve_access_request_from_a_different_operator( + self, + ): + approved_admin_user_operator = baker.make_recipe('utils.approved_user_operator', role=UserOperator.Roles.ADMIN) + pending_user_operator = baker.make_recipe('utils.user_operator') + + with pytest.raises(Exception, match='Your user is not associated with this operator.'): + UserOperatorServiceV2.update_status_and_create_contact( + pending_user_operator.id, + UserOperatorStatusUpdate(status='Approved', role=UserOperator.Roles.ADMIN), + approved_admin_user_operator.user.user_guid, + ) + + @staticmethod + def test_cas_admin_declines_access_request(): + approved_admin_user_operator = baker.make_recipe('utils.approved_user_operator', role=UserOperator.Roles.ADMIN) + pending_user_operator = baker.make_recipe('utils.user_operator', operator=approved_admin_user_operator.operator) + + pending_user_operator.user.business_guid = approved_admin_user_operator.user.business_guid + + UserOperatorServiceV2.update_status_and_create_contact( + pending_user_operator.id, + UserOperatorStatusUpdate(status='Declined', role=UserOperator.Roles.ADMIN), + approved_admin_user_operator.user.user_guid, + ) + + pending_user_operator.refresh_from_db() # refresh the pending_user_operator object to get the updated status + assert pending_user_operator.status == UserOperator.Statuses.DECLINED + assert pending_user_operator.role == UserOperator.Roles.PENDING + assert pending_user_operator.verified_by == approved_admin_user_operator.user + + @staticmethod + def test_cas_admin_undoes_approved_access_request(): + approved_admin_user_operator = baker.make_recipe('utils.approved_user_operator', role=UserOperator.Roles.ADMIN) + previously_approved_user_operator = baker.make_recipe( + 'utils.user_operator', operator=approved_admin_user_operator.operator, role=UserOperator.Roles.REPORTER + ) + + previously_approved_user_operator.user.business_guid = approved_admin_user_operator.user.business_guid + UserOperatorServiceV2.update_status_and_create_contact( + previously_approved_user_operator.id, + UserOperatorStatusUpdate(status='Pending', role=UserOperator.Roles.PENDING), + approved_admin_user_operator.user.user_guid, + ) + + previously_approved_user_operator.refresh_from_db() # refresh the previously_approved_user_operator object to get the updated status + assert previously_approved_user_operator.status == UserOperator.Statuses.PENDING + assert previously_approved_user_operator.role == UserOperator.Roles.PENDING + assert previously_approved_user_operator.verified_by is None + + @staticmethod + def test_update_status_and_create_contact_success(): + industry_operator_user = baker.make_recipe( + 'utils.industry_operator_user', + first_name="Wednesday", + last_name="Addams", + email="wednesday.addams@email.com", + phone_number='+16044011234', + position_title="child", + ) + approved_admin_user_operator = baker.make_recipe('utils.approved_user_operator', role=UserOperator.Roles.ADMIN) + pending_user_operator = baker.make_recipe( + 'utils.user_operator', + operator=approved_admin_user_operator.operator, + user=industry_operator_user, + ) + + # Set some existing contacts to make sure the service doesn't override them + pending_user_operator.operator.contacts.set(baker.make_recipe('utils.contact', _quantity=3)) + + pending_user_operator.user.business_guid = approved_admin_user_operator.user.business_guid + + UserOperatorServiceV2.update_status_and_create_contact( + pending_user_operator.id, + UserOperatorStatusUpdate(status='Approved', role=UserOperator.Roles.ADMIN), + approved_admin_user_operator.user.user_guid, + ) + pending_user_operator.refresh_from_db() # refresh the pending_user_operator object to get the updated status + assert pending_user_operator.status == UserOperator.Statuses.APPROVED + assert pending_user_operator.role == UserOperator.Roles.ADMIN + assert pending_user_operator.verified_by == approved_admin_user_operator.user + + assert Contact.objects.count() == 4 + assert pending_user_operator.operator.contacts.count() == 4 + assert Contact.objects.filter(first_name="Wednesday").exists() diff --git a/bc_obps/service/transfer_event_service.py b/bc_obps/service/transfer_event_service.py index 2dbc5fd3da..68fd064808 100644 --- a/bc_obps/service/transfer_event_service.py +++ b/bc_obps/service/transfer_event_service.py @@ -1,15 +1,20 @@ import logging from datetime import datetime -from typing import cast, List +from typing import cast, List, Union 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.constants import UNAUTHORIZED_MESSAGE +from registration.models import FacilityDesignatedOperationTimeline, OperationDesignatedOperatorTimeline, User from registration.models.event.transfer_event import TransferEvent from typing import Optional from ninja import Query -from registration.schema.v2.transfer_event import TransferEventCreateIn, TransferEventFilterSchema +from registration.schema.v2.transfer_event import ( + TransferEventCreateIn, + TransferEventFilterSchema, + TransferEventUpdateIn, +) from service.data_access_service.facility_designated_operation_timeline_service import ( FacilityDesignatedOperationTimelineDataAccessService, ) @@ -38,6 +43,7 @@ def list_transfer_events( queryset = ( filters.filter(TransferEvent.objects.order_by(sort_by)) .values( + 'id', 'effective_date', 'status', 'created_at', @@ -51,38 +57,62 @@ def list_transfer_events( return cast(QuerySet[TransferEvent], queryset) @classmethod - def validate_no_overlapping_transfer_events( - cls, operation_id: Optional[UUID] = None, facility_ids: Optional[List[UUID]] = None + def _validate_no_overlapping_transfer_events( + cls, + operation_id: Optional[UUID] = None, + facility_ids: Optional[List[UUID]] = None, + current_transfer_id: Optional[UUID] = None, ) -> None: """ Validates that there are no overlapping active transfer events for the given operation or facilities. + If we are updating an existing transfer event, we can exclude it from the check. """ if operation_id: # Check for overlapping transfer events with the operation - overlapping_operation = TransferEvent.objects.filter( + overlapping_operation_query = TransferEvent.objects.filter( operation_id=operation_id, status__in=[ TransferEvent.Statuses.TO_BE_TRANSFERRED, TransferEvent.Statuses.COMPLETE, ], ) - if overlapping_operation.exists(): + + if current_transfer_id: + overlapping_operation_query = overlapping_operation_query.exclude(id=current_transfer_id) + + if overlapping_operation_query.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( + overlapping_facilities_query = TransferEvent.objects.filter( facilities__id__in=facility_ids, status__in=[ TransferEvent.Statuses.TO_BE_TRANSFERRED, TransferEvent.Statuses.COMPLETE, ], ).distinct() - if overlapping_facilities.exists(): + + if current_transfer_id: + overlapping_facilities_query = overlapping_facilities_query.exclude(id=current_transfer_id) + + if overlapping_facilities_query.exists(): raise Exception( "One or more facilities in this transfer event are already part of an active transfer event." ) + @classmethod + def _process_event_if_effective( + cls, + payload: Union[TransferEventCreateIn, TransferEventUpdateIn], + transfer_event: TransferEvent, + user_guid: UUID, + ) -> None: + # 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[union-attr] # mypy not aware of model schema field in TransferEventCreateIn + cls._process_single_event(transfer_event, user_guid) + @classmethod def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) -> TransferEvent: user = UserDataAccessService.get_by_guid(user_guid) @@ -92,9 +122,9 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) # Validate against overlapping transfer events if payload.transfer_entity == "Operation": - cls.validate_no_overlapping_transfer_events(operation_id=payload.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) + cls._validate_no_overlapping_transfer_events(facility_ids=payload.facilities) # Prepare the payload for the data access service prepared_payload = { @@ -142,10 +172,7 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn) 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) + cls._process_event_if_effective(payload, transfer_event, user_guid) return transfer_event @@ -260,3 +287,62 @@ def _process_operation_transfer(cls, event: TransferEvent, user_guid: UUID) -> N # 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 + + @classmethod + def get_if_authorized(cls, user_guid: UUID, transfer_id: UUID) -> TransferEvent: + user: User = UserDataAccessService.get_by_guid(user_guid) + if user.is_industry_user(): + raise Exception(UNAUTHORIZED_MESSAGE) + transfer_event: TransferEvent = TransferEventDataAccessService.get_by_id(transfer_id) + return transfer_event + + @classmethod + def _get_and_validate_transfer_event_for_update(cls, transfer_id: UUID, user_guid: UUID) -> TransferEvent: + user = UserDataAccessService.get_by_guid(user_guid) + if not user.is_cas_analyst(): + raise Exception(UNAUTHORIZED_MESSAGE) + transfer_event = TransferEventDataAccessService.get_by_id(transfer_id) + if transfer_event.status != TransferEvent.Statuses.TO_BE_TRANSFERRED: + raise Exception("Only transfer events with status 'To be transferred' can be modified.") + return transfer_event + + @classmethod + def delete_transfer_event(cls, user_guid: UUID, transfer_id: UUID) -> None: + transfer_event = cls._get_and_validate_transfer_event_for_update(transfer_id, user_guid) + transfer_event.delete() + return None + + @classmethod + def _update_operation_transfer_event( + cls, user_guid: UUID, transfer_id: UUID, payload: TransferEventUpdateIn + ) -> None: + operation_id = payload.operation + if not operation_id: + raise Exception("Operation is required for operation transfer events.") + cls._validate_no_overlapping_transfer_events(operation_id=operation_id, current_transfer_id=transfer_id) + TransferEventDataAccessService.update_transfer_event( + user_guid, transfer_id, {"operation_id": operation_id, "effective_date": payload.effective_date} # type: ignore[attr-defined] # mypy not aware of model schema field + ) + + @classmethod + def _update_facility_transfer_event( + cls, user_guid: UUID, transfer_id: UUID, payload: TransferEventUpdateIn + ) -> None: + facility_ids = payload.facilities + if not facility_ids: + raise Exception("Facilities are required for facility transfer events.") + cls._validate_no_overlapping_transfer_events(facility_ids=facility_ids, current_transfer_id=transfer_id) + updated_transfer_event = TransferEventDataAccessService.update_transfer_event( + user_guid, transfer_id, payload.dict(include=["effective_date"]) + ) + updated_transfer_event.facilities.set(facility_ids) # type: ignore[arg-type] # mypy requires actual Facility objects but passing UUIDs is fine + + @classmethod + @transaction.atomic + def update_transfer_event(cls, user_guid: UUID, transfer_id: UUID, payload: TransferEventUpdateIn) -> TransferEvent: + transfer_event = cls._get_and_validate_transfer_event_for_update(transfer_id, user_guid) + {"Operation": cls._update_operation_transfer_event, "Facility": cls._update_facility_transfer_event,}[ + payload.transfer_entity + ](user_guid, transfer_id, payload) + cls._process_event_if_effective(payload, transfer_event, user_guid) + return transfer_event diff --git a/bc_obps/service/user_operator_service_v2.py b/bc_obps/service/user_operator_service_v2.py index 1182e92535..fd89e66b2b 100644 --- a/bc_obps/service/user_operator_service_v2.py +++ b/bc_obps/service/user_operator_service_v2.py @@ -1,19 +1,24 @@ from typing import Dict, Optional from uuid import UUID - -from django.db import transaction -from django.db.models import QuerySet -from django.db.models.functions import Lower -from ninja import Query - -from registration.constants import UNAUTHORIZED_MESSAGE -from registration.schema.v2.user_operator import UserOperatorFilterSchema +from registration.emails import send_operator_access_request_email +from registration.enums.enums import AccessRequestStates, AccessRequestTypes +from registration.schema.v1.contact import ContactIn +from registration.schema.v1.user_operator import UserOperatorStatusUpdate +from registration.schema.v2.operator import OperatorIn from registration.utils import update_model_instance -from registration.models import Operator, UserOperator +from service.contact_service import ContactService from service.data_access_service.user_operator_service import UserOperatorDataAccessService -from registration.schema.v2.operator import OperatorIn from service.data_access_service.user_service import UserDataAccessService +from service.data_access_service.operator_service import OperatorDataAccessService +from registration.models import Operator, User, UserOperator +from django.db import transaction +from registration.constants import UNAUTHORIZED_MESSAGE from service.operator_service_v2 import OperatorServiceV2 +from registration.schema.v2.user_operator import UserOperatorFilterSchema +from django.db.models import QuerySet +from django.db.models.functions import Lower +from ninja import Query +from registration.utils import set_verification_columns class UserOperatorServiceV2: @@ -109,3 +114,79 @@ def list_user_operators_v2( return filters.filter(base_qs).order_by(lower_sort_field.desc()) # Apply ascending order return filters.filter(base_qs).order_by(lower_sort_field) + + @classmethod + @transaction.atomic() + def update_status_and_create_contact( + cls, user_operator_id: UUID, payload: UserOperatorStatusUpdate, admin_user_guid: UUID + ) -> UserOperator: + """Function to update the user_operator status. If they are being approved, we create a Contact record for them.""" + admin_user: User = UserDataAccessService.get_by_guid(admin_user_guid) + user_operator: UserOperator = UserOperatorDataAccessService.get_user_operator_by_id(user_operator_id) + + # industry users can only update the status of user_operators from the same operator as themselves + if admin_user.is_industry_user(): + # operator_business_guid can be None if no admins are approved yet (business_guids come from admin users) + try: + operator_business_guid = OperatorDataAccessService.get_operators_business_guid( + user_operator.operator.id + ) + except Exception: + operator_business_guid = None + if operator_business_guid != admin_user.business_guid: + raise PermissionError("Your user is not associated with this operator.") + + user_operator.status = payload.status # type: ignore[attr-defined] + updated_role = payload.role + + if user_operator.status in [UserOperator.Statuses.APPROVED, UserOperator.Statuses.DECLINED]: + set_verification_columns(user_operator, admin_user_guid) + + if user_operator.status == UserOperator.Statuses.DECLINED: + # Set role to pending for now but we may want to add a new role for declined + user_operator.role = UserOperator.Roles.PENDING + + if user_operator.status == UserOperator.Statuses.APPROVED and updated_role != UserOperator.Roles.PENDING: + # we only update the role if the user_operator is being approved + user_operator.role = updated_role # type: ignore[assignment] + contact_payload = ContactIn( + first_name=user_operator.user.first_name, + last_name=user_operator.user.last_name, + email=user_operator.user.email, + phone_number=str(user_operator.user.phone_number), # ContactIn expects a string, + position_title=user_operator.user.position_title, + ) + contact = ContactService.create_contact(admin_user_guid, contact_payload) + user_operator.operator.contacts.add(contact) + + access_request_type: AccessRequestTypes = AccessRequestTypes.OPERATOR_WITH_ADMIN + if admin_user.is_irc_user(): + if user_operator.status == UserOperator.Statuses.DECLINED: + access_request_type = AccessRequestTypes.ADMIN + else: + # use the email template for new operator and admin approval if the creator of the operator is the same as the user who requested access + # Otherwise, use the email template for admin approval + access_request_type = ( + AccessRequestTypes.NEW_OPERATOR_AND_ADMIN + if user_operator.operator.created_by == user_operator.user + else AccessRequestTypes.ADMIN + ) + # Send email to user if their request was approved or declined (using the appropriate email template) + send_operator_access_request_email( + AccessRequestStates(user_operator.status), + # If the admin user is an IRC user, the access request type is admin, + # otherwise the admin user is an external user and the access request is for an operator with existing admin + access_request_type, + user_operator.operator.legal_name, + user_operator.user.get_full_name(), + user_operator.user.email, + ) + + elif user_operator.status == UserOperator.Statuses.PENDING: + user_operator.verified_at = None + user_operator.verified_by_id = None + user_operator.role = UserOperator.Roles.PENDING + user_operator.save(update_fields=["status", "verified_at", "verified_by_id", "role"]) + user_operator.set_create_or_update(admin_user_guid) + + return user_operator diff --git a/bciers/apps/administration/app/components/contacts/ContactForm.tsx b/bciers/apps/administration/app/components/contacts/ContactForm.tsx index 14607988fb..d9a9b58603 100644 --- a/bciers/apps/administration/app/components/contacts/ContactForm.tsx +++ b/bciers/apps/administration/app/components/contacts/ContactForm.tsx @@ -1,15 +1,13 @@ "use client"; -import { UUID } from "crypto"; import { useState } from "react"; import { useRouter } from "next/navigation"; import SingleStepTaskListForm from "@bciers/components/form/SingleStepTaskListForm"; import { actionHandler } from "@bciers/actions"; import { ContactFormData } from "./types"; -import getUserData from "./getUserData"; -import { IChangeEvent } from "@rjsf/core"; import { FormMode } from "@bciers/utils/src/enums"; import { contactsUiSchema } from "@/administration/app/data/jsonSchema/contact"; +import { useSessionRole } from "@bciers/utils/src/sessionUtils"; interface Props { schema: any; @@ -35,18 +33,7 @@ export default function ContactForm({ const [formState, setFormState] = useState(formData ?? {}); const [isCreatingState, setIsCreatingState] = useState(isCreating); const [key, setKey] = useState(Math.random()); - const [selectedUser, setSelectedUser] = useState(""); - - const handleSelectUserChange = async (userId: UUID) => { - try { - setSelectedUser(userId); - const userData: ContactFormData = await getUserData(userId); - setFormState(userData); - setKey(Math.random()); - } catch (err) { - setError("Failed to fetch user data!" as any); - } - }; + const role = useSessionRole(); return ( } + inlineMessage={ + isCreatingState && !role.includes("cas") && + } onSubmit={async (data: { formData?: any }) => { const updatedFormData = { ...formState, ...data.formData }; setFormState(updatedFormData); @@ -101,12 +91,6 @@ export default function ContactForm({ }?contacts_title=${response.first_name} ${response.last_name}`; router.replace(replaceUrl); }} - onChange={(e: IChangeEvent) => { - let newSelectedUser = e.formData?.section1?.selected_user; - if (newSelectedUser && newSelectedUser !== selectedUser) { - handleSelectUserChange(e.formData.section1.selected_user); - } - }} onCancel={() => router.replace("/contacts")} /> ); diff --git a/bciers/apps/administration/app/components/contacts/ContactPage.tsx b/bciers/apps/administration/app/components/contacts/ContactPage.tsx index 86311f233e..5a29bdb8f1 100644 --- a/bciers/apps/administration/app/components/contacts/ContactPage.tsx +++ b/bciers/apps/administration/app/components/contacts/ContactPage.tsx @@ -44,11 +44,7 @@ export default async function ContactPage({ {isCreating ? "Add Contact" : "Contact Details"} { const localSchema = safeJsonParse(JSON.stringify(schema)); // deep copy - // For now, we show the `existing_bciers_user` toggle and `selected_user` combobox only when creating a new contact. We should not show `places_assigned` when creating + // We should not show `places_assigned` when creating if (isCreating) { delete localSchema.properties.section1.properties.places_assigned; - const userOperatorUserOptions = userOperatorUsers.map((user) => ({ - type: "string", - title: user.full_name, - enum: [user.id], - value: user.id, - })); - - if ( - Array.isArray(userOperatorUserOptions) && - userOperatorUserOptions.length > 0 - ) { - localSchema.properties.section1.allOf[0].then.properties.selected_user.anyOf = - userOperatorUserOptions; - } - } else { - delete localSchema.properties.section1.properties.existing_bciers_user; - delete localSchema.properties.section1.allOf; } return localSchema; diff --git a/bciers/apps/administration/app/components/facilities/types.ts b/bciers/apps/administration/app/components/facilities/types.ts index 5abc7d1b2b..07eb231a31 100644 --- a/bciers/apps/administration/app/components/facilities/types.ts +++ b/bciers/apps/administration/app/components/facilities/types.ts @@ -1,9 +1,11 @@ +import { UUID } from "crypto"; + export interface FacilityRow { - id: number; + id: UUID; facility__bcghg_id__id: string; facility__name: string; facility__type: string; - facility__id: number; + facility__id: UUID; status: string; facility__latitude_of_largest_emissions: string; facility__longitude_of_largest_emissions: string; diff --git a/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx b/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx index d8de303135..d390ca5dd2 100644 --- a/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx +++ b/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { UUID } from "crypto"; import SingleStepTaskListForm from "@bciers/components/form/SingleStepTaskListForm"; @@ -18,6 +18,8 @@ import { } from "apps/registration/app/components/operations/registration/enums"; import { FormMode, FrontEndRoles } from "@bciers/utils/src/enums"; import { useSessionRole } from "@bciers/utils/src/sessionUtils"; +import Note from "@bciers/components/layout/Note"; +import Link from "next/link"; const OperationInformationForm = ({ formData, @@ -29,15 +31,16 @@ const OperationInformationForm = ({ schema: RJSFSchema; }) => { const [error, setError] = useState(undefined); - const router = useRouter(); - // To get the user's role from the session const role = useSessionRole(); + const searchParams = useSearchParams(); + const isRedirectedFromContacts = searchParams.get("from_contacts") as string; const handleSubmit = async (data: { formData?: OperationInformationFormData; }) => { + setError(undefined); const response = await actionHandler( `registration/operations/${operationId}`, "PUT", @@ -48,6 +51,16 @@ const OperationInformationForm = ({ ); if (response?.error) { + // Users get this error when they select a contact that's missing address information. We include a link to the Contacts page because the user has to fix the error from there, not here in the operation form. + if (response.error.includes("Please return to Contacts")) { + const splitError = response.error.split("Contacts"); + response.error = ( + <> + {splitError[0]} Contacts + {splitError[1]} + + ); + } setError(response.error); return { error: response.error }; } @@ -67,29 +80,36 @@ const OperationInformationForm = ({ return { error: response2.error }; } }; - return ( - router.push("/operations")} - formContext={{ - operationId, - isRegulatedOperation: regulatedOperationPurposes.includes( - formData.registration_purpose as RegistrationPurposes, - ), - isCasDirector: role === FrontEndRoles.CAS_DIRECTOR, - isEio: formData.registration_purpose?.match( - RegistrationPurposes.ELECTRICITY_IMPORT_OPERATION.valueOf(), - ), - status: formData.status, - }} - /> + <> + {isRedirectedFromContacts && !role.includes("cas_") && ( + + To remove the current operation representative, please select a new + contact to replace them. + + )} + router.push("/operations")} + formContext={{ + operationId, + isRegulatedOperation: regulatedOperationPurposes.includes( + formData.registration_purpose as RegistrationPurposes, + ), + isCasDirector: role === FrontEndRoles.CAS_DIRECTOR, + isEio: formData.registration_purpose?.match( + RegistrationPurposes.ELECTRICITY_IMPORT_OPERATION.valueOf(), + ), + status: formData.status, + }} + /> + ); }; diff --git a/bciers/apps/administration/app/data/jsonSchema/contact.ts b/bciers/apps/administration/app/data/jsonSchema/contact.ts index 753f460c2e..7a1c659ff6 100644 --- a/bciers/apps/administration/app/data/jsonSchema/contact.ts +++ b/bciers/apps/administration/app/data/jsonSchema/contact.ts @@ -1,18 +1,13 @@ import { RJSFSchema } from "@rjsf/utils"; import provinceOptions from "@bciers/data/provinces.json"; import SectionFieldTemplate from "@bciers/components/form/fields/SectionFieldTemplate"; -import { ArrayFieldTemplate } from "@bciers/components/form/fields"; +import { PlacesAssignedFieldTemplate } from "@bciers/components/form/fields"; const section1: RJSFSchema = { type: "object", title: "Personal Information", required: ["first_name", "last_name"], properties: { - existing_bciers_user: { - type: "boolean", - default: true, - title: "Is this contact a user in BCIERS?", - }, first_name: { type: "string", title: "First Name", @@ -23,34 +18,18 @@ const section1: RJSFSchema = { }, places_assigned: { type: "array", - default: ["None"], title: "Places assigned", readOnly: true, items: { - type: "string", - }, - }, - }, - allOf: [ - { - if: { - properties: { - existing_bciers_user: { - const: true, - }, - }, - }, - then: { - required: ["selected_user"], + type: "object", properties: { - selected_user: { - type: "string", - title: "Select the user", - }, + role_name: { type: "string" }, + operation_name: { type: "string" }, + operation_id: { type: "string" }, }, }, }, - ], + }, }; const section2: RJSFSchema = { @@ -86,6 +65,7 @@ const section3: RJSFSchema = { const section4: RJSFSchema = { type: "object", title: "Address Information", + required: ["street_address", "municipality", "province", "postal_code"], properties: { street_address: { type: "string", @@ -126,30 +106,29 @@ export const contactsUiSchema = { }, section1: { "ui:FieldTemplate": SectionFieldTemplate, - "ui:order": [ - "existing_bciers_user", - "selected_user", - "first_name", - "last_name", - "places_assigned", - ], - existing_bciers_user: { - "ui:widget": "ToggleWidget", - }, - selected_user: { - "ui:widget": "ComboBox", - "ui:placeholder": "Select the user", - }, + "ui:order": ["selected_user", "first_name", "last_name", "places_assigned"], places_assigned: { - "ui:ArrayFieldTemplate": ArrayFieldTemplate, - "ui:options": { - note: "You cannot delete this contact unless you replace them with other contact(s) in the place(s) above.", - addable: false, - removable: false, - }, + "ui:ArrayFieldTemplate": PlacesAssignedFieldTemplate, "ui:classNames": "[&>div:last-child]:w-2/3", items: { "ui:widget": "ReadOnlyWidget", + "ui:options": { + label: false, + inline: true, + }, + role_name: { + "ui:options": { + label: false, + }, + }, + operation_name: { + "ui:options": { + label: false, + }, + }, + operation_id: { + "ui:widget": "hidden", + }, }, }, }, diff --git a/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationOperationInformation.ts b/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationOperationInformation.ts index 626968cd85..a005f49513 100644 --- a/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationOperationInformation.ts +++ b/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationOperationInformation.ts @@ -45,6 +45,7 @@ export const administrationOperationInformationUiSchema: UiSchema = { section3: { ...registrationInformationUiSchema, "ui:order": [ + "operation_representatives", "registration_purpose", "regulated_operation_preface", "regulated_products", diff --git a/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts b/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts index 50a6c522ac..2d534eef68 100644 --- a/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts +++ b/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts @@ -1,7 +1,7 @@ import SectionFieldTemplate from "@bciers/components/form/fields/SectionFieldTemplate"; import { TitleOnlyFieldTemplate } from "@bciers/components/form/fields"; import { RJSFSchema, UiSchema } from "@rjsf/utils"; -import { getRegulatedProducts } from "@bciers/actions/api"; +import { getRegulatedProducts, getContacts } from "@bciers/actions/api"; import { RegistrationPurposes } from "apps/registration/app/components/operations/registration/enums"; export const createAdministrationRegistrationInformationSchema = async ( @@ -10,6 +10,13 @@ export const createAdministrationRegistrationInformationSchema = async ( // fetch db values that are dropdown options const regulatedProducts: { id: number; name: string }[] = await getRegulatedProducts(); + if (regulatedProducts && "error" in regulatedProducts) + throw new Error("Failed to retrieve regulated products information"); + const contacts: { + items: [{ id: number; first_name: string; last_name: string }]; + } = await getContacts(); + if (contacts && "error" in contacts) + throw new Error("Failed to retrieve contacts information"); const isRegulatedProducts = registrationPurposeValue === @@ -23,7 +30,10 @@ export const createAdministrationRegistrationInformationSchema = async ( const registrationInformationSchema: RJSFSchema = { title: "Registration Information", type: "object", - required: isRegulatedProducts ? ["regulated_products"] : [], + required: [ + "operation_representatives", + ...(isRegulatedProducts ? ["regulated_products"] : []), + ], properties: { registration_purpose: { type: "string", @@ -47,6 +57,19 @@ export const createAdministrationRegistrationInformationSchema = async ( }, }, }), + operation_representatives: { + title: "Operation Representative(s)", + type: "array", + minItems: 1, + items: { + enum: contacts.items.map((contact) => contact.id), + // Ts-ignore until we refactor enumNames https://github.com/bcgov/cas-registration/issues/2176 + // @ts-ignore + enumNames: contacts.items.map( + (contact) => `${contact.first_name} ${contact.last_name}`, + ), + }, + }, ...(isOptIn && { opted_in_preface: { // Not an actual field, just used to display a message @@ -114,6 +137,9 @@ export const registrationInformationUiSchema: UiSchema = { "new_entrant_application", ], "ui:FieldTemplate": SectionFieldTemplate, + operation_representatives: { + "ui:widget": "MultiSelectWidget", + }, regulated_operation_preface: { "ui:classNames": "text-bc-bg-blue text-lg", "ui:FieldTemplate": TitleOnlyFieldTemplate, diff --git a/bciers/apps/administration/tests/components/contacts/ContactForm.test.tsx b/bciers/apps/administration/tests/components/contacts/ContactForm.test.tsx index ea4adf0cd8..c013b5d60f 100644 --- a/bciers/apps/administration/tests/components/contacts/ContactForm.test.tsx +++ b/bciers/apps/administration/tests/components/contacts/ContactForm.test.tsx @@ -1,6 +1,6 @@ import { render, screen, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { actionHandler, useRouter } from "@bciers/testConfig/mocks"; +import { actionHandler, useRouter, useSession } from "@bciers/testConfig/mocks"; import { contactsSchema, contactsUiSchema, @@ -26,17 +26,19 @@ const contactFormData = { municipality: "Cityville", province: "ON", postal_code: "A1B 2C3", - places_assigned: ["Operation Representative - Operation 1"], + places_assigned: [ + { + role_name: "Operation Representative", + operation_name: "Operation 1", + operation_id: "c0743c09-82fa-4186-91aa-4b5412e3415c", + }, + ], }; export const checkEmptyContactForm = () => { expect( screen.getByRole("heading", { name: /Personal Information/i }), ).toBeVisible(); - expect( - screen.getByLabelText(/Is this contact a user in BCIERS/i), - ).toBeChecked(); - expect(screen.getByLabelText(/Select the user/i)).toHaveValue(""); expect(screen.getByLabelText(/First Name/i)).toHaveValue(""); expect(screen.getByLabelText(/Last Name/i)).toHaveValue(""); expect( @@ -59,11 +61,6 @@ export const checkEmptyContactForm = () => { expect(screen.getByLabelText(/Postal Code/i)).toHaveValue(""); }; export const fillContactForm = async () => { - // Switch off the user combobox(so it doesn't raise form error) - await userEvent.click( - screen.getByLabelText(/Is this contact a user in BCIERS/i), - ); - // Personal Information await userEvent.type(screen.getByLabelText(/First Name/i), "John"); await userEvent.type(screen.getByLabelText(/Last Name/i), "Doe"); @@ -101,12 +98,19 @@ export const fillContactForm = async () => { describe("ContactForm component", () => { beforeEach(async () => { vi.clearAllMocks(); + useSession.mockReturnValue({ + data: { + user: { + app_role: "industry_user_admin", + }, + }, + }); }); it("renders the empty contact form when creating a new contact", async () => { render( { expect(screen.getByRole("button", { name: /submit/i })).toBeEnabled(); expect(screen.getByRole("button", { name: /cancel/i })).toBeEnabled(); }); - it("loads existing readonly contact form data", async () => { - const readOnlyContactSchema = createContactSchema( - contactsSchema, - [], - false, - ); + it("loads existing readonly contact form data for an internal user", async () => { + useSession.mockReturnValue({ + data: { + user: { + app_role: "industry_user_admin", + }, + }, + }); + const readOnlyContactSchema = createContactSchema(contactsSchema, false); const { container } = render( , ); - // form fields + + // Inline message expect( - screen.queryByText(/Is this contact a user in BCIERS/i), + screen.queryByText( + /To assign this representative to an operation, go to the operation information form/i, + ), ).not.toBeInTheDocument(); - expect(screen.queryByText(/Select the user/i)).not.toBeInTheDocument(); expect( container.querySelector("#root_section1_first_name"), @@ -155,9 +163,11 @@ describe("ContactForm component", () => { ).toHaveTextContent("Doe"); expect(screen.getByText(/Places Assigned/i)).toBeVisible(); - expect( - screen.getByText(/Operation Representative - Operation 1/i), - ).toBeVisible(); + expect(screen.getByText(/Operation Representative/i)).toBeVisible(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "/operations/c0743c09-82fa-4186-91aa-4b5412e3415c?operations_title=Operation 1&from_contacts=true", + ); expect( container.querySelector("#root_section2_position_title"), @@ -192,8 +202,7 @@ describe("ContactForm component", () => { it("does not allow new contact form submission if there are validation errors (empty form data)", async () => { render( , @@ -202,7 +211,7 @@ describe("ContactForm component", () => { act(() => { submitButton.click(); }); - expect(screen.getAllByText(/Required field/i)).toHaveLength(6); // 5 required fields + 1 for the user combobox + expect(screen.getAllByText(/Required field/i)).toHaveLength(9); }); it( "fills the mandatory form fields, creates new contact, and redirects on success", @@ -212,8 +221,7 @@ describe("ContactForm component", () => { async () => { render( , @@ -239,7 +247,6 @@ describe("ContactForm component", () => { "/contacts", { body: JSON.stringify({ - existing_bciers_user: false, first_name: "John", last_name: "Doe", position_title: "Senior Officer", @@ -272,8 +279,7 @@ describe("ContactForm component", () => { async () => { render( , @@ -287,11 +293,6 @@ describe("ContactForm component", () => { }; actionHandler.mockReturnValueOnce(response); - // Switch off the user combobox(so it doesn't raise form error) - await userEvent.click( - screen.getByLabelText(/Is this contact a user in BCIERS/i), - ); - // Personal Information await userEvent.type(screen.getByLabelText(/First Name/i), "John"); await userEvent.type(screen.getByLabelText(/Last Name/i), "Doe"); @@ -310,6 +311,18 @@ describe("ContactForm component", () => { "+16044011234", ); + await userEvent.type( + screen.getByLabelText(/Business Mailing Address+/i), + "123 Street", + ); + await userEvent.type(screen.getByLabelText(/Municipality+/i), "Toronto"); + + const provinceDropdown = screen.getByLabelText(/Province+/i); + await userEvent.click(provinceDropdown); + await userEvent.click(screen.getByText(/ontario/i)); + + await userEvent.type(screen.getByLabelText(/Postal Code+/i), "H0H 0H0"); + // Submit await userEvent.click(screen.getByRole("button", { name: /submit/i })); @@ -321,12 +334,15 @@ describe("ContactForm component", () => { "/contacts", { body: JSON.stringify({ - existing_bciers_user: false, first_name: "John", last_name: "Doe", position_title: "Senior Officer", email: "john.doe@example.com", phone_number: "+1 1 604 401 1234", + street_address: "123 Street", + municipality: "Toronto", + province: "ON", + postal_code: "H0H0H0", }), }, ); @@ -361,27 +377,25 @@ describe("ContactForm component", () => { "/contacts/123", { body: JSON.stringify({ - existing_bciers_user: false, first_name: "John updated", last_name: "Doe updated", position_title: "Senior Officer", email: "john.doe@example.com", phone_number: "+1 1 604 401 1234", + street_address: "123 Street", + municipality: "Toronto", + province: "ON", + postal_code: "H0H0H0", }), }, ); }, ); it("updates existing contact form data and hits the correct endpoint", async () => { - const readOnlyContactSchema = createContactSchema( - contactsSchema, - [], - false, - ); + const readOnlyContactSchema = createContactSchema(contactsSchema, false); render( , @@ -417,7 +431,13 @@ describe("ContactForm component", () => { body: JSON.stringify({ first_name: "John updated", last_name: "Doe updated", - places_assigned: ["Operation Representative - Operation 1"], + places_assigned: [ + { + role_name: "Operation Representative", + operation_name: "Operation 1", + operation_id: "c0743c09-82fa-4186-91aa-4b5412e3415c", + }, + ], position_title: "Senior Officer", email: "john.doe@example.com", phone_number: "+16044011234", @@ -430,15 +450,10 @@ describe("ContactForm component", () => { ); }); it("renders the places assigned field in read-only mode when editing", async () => { - const readOnlyContactSchema = createContactSchema( - contactsSchema, - [], - false, - ); + const readOnlyContactSchema = createContactSchema(contactsSchema, false); render( , diff --git a/bciers/apps/administration/tests/components/contacts/ContactPage.test.tsx b/bciers/apps/administration/tests/components/contacts/ContactPage.test.tsx index 8020a34d24..152d9d380a 100644 --- a/bciers/apps/administration/tests/components/contacts/ContactPage.test.tsx +++ b/bciers/apps/administration/tests/components/contacts/ContactPage.test.tsx @@ -3,10 +3,6 @@ import { useSession, useRouter } from "@bciers/testConfig/mocks"; import { getContact, getUserOperatorUsers } from "./mocks"; import ContactPage from "apps/administration/app/components/contacts/ContactPage"; -useSession.mockReturnValue({ - get: vi.fn(), -}); - useRouter.mockReturnValue({ query: {}, replace: vi.fn(), @@ -29,6 +25,14 @@ const contactFormData = { describe("Contact component", () => { beforeEach(async () => { vi.resetAllMocks(); + useSession.mockReturnValue({ + get: vi.fn(), + data: { + user: { + app_role: "industry_user_admin", + }, + }, + }); }); it("renders the appropriate error component when getContact fails", async () => { diff --git a/bciers/apps/administration/tests/components/contacts/mocks.ts b/bciers/apps/administration/tests/components/contacts/mocks.ts index f64c958ca1..25516beea5 100644 --- a/bciers/apps/administration/tests/components/contacts/mocks.ts +++ b/bciers/apps/administration/tests/components/contacts/mocks.ts @@ -1,6 +1,7 @@ const fetchContactsPageData = vi.fn(); const getContact = vi.fn(); const getUserOperatorUsers = vi.fn(); +const getContacts = vi.fn(); vi.mock( "apps/administration/app/components/contacts/fetchContactsPageData", @@ -20,4 +21,8 @@ vi.mock( }), ); -export { fetchContactsPageData, getContact, getUserOperatorUsers }; +vi.mock("libs/actions/src/api/getContacts", () => ({ + default: getContacts, +})); + +export { fetchContactsPageData, getContact, getUserOperatorUsers, getContacts }; diff --git a/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx b/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx index 112b4b17dc..a6312d2bdd 100644 --- a/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx +++ b/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx @@ -1,7 +1,11 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { RJSFSchema } from "@rjsf/utils"; import OperationInformationForm from "@/administration/app/components/operations/OperationInformationForm"; -import { actionHandler, useSession } from "@bciers/testConfig/mocks"; +import { + actionHandler, + useSearchParams, + useSession, +} from "@bciers/testConfig/mocks"; import { getBusinessStructures, getNaicsCodes, @@ -14,6 +18,7 @@ import { FrontEndRoles, OperationStatus } from "@bciers/utils/src/enums"; import { expect } from "vitest"; import userEvent from "@testing-library/user-event"; import { RegistrationPurposes } from "@/registration/app/components/operations/registration/enums"; +import { getContacts } from "../contacts/mocks"; useSession.mockReturnValue({ data: { @@ -22,6 +27,10 @@ useSession.mockReturnValue({ }, }, }); +useSearchParams.mockReturnValue({ + get: vi.fn(), +}); + const mockDataUri = "data:application/pdf;name=testpdf.pdf;base64,ZHVtbXk="; export const fetchFormEnums = () => { @@ -64,6 +73,25 @@ export const fetchFormEnums = () => { { id: 2, name: "Cement equivalent" }, ]); + // Contacts + getContacts.mockResolvedValue({ + items: [ + { + id: 1, + first_name: "Ivy", + last_name: "Jones", + email: "ivy.jones@example.com", + }, + { + id: 2, + first_name: "Jack", + last_name: "King", + email: "jack.king@example.com", + }, + ], + count: 2, + }); + // Registration purposes actionHandler.mockResolvedValue(["Potential Reporting Operation"]); }; @@ -162,7 +190,7 @@ const formData = { naics_code_id: 1, secondary_naics_code_id: 2, operation_has_multiple_operators: true, - activities: [1, 2, 3, 4, 5], + activities: [1, 2], multiple_operators_array: [ { mo_is_extraprovincial_company: false, @@ -178,7 +206,7 @@ const formData = { }, ], registration_purpose: "Reporting Operation", - regulated_products: [6], + regulated_products: [2], opt_in: false, }; @@ -801,4 +829,152 @@ describe("the OperationInformationForm component", () => { }, ); }); + + it("should not allow external users to remove their operation rep", async () => { + useSession.mockReturnValue({ + data: { + user: { + app_role: "industry_user_admin", + }, + }, + }); + + fetchFormEnums(); + const createdFormSchema = + await createAdministrationOperationInformationSchema( + formData.registration_purpose, + OperationStatus.REGISTERED, + ); + + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Edit" })); + const cancelChipIcon = screen.getAllByTestId("CancelIcon"); + await userEvent.click(cancelChipIcon[2]); // 0-1 are activities + expect(screen.queryByText(/ivy/i)).not.toBeInTheDocument(); + + const submitButton = screen.getByRole("button", { + name: "Submit", + }); + await userEvent.click(submitButton); + expect(actionHandler).toHaveBeenCalledTimes(0); + expect(screen.getByText(/Must not have fewer than 1 items/i)).toBeVisible(); + }); + + it("should allow external users to replace their operation rep", async () => { + const testFormData = { + name: "Operation 3", + type: "Single Facility Operation", + naics_code_id: 1, + secondary_naics_code_id: 2, + operation_has_multiple_operators: false, + activities: [1, 2], + registration_purpose: "Reporting Operation", + regulated_products: [1], + opt_in: false, + operation_representatives: [1], + boundary_map: mockDataUri, + process_flow_diagram: mockDataUri, + }; + useSession.mockReturnValue({ + data: { + user: { + app_role: "industry_user_admin", + }, + }, + }); + + fetchFormEnums(); + const createdFormSchema = + await createAdministrationOperationInformationSchema( + testFormData.registration_purpose, + OperationStatus.REGISTERED, + ); + + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Edit" })); + const cancelChipIcon = screen.getAllByTestId("CancelIcon"); + await userEvent.click(cancelChipIcon[2]); // 0-1 are activities + expect(screen.queryByText(/ivy/i)).not.toBeInTheDocument(); + const operationRepresentativesComboBoxInput = screen.getByRole("combobox", { + name: /Operation Representative(s)*/i, + }); + const openOperationReps = operationRepresentativesComboBoxInput + .parentElement?.children[1]?.children[0] as HTMLInputElement; + await userEvent.click(openOperationReps); + await userEvent.type( + operationRepresentativesComboBoxInput, + "Jack King{enter}", + ); + + const submitButton = screen.getByRole("button", { + name: "Submit", + }); + await userEvent.click(submitButton); + expect(actionHandler).toHaveBeenCalledTimes(1); + expect(actionHandler).toHaveBeenCalledWith( + `registration/operations/${operationId}`, + "PUT", + "", + { + body: JSON.stringify({ + name: "Operation 3", + type: "Single Facility Operation", + naics_code_id: 1, + secondary_naics_code_id: 2, + activities: [1, 2], + process_flow_diagram: mockDataUri, + boundary_map: mockDataUri, + operation_has_multiple_operators: false, + registration_purpose: "Reporting Operation", + operation_representatives: [2], + }), + }, + ); + }); + + it("should show a note if user navigated to operation from the contacts form", async () => { + useSession.mockReturnValue({ + data: { + user: { + app_role: "industry_user_admin", + }, + }, + }); + const mockGet = vi.fn(); + useSearchParams.mockReturnValue({ + get: mockGet, + }); + mockGet.mockReturnValue("true"); + fetchFormEnums(); + const createdFormSchema = + await createAdministrationOperationInformationSchema( + formData.registration_purpose, + OperationStatus.REGISTERED, + ); + + render( + , + ); + expect( + screen.getByText( + /To remove the current operation representative, please select a new contact to replace them./i, + ), + ).toBeVisible(); + }); }); diff --git a/bciers/apps/administration/tests/components/operations/OperationInformationPage.test.tsx b/bciers/apps/administration/tests/components/operations/OperationInformationPage.test.tsx index e02e046998..2796bc0de3 100644 --- a/bciers/apps/administration/tests/components/operations/OperationInformationPage.test.tsx +++ b/bciers/apps/administration/tests/components/operations/OperationInformationPage.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import OperationInformationPage from "@/administration/app/components/operations/OperationInformationPage"; import { getOperationWithDocuments } from "./mocks"; -import { useSession } from "@bciers/testConfig/mocks"; +import { useSearchParams, useSession } from "@bciers/testConfig/mocks"; import { fetchFormEnums } from "./OperationInformationForm.test"; import { beforeAll } from "vitest"; import { OperationStatus } from "@bciers/utils/src/enums"; @@ -46,6 +46,9 @@ describe("the OperationInformationPage component", () => { }, }, }); + useSearchParams.mockReturnValue({ + get: vi.fn(), + }); }); it("renders the OperationInformationPage component without Registration Information", async () => { fetchFormEnums(); diff --git a/bciers/apps/registration/app/bceidbusiness/industry_user/event/page.tsx b/bciers/apps/registration/app/bceidbusiness/industry_user/event/page.tsx deleted file mode 100644 index bed06fcf68..0000000000 --- a/bciers/apps/registration/app/bceidbusiness/industry_user/event/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// 🚩 flagging that for shared routes between roles, "Page" code is a component for code maintainability - -export default async function Page() { - return <> 😀 👋 ; -} diff --git a/bciers/apps/registration/app/bceidbusiness/industry_user_admin/event/page.tsx b/bciers/apps/registration/app/bceidbusiness/industry_user_admin/event/page.tsx deleted file mode 100644 index bed06fcf68..0000000000 --- a/bciers/apps/registration/app/bceidbusiness/industry_user_admin/event/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// 🚩 flagging that for shared routes between roles, "Page" code is a component for code maintainability - -export default async function Page() { - return <> 😀 👋 ; -} diff --git a/bciers/apps/registration/app/components/transfers/TransferDetailForm.tsx b/bciers/apps/registration/app/components/transfers/TransferDetailForm.tsx new file mode 100644 index 0000000000..45f8d7b744 --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/TransferDetailForm.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@mui/material"; +import SubmitButton from "@bciers/components/button/SubmitButton"; +import { useRouter } from "next/navigation"; +import { IChangeEvent } from "@rjsf/core"; +import { TransferDetailFormData } from "@/registration/app/components/transfers/types"; +import { editTransferUISchema } from "@/registration/app/data/jsonSchema/transfer/transferDetail"; +import { actionHandler } from "@bciers/actions"; +import SimpleModal from "@bciers/components/modal/SimpleModal"; +import { UUID } from "crypto"; +import { useSessionRole } from "@bciers/utils/src/sessionUtils"; +import { FrontEndRoles } from "@bciers/utils/src/enums"; +import { TransferEventStatus } from "@/registration/app/components/transfers/enums"; +import { RJSFSchema } from "@rjsf/utils"; +import SingleStepTaskListForm from "@bciers/components/form/SingleStepTaskListForm"; + +interface TransferDetailFormProps { + formData: TransferDetailFormData; + transferId: UUID; + schema: RJSFSchema; +} + +export default function TransferDetailForm({ + formData, + transferId, + schema, +}: Readonly) { + // To get the user's role from the session + const role = useSessionRole(); + const isCasAnalyst = role === FrontEndRoles.CAS_ANALYST; + const isEditable = + isCasAnalyst && formData.status === TransferEventStatus.TO_BE_TRANSFERRED; + + const router = useRouter(); + const [modalOpen, setModalOpen] = useState(false); + const [error, setError] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + const [disabled, setDisabled] = useState(true); + const [key, setKey] = useState(Math.random()); // NOSONAR + + const handleCancelTransfer = async () => { + const endpoint = `registration/transfer-events/${transferId}`; + setIsSubmitting(true); + const response = await actionHandler(endpoint, "DELETE", "/transfers"); + if (response?.error) { + setError(response.error as any); + setModalOpen(false); + setIsSubmitting(false); + return; + } + router.push("/transfers"); + }; + + const submitHandler = async (e: IChangeEvent) => { + setError(undefined); + setIsSubmitting(true); + const endpoint = `registration/transfer-events/${transferId}`; + const pathToRevalidate = `/transfers/${transferId}`; + const response = await actionHandler(endpoint, "PATCH", pathToRevalidate, { + body: JSON.stringify({ + ...e.formData, + }), + }); + setIsSubmitting(false); + if (!response || response?.error) { + setDisabled(false); + setError(response.error as any); + } else { + setDisabled(true); + } + }; + + const backButton = ( + + ); + + const customButtonSection = ( + <> + {isEditable ? ( +
+
+ {backButton} + +
+ {disabled ? ( + + ) : ( + + Transfer Entity + + )} +
+ ) : ( +
{backButton}
+ )} + + ); + + return ( +
+ setModalOpen(false)} + onConfirm={handleCancelTransfer} + confirmText="Yes, cancel this transfer" + cancelText="No, don't cancel" + isSubmitting={isSubmitting} + > + Are you sure you want to cancel this transfer? + + router.push("/transfers")} + customButtonSection={customButtonSection} + /> +
+ ); +} diff --git a/bciers/apps/registration/app/components/transfers/TransferForm.tsx b/bciers/apps/registration/app/components/transfers/TransferForm.tsx index 9aee907020..f2356d5e33 100644 --- a/bciers/apps/registration/app/components/transfers/TransferForm.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferForm.tsx @@ -101,6 +101,8 @@ export default function TransferForm({ } = await fetchOperationsPageData({ paginate_results: false, operator_id: operatorId, + end_date: true, // this indicates that the end_date is not null, + status: "Active", // only fetch active facilities }); if (!response || "error" in response || !response.rows) { setError("Failed to fetch operations data!" as any); diff --git a/bciers/apps/registration/app/components/transfers/TransferPage.tsx b/bciers/apps/registration/app/components/transfers/TransferPage.tsx index 9ca19a2b7f..a0cd77acfa 100644 --- a/bciers/apps/registration/app/components/transfers/TransferPage.tsx +++ b/bciers/apps/registration/app/components/transfers/TransferPage.tsx @@ -1,22 +1,81 @@ import TransferForm from "@/registration/app/components/transfers/TransferForm"; +import TransferDetailForm from "@/registration/app/components/transfers/TransferDetailForm"; import fetchOperatorsPageData from "@/administration/app/components/operators/fetchOperatorsPageData"; import { OperatorRow } from "@/administration/app/components/operators/types"; -import { TransferFormData } from "@/registration/app/components/transfers/types"; +import { + TransferDetailFormData, + TransferFormData, +} from "@/registration/app/components/transfers/types"; +import { validate as isValidUUID } from "uuid"; +import { UUID } from "crypto"; +import getTransferEvent from "@/registration/app/components/transfers/getTransferEvent"; +import Loading from "@bciers/components/loading/SkeletonGrid"; +import { Suspense } from "react"; +import { + facilityEntitySchema, + operationEntitySchema, +} from "@/registration/app/data/jsonSchema/transfer/transferDetail"; // 🧩 Main component -export default async function TransferPage() { - const operators: { +export default async function TransferPage({ + transferId, +}: Readonly<{ + transferId?: UUID; +}>) { + let transferFormData: { [key: string]: any } | { error: string } = {}; + let operators: { rows: OperatorRow[]; row_count: number; - } = await fetchOperatorsPageData({ paginate_result: "False" }); + } = { rows: [], row_count: 0 }; // Initialize operators with a default value + if (transferId) { + // to show the transfer detail form + if (!isValidUUID(transferId)) + throw new Error(`Invalid transfer id: ${transferId}`); - if (!operators || "error" in operators || !operators.rows) - throw new Error("Failed to fetch operators data"); + transferFormData = await getTransferEvent(transferId); + if (!transferFormData || "error" in transferFormData) { + throw new Error("Error fetching transfer information."); + } + } else { + // to show the new transfer form + const fetchedOperators = await fetchOperatorsPageData({ + paginate_result: "False", + }); + if ( + !fetchedOperators || + "error" in fetchedOperators || + !fetchedOperators.rows + ) + throw new Error("Failed to fetch operators data"); + + operators = fetchedOperators; // Populate operators + } return ( - + }> + {!!transferId ? ( + + ) : ( + + )} + ); } diff --git a/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx index c6731fbeb6..d7d38b92b5 100644 --- a/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGrid.tsx @@ -11,8 +11,12 @@ import transferGroupColumns from "@/registration/app/components/datagrid/models/ import fetchTransferEventsPageData from "@/registration/app/components/transfers/fetchTransferEventsPageData"; const TransfersActionCell = ActionCellFactory({ - generateHref: (params: GridRenderCellParams) => { - return `/transfers/${params.row.id}`; + generateHref: ({ row }: GridRenderCellParams) => { + const title = + row.operation__name && row.operation__name !== "N/A" + ? row.operation__name + : row.facilities__name; + return `/transfers/${row.transfer_id}?transfers_title=${title}`; }, cellText: "View Details", }); @@ -48,6 +52,8 @@ const TransfersDataGrid = ({ fetchPageData={fetchTransferEventsPageData} paginationMode="server" initialData={initialData} + // We need to generate a unique id for each row to avoid issues with the DataGrid(MUI requires a unique id for each row) + getRowId={(row) => `${row.transfer_id} - ${row.facilities__name}`} /> ); }; diff --git a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx index 1feecb969d..eb801df501 100644 --- a/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx +++ b/bciers/apps/registration/app/components/transfers/TransfersDataGridPage.tsx @@ -11,9 +11,9 @@ import { getSessionRole } from "@bciers/utils/src/sessionUtils"; // 🧩 Main component export default async function TransfersDataGridPage({ searchParams, -}: { +}: Readonly<{ searchParams: TransfersSearchParams; -}) { +}>) { // Fetch transfers data const transfers: { rows: TransferRow[]; @@ -28,21 +28,21 @@ export default async function TransfersDataGridPage({ // Render the DataGrid component return ( - }> -
-

Transfers

- {isCasAnalyst && ( -
- - {/* textTransform to remove uppercase text */} - - -
- )} +
+

Transfers

+ {isCasAnalyst && ( +
+ + {/* textTransform to remove uppercase text */} + + +
+ )} + }> -
- + +
); } diff --git a/bciers/apps/registration/app/components/transfers/enums.ts b/bciers/apps/registration/app/components/transfers/enums.ts new file mode 100644 index 0000000000..c1086aa12c --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/enums.ts @@ -0,0 +1,5 @@ +export enum TransferEventStatus { + COMPLETE = "Complete", + TO_BE_TRANSFERRED = "To be transferred", + TRANSFERRED = "Transferred", +} diff --git a/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.ts b/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.ts index ce469e0929..ce5cea9773 100644 --- a/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.ts +++ b/bciers/apps/registration/app/components/transfers/fetchTransferEventsPageData.ts @@ -15,7 +15,7 @@ export const formatTransferRows = (rows: GridRowsProp) => { created_at, }) => { return { - id, + transfer_id: id, operation__name: operation__name || "N/A", facilities__name: facilities__name || "N/A", status, diff --git a/bciers/apps/registration/app/components/transfers/getTransferEvent.ts b/bciers/apps/registration/app/components/transfers/getTransferEvent.ts new file mode 100644 index 0000000000..7ed00a0158 --- /dev/null +++ b/bciers/apps/registration/app/components/transfers/getTransferEvent.ts @@ -0,0 +1,11 @@ +import { actionHandler } from "@bciers/actions"; +import { UUID } from "crypto"; + +// 🛠️ Function to fetch a transfer event by uuid +export default async function getTransferEvent(uuid: UUID) { + return actionHandler( + `registration/transfer-events/${uuid}`, + "GET", + `/transfers/${uuid}`, + ); +} diff --git a/bciers/apps/registration/app/components/transfers/types.ts b/bciers/apps/registration/app/components/transfers/types.ts index 2e801a9a3b..344fef35d3 100644 --- a/bciers/apps/registration/app/components/transfers/types.ts +++ b/bciers/apps/registration/app/components/transfers/types.ts @@ -1,5 +1,8 @@ +import { UUID } from "crypto"; +import { TransferEventStatus } from "@/registration/app/components/transfers/enums"; + export interface TransferRow { - id: string; + transfer_id: UUID; // actual transfer ID operation__name?: string; facilities__name?: string; status: string; @@ -17,11 +20,39 @@ export interface TransfersSearchParams { export interface TransferFormData { [key: string]: string | number | undefined | string[]; from_operator: string; + from_operator_id: UUID; to_operator: string; + to_operator_id: UUID; transfer_entity: string; operation?: string; + operation_id?: UUID; from_operation?: string; + from_operation_id?: UUID; facilities?: string[]; + facilities_ids?: UUID[]; + to_operation?: string; + to_operation_id?: UUID; + effective_date: string; +} + +export interface ExistingFacilities { + id: UUID; + name: string; +} + +export interface TransferDetailFormData { + [key: string]: string | number | undefined | string[] | ExistingFacilities[]; + from_operator: string; + from_operator_id: UUID; + to_operator: string; + transfer_entity: string; + operation?: UUID; + operation_name?: string; + from_operation?: string; + from_operation_id?: UUID; + facilities?: UUID[]; + existing_facilities: ExistingFacilities[]; to_operation?: string; effective_date: string; + status: TransferEventStatus; } diff --git a/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts b/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts index 453521e056..0a78f5fe67 100644 --- a/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts +++ b/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts @@ -2,6 +2,10 @@ import FieldTemplate from "@bciers/components/form/fields/FieldTemplate"; import TitleOnlyFieldTemplate from "@bciers/components/form/fields/TitleOnlyFieldTemplate"; import { GenerateNewEntrantFormMessage } from "apps/registration/app/components/operations/registration/form/titles"; import { RJSFSchema, UiSchema } from "@rjsf/utils"; +import { + newEntrantApril1OrLater, + newEntrantBeforeMarch31, +} from "@bciers/utils/src/urls"; export const newEntrantOperationSchema: RJSFSchema = { title: "New Entrant Operation", @@ -83,14 +87,14 @@ export const newEntrantOperationUiSchema: UiSchema = { "ui:FieldTemplate": TitleOnlyFieldTemplate, "ui:title": GenerateNewEntrantFormMessage( "on or before March 31, 2024", - "url-1-tbd", + newEntrantBeforeMarch31, ), }, on_or_after_april_1_section: { "ui:FieldTemplate": TitleOnlyFieldTemplate, "ui:title": GenerateNewEntrantFormMessage( "on or after April 1, 2024", - "url-2-tbd", + newEntrantApril1OrLater, ), }, new_entrant_application: { diff --git a/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts index 5126786937..3840f2cdfd 100644 --- a/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts +++ b/bciers/apps/registration/app/data/jsonSchema/transfer/transfer.ts @@ -161,10 +161,16 @@ export const createTransferSchema = ( 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})`, - ); + facilityOptions.map((facility: FacilityRow) => { + const { + facility__name: facilityName, + facility__latitude_of_largest_emissions: facilityLatitude, + facility__longitude_of_largest_emissions: facilityLongitude, + } = facility; + return facilityLatitude && facilityLongitude + ? `${facilityName} - (${facilityLatitude}, ${facilityLongitude})` + : facilityName; + }); } return transferSchemaCopy; diff --git a/bciers/apps/registration/app/data/jsonSchema/transfer/transferDetail.ts b/bciers/apps/registration/app/data/jsonSchema/transfer/transferDetail.ts new file mode 100644 index 0000000000..a34bdded21 --- /dev/null +++ b/bciers/apps/registration/app/data/jsonSchema/transfer/transferDetail.ts @@ -0,0 +1,245 @@ +import { RJSFSchema, UiSchema } from "@rjsf/utils"; +import FieldTemplate from "@bciers/components/form/fields/FieldTemplate"; +import SectionFieldTemplate from "@bciers/components/form/fields/SectionFieldTemplate"; +import { FacilityRow } from "@/administration/app/components/facilities/types"; +import { OperationRow } from "@/administration/app/components/operations/types"; +import { fetchOperationsPageData } from "@bciers/actions/api"; +import fetchFacilitiesPageData from "@/administration/app/components/facilities/fetchFacilitiesPageData"; +import { ExistingFacilities } from "@/registration/app/components/transfers/types"; + +const sharedSchemaProperties: RJSFSchema = { + properties: { + //Not an actual field in the db - this is just to make the form look like the wireframes + transfer_header: { + type: "object", + readOnly: true, + title: "Transfer Entity", + }, + from_operator: { + type: "string", + readOnly: true, + title: "Current Operator", + }, + to_operator: { + type: "string", + readOnly: true, + title: "New operator", + }, + transfer_entity: { + type: "string", + readOnly: true, + title: "What is being transferred?", + oneOf: [ + { const: "Operation", title: "Operation" }, + { const: "Facility", title: "Facility" }, + ], + }, + effective_date: { + type: "string", + title: "Effective date of transfer", + }, + }, +}; + +export const operationEntitySchema = async ( + existingOperationId: string, + existingOperationName: string, + fromOperatorId?: string, +): Promise => { + // Fetch the operations data based on the operator id + const operationsByOperator = await fetchOperationsPageData({ + paginate_results: false, + operator_id: fromOperatorId, + end_date: true, // this indicates that the end_date is not null, + status: "Active", // only fetch active facilities + }); + if ( + !operationsByOperator || + "error" in operationsByOperator || + !operationsByOperator.rows + ) + throw new Error("Failed to fetch operations data!" as any); + + /* + Add the existing operation to the operation options + For transferred operations, this option is no longer available from operationsByOperator, so we add it manually + */ + const operationOptions = [ + { + const: existingOperationId, + title: existingOperationName, + }, + ...operationsByOperator.rows + .filter( + ({ operation__id }: OperationRow) => + operation__id !== existingOperationId, + ) + .map(({ operation__id, operation__name }: OperationRow) => ({ + const: operation__id, + title: operation__name, + })), + ].sort((a: any, b: any) => a.title.localeCompare(b.title)); // Sort the operations alphabetically + + return { + type: "object", + properties: { + section: { + type: "object", + title: "Transfer Details", + required: ["operation", "effective_date"], + properties: { + ...sharedSchemaProperties.properties, + operation: { + type: "string", + title: "Operation", + anyOf: operationOptions, + }, + }, + }, + }, + }; +}; + +export const facilityEntitySchema = async ( + existingFacilities: ExistingFacilities[], + fromOperationId: string, +): Promise => { + const facilitiesByOperation = await fetchFacilitiesPageData(fromOperationId, { + paginate_results: false, + end_date: true, // this indicates that the end_date is not null, + status: "Active", // only fetch active facilities + }); + if ( + !facilitiesByOperation || + "error" in facilitiesByOperation || + !facilitiesByOperation.rows + ) + throw new Error("Failed to fetch facilities data!" as any); + + /* + Add the existing facilities to the facility options + For transferred facilities, this option is no longer available from facilitiesByOperation, so we add it manually + */ + // Extract existing facility IDs and names + const existingFacilityIds = existingFacilities.map((facility) => facility.id); + const existingFacilityNames = existingFacilities.map( + (facility) => facility.name, + ); + + // Initialize facility enum with existing facilities + let facilityEnum = { + enum: existingFacilityIds, + enumNames: existingFacilityNames, + }; + + // Filter and append new facilities from the fetched data + const newFacilities = facilitiesByOperation.rows.filter( + (facility: FacilityRow) => + !existingFacilityIds.includes(facility.facility__id), + ); + if (newFacilities.length > 0) { + facilityEnum = { + enum: facilityEnum.enum.concat( + newFacilities.map((facility: FacilityRow) => facility.facility__id), + ), + enumNames: facilityEnum.enumNames.concat( + newFacilities.map((facility: FacilityRow) => { + const { + facility__name: facilityName, + facility__latitude_of_largest_emissions: facilityLatitude, + facility__longitude_of_largest_emissions: facilityLongitude, + } = facility; + return facilityLatitude && facilityLongitude + ? `${facilityName} - (${facilityLatitude}, ${facilityLongitude})` + : facilityName; + }), + ), + }; + } + + return { + type: "object", + properties: { + section: { + type: "object", + title: "Transfer Details", + required: ["facilities", "effective_date"], + properties: { + ...sharedSchemaProperties.properties, + from_operation: { + type: "string", + title: "Current operation", + }, + facilities: { + type: "array", + title: "Facilities", + minItems: 1, + items: { + type: "string", + ...facilityEnum, + }, + }, + to_operation: { + type: "string", + title: "New operation", + }, + }, + }, + }, + }; +}; + +export const editTransferUISchema: UiSchema = { + "ui:FieldTemplate": SectionFieldTemplate, + section: { + "ui:FieldTemplate": SectionFieldTemplate, + "ui:options": { + label: false, + }, + "ui:order": [ + "transfer_header", + "from_operator", + "to_operator", + "transfer_entity", + "operation", + "from_operation", + "facilities", + "to_operation", + "effective_date", + ], + transfer_header: { + "ui:FieldTemplate": FieldTemplate, + "ui:classNames": "form-heading mb-8", + }, + from_operator: { + "ui:widget": "ReadOnlyWidget", + }, + to_operator: { + "ui:widget": "ReadOnlyWidget", + }, + transfer_entity: { + "ui:widget": "ReadOnlyRadioWidget", + "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": "ReadOnlyWidget", + }, + facilities: { + "ui:widget": "MultiSelectWidget", + "ui:placeholder": "Select facilities", + }, + to_operation: { + "ui:widget": "ReadOnlyWidget", + }, + }, +}; diff --git a/bciers/apps/registration/app/idir/cas_admin/event/page.tsx b/bciers/apps/registration/app/idir/cas_admin/event/page.tsx deleted file mode 100644 index bed06fcf68..0000000000 --- a/bciers/apps/registration/app/idir/cas_admin/event/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// 🚩 flagging that for shared routes between roles, "Page" code is a component for code maintainability - -export default async function Page() { - return <> 😀 👋 ; -} diff --git a/bciers/apps/registration/app/idir/cas_admin/transfers/[transferId]/page.tsx b/bciers/apps/registration/app/idir/cas_admin/transfers/[transferId]/page.tsx new file mode 100644 index 0000000000..38eb4cecd2 --- /dev/null +++ b/bciers/apps/registration/app/idir/cas_admin/transfers/[transferId]/page.tsx @@ -0,0 +1,16 @@ +import { UUID } from "crypto"; +import { Suspense } from "react"; +import Loading from "@bciers/components/loading/SkeletonForm"; +import TransferPage from "@/registration/app/components/transfers/TransferPage"; + +export default async function Page({ + params: { transferId }, +}: Readonly<{ + params: { transferId: UUID }; +}>) { + return ( + }> + + + ); +} diff --git a/bciers/apps/registration/app/idir/cas_analyst/event/page.tsx b/bciers/apps/registration/app/idir/cas_analyst/event/page.tsx deleted file mode 100644 index bed06fcf68..0000000000 --- a/bciers/apps/registration/app/idir/cas_analyst/event/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// 🚩 flagging that for shared routes between roles, "Page" code is a component for code maintainability - -export default async function Page() { - return <> 😀 👋 ; -} diff --git a/bciers/apps/registration/app/idir/cas_analyst/transfers/[transferId]/page.tsx b/bciers/apps/registration/app/idir/cas_analyst/transfers/[transferId]/page.tsx new file mode 100644 index 0000000000..38eb4cecd2 --- /dev/null +++ b/bciers/apps/registration/app/idir/cas_analyst/transfers/[transferId]/page.tsx @@ -0,0 +1,16 @@ +import { UUID } from "crypto"; +import { Suspense } from "react"; +import Loading from "@bciers/components/loading/SkeletonForm"; +import TransferPage from "@/registration/app/components/transfers/TransferPage"; + +export default async function Page({ + params: { transferId }, +}: Readonly<{ + params: { transferId: UUID }; +}>) { + return ( + }> + + + ); +} diff --git a/bciers/apps/registration/app/idir/cas_director/event/page.tsx b/bciers/apps/registration/app/idir/cas_director/event/page.tsx deleted file mode 100644 index bed06fcf68..0000000000 --- a/bciers/apps/registration/app/idir/cas_director/event/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// 🚩 flagging that for shared routes between roles, "Page" code is a component for code maintainability - -export default async function Page() { - return <> 😀 👋 ; -} diff --git a/bciers/apps/registration/app/idir/cas_director/transfers/[transferId]/page.tsx b/bciers/apps/registration/app/idir/cas_director/transfers/[transferId]/page.tsx new file mode 100644 index 0000000000..38eb4cecd2 --- /dev/null +++ b/bciers/apps/registration/app/idir/cas_director/transfers/[transferId]/page.tsx @@ -0,0 +1,16 @@ +import { UUID } from "crypto"; +import { Suspense } from "react"; +import Loading from "@bciers/components/loading/SkeletonForm"; +import TransferPage from "@/registration/app/components/transfers/TransferPage"; + +export default async function Page({ + params: { transferId }, +}: Readonly<{ + params: { transferId: UUID }; +}>) { + return ( + }> + + + ); +} diff --git a/bciers/apps/registration/app/idir/cas_view_only/event/page.tsx b/bciers/apps/registration/app/idir/cas_view_only/event/page.tsx deleted file mode 100644 index bed06fcf68..0000000000 --- a/bciers/apps/registration/app/idir/cas_view_only/event/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// 🚩 flagging that for shared routes between roles, "Page" code is a component for code maintainability - -export default async function Page() { - return <> 😀 👋 ; -} diff --git a/bciers/apps/registration/app/idir/cas_view_only/transfers/[transferId]/page.tsx b/bciers/apps/registration/app/idir/cas_view_only/transfers/[transferId]/page.tsx new file mode 100644 index 0000000000..38eb4cecd2 --- /dev/null +++ b/bciers/apps/registration/app/idir/cas_view_only/transfers/[transferId]/page.tsx @@ -0,0 +1,16 @@ +import { UUID } from "crypto"; +import { Suspense } from "react"; +import Loading from "@bciers/components/loading/SkeletonForm"; +import TransferPage from "@/registration/app/components/transfers/TransferPage"; + +export default async function Page({ + params: { transferId }, +}: Readonly<{ + params: { transferId: UUID }; +}>) { + return ( + }> + + + ); +} diff --git a/bciers/apps/registration/tests/components/operations/registration/NewEntrantOperationForm.test.tsx b/bciers/apps/registration/tests/components/operations/registration/NewEntrantOperationForm.test.tsx index f66ea77477..fa3d3cd93d 100644 --- a/bciers/apps/registration/tests/components/operations/registration/NewEntrantOperationForm.test.tsx +++ b/bciers/apps/registration/tests/components/operations/registration/NewEntrantOperationForm.test.tsx @@ -98,7 +98,10 @@ describe("the NewEntrantOperationForm component", () => { expect( screen.getByRole("link", { name: /application form template/i }), - ).toHaveAttribute("href", "url-2-tbd"); + ).toHaveAttribute( + "href", + "https://www2.gov.bc.ca/assets/download/751CDDAE4C9A411E974EEA9737CD42C6", + ); }); it("should display the correct url and message for the before March 31 date choice", async () => { @@ -127,7 +130,10 @@ describe("the NewEntrantOperationForm component", () => { expect( screen.getByRole("link", { name: /application form template/i }), - ).toHaveAttribute("href", "url-1-tbd"); + ).toHaveAttribute( + "href", + "https://www2.gov.bc.ca/assets/download/F5375D72BE1C450AB52C2E3E6A618959", + ); }); it("should display required field message if the users submits without attaching a file", async () => { diff --git a/bciers/apps/registration/tests/components/transfers/TransferDetailForm.test.tsx b/bciers/apps/registration/tests/components/transfers/TransferDetailForm.test.tsx new file mode 100644 index 0000000000..9567f99c63 --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/TransferDetailForm.test.tsx @@ -0,0 +1,399 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import expectButton from "@bciers/testConfig/helpers/expectButton"; +import { actionHandler, useRouter, useSession } from "@bciers/testConfig/mocks"; +import { fetchOperationsPageData } from "@/administration/tests/components/operations/mocks"; +import { fetchFacilitiesPageData } from "@/administration/tests/components/facilities/mocks"; +import TransferDetailForm from "@/registration/app/components/transfers/TransferDetailForm"; +import { Session } from "@bciers/testConfig/types"; +import { randomUUID, UUID } from "crypto"; +import { + facilityEntitySchema, + operationEntitySchema, +} from "@/registration/app/data/jsonSchema/transfer/transferDetail"; +import { TransferEventStatus } from "@/registration/app/components/transfers/enums"; +import { ExistingFacilities } from "@/registration/app/components/transfers/types"; +import { FrontEndRoles } from "@bciers/utils/src/enums"; +import userEvent from "@testing-library/user-event"; +import expectComboBox from "@bciers/testConfig/helpers/expectComboBox"; + +const mockRouterPush = vi.fn(); +useRouter.mockReturnValue({ + query: {}, + push: mockRouterPush, +}); + +useSession.mockReturnValue({ + data: { + user: { + app_role: "cas_analyst", + }, + }, +} as Session); + +const transferId = randomUUID(); + +const mockFacilities = { + rows: [ + { + facility__id: "8be4c7aa-6ab3-4aad-9206-0ef914fea067" as UUID, + facility__name: "Name 1", + facility__latitude_of_largest_emissions: 11.0, + facility__longitude_of_largest_emissions: 22.0, + }, + { + facility__id: "8be4c7aa-6ab3-4aad-9206-0ef914fea068" as UUID, + facility__name: "Name 2", + facility__latitude_of_largest_emissions: 33.0, + facility__longitude_of_largest_emissions: 44.0, + }, + { + facility__id: "8be4c7aa-6ab3-4aad-9206-0ef914fea069" as UUID, + facility__name: "Name 3", + facility__latitude_of_largest_emissions: 55.0, + facility__longitude_of_largest_emissions: 66.0, + }, + { + facility__id: "8be4c7aa-6ab3-4aad-9206-0ef914fea070" as UUID, + facility__name: "Name 4", + facility__latitude_of_largest_emissions: 77.0, + facility__longitude_of_largest_emissions: 88.0, + }, + ], + row_count: 4, +}; + +const mockOperations = { + rows: [ + { + operation__id: "8be4c7aa-6ab3-4aad-9206-0ef914fea065" as UUID, + operation__name: "Operation 1", + }, + { + operation__id: "8be4c7aa-6ab3-4aad-9206-0ef914fea066" as UUID, + operation__name: "Operation 2", + }, + ], + row_count: 2, +}; + +const operationEntityTransferFormData = { + from_operator: "Operator 1", + from_operator_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063" as UUID, + to_operator: "Operator 2", + transfer_entity: "Operation", + operation: "8be4c7aa-6ab3-4aad-9206-0ef914fea065" as UUID, + operation_name: "Operation 1", + existing_facilities: [], + effective_date: "2022-12-31", + status: TransferEventStatus.TO_BE_TRANSFERRED, +}; + +const facilityEntityTransferFormData = { + from_operator: "Operator 1", + from_operator_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063" as UUID, + to_operator: "Operator 2", + transfer_entity: "Facility", + from_operation: "Operation 1", + from_operation_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea065" as UUID, + to_operation: "Operation 2", + facilities: [ + "8be4c7aa-6ab3-4aad-9206-0ef914fea067" as UUID, + "8be4c7aa-6ab3-4aad-9206-0ef914fea068" as UUID, + ], + existing_facilities: [ + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea067" as UUID, + name: "Name 1 - (11, 22)", + }, + { + id: "8be4c7aa-6ab3-4aad-9206-0ef914fea068" as UUID, + name: "Name 2 - (33, 44)", + }, + ] as ExistingFacilities[], + effective_date: "2022-12-31", + status: TransferEventStatus.TO_BE_TRANSFERRED, +}; + +const renderOperationEntityTransferDetailForm = async ( + formData: any = operationEntityTransferFormData, +) => { + fetchOperationsPageData.mockResolvedValue(mockOperations); + const schema = await operationEntitySchema( + formData.operation, + formData.operation_name, + formData.from_operator_id, + ); + render( + , + ); +}; + +const renderFacilityEntityTransferDetailForm = async () => { + fetchFacilitiesPageData.mockResolvedValue(mockFacilities); + const schema = await facilityEntitySchema( + facilityEntityTransferFormData.existing_facilities, + facilityEntityTransferFormData.from_operator_id, + ); + render( + , + ); +}; + +const checkButtons = (editable: boolean = true) => { + expectButton("Back"); + if (editable) { + expectButton("Edit Details"); + expectButton("Cancel Transfer"); + } else { + expect(screen.queryByRole("button", { name: /edit details/i })).toBeNull(); + expect( + screen.queryByRole("button", { name: /cancel transfer/i }), + ).toBeNull(); + } +}; + +const checkFormFieldsAndLabels = (fields: string[]) => { + fields.forEach((field: string) => { + const fieldRegex = new RegExp(field, "i"); + expect(screen.getByText(fieldRegex)).toBeVisible(); + }); +}; + +describe("The TransferDetailForm component", () => { + beforeEach(async () => { + vi.clearAllMocks(); + }); + + it("should render the TransferDetailForm component for an operation entity transfer", async () => { + await renderOperationEntityTransferDetailForm(); + const formFieldsAndLabels = [ + "current operator", + "operator 1", + "new operator", + "operator 2", + "what is being transferred?", + "operation 1", + "effective date of transfer", + "2022-12-31", + ]; + checkFormFieldsAndLabels(formFieldsAndLabels); + checkButtons(); + }); + + it("should render the TransferDetailForm component for a facility entity transfer", async () => { + await renderFacilityEntityTransferDetailForm(); + const formFieldsAndLabels = [ + "current operator", + "operator 1", + "new operator", + "operator 2", + "what is being transferred?", + "facility", + "current operation", + "operation 1", + "facilities", + "name 1 - \\(11, 22\\), name 2 - \\(33, 44\\)", + "new operation", + "effective date of transfer", + "2022-12-31", + ]; + + checkFormFieldsAndLabels(formFieldsAndLabels); + checkButtons(); + }); + + it("should not render the edit details and cancel transfer buttons when the transfer status is not TO_BE_TRANSFERRED", async () => { + const modifiedFormData = { + ...operationEntityTransferFormData, + status: TransferEventStatus.COMPLETE, + }; + await renderOperationEntityTransferDetailForm(modifiedFormData); + checkButtons(false); + }); + + it("should take the user back to the transfer requests table when the back button is clicked", async () => { + await renderOperationEntityTransferDetailForm(); + await userEvent.click(screen.getByRole("button", { name: /back/i })); + expect(mockRouterPush).toHaveBeenCalledWith("/transfers"); + }); + + it("should allow the user to cancel the transfer", async () => { + await renderOperationEntityTransferDetailForm(); + await userEvent.click( + screen.getByRole("button", { name: /cancel transfer/i }), + ); + // make sure the modal is displayed + expect( + screen.getByRole("heading", { + name: /confirmation/i, + }), + ).toBeVisible(); + expect( + screen.getByText(/are you sure you want to cancel this transfer\?/i), + ).toBeVisible(); + expect( + screen.getByRole("button", { + name: /no, don't cancel/i, + }), + ).toBeVisible(); + expect(screen.getByText(/yes, cancel this transfer/i)).toBeVisible(); + await userEvent.click( + screen.getByRole("button", { name: /yes, cancel this transfer/i }), + ); + // make sure the action is called + expect(actionHandler).toHaveBeenCalledWith( + `registration/transfer-events/${transferId}`, + "DELETE", + "/transfers", + ); + // make sure the user is redirected to the transfers table + expect(mockRouterPush).toHaveBeenCalledWith("/transfers"); + }); + + it("should allow the user to edit the details of the transfer - Operation Entity", async () => { + actionHandler.mockResolvedValueOnce({}); // to handle the PATCH request response + fetchOperationsPageData.mockResolvedValue(mockOperations); + await renderOperationEntityTransferDetailForm(); + await userEvent.click( + screen.getByRole("button", { name: /edit details/i }), + ); + expectComboBox(/operation\*/i, "Operation 1"); + await userEvent.type( + screen.getByRole("combobox", { name: /operation\*/i }), + "op", + ); + + // make sure the current operation is displayed in the options + await waitFor(() => { + expect( + screen.getByRole("option", { + name: /operation 1/i, + }), + ).toBeVisible(); + expect( + screen.getByRole("option", { + name: /operation 2/i, + }), + ).toBeVisible(); + }); + await userEvent.click(screen.getByText(/operation 2/i)); + expectComboBox(/operation\*/i, "Operation 2"); + expect( + screen.getByRole("textbox", { name: /effective date of transfer/i }), + ).toHaveValue("2022-12-31"); + + // submit the form + await userEvent.click( + screen.getByRole("button", { name: /transfer entity/i }), + ); + expect(actionHandler).toHaveBeenCalledWith( + `registration/transfer-events/${transferId}`, + "PATCH", + `/transfers/${transferId}`, + { + body: JSON.stringify({ + from_operator: "Operator 1", + to_operator: "Operator 2", + transfer_entity: "Operation", + effective_date: "2022-12-31", + operation: "8be4c7aa-6ab3-4aad-9206-0ef914fea066", + }), + }, + ); + // make sure the snackbar is displayed + expect( + screen.getByText(/all changes have been successfully saved/i), + ).toBeVisible(); + }); + + it("should allow the user to edit the details of the transfer - Facility Entity", async () => { + actionHandler.mockResolvedValueOnce({}); // to handle the PATCH request response + await renderFacilityEntityTransferDetailForm(); + await userEvent.click( + screen.getByRole("button", { name: /edit details/i }), + ); + + // make sure existing facilities are displayed and included when editing the form + expect( + screen.getByRole("button", { + name: /name 1 \- \(11, 22\)/i, + }), + ).toBeVisible(); + expect( + screen.getByRole("button", { + name: /name 2 \- \(33, 44\)/i, + }), + ).toBeVisible(); + + // make sure the existing facilities are displayed in the options + await userEvent.click( + screen.getByRole("combobox", { name: /facilities/i }), + ); + const options = [ + "Name 1 - (11, 22)", + "Name 2 - (33, 44)", + "Name 3 - (55, 66)", + "Name 4 - (77, 88)", + ]; + options.forEach((option) => { + expect(screen.getByText(option)).toBeVisible(); + }); + + // add the last facility + await userEvent.click(screen.getByText("Name 4 - (77, 88)")); + + // make sure the facility is added to the list + expect(screen.getByText("Name 4 - (77, 88)")).toBeVisible(); + + // submit the form + await userEvent.click( + screen.getByRole("button", { name: /transfer entity/i }), + ); + expect(actionHandler).toHaveBeenCalledWith( + `registration/transfer-events/${transferId}`, + "PATCH", + `/transfers/${transferId}`, + { + body: JSON.stringify({ + from_operator: "Operator 1", + to_operator: "Operator 2", + transfer_entity: "Facility", + effective_date: "2022-12-31", + from_operation: "Operation 1", + facilities: [ + "8be4c7aa-6ab3-4aad-9206-0ef914fea067", + "8be4c7aa-6ab3-4aad-9206-0ef914fea068", + "8be4c7aa-6ab3-4aad-9206-0ef914fea070", + ], + to_operation: "Operation 2", + }), + }, + ); + + // make sure the snackbar is displayed + expect( + screen.getByText(/all changes have been successfully saved/i), + ).toBeVisible(); + }); + + it("should not render the edit details and cancel transfer buttons for a user with a role other than CAS_ANALYST", async () => { + const userAppRole = FrontEndRoles.CAS_ADMIN; + useSession.mockReturnValue({ + data: { + user: { + app_role: userAppRole, + }, + }, + } as Session); + await renderOperationEntityTransferDetailForm(); + checkButtons(false); + }); +}); diff --git a/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx b/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx index d0ec7a5d96..252e9033aa 100644 --- a/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx +++ b/bciers/apps/registration/tests/components/transfers/TransferForm.test.tsx @@ -3,6 +3,7 @@ import { UUID } from "crypto"; import { expect } from "vitest"; import expectButton from "@bciers/testConfig/helpers/expectButton"; import expectRadio from "@bciers/testConfig/helpers/expectRadio"; +import expectComboBox from "@bciers/testConfig/helpers/expectComboBox"; import { actionHandler } from "@bciers/testConfig/mocks"; import { fetchOperationsPageData } from "@/administration/tests/components/operations/mocks"; import { fetchFacilitiesPageData } from "@/administration/tests/components/facilities/mocks"; @@ -43,11 +44,6 @@ 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" }, @@ -132,8 +128,8 @@ describe("The TransferForm component", () => { "Transfer Entity", ); expect(screen.getByText(/select the operators involved/i)).toBeVisible(); - checkComboBoxExists(/current operator/i); - checkComboBoxExists(/select the new operator/i); + expectComboBox(/current operator/i); + expectComboBox(/select the new operator/i); expect(screen.getByText(/what is being transferred?/i)).toBeVisible(); expectRadio(/operation/i); expectRadio(/facility/i); @@ -170,12 +166,16 @@ describe("The TransferForm component", () => { expect(fetchOperationsPageData).toHaveBeenCalledWith({ operator_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea063", paginate_results: false, + end_date: true, + status: "Active", }); selectOperator(/current operator\*/i, "Operator 2"); expect(fetchOperationsPageData).toHaveBeenCalledTimes(2); expect(fetchOperationsPageData).toHaveBeenCalledWith({ operator_id: "8be4c7aa-6ab3-4aad-9206-0ef914fea064", paginate_results: false, + end_date: true, + status: "Active", }); }); diff --git a/bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx b/bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx index e913b4091c..8e0ef7ef90 100644 --- a/bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx +++ b/bciers/apps/registration/tests/components/transfers/TransferPage.test.tsx @@ -1,6 +1,9 @@ +import { randomUUID } from "crypto"; import { render, screen } from "@testing-library/react"; -import { fetchOperatorsPageData } from "apps/administration/tests/components/operators/mocks"; +import { getTransferEvent } from "@/registration/tests/components/transfers/mocks"; +import { fetchOperatorsPageData } from "@/administration/tests/components/operators/mocks"; import TransferPage from "@/registration/app/components/transfers/TransferPage"; +import { operationEntitySchema } from "@/registration/app/data/jsonSchema/transfer/transferDetail"; describe("Transfer page", () => { beforeEach(async () => { @@ -13,7 +16,7 @@ describe("Transfer page", () => { row_count: undefined, }); await expect(async () => { - render(await TransferPage()); + render(await TransferPage({})); // passing empty object as props so that it doesn't throw an error when destructuring }).rejects.toThrow("Failed to fetch operators data"); }); @@ -26,7 +29,7 @@ describe("Transfer page", () => { ], row_count: 1, }); - render(await TransferPage()); + render(await TransferPage({})); // passing empty object as props so that it doesn't throw an error when destructuring expect(screen.getByTestId("field-template-label")).toHaveTextContent( "Transfer Entity", ); @@ -34,4 +37,51 @@ describe("Transfer page", () => { screen.getByText(/select the operators involved/i), ).toBeInTheDocument(); }); + it("throws an error when transferId is not a valid UUID", async () => { + await expect(async () => { + const transferId = "invalid-uuid"; + // @ts-ignore + render(await TransferPage({ transferId })); + }).rejects.toThrow("Invalid transfer id: invalid-uuid"); + }); + it("throws an error when there's a problem fetching transfer information", async () => { + getTransferEvent.mockResolvedValue({ error: "error" }); + await expect(async () => { + render(await TransferPage({ transferId: randomUUID() })); + }).rejects.toThrow("Error fetching transfer information."); + }); + it("renders the TransferDetailForm if transferId is provided", async () => { + // Mocking the TransferDetailForm component + vi.mock( + "apps/registration/app/components/transfers/TransferDetailForm", + () => ({ + default: () =>
Mocked TransferDetailForm
, + }), + ); + + // Mocking the operationEntitySchema function + vi.mock( + "apps/registration/app/data/jsonSchema/transfer/transferDetail", + () => ({ + operationEntitySchema: vi.fn(), + }), + ); + const mockOperationEntitySchema = operationEntitySchema as ReturnType< + typeof vi.fn + >; + const [operationId, fromOperatorId] = [randomUUID(), randomUUID()]; + getTransferEvent.mockResolvedValue({ + transfer_entity: "Operation", + operation: operationId, + operation_name: "Operation name", + from_operator_id: fromOperatorId, + }); + render(await TransferPage({ transferId: randomUUID() })); + expect(screen.getByText("Mocked TransferDetailForm")).toBeInTheDocument(); + expect(mockOperationEntitySchema).toHaveBeenCalledWith( + operationId, + "Operation name", + fromOperatorId, + ); + }); }); diff --git a/bciers/apps/registration/tests/components/transfers/TransfersDataGrid.test.tsx b/bciers/apps/registration/tests/components/transfers/TransfersDataGrid.test.tsx index cb8cb0df4c..7b7acaa1af 100644 --- a/bciers/apps/registration/tests/components/transfers/TransfersDataGrid.test.tsx +++ b/bciers/apps/registration/tests/components/transfers/TransfersDataGrid.test.tsx @@ -17,6 +17,7 @@ const mockResponse = { rows: [ { id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c9", + transfer_id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c1", operation__name: "Operation 1", facilities__name: "N/A", status: "Transferred", @@ -25,6 +26,7 @@ const mockResponse = { }, { id: "d99725a7-1c3a-47cb-a59b-e2388ce0fa18", + transfer_id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c2", operation__name: "Operation 2", facilities__name: "N/A", status: "To be transferred", @@ -33,6 +35,7 @@ const mockResponse = { }, { id: "f486f2fb-62ed-438d-bb3e-0819b51e3aeb", + transfer_id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c3", operation__name: "N/A", facilities__name: "Facility 1", status: "Completed", @@ -41,6 +44,7 @@ const mockResponse = { }, { id: "459b80f9-b5f3-48aa-9727-90c30eaf3a58", + transfer_id: "3b5b95ea-2a1a-450d-8e2e-2e15feed96c4", operation__name: "N/A", facilities__name: "Facility 2", status: "Completed", @@ -89,22 +93,36 @@ describe("TransfersDataGrid component", () => { within(operation1Row).getByText("Feb 1, 2025 1:00:00 a.m. PST"), ).toBeInTheDocument(); expect(within(operation1Row).getByText("View Details")).toBeInTheDocument(); + // check generated href for view details + expect( + within(operation1Row).getByRole("link", { name: "View Details" }), + ).toHaveAttribute( + "href", + "/transfers/3b5b95ea-2a1a-450d-8e2e-2e15feed96c1?transfers_title=Operation 1", + ); - const opeartion2Row = rows[3]; + const operation2Row = rows[3]; expect( - within(opeartion2Row).getByText("Jul 5, 2024 4:25:37 p.m. PDT"), + within(operation2Row).getByText("Jul 5, 2024 4:25:37 p.m. PDT"), ).toBeInTheDocument(); - expect(within(opeartion2Row).getByText("Operation 2")).toBeInTheDocument(); - expect(within(opeartion2Row).getByText("N/A")).toBeInTheDocument(); + expect(within(operation2Row).getByText("Operation 2")).toBeInTheDocument(); + expect(within(operation2Row).getByText("N/A")).toBeInTheDocument(); expect( - within(opeartion2Row).getByText("To be transferred"), + within(operation2Row).getByText("To be transferred"), ).toBeInTheDocument(); expect( - within(opeartion2Row).getByText("Aug 21, 2024 2:00:00 a.m. PDT"), + within(operation2Row).getByText("Aug 21, 2024 2:00:00 a.m. PDT"), ).toBeInTheDocument(); - expect(within(opeartion2Row).getByText("View Details")).toBeInTheDocument(); - const facility1Row = rows[4]; + expect(within(operation2Row).getByText("View Details")).toBeInTheDocument(); + // check generated href for view details + expect( + within(operation2Row).getByRole("link", { name: "View Details" }), + ).toHaveAttribute( + "href", + "/transfers/3b5b95ea-2a1a-450d-8e2e-2e15feed96c2?transfers_title=Operation 2", + ); + const facility1Row = rows[4]; expect( within(facility1Row).getByText("Jul 5, 2024 4:25:37 p.m. PDT"), ).toBeInTheDocument(); @@ -115,6 +133,13 @@ describe("TransfersDataGrid component", () => { within(facility1Row).getByText("Dec 25, 2024 1:00:00 a.m. PST"), ).toBeInTheDocument(); expect(within(facility1Row).getByText("View Details")).toBeInTheDocument(); + // check generated href for view details + expect( + within(facility1Row).getByRole("link", { name: "View Details" }), + ).toHaveAttribute( + "href", + "/transfers/3b5b95ea-2a1a-450d-8e2e-2e15feed96c3?transfers_title=Facility 1", + ); const facility2Row = rows[5]; expect( @@ -127,5 +152,12 @@ describe("TransfersDataGrid component", () => { within(facility2Row).getByText("Dec 25, 2024 1:00:00 a.m. PST"), ).toBeInTheDocument(); expect(within(facility2Row).getByText("View Details")).toBeInTheDocument(); + // check generated href for view details + expect( + within(facility2Row).getByRole("link", { name: "View Details" }), + ).toHaveAttribute( + "href", + "/transfers/3b5b95ea-2a1a-450d-8e2e-2e15feed96c4?transfers_title=Facility 2", + ); }); }); diff --git a/bciers/apps/registration/tests/components/transfers/mocks.ts b/bciers/apps/registration/tests/components/transfers/mocks.ts new file mode 100644 index 0000000000..65cde7c1d2 --- /dev/null +++ b/bciers/apps/registration/tests/components/transfers/mocks.ts @@ -0,0 +1,7 @@ +const getTransferEvent = vi.fn(); + +vi.mock("@/registration/app/components/transfers/getTransferEvent", () => ({ + default: getTransferEvent, +})); + +export { getTransferEvent }; diff --git a/bciers/apps/reporting/src/app/components/additionalInformation/additionalReportingData/AdditionalReportingDataPage.tsx b/bciers/apps/reporting/src/app/components/additionalInformation/additionalReportingData/AdditionalReportingDataPage.tsx index a2b804d913..70038fbcf2 100644 --- a/bciers/apps/reporting/src/app/components/additionalInformation/additionalReportingData/AdditionalReportingDataPage.tsx +++ b/bciers/apps/reporting/src/app/components/additionalInformation/additionalReportingData/AdditionalReportingDataPage.tsx @@ -6,7 +6,7 @@ import { HasReportVersion } from "@reporting/src/app/utils/defaultPageFactoryTyp const REGULATED_OPERATION = "OBPS Regulated Operation"; const NEW_ENTRANT = "New Entrant Operation"; -function transformReportAdditionalData(reportAdditionalData: any) { +export function transformReportAdditionalData(reportAdditionalData: any) { const captureType = []; if (reportAdditionalData.emissions_on_site_use !== null) { captureType.push("On-site use"); diff --git a/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryForm.tsx b/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryForm.tsx index 19bfbb0af3..5a5e49b002 100644 --- a/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryForm.tsx +++ b/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryForm.tsx @@ -14,7 +14,6 @@ import { multiStepHeaderSteps } from "@reporting/src/app/components/taskList/mul interface Props { versionId: number; - needsVerification: boolean; summaryFormData: { emissions_attributable_for_reporting: string; reporting_only_emissions: string; @@ -43,14 +42,11 @@ interface Props { const ComplianceSummaryForm: React.FC = ({ versionId, - needsVerification, summaryFormData, taskListElements, }) => { const backUrl = `/reports/${versionId}/additional-reporting-data`; - const verificationUrl = `/reports/${versionId}/verification`; - const finalReviewUrl = `/reports/${versionId}/final-review`; - const continueUrl = needsVerification ? verificationUrl : finalReviewUrl; + const continueUrl = `/reports/${versionId}/final-review`; return ( diff --git a/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryPage.tsx b/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryPage.tsx index 2ceba98acf..02163ef27c 100644 --- a/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryPage.tsx +++ b/bciers/apps/reporting/src/app/components/complianceSummary/ComplianceSummaryPage.tsx @@ -1,6 +1,5 @@ import ComplianceSummaryForm from "@reporting/src/app/components/complianceSummary/ComplianceSummaryForm"; import { HasReportVersion } from "@reporting/src/app/utils/defaultPageFactoryTypes"; -import { getReportNeedsVerification } from "@reporting/src/app/utils/getReportNeedsVerification"; import { getComplianceSummaryTaskList } from "@reporting/src/app/components/taskList/4_complianceSummary"; import { getComplianceData } from "@reporting/src/app/utils/getComplianceData"; @@ -9,11 +8,9 @@ export default async function ComplianceSummaryPage({ }: HasReportVersion) { const complianceData = await getComplianceData(version_id); //🔍 Check if reports need verification - const needsVerification = await getReportNeedsVerification(version_id); return ( diff --git a/bciers/apps/reporting/src/app/components/facility/FacilityEmissionAllocationForm.tsx b/bciers/apps/reporting/src/app/components/facility/FacilityEmissionAllocationForm.tsx index c67ba7879b..b101f195a9 100644 --- a/bciers/apps/reporting/src/app/components/facility/FacilityEmissionAllocationForm.tsx +++ b/bciers/apps/reporting/src/app/components/facility/FacilityEmissionAllocationForm.tsx @@ -11,6 +11,8 @@ import { import { IChangeEvent } from "@rjsf/core"; import { multiStepHeaderSteps } from "@reporting/src/app/components/taskList/multiStepHeaderConfig"; import { getFacilitiesInformationTaskList } from "@reporting/src/app/components/taskList/2_facilitiesInformation"; +import { EmissionAllocationData, Product } from "./types"; +import { calculateEmissionData } from "./calculateEmissionsData"; // 📊 Interface for props passed to the component interface Props { @@ -20,19 +22,6 @@ interface Props { initialData: any; } -interface Product { - allocated_quantity: number; - report_product_id: number; - product_name: string; -} - -interface EmissionAllocationData { - emission_category: string; - emission_total: number; - category_type: string; - products: Product[]; -} - interface FormData { report_product_emission_allocations: EmissionAllocationData[]; basic_emission_allocation_data: EmissionAllocationData[]; @@ -42,38 +31,6 @@ interface FormData { allocation_methodology: string; allocation_other_methodology_description: string; } -// Function that makes sure the percentage does not show 100 when it is not exactly 100 -const handlePercentageNearHundred = (value: number) => { - let res; - if (value > 100.0 && value < 100.01) { - res = 100.01; - } else if (value < 100.0 && value > 99.99) { - res = 99.99; - } else { - res = value; - } - - return parseFloat(res.toFixed(4)); -}; - -// 🛠️ Function to calculate category products allocation sum and set total sum in products_emission_allocation_sum -const calculateEmissionData = (category: EmissionAllocationData) => { - const sum = category.products.reduce( - (total, product) => - total + (parseFloat(product.allocated_quantity.toString()) || 0), - 0, - ); - - const emissionTotal = Number(category.emission_total) || 1; - - const percentage = handlePercentageNearHundred((sum / emissionTotal) * 100); - - return { - ...category, - products_emission_allocation_sum: `${percentage.toFixed(2)}%`, - emission_total: category.emission_total.toString(), - }; -}; // 🛠️ Function to validate that emissions totals equal emissions allocations const validateEmissions = (formData: FormData): boolean => { diff --git a/bciers/apps/reporting/src/app/components/facility/calculateEmissionsData.ts b/bciers/apps/reporting/src/app/components/facility/calculateEmissionsData.ts new file mode 100644 index 0000000000..86bebe816e --- /dev/null +++ b/bciers/apps/reporting/src/app/components/facility/calculateEmissionsData.ts @@ -0,0 +1,34 @@ +import { EmissionAllocationData } from "./types"; + +// Function that makes sure the percentage does not show 100 when it is not exactly 100 +const handlePercentageNearHundred = (value: number) => { + let res; + if (value > 100.0 && value < 100.01) { + res = 100.01; + } else if (value < 100.0 && value > 99.99) { + res = 99.99; + } else { + res = value; + } + + return parseFloat(res.toFixed(4)); +}; + +// 🛠️ Function to calculate category products allocation sum and set total sum in products_emission_allocation_sum +export const calculateEmissionData = (category: EmissionAllocationData) => { + const sum = category.products.reduce( + (total, product) => + total + (parseFloat(product.allocated_quantity.toString()) || 0), + 0, + ); + + const emissionTotal = Number(category.emission_total) || 1; + + const percentage = handlePercentageNearHundred((sum / emissionTotal) * 100); + + return { + ...category, + products_emission_allocation_sum: `${percentage.toFixed(2)}%`, + emission_total: category.emission_total.toString(), + }; +}; diff --git a/bciers/apps/reporting/src/app/components/facility/types.ts b/bciers/apps/reporting/src/app/components/facility/types.ts new file mode 100644 index 0000000000..71f8d8ee52 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/facility/types.ts @@ -0,0 +1,12 @@ +export interface Product { + allocated_quantity: number; + report_product_id: number; + product_name: string; +} + +export interface EmissionAllocationData { + emission_category: string; + emission_total: number; + category_type: string; + products: Product[]; +} diff --git a/bciers/apps/reporting/src/app/components/finalReview/FinalReviewForm.tsx b/bciers/apps/reporting/src/app/components/finalReview/FinalReviewForm.tsx index 1b2d65b315..3fdcb991e7 100644 --- a/bciers/apps/reporting/src/app/components/finalReview/FinalReviewForm.tsx +++ b/bciers/apps/reporting/src/app/components/finalReview/FinalReviewForm.tsx @@ -5,17 +5,52 @@ import { HasReportVersion } from "@reporting/src/app/utils/defaultPageFactoryTyp import { multiStepHeaderSteps } from "@reporting/src/app/components/taskList/multiStepHeaderConfig"; import { TaskListElement } from "@bciers/components/navigation/reportingTaskList/types"; import { useRouter } from "next/navigation"; +import { uiSchemaMap } from "../activities/uiSchemas/schemaMaps"; +import { nonAttributableEmissionUiSchema } from "@reporting/src/data/jsonSchema/nonAttributableEmissions/nonAttributableEmissions"; +import { productionDataUiSchema } from "@reporting/src/data/jsonSchema/productionData"; +import { emissionAllocationUiSchema } from "@reporting/src/data/jsonSchema/facility/facilityEmissionAllocation"; +import { ReviewData } from "./reviewDataFactory/factory"; +import { withTheme } from "@rjsf/core"; +import { customizeValidator } from "@rjsf/validator-ajv8"; +import finalReviewTheme from "./formCustomization/finalReviewTheme"; +import { additionalReportingDataUiSchema } from "@reporting/src/data/jsonSchema/additionalReportingData/additionalReportingData"; +import { complianceSummaryUiSchema } from "@reporting/src/data/jsonSchema/complianceSummary"; +import { useState } from "react"; interface Props extends HasReportVersion { taskListElements: TaskListElement[]; + data: ReviewData[]; } -const FinalReviewForm: React.FC = ({ version_id, taskListElements }) => { +// These uiSchemas need to be loaded on the client side, they contain interactive, stateful components. +const finalReviewSchemaMap: { [key: string]: any } = { + ...uiSchemaMap, + nonAttributableEmissions: nonAttributableEmissionUiSchema, + productionData: productionDataUiSchema, + emissionAllocation: emissionAllocationUiSchema, + additionalReportingData: additionalReportingDataUiSchema, + complianceSummary: complianceSummaryUiSchema, +}; + +const resolveUiSchema = (uiSchema: any) => { + if (typeof uiSchema !== "string") return uiSchema; + return finalReviewSchemaMap[uiSchema]; +}; + +const Form = withTheme(finalReviewTheme); + +const FinalReviewForm: React.FC = ({ + version_id, + taskListElements, + data, +}) => { const router = useRouter(); - const saveAndContinueUrl = `/reports/${version_id}/sign-off`; - const backUrl = `/reports/${version_id}/attachments`; + const saveAndContinueUrl = `/reports/${version_id}/verification`; + const backUrl = `/reports/${version_id}/compliance-summary`; + const [isRedirecting, setIsRedirecting] = useState(false); const submitHandler = async () => { + setIsRedirecting(true); router.push(saveAndContinueUrl); }; @@ -24,15 +59,29 @@ const FinalReviewForm: React.FC = ({ version_id, taskListElements }) => { steps={multiStepHeaderSteps} initialStep={4} onSubmit={submitHandler} + isRedirecting={isRedirecting} taskListElements={taskListElements} cancelUrl="#" backUrl={backUrl} continueUrl={saveAndContinueUrl} noFormSave={() => {}} + submittingButtonText="Continue" + noSaveButton > - Placeholder for Final Review -
- Version ID: {version_id} + {data.map((form, idx) => ( +
+ ))} ); }; diff --git a/bciers/apps/reporting/src/app/components/finalReview/FinalReviewPage.tsx b/bciers/apps/reporting/src/app/components/finalReview/FinalReviewPage.tsx index 3798a1e6fd..3d168753ad 100644 --- a/bciers/apps/reporting/src/app/components/finalReview/FinalReviewPage.tsx +++ b/bciers/apps/reporting/src/app/components/finalReview/FinalReviewPage.tsx @@ -5,6 +5,7 @@ import { } from "@reporting/src/app/components/taskList/5_signOffSubmit"; import { getReportNeedsVerification } from "@reporting/src/app/utils/getReportNeedsVerification"; import FinalReviewForm from "@reporting/src/app/components/finalReview/FinalReviewForm"; +import reviewDataFactory, { ReviewData } from "./reviewDataFactory/factory"; export default async function FinalReviewPage({ version_id, @@ -17,10 +18,13 @@ export default async function FinalReviewPage({ needsVerification, ); + const finalReviewData: ReviewData[] = await reviewDataFactory(version_id); + return ( ); } diff --git a/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewArrayFieldTemplate.tsx b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewArrayFieldTemplate.tsx new file mode 100644 index 0000000000..8b4ed1e409 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewArrayFieldTemplate.tsx @@ -0,0 +1,43 @@ +import { ArrayFieldTemplateProps, FieldTemplateProps } from "@rjsf/utils"; + +const FinalReviewArrayFieldTemplate = ({ + items, + uiSchema, +}: ArrayFieldTemplateProps) => { + const customTitleName = uiSchema?.["ui:options"]?.title as string; + + return ( +
+ {items?.map((item, i: number) => { + return ( +
+
+ {customTitleName && ( + + {customTitleName} {i + 1} + + )} +
+ {{ + ...item.children, + props: { + ...item.children.props, + uiSchema: { + ...item.children.props.uiSchema, + "ui:FieldTemplate": ({ children }: FieldTemplateProps) => ( + <>{children} + ), + "ui:options": { + label: false, + }, + }, + }, + }} +
+ ); + })} +
+ ); +}; + +export default FinalReviewArrayFieldTemplate; diff --git a/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewFieldTemplate.tsx b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewFieldTemplate.tsx new file mode 100644 index 0000000000..5d7b23607d --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewFieldTemplate.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { FieldTemplateProps } from "@rjsf/utils"; +import AlertIcon from "@bciers/components/icons/AlertIcon"; + +function FinalReviewFieldTemplate({ + id, + label, + help, + description, + rawErrors, + required, + children, + uiSchema, + classNames, +}: FieldTemplateProps) { + const isHidden = uiSchema?.["ui:widget"] === "hidden"; + if (isHidden) return null; + + const isErrors = rawErrors && rawErrors.length > 0; + const error = rawErrors && rawErrors[0]; + + // UI Schema options + const options = uiSchema?.["ui:options"] || {}; + const isLabel = options?.label !== false; + // Allow width override if inline is true + const inline = options?.inline; + const cellWidth = inline ? "lg:w-full" : "lg:w-4/12"; + + return ( +
+
+ {isLabel && ( +
+ +
+ )} +
+ {children} +
+ {options.displayUnit && ( +
+ {" "} +

{options.displayUnit as any}

+
+ )} + {isErrors && ( +
+
+ +
+ {error} +
+ )} +
+
+ {isLabel &&
} + {description || help ? ( +
+ {description} + {help} +
+ ) : null} +
+
+ ); +} + +export default FinalReviewFieldTemplate; diff --git a/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewMultiSelectWidget.tsx b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewMultiSelectWidget.tsx new file mode 100644 index 0000000000..2e405a2b0b --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewMultiSelectWidget.tsx @@ -0,0 +1,34 @@ +import { WidgetProps } from "@rjsf/utils/lib/types"; +import { + FieldSchema, + mapOptions, +} from "@bciers/components/form/widgets/MultiSelectWidget"; + +const FinalReviewMultiSelectWidget: React.FC = ({ + id, + value, + schema, + uiSchema, +}) => { + const fieldSchema = schema.items as FieldSchema; + const options = mapOptions(fieldSchema); + const selectedOptions = options.filter((option) => value.includes(option.id)); + const displayInline = uiSchema?.["ui:inline"]; + const separator = displayInline ? ", " : "\n"; + + const displayOptions = selectedOptions + .map((option) => `${displayInline ? "" : "- "}${option.label}`) + .join(separator); + + return ( +
+ {displayOptions} +
+ ); +}; + +export default FinalReviewMultiSelectWidget; diff --git a/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewStringField.tsx b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewStringField.tsx new file mode 100644 index 0000000000..2b06de7f2c --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/FinalReviewStringField.tsx @@ -0,0 +1,5 @@ +import { FieldProps } from "@rjsf/utils"; + +export default function FinalReviewStringField(props: FieldProps) { + return
{props.formData}
; +} diff --git a/bciers/apps/reporting/src/app/components/finalReview/formCustomization/finalReviewTheme.tsx b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/finalReviewTheme.tsx new file mode 100644 index 0000000000..fcf7419e14 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/formCustomization/finalReviewTheme.tsx @@ -0,0 +1,24 @@ +import readOnlyTheme from "@bciers/components/form/theme/readOnlyTheme"; +import FinalReviewFieldTemplate from "./FinalReviewFieldTemplate"; +import FinalReviewStringField from "./FinalReviewStringField"; +import FinalReviewArrayFieldTemplate from "./FinalReviewArrayFieldTemplate"; +import FinalReviewMultiSelectWidget from "./FinalReviewMultiSelectWidget"; + +const finalReviewTheme = { + ...readOnlyTheme, + widgets: { + ...readOnlyTheme.widgets, + MultiSelectWidget: FinalReviewMultiSelectWidget, + }, + templates: { + ...readOnlyTheme.templates, + FieldTemplate: FinalReviewFieldTemplate, + ArrayFieldTemplate: FinalReviewArrayFieldTemplate, + }, + fields: { + ...readOnlyTheme.fields, + StringField: FinalReviewStringField, + }, +}; + +export default finalReviewTheme; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/activityFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/activityFactoryItem.ts new file mode 100644 index 0000000000..7b49af722a --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/activityFactoryItem.ts @@ -0,0 +1,48 @@ +import { ReviewData, ReviewDataFactoryItem } from "./factory"; +import { getOrderedActivities } from "@reporting/src/app/utils/getOrderedActivities"; +import safeJsonParse from "@bciers/utils/src/safeJsonParse"; +import { getActivityInitData } from "@reporting/src/app/utils/getActivityInitData"; +import { getActivityFormData } from "@reporting/src/app/utils/getActivityFormData"; +import { getActivitySchema } from "@reporting/src/app/utils/getActivitySchema"; + +const activityFactoryItem: ReviewDataFactoryItem = async ( + versionId: number, + facilityId, +) => { + const orderedActivities: any[] = await getOrderedActivities( + versionId, + facilityId, + ); + + const activityReviewData: ReviewData[] = []; + + for (const activity of orderedActivities) { + const initData = safeJsonParse( + await getActivityInitData(versionId, facilityId, activity.id), + ); + + const formData = await getActivityFormData( + versionId, + facilityId, + activity.id, + ); + + const sourceTypeQueryString = Object.entries(initData.sourceTypeMap) + .filter(([, v]) => String(v) in formData) + .map(([k]) => `&source_types[]=${k}`) + .join(""); + + const schema = safeJsonParse( + await getActivitySchema(versionId, activity.id, sourceTypeQueryString), + ).schema; + activityReviewData.push({ + schema: schema, + uiSchema: activity.slug, + data: formData, + }); + } + + return activityReviewData; +}; + +export default activityFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/additionalReportingDataFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/additionalReportingDataFactoryItem.ts new file mode 100644 index 0000000000..a918833c35 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/additionalReportingDataFactoryItem.ts @@ -0,0 +1,31 @@ +import { ReviewDataFactoryItem } from "./factory"; +import { getReportAdditionalData } from "@reporting/src/app/utils/getReportAdditionalData"; +import { transformReportAdditionalData } from "../../additionalInformation/additionalReportingData/AdditionalReportingDataPage"; +import { + additionalReportingDataSchema, + additionalReportingDataWithElectricityGeneratedSchema, +} from "@reporting/src/data/jsonSchema/additionalReportingData/additionalReportingData"; +import { getRegistrationPurpose } from "@reporting/src/app/utils/getRegistrationPurpose"; + +const additionalReportingDataFactoryItem: ReviewDataFactoryItem = async ( + versionId, +) => { + const isRegulatedOperation = + (await getRegistrationPurpose(versionId))?.registration_purpose === + "OBPS Regulated Operation"; + + const reportAdditionalData = await getReportAdditionalData(versionId); + const transformedData = transformReportAdditionalData(reportAdditionalData); + + return [ + { + schema: isRegulatedOperation + ? additionalReportingDataWithElectricityGeneratedSchema + : additionalReportingDataSchema, + data: transformedData, + uiSchema: "additionalReportingData", + }, + ]; +}; + +export default additionalReportingDataFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/allocationOfEmissionsFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/allocationOfEmissionsFactoryItem.ts new file mode 100644 index 0000000000..a23ee74325 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/allocationOfEmissionsFactoryItem.ts @@ -0,0 +1,45 @@ +import { getEmissionAllocations } from "@reporting/src/app/utils/getEmissionAllocations"; +import { ReviewDataFactoryItem } from "./factory"; +import { emissionAllocationSchema } from "@reporting/src/data/jsonSchema/facility/facilityEmissionAllocation"; +import { calculateEmissionData } from "../../facility/calculateEmissionsData"; + +const allocationOfEmissionsFactoryItem: ReviewDataFactoryItem = async ( + versionId, + facilityId, +) => { + const initialData = await getEmissionAllocations(versionId, facilityId); + + const formData = { + allocation_methodology: initialData.allocation_methodology, + allocation_other_methodology_description: + initialData.allocation_other_methodology_description, + basic_emission_allocation_data: + initialData.report_product_emission_allocations + .filter((category: any) => category.category_type === "basic") + .map(calculateEmissionData), + fuel_excluded_emission_allocation_data: + initialData.report_product_emission_allocations + .filter((category: any) => category.category_type === "fuel_excluded") + .map(calculateEmissionData), + total_emission_allocations: { + facility_total_emissions: initialData.facility_total_emissions, + products: initialData.report_product_emission_allocation_totals, + }, + }; + + return [ + { + schema: emissionAllocationSchema, + data: formData, + uiSchema: "emissionAllocation", + context: { + facility_emission_data: formData.basic_emission_allocation_data.concat( + formData.fuel_excluded_emission_allocation_data, + ), + total_emission_allocations: formData.total_emission_allocations, + }, + }, + ]; +}; + +export default allocationOfEmissionsFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/complianceSummaryFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/complianceSummaryFactoryItem.ts new file mode 100644 index 0000000000..52b4f9d96f --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/complianceSummaryFactoryItem.ts @@ -0,0 +1,19 @@ +import { complianceSummarySchema } from "@reporting/src/data/jsonSchema/complianceSummary"; +import { ReviewDataFactoryItem } from "./factory"; +import { getComplianceData } from "@reporting/src/app/utils/getComplianceData"; + +const complianceSummaryFactoryItem: ReviewDataFactoryItem = async ( + versionId, +) => { + const complianceData = await getComplianceData(versionId); + + return [ + { + schema: complianceSummarySchema, + data: complianceData, + uiSchema: "complianceSummary", + }, + ]; +}; + +export default complianceSummaryFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/emissionsSummaryFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/emissionsSummaryFactoryItem.ts new file mode 100644 index 0000000000..9fddb47912 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/emissionsSummaryFactoryItem.ts @@ -0,0 +1,49 @@ +import { + facilityEmissionSummarySchema, + facilityEmissionSummaryUiSchema, +} from "@reporting/src/data/jsonSchema/facilityEmissionSummary"; +import { ReviewDataFactoryItem } from "./factory"; +import { getSummaryData } from "@reporting/src/app/utils/getSummaryData"; + +const emissionsSummaryFactoryItem: ReviewDataFactoryItem = async ( + versionId, + facilityId, +) => { + const summaryData = await getSummaryData(versionId, facilityId); + + const formData = { + attributableForReporting: summaryData.attributable_for_reporting, + attributableForReportingThreshold: summaryData.attributable_for_threshold, + reportingOnlyEmission: summaryData.reporting_only, + emissionCategories: { + flaring: summaryData.flaring, + fugitive: summaryData.fugitive, + industrialProcess: summaryData.industrial_process, + onSiteTransportation: summaryData.onsite, + stationaryCombustion: summaryData.stationary, + ventingUseful: summaryData.venting_useful, + ventingNonUseful: summaryData.venting_non_useful, + waste: summaryData.waste, + wastewater: summaryData.wastewater, + }, + fuelExcluded: { + woodyBiomass: summaryData.woody_biomass, + excludedBiomass: summaryData.excluded_biomass, + excludedNonBiomass: summaryData.excluded_non_biomass, + }, + otherExcluded: { + lfoExcluded: summaryData.lfo_excluded, + fogExcluded: "0", // To be handled once we implement a way to capture FOG emissions + }, + }; + + return [ + { + schema: facilityEmissionSummarySchema, + data: formData, + uiSchema: facilityEmissionSummaryUiSchema, + }, + ]; +}; + +export default emissionsSummaryFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/factory.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/factory.ts new file mode 100644 index 0000000000..0ccd7965aa --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/factory.ts @@ -0,0 +1,40 @@ +import { getFacilityReport } from "@reporting/src/app/utils/getFacilityReport"; +import activityFactoryItem from "./activityFactoryItem"; +import nonAttributableEmissionsFactoryItem from "./nonAttributableEmissionsFactoryItem"; +import operationReviewFactoryItem from "./operationReviewFactoryItem"; +import personResponsibleFactoryItem from "./personResponsibleFactoryItem"; +import emissionsSummaryFactoryItem from "./emissionsSummaryFactoryItem"; +import productionDataFactoryItem from "./productionDataFactoryItem"; +import allocationOfEmissionsFactoryItem from "./allocationOfEmissionsFactoryItem"; +import { RJSFSchema } from "@rjsf/utils"; +import additionalReportingDataFactoryItem from "./additionalReportingDataFactoryItem"; +import complianceSummaryFactoryItem from "./complianceSummaryFactoryItem"; + +export type ReviewData = { + schema: RJSFSchema; + uiSchema: Object | string; + data: any; + context?: any; +}; +export type ReviewDataFactoryItem = ( + version_id: number, + facility_id: string, +) => Promise; + +export default async function reviewDataFactory( + versionId: number, +): Promise { + const facilityId = (await getFacilityReport(versionId)).facility_id; + + return [ + ...(await operationReviewFactoryItem(versionId, facilityId)), + ...(await personResponsibleFactoryItem(versionId, facilityId)), + ...(await activityFactoryItem(versionId, facilityId)), + ...(await nonAttributableEmissionsFactoryItem(versionId, facilityId)), + ...(await emissionsSummaryFactoryItem(versionId, facilityId)), + ...(await productionDataFactoryItem(versionId, facilityId)), + ...(await allocationOfEmissionsFactoryItem(versionId, facilityId)), + ...(await additionalReportingDataFactoryItem(versionId, facilityId)), + ...(await complianceSummaryFactoryItem(versionId, facilityId)), + ]; +} diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/nonAttributableEmissionsFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/nonAttributableEmissionsFactoryItem.ts new file mode 100644 index 0000000000..b03679b5fd --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/nonAttributableEmissionsFactoryItem.ts @@ -0,0 +1,47 @@ +import { getAllGasTypes } from "@reporting/src/app/utils/getAllGasTypes"; +import { ReviewDataFactoryItem } from "./factory"; +import { generateUpdatedSchema } from "@reporting/src/data/jsonSchema/nonAttributableEmissions/nonAttributableEmissions"; +import { getAllEmissionCategories } from "@reporting/src/app/utils/getAllEmissionCategories"; +import { getNonAttributableEmissionsData } from "@reporting/src/app/utils/getNonAttributableEmissionsData"; + +const nonAttributableEmissionsFactoryItem: ReviewDataFactoryItem = async ( + versionId, + facilityId, +) => { + const gasTypes = await getAllGasTypes(); + const emissionCategories = await getAllEmissionCategories(); + + const schema = generateUpdatedSchema(gasTypes, emissionCategories); + + const emissionFormData = await getNonAttributableEmissionsData( + versionId, + facilityId, + ); + + const data = { + emissions_exceeded: emissionFormData.length > 0, + activities: emissionFormData.map((item: any) => ({ + id: item.id, + activity: item.activity, + source_type: item.source_type, + emission_category: emissionCategories.find( + (ec: any) => ec.id === item.emission_category, + ).category_name, + gas_type: item.gas_type.map( + (gasId: number) => + gasTypes.find((gasType: any) => gasType.id === gasId) + .chemical_formula, + ), + })), + }; + + return [ + { + schema: schema, + data: data, + uiSchema: "nonAttributableEmissions", + }, + ]; +}; + +export default nonAttributableEmissionsFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/operationReviewFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/operationReviewFactoryItem.ts new file mode 100644 index 0000000000..6b847dbbb5 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/operationReviewFactoryItem.ts @@ -0,0 +1,59 @@ +import { + operationReviewSchema, + operationReviewUiSchema, + updateSchema, +} from "@reporting/src/data/jsonSchema/operations"; +import { ReviewDataFactoryItem } from "./factory"; +import { getReportingOperation } from "@reporting/src/app/utils/getReportingOperation"; +import { getReportingYear } from "@reporting/src/app/utils/getReportingYear"; +import { formatDate } from "@reporting/src/app/utils/formatDate"; +import { getRegistrationPurpose } from "@reporting/src/app/utils/getRegistrationPurpose"; +import { getAllActivities } from "@reporting/src/app/utils/getAllReportingActivities"; +import { getRegulatedProducts } from "@bciers/actions/api"; + +const operationReviewFactoryItem: ReviewDataFactoryItem = async (versionId) => { + const reportingOperationData = await getReportingOperation(versionId); + + const reportingYear = await getReportingYear(); + const reportingWindowEnd = formatDate( + reportingYear.reporting_window_end, + "MMM DD YYYY", + ); + + const registrationPurpose = (await getRegistrationPurpose(versionId)) + .registration_purpose; + + const allActivities = await getAllActivities(); + const allRegulatedProducts = await getRegulatedProducts(); + + const schema: any = updateSchema( + operationReviewSchema, + reportingOperationData, + registrationPurpose, + reportingWindowEnd, + allActivities, + allRegulatedProducts, + reportingOperationData.report_operation_representatives, + ); + + // Purpose note doesn't show up on the final review page + delete schema.properties.purpose_note; + + const formData = { + ...reportingOperationData.report_operation, + operation_representative_name: + reportingOperationData.report_operation_representatives.flatMap( + (rep: any) => (rep.selected_for_report ? [rep.id] : []), + ), + }; + + return [ + { + schema: schema, + data: formData, + uiSchema: operationReviewUiSchema, + }, + ]; +}; + +export default operationReviewFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/personResponsibleFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/personResponsibleFactoryItem.ts new file mode 100644 index 0000000000..ad14023f6f --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/personResponsibleFactoryItem.ts @@ -0,0 +1,35 @@ +import { + personResponsibleSchema, + personResponsibleUiSchema, +} from "@reporting/src/data/jsonSchema/personResponsible"; +import { ReviewDataFactoryItem } from "./factory"; +import { createPersonResponsibleSchema } from "../../operations/personResponsible/createPersonResponsibleSchema"; +import { getReportingPersonResponsible } from "@reporting/src/app/utils/getReportingPersonResponsible"; + +const personResponsibleFactoryItem: ReviewDataFactoryItem = async ( + versionId, +) => { + const { sync_button: any, ...personResponsibleUiSchemaWithoutSyncButton } = + personResponsibleUiSchema; + + personResponsibleSchema.properties = { + person_responsible: { type: "string", title: " " }, + }; + + const contactData = await getReportingPersonResponsible(versionId); + + return [ + { + schema: createPersonResponsibleSchema( + personResponsibleSchema, + [], + 1, + contactData, + ), + uiSchema: personResponsibleUiSchemaWithoutSyncButton, + data: contactData, + }, + ]; +}; + +export default personResponsibleFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/productionDataFactoryItem.ts b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/productionDataFactoryItem.ts new file mode 100644 index 0000000000..5da39fef97 --- /dev/null +++ b/bciers/apps/reporting/src/app/components/finalReview/reviewDataFactory/productionDataFactoryItem.ts @@ -0,0 +1,31 @@ +import { ReviewDataFactoryItem } from "./factory"; +import { getProductionData } from "@bciers/actions/api"; +import { buildProductionDataSchema } from "@reporting/src/data/jsonSchema/productionData"; + +const productionDataFactoryItem: ReviewDataFactoryItem = async ( + versionId, + facilityId, +) => { + const productionData = await getProductionData(versionId, facilityId); + + const schema: any = buildProductionDataSchema( + "Jan 1", + "Dec 31", + productionData.allowed_products.map((p) => p.name), + ); + + return [ + { + schema: schema, + data: { + product_selection: productionData.report_products.map( + (i) => i.product_name, + ), + production_data: productionData.report_products, + }, + uiSchema: "productionData", + }, + ]; +}; + +export default productionDataFactoryItem; diff --git a/bciers/apps/reporting/src/app/components/operations/OperationReviewForm.tsx b/bciers/apps/reporting/src/app/components/operations/OperationReviewForm.tsx index 35154520ad..949e7abb30 100644 --- a/bciers/apps/reporting/src/app/components/operations/OperationReviewForm.tsx +++ b/bciers/apps/reporting/src/app/components/operations/OperationReviewForm.tsx @@ -239,10 +239,8 @@ export default function OperationReviewForm({ onConfirm={confirmReportTypeChange} confirmText="Change report type" > -

- Are you sure you want to change your report type? If you proceed, all - of the form data you have entered will be lost. -

+ Are you sure you want to change your report type? If you proceed, all of + the form data you have entered will be lost. { // Build the TaskListElement array const elements: TaskListElement[] = [ + { + type: "Page", // Set the type to "Page" + title: "Final review", + link: `/reports/${versionId}/final-review`, + isActive: activeIndex === ActivePage.FinalReview, + }, { type: "Page", // Set the type to "Page" title: "Verification", @@ -29,12 +35,6 @@ export const getSignOffAndSubmitSteps: ( link: `/reports/${versionId}/attachments`, isActive: activeIndex === ActivePage.Attachments, }, - { - type: "Page", // Set the type to "Page" - title: "Final review", - link: `/reports/${versionId}/final-review`, - isActive: activeIndex === ActivePage.FinalReview, - }, { type: "Page", // Set the type to "Page" title: "Sign-off", diff --git a/bciers/apps/reporting/src/app/components/verification/VerificationForm.tsx b/bciers/apps/reporting/src/app/components/verification/VerificationForm.tsx index 3ecece8716..d44824fe4e 100644 --- a/bciers/apps/reporting/src/app/components/verification/VerificationForm.tsx +++ b/bciers/apps/reporting/src/app/components/verification/VerificationForm.tsx @@ -34,7 +34,7 @@ export default function VerificationForm({ const queryString = serializeSearchParams(searchParams); const saveAndContinueUrl = `/reports/${version_id}/attachments${queryString}`; - const backUrl = `/reports/${version_id}/compliance-summary${queryString}`; + const backUrl = `/reports/${version_id}/final-review${queryString}`; const handleChange = (e: IChangeEvent) => { const updatedData = { ...e.formData }; diff --git a/bciers/apps/reporting/src/app/utils/getActivityInitData.ts b/bciers/apps/reporting/src/app/utils/getActivityInitData.ts new file mode 100644 index 0000000000..1e840f51a4 --- /dev/null +++ b/bciers/apps/reporting/src/app/utils/getActivityInitData.ts @@ -0,0 +1,20 @@ +import { actionHandler } from "@bciers/actions"; + +export async function getActivityInitData( + versionId: number, + facilityId: string, + activityId: number, +) { + const response = await actionHandler( + `reporting/report-version/${versionId}/facility-report/${facilityId}/initial-activity-data?activity_id=${activityId}`, + "GET", + "", + ); + + if (response.error) { + throw new Error( + `Error fetching activity init data for version ${versionId}, activity ${activityId}`, + ); + } + return response; +} diff --git a/bciers/apps/reporting/src/app/utils/getActivitySchema.ts b/bciers/apps/reporting/src/app/utils/getActivitySchema.ts new file mode 100644 index 0000000000..bdecc4446c --- /dev/null +++ b/bciers/apps/reporting/src/app/utils/getActivitySchema.ts @@ -0,0 +1,20 @@ +import { actionHandler } from "@bciers/actions"; + +export async function getActivitySchema( + versionId: number, + activityId: number, + sourceTypeQueryString: string, +) { + const response = await actionHandler( + `reporting/build-form-schema?activity=${activityId}&report_version_id=${versionId}${sourceTypeQueryString}`, + "GET", + "", + ); + + if (response.error) { + throw new Error( + `Error fetching schema for version ${versionId}, activity ${activityId}, source types ${sourceTypeQueryString}`, + ); + } + return response; +} diff --git a/bciers/apps/reporting/src/data/jsonSchema/additionalReportingData/additionalReportingData.ts b/bciers/apps/reporting/src/data/jsonSchema/additionalReportingData/additionalReportingData.ts index 8c27b22617..b651cd730e 100644 --- a/bciers/apps/reporting/src/data/jsonSchema/additionalReportingData/additionalReportingData.ts +++ b/bciers/apps/reporting/src/data/jsonSchema/additionalReportingData/additionalReportingData.ts @@ -3,8 +3,8 @@ import FieldTemplate from "@bciers/components/form/fields/FieldTemplate"; import { TitleOnlyFieldTemplate } from "@bciers/components/form/fields"; import SectionFieldTemplate from "@bciers/components/form/fields/SectionFieldTemplate"; import { CapturedEmmissionsInfo } from "@reporting/src/data/jsonSchema/additionalReportingData/additionalMessage"; -import { RadioWidget } from "@bciers/components/form/widgets"; -import multiSelectWidget from "@bciers/components/form/widgets/MultiSelectWidget"; +import RadioWidget from "@bciers/components/form/widgets/RadioWidget"; +import MultiSelectWidget from "@bciers/components/form/widgets/MultiSelectWidget"; export const additionalReportingDataSchema: RJSFSchema = { type: "object", @@ -135,7 +135,7 @@ export const additionalReportingDataUiSchema = { "ui:widget": RadioWidget, }, capture_type: { - "ui:widget": multiSelectWidget, + "ui:widget": MultiSelectWidget, "ui:options": { style: { width: "100%", textAlign: "justify" } }, "ui:placeholder": "Capture type", }, diff --git a/bciers/apps/reporting/src/data/jsonSchema/facility/facilityEmissionAllocation.tsx b/bciers/apps/reporting/src/data/jsonSchema/facility/facilityEmissionAllocation.tsx index 3f7ac2c24d..b46a710bc6 100644 --- a/bciers/apps/reporting/src/data/jsonSchema/facility/facilityEmissionAllocation.tsx +++ b/bciers/apps/reporting/src/data/jsonSchema/facility/facilityEmissionAllocation.tsx @@ -4,7 +4,7 @@ import { TitleOnlyFieldTemplate, } from "@bciers/components/form/fields"; import { ReadOnlyWidget } from "@bciers/components/form/widgets/readOnly"; -import { TextAreaWidget } from "@bciers/components/form/widgets"; +import TextAreaWidget from "@bciers/components/form/widgets/TextAreaWidget"; import { RJSFSchema, UiSchema, diff --git a/bciers/apps/reporting/src/data/jsonSchema/nonAttributableEmissions/nonAttributableEmissions.ts b/bciers/apps/reporting/src/data/jsonSchema/nonAttributableEmissions/nonAttributableEmissions.ts index 30987bd6b0..4eb04ca7fb 100644 --- a/bciers/apps/reporting/src/data/jsonSchema/nonAttributableEmissions/nonAttributableEmissions.ts +++ b/bciers/apps/reporting/src/data/jsonSchema/nonAttributableEmissions/nonAttributableEmissions.ts @@ -1,13 +1,11 @@ import { RJSFSchema } from "@rjsf/utils"; import FieldTemplate from "@bciers/components/form/fields/FieldTemplate"; import { TitleOnlyFieldTemplate } from "@bciers/components/form/fields"; -import { - CheckboxGroupWidget, - RadioWidget, - SelectWidget, -} from "@bciers/components/form/widgets"; import { NonAttributableEmmissionsInfo } from "@reporting/src/data/jsonSchema/nonAttributableEmissions/additionalMessage"; import NestedArrayFieldTemplate from "@bciers/components/form/fields/NestedArrayFieldTemplate"; +import RadioWidget from "@bciers/components/form/widgets/RadioWidget"; +import SelectWidget from "@bciers/components/form/widgets/SelectWidget"; +import CheckboxGroupWidget from "@bciers/components/form/widgets/CheckboxGroupWidget"; export const nonAttributableEmissionSchema: RJSFSchema = { type: "object", diff --git a/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryForm.test.tsx b/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryForm.test.tsx index 892147b1cd..31fcd050f3 100644 --- a/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryForm.test.tsx +++ b/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryForm.test.tsx @@ -57,7 +57,6 @@ describe("ComplianceSummaryForm", () => { it("should render the calculation summary data", async () => { render( { it("should render the regulatory values summary data", async () => { render( { it("should render the production summary data", async () => { render( { render( , @@ -138,11 +134,10 @@ describe("ComplianceSummaryForm", () => { ); }); - it("should render a continue button that navigates to the verification page", async () => { + it("should render a continue button that navigates to the final review page", async () => { render( , @@ -156,14 +151,13 @@ describe("ComplianceSummaryForm", () => { fireEvent.click(button); - expect(mockPush).toHaveBeenCalledWith(`/reports/1/verification`); + expect(mockPush).toHaveBeenCalledWith(`/reports/1/final-review`); }); it("should render a continue button that navigates to the final review page", async () => { render( , diff --git a/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryPage.test.tsx b/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryPage.test.tsx index 1f4d1a03ea..a6d2bee899 100644 --- a/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryPage.test.tsx +++ b/bciers/apps/reporting/src/tests/components/complianceSummary/ComplianceSummaryPage.test.tsx @@ -48,7 +48,6 @@ describe("ComplianceSummaryPage", () => { expect(mockComplianceSummaryForm).toHaveBeenCalledWith( { versionId, - needsVerification: false, summaryFormData: complianceData, taskListElements: getComplianceSummaryTaskList(), }, @@ -71,7 +70,6 @@ describe("ComplianceSummaryPage", () => { expect(mockComplianceSummaryForm).toHaveBeenCalledWith( { versionId, - needsVerification: true, summaryFormData: complianceData, taskListElements: getComplianceSummaryTaskList(), }, @@ -94,7 +92,6 @@ describe("ComplianceSummaryPage", () => { expect(mockComplianceSummaryForm).toHaveBeenCalledWith( { versionId, - needsVerification: true, summaryFormData: complianceData, taskListElements: getComplianceSummaryTaskList(), }, diff --git a/bciers/apps/reporting/src/tests/components/finalReview/FinalReviewForm.test.tsx b/bciers/apps/reporting/src/tests/components/finalReview/FinalReviewForm.test.tsx new file mode 100644 index 0000000000..b4cbe5de7f --- /dev/null +++ b/bciers/apps/reporting/src/tests/components/finalReview/FinalReviewForm.test.tsx @@ -0,0 +1,50 @@ +import { useRouter } from "@bciers/testConfig/mocks"; +import FinalReviewForm from "@reporting/src/app/components/finalReview/FinalReviewForm"; +import { fireEvent, render, screen } from "@testing-library/react"; + +// ✨ Mocks +const mockRouterPush = vi.fn(); +useRouter.mockReturnValue({ + push: mockRouterPush, +}); + +describe("The FinalReviewForm component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes to the compliance summary page when the back button is clicked", () => { + const expectedRoute = `/reports/12345/compliance-summary`; + + render( + , + ); + + // Click the "Back" button + const backButton = screen.getByRole("button", { + name: "Back", + }); + fireEvent.click(backButton); + + // Assert that the router's push method was called with the expected route + expect(mockRouterPush).toHaveBeenCalledTimes(1); + expect(mockRouterPush).toHaveBeenCalledWith(expectedRoute); + }); + it("routes to the verification page when the submit button is clicked", () => { + const expectedRoute = `/reports/12345/verification`; + + render( + , + ); + + // Click the "Save and continue" button + const button = screen.getByRole("button", { + name: "Continue", + }); + fireEvent.click(button); + + // Assert that the router's push method was called with the expected route + expect(mockRouterPush).toHaveBeenCalledTimes(1); + expect(mockRouterPush).toHaveBeenCalledWith(expectedRoute); + }); +}); diff --git a/bciers/apps/reporting/src/tests/components/verification/VerificationForm.test.tsx b/bciers/apps/reporting/src/tests/components/verification/VerificationForm.test.tsx index 9b333c1661..852008a9b1 100644 --- a/bciers/apps/reporting/src/tests/components/verification/VerificationForm.test.tsx +++ b/bciers/apps/reporting/src/tests/components/verification/VerificationForm.test.tsx @@ -246,9 +246,9 @@ describe("VerificationForm component", () => { ); }, ); - it("routes to the compliance summary page when the Back button is clicked", () => { + it("routes to the final review summary page when the Back button is clicked", () => { const queryString = "?"; // Update this based on your query string logic if necessary. - const expectedRoute = `/reports/${config.mockVersionId}/compliance-summary${queryString}`; + const expectedRoute = `/reports/${config.mockVersionId}/final-review${queryString}`; renderVerificationForm(); diff --git a/bciers/libs/components/src/form/MultiStepWrapperWithTaskList.tsx b/bciers/libs/components/src/form/MultiStepWrapperWithTaskList.tsx index 0e4344b7f9..7dc085bc70 100644 --- a/bciers/libs/components/src/form/MultiStepWrapperWithTaskList.tsx +++ b/bciers/libs/components/src/form/MultiStepWrapperWithTaskList.tsx @@ -27,6 +27,7 @@ interface Props { isSaving?: boolean; isRedirecting?: boolean; noFormSave?: () => void; + noSaveButton?: boolean; } const MultiStepWrapperWithTaskList: React.FC = ({ @@ -42,6 +43,7 @@ const MultiStepWrapperWithTaskList: React.FC = ({ isSaving, isRedirecting, noFormSave, + noSaveButton, }) => { return ( @@ -68,6 +70,7 @@ const MultiStepWrapperWithTaskList: React.FC = ({ saveAndContinue={onSubmit} isRedirecting={isRedirecting} isSaving={isSaving} + noSaveButton={noSaveButton} />
diff --git a/bciers/libs/components/src/form/SingleStepTaskListForm.tsx b/bciers/libs/components/src/form/SingleStepTaskListForm.tsx index f2230ec790..bbdb295fef 100644 --- a/bciers/libs/components/src/form/SingleStepTaskListForm.tsx +++ b/bciers/libs/components/src/form/SingleStepTaskListForm.tsx @@ -6,10 +6,12 @@ import { IChangeEvent } from "@rjsf/core"; import { RJSFSchema, UiSchema } from "@rjsf/utils"; import FormBase from "@bciers/components/form/FormBase"; import TaskList from "@bciers/components/form/components/TaskList"; -import { createNestedFormData, createUnnestedFormData } from "./formDataUtils"; -import { FormMode } from "@bciers/utils/src/enums"; +import { + createNestedFormData, + createUnnestedFormData, +} from "@bciers/components/form/formDataUtils"; +import { FormMode, FrontendMessages } from "@bciers/utils/src/enums"; import SnackBar from "@bciers/components/form/components/SnackBar"; -import { FrontendMessages } from "@bciers/utils/src/enums"; import SubmitButton from "@bciers/components/button/SubmitButton"; interface SingleStepTaskListFormProps { @@ -27,6 +29,7 @@ interface SingleStepTaskListFormProps { formContext?: { [key: string]: any }; showTasklist?: boolean; showCancelButton?: boolean; + customButtonSection?: React.ReactNode; } const SingleStepTaskListForm = ({ @@ -44,6 +47,7 @@ const SingleStepTaskListForm = ({ formContext, showTasklist = true, showCancelButton = true, + customButtonSection, }: SingleStepTaskListFormProps) => { const hasFormData = Object.keys(rawFormData).length > 0; const formData = hasFormData ? createNestedFormData(rawFormData, schema) : {}; @@ -120,40 +124,42 @@ const SingleStepTaskListForm = ({
{error && {error}}
-
- {allowEdit && ( -
- {isDisabled ? ( - - ) : ( - - Submit - - )} -
- )} - {showCancelButton && ( - - )} -
+ {customButtonSection || ( +
+ {allowEdit && ( +
+ {isDisabled ? ( + + ) : ( + + Submit + + )} +
+ )} + {showCancelButton && ( + + )} +
+ )} diff --git a/bciers/libs/components/src/form/components/ReportingStepButtons.tsx b/bciers/libs/components/src/form/components/ReportingStepButtons.tsx index 15bc600b45..1f657e2036 100644 --- a/bciers/libs/components/src/form/components/ReportingStepButtons.tsx +++ b/bciers/libs/components/src/form/components/ReportingStepButtons.tsx @@ -14,6 +14,7 @@ interface StepButtonProps { saveAndContinue?: () => void; buttonText?: string; noFormSave?: () => void; + noSaveButton?: boolean; } const ReportingStepButtons: React.FunctionComponent = ({ @@ -27,6 +28,7 @@ const ReportingStepButtons: React.FunctionComponent = ({ saveAndContinue, buttonText, noFormSave, + noSaveButton, }) => { const router = useRouter(); const saveButtonContent = isSaving ? ( @@ -66,18 +68,20 @@ const ReportingStepButtons: React.FunctionComponent = ({ Back )} - + {!noSaveButton && ( + + )} - + ); diff --git a/bciers/libs/components/src/navigation/Bread.test.tsx b/bciers/libs/components/src/navigation/Bread.test.tsx index 4a8b51cc28..a020efffde 100644 --- a/bciers/libs/components/src/navigation/Bread.test.tsx +++ b/bciers/libs/components/src/navigation/Bread.test.tsx @@ -17,12 +17,12 @@ describe("The Breadcrumb component", () => { const testCases = [ { url: "http://localhost:3000/administration/contacts", - expectedBreadcrumbs: ["Home", "Administration", "Contacts"], + expectedBreadcrumbs: ["Dashboard", "Administration", "Contacts"], }, { url: "http://localhost:3000/administration/contacts/add-contact", expectedBreadcrumbs: [ - "Home", + "Dashboard", "Administration", "Contacts", "Add Contact", @@ -30,16 +30,21 @@ describe("The Breadcrumb component", () => { }, { url: "http://localhost:3000/administration/contacts/10?contacts_title=Henry%20Ives", - expectedBreadcrumbs: ["Home", "Administration", "Contacts", "Henry Ives"], + expectedBreadcrumbs: [ + "Dashboard", + "Administration", + "Contacts", + "Henry Ives", + ], }, { url: "http://localhost:3000/administration/operations", - expectedBreadcrumbs: ["Home", "Administration", "Operations"], + expectedBreadcrumbs: ["Dashboard", "Administration", "Operations"], }, { url: "http://localhost:3000/administration/operations/002d5a9e-32a6-4191-938c-2c02bfec592d?operations_title=Operation+2", expectedBreadcrumbs: [ - "Home", + "Dashboard", "Administration", "Operations", "Operation 2", @@ -48,29 +53,56 @@ describe("The Breadcrumb component", () => { { url: "http://localhost:3000/administration/operations/002d5a9e-32a6-4191-938c-2c02bfec592d/facilities?operations_title=Operation+2", expectedBreadcrumbs: [ - "Home", + "Dashboard", "Administration", "Operations", "Operation 2", + "Facilities", ], }, { url: "http://localhost:3000/administration/operations/002d5a9e-32a6-4191-938c-2c02bfec592d/facilities/f486f2fb-62ed-438d-bb3e-0819b51e3aeb?operations_title=Operation%202&facilities_title=Facility%201", expectedBreadcrumbs: [ - "Home", + "Dashboard", "Administration", "Operations", "Operation 2", + "Facilities", "Facility 1", ], }, { url: "http://localhost:3000/registration/register-an-operation", - expectedBreadcrumbs: ["Home", "Registration", "Register An Operation"], + expectedBreadcrumbs: [ + "Dashboard", + "Registration", + "Register An Operation", + ], }, { url: "http://localhost:3000/registration/register-an-operation/2", - expectedBreadcrumbs: ["Home", "Registration", "Register An Operation"], + expectedBreadcrumbs: [ + "Dashboard", + "Registration", + "Register An Operation", + ], + }, + { + url: "http://localhost:3000/reporting/reports", + expectedBreadcrumbs: ["Dashboard", "Reporting", "Reports"], + }, + { + url: "http://localhost:3000/reporting/reports/1/review-operator-data", + expectedBreadcrumbs: [ + "Dashboard", + "Reporting", + "Reports", + "Review Operator Data", + ], + }, + { + url: "http://localhost:3000/reporting/reports/1/facilities/f486f2fb-62ed-438d-bb3e-0819b51e3aff/activities", + expectedBreadcrumbs: ["Dashboard", "Reporting", "Reports", "Activities"], }, ]; @@ -82,7 +114,7 @@ describe("The Breadcrumb component", () => { , ); diff --git a/bciers/libs/components/src/navigation/Bread.tsx b/bciers/libs/components/src/navigation/Bread.tsx index 3975eab078..fee1c64f12 100644 --- a/bciers/libs/components/src/navigation/Bread.tsx +++ b/bciers/libs/components/src/navigation/Bread.tsx @@ -59,8 +59,17 @@ export default function Bread({ const slicedPathNames = lastLinkIndex !== -1 ? pathNames.slice(0, lastLinkIndex + 1) : pathNames; - // 🛠️ Function to translate an uuid or number segment using querystring value - function translateNumericPart(segment: string, index: number): string | null { + // 🛠️ Function to transform path segment crumb content based on conditions: segmant paths; uuid; number + function transformPathSegment(segment: string, index: number): string | null { + // Check if "reports" is in the pathNames and the current segment is "Facilities" + if ( + pathNames.some((path) => path.toLowerCase() === "reports") && + segment.toLowerCase() === "facilities" + ) { + return null; + } + + // Check if the current segment is an ID if (isValidUUID(segment) || isNumeric(segment)) { const precedingSegment = pathNames[index - 1] ? unslugifyAndCapitalize(pathNames[index - 1]) @@ -78,7 +87,7 @@ export default function Bread({ return crumbTitles?.title || null; } - // If the segment is not a UUID or numeric value, return it as-is + // Return segment as-is return segment; } @@ -111,11 +120,11 @@ export default function Bread({ {slicedPathNames.map((link, index) => { const isLastItem = index === slicedPathNames.length - 1; const content = capitalizeLinks - ? translateNumericPart(unslugifyAndCapitalize(link), index) - : translateNumericPart(link, index); + ? transformPathSegment(unslugifyAndCapitalize(link), index) + : transformPathSegment(link, index); - // 🚨 Skip rendering if content is null (segment should be omitted) or if content is facilities - if (!content || content === "Facilities") { + // 🚨 Skip rendering if content is null (segment should be omitted) + if (!content) { return null; } diff --git a/bciers/libs/testConfig/src/helpers/expectComboBox.ts b/bciers/libs/testConfig/src/helpers/expectComboBox.ts new file mode 100644 index 0000000000..f2470354bd --- /dev/null +++ b/bciers/libs/testConfig/src/helpers/expectComboBox.ts @@ -0,0 +1,13 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { screen } from "@testing-library/react"; + +export const expectComboBox = (label: RegExp, expectedValue?: string) => { + expect(screen.getByLabelText(label)).toBeVisible(); + expect(screen.getByRole("combobox", { name: label })).toBeVisible(); + + if (expectedValue) { + expect(screen.getByLabelText(label)).toHaveValue(expectedValue); + } +}; + +export default expectComboBox; diff --git a/bciers/libs/utils/src/formatTimestamp.ts b/bciers/libs/utils/src/formatTimestamp.ts index 6185f14981..f39eb914aa 100644 --- a/bciers/libs/utils/src/formatTimestamp.ts +++ b/bciers/libs/utils/src/formatTimestamp.ts @@ -11,7 +11,6 @@ const formatTimestamp = (timestamp: string) => { const timeWithTimeZone = new Date(timestamp).toLocaleString("en-CA", { hour: "numeric", minute: "numeric", - second: "numeric", timeZoneName: "short", timeZone: "America/Vancouver", }); diff --git a/bciers/libs/utils/src/urls.ts b/bciers/libs/utils/src/urls.ts index 555922c449..0914ad62b0 100644 --- a/bciers/libs/utils/src/urls.ts +++ b/bciers/libs/utils/src/urls.ts @@ -18,3 +18,9 @@ export const naicsLink = export const ggerrLink = "https://www.bclaws.gov.bc.ca/civix/document/id/lc/statreg/249_2015"; + +export const newEntrantBeforeMarch31 = + "https://www2.gov.bc.ca/assets/download/F5375D72BE1C450AB52C2E3E6A618959"; + +export const newEntrantApril1OrLater = + "https://www2.gov.bc.ca/assets/download/751CDDAE4C9A411E974EEA9737CD42C6"; diff --git a/package.json b/package.json index 7abdab8f7f..233ab04fd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cas-registration", - "version": "1.18.0", + "version": "1.19.0", "main": "index.js", "repository": "https://github.com/bcgov/cas-registration.git", "author": "ggircs@gov.bc.ca",