diff --git a/bc_obps/bc_obps/settings.py b/bc_obps/bc_obps/settings.py index 043e30958f..b8fcca5974 100644 --- a/bc_obps/bc_obps/settings.py +++ b/bc_obps/bc_obps/settings.py @@ -47,6 +47,7 @@ os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") ) GS_FILE_OVERWRITE = False + GS_BLOB_CHUNK_SIZE = int(2.5 * 1024 * 1024) # 2.5MB # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") diff --git a/bc_obps/common/tests/endpoints/auth/constants.py b/bc_obps/common/tests/endpoints/auth/constants.py index 6f4bea0d6e..dab9fa7398 100644 --- a/bc_obps/common/tests/endpoints/auth/constants.py +++ b/bc_obps/common/tests/endpoints/auth/constants.py @@ -214,7 +214,7 @@ "kwargs": {"report_version_id": MOCK_INT}, }, { - "method": "put", + "method": "post", "endpoint_name": "register_edit_operation_information", "kwargs": {"operation_id": MOCK_UUID}, }, @@ -234,7 +234,7 @@ "kwargs": {"version_id": MOCK_INT}, }, { - "method": "put", + "method": "post", "endpoint_name": "update_operation", "kwargs": {"operation_id": MOCK_UUID}, }, @@ -258,7 +258,7 @@ "kwargs": {"operation_id": MOCK_UUID}, }, { - "method": "put", + "method": "post", "endpoint_name": "create_or_replace_new_entrant_application", "kwargs": {"operation_id": MOCK_UUID}, }, diff --git a/bc_obps/registration/api/_operations/_operation_id/_registration/new_entrant_application.py b/bc_obps/registration/api/_operations/_operation_id/_registration/new_entrant_application.py index 269d21e133..698f3a0fe4 100644 --- a/bc_obps/registration/api/_operations/_operation_id/_registration/new_entrant_application.py +++ b/bc_obps/registration/api/_operations/_operation_id/_registration/new_entrant_application.py @@ -1,13 +1,15 @@ from typing import Literal, Tuple from uuid import UUID from django.http import HttpRequest +from ninja import File, Form, UploadedFile from registration.constants import OPERATION_TAGS +from registration.schema.generic import Message from service.error_service.custom_codes_4xx import custom_codes_4xx -from registration.schema import ( +from registration.schema.operation import ( + OperationNewEntrantApplicationInWithDocuments, OperationUpdateOut, OperationNewEntrantApplicationIn, OperationNewEntrantApplicationOut, - Message, ) from service.operation_service import OperationService from common.permissions import authorize @@ -32,7 +34,7 @@ def get_operation_new_entrant_application(request: HttpRequest, operation_id: UU return 200, OperationService.get_if_authorized(get_current_user_guid(request), operation_id, ['id', 'operator_id']) -@router.put( +@router.post( "/operations/{uuid:operation_id}/registration/new-entrant-application", response={200: OperationUpdateOut, custom_codes_4xx: Message}, tags=OPERATION_TAGS, @@ -40,8 +42,15 @@ def get_operation_new_entrant_application(request: HttpRequest, operation_id: UU auth=authorize("approved_industry_user"), ) def create_or_replace_new_entrant_application( - request: HttpRequest, operation_id: UUID, payload: OperationNewEntrantApplicationIn + request: HttpRequest, + operation_id: UUID, + details: Form[OperationNewEntrantApplicationIn], + new_entrant_application: UploadedFile = File(None), ) -> Tuple[Literal[200], Operation]: + payload = OperationNewEntrantApplicationInWithDocuments( + date_of_first_shipment=details.date_of_first_shipment, + **({'new_entrant_application': new_entrant_application} if new_entrant_application else {}) + ) return 200, OperationService.create_or_replace_new_entrant_application( get_current_user_guid(request), operation_id, payload ) diff --git a/bc_obps/registration/api/_operations/_operation_id/_registration/operation.py b/bc_obps/registration/api/_operations/_operation_id/_registration/operation.py index e7fa7f763f..aaee5431e4 100644 --- a/bc_obps/registration/api/_operations/_operation_id/_registration/operation.py +++ b/bc_obps/registration/api/_operations/_operation_id/_registration/operation.py @@ -1,9 +1,16 @@ from typing import Literal, Tuple from uuid import UUID from django.http import HttpRequest -from registration.schema import OperationInformationIn, OperationUpdateOut, OperationRegistrationOut, Message +from registration.schema.generic import Message from service.operation_service import OperationService from registration.constants import OPERATION_TAGS +from ninja import File, Form, UploadedFile +from registration.schema.operation import ( + OperationRegistrationIn, + OperationRegistrationInWithDocuments, + OperationUpdateOut, + OperationRegistrationOut, +) from common.permissions import authorize from common.api.utils import get_current_user_guid from service.error_service.custom_codes_4xx import custom_codes_4xx @@ -26,9 +33,8 @@ def register_get_operation_information(request: HttpRequest, operation_id: UUID) ##### PUT ##### - - -@router.put( +# https://stackoverflow.com/questions/77083771/django-ninja-update-a-filefield +@router.post( "/operations/{uuid:operation_id}/registration/operation", response={200: OperationUpdateOut, custom_codes_4xx: Message}, tags=OPERATION_TAGS, @@ -37,6 +43,16 @@ def register_get_operation_information(request: HttpRequest, operation_id: UUID) auth=authorize('approved_industry_user'), ) def register_edit_operation_information( - request: HttpRequest, operation_id: UUID, payload: OperationInformationIn + request: HttpRequest, + operation_id: UUID, + details: Form[OperationRegistrationIn], + # documents are optional because if the user hasn't given us an updated document, we don't have to do anything + boundary_map: UploadedFile = File(None), + process_flow_diagram: UploadedFile = File(None), ) -> Tuple[Literal[200], Operation]: + payload = OperationRegistrationInWithDocuments( + **details.dict(by_alias=True), + **({'boundary_map': boundary_map} if boundary_map else {}), + **({'process_flow_diagram': process_flow_diagram} if process_flow_diagram else {}) + ) return 200, OperationService.register_operation_information(get_current_user_guid(request), operation_id, payload) diff --git a/bc_obps/registration/api/_operations/operation_id.py b/bc_obps/registration/api/_operations/operation_id.py index 51294f5d38..8f1e3173e4 100644 --- a/bc_obps/registration/api/_operations/operation_id.py +++ b/bc_obps/registration/api/_operations/operation_id.py @@ -1,6 +1,10 @@ from typing import Literal, Tuple from uuid import UUID -from registration.schema import OperationInformationInUpdate, OperationOutV2, OperationOutWithDocuments, Message +from registration.schema.operation import ( + OperationAdministrationIn, + OperationAdministrationInWithDocuments, + OperationAdministrationOut, +) from common.permissions import authorize from django.http import HttpRequest from registration.constants import OPERATION_TAGS @@ -9,6 +13,8 @@ from common.api.utils import get_current_user_guid from registration.api.router import router from registration.models import Operation +from registration.schema.generic import Message +from ninja import File, Form, UploadedFile ##### GET ##### @@ -16,7 +22,7 @@ @router.get( "/operations/{uuid:operation_id}", - response={200: OperationOutV2, custom_codes_4xx: Message}, + response={200: OperationAdministrationOut, custom_codes_4xx: Message}, tags=OPERATION_TAGS, description="""Retrieves the details of a specific operation by its ID. Unlike the v1 endpoint, this endpoint does not return the new entrant application field as it can be quite large and cause slow requests. If you need the new entrant application field, @@ -30,7 +36,7 @@ def get_operation(request: HttpRequest, operation_id: UUID) -> Tuple[Literal[200 @router.get( "/operations/{uuid:operation_id}/with-documents", - response={200: OperationOutWithDocuments, custom_codes_4xx: Message}, + response={200: OperationAdministrationOut, custom_codes_4xx: Message}, tags=OPERATION_TAGS, description="""Retrieves the details of a specific operation by its ID along with it's documents""", exclude_none=True, @@ -43,14 +49,27 @@ def get_operation_with_documents(request: HttpRequest, operation_id: UUID) -> Tu ##### PUT ###### -@router.put( +@router.post( "/operations/{uuid:operation_id}", - response={200: OperationOutV2, custom_codes_4xx: Message}, + response={200: OperationAdministrationOut, custom_codes_4xx: Message}, tags=OPERATION_TAGS, description="Updates the details of a specific operation by its ID.", auth=authorize("approved_industry_user"), ) def update_operation( - request: HttpRequest, operation_id: UUID, payload: OperationInformationInUpdate + request: HttpRequest, + operation_id: UUID, + details: Form[OperationAdministrationIn], + # documents are optional because if the user hasn't given us an updated document, we don't do anything + boundary_map: UploadedFile = File(None), + process_flow_diagram: UploadedFile = File(None), + new_entrant_application: UploadedFile = File(None), ) -> Tuple[Literal[200], Operation]: + + payload = OperationAdministrationInWithDocuments( + **details.dict(by_alias=True), + **({'boundary_map': boundary_map} if boundary_map else {}), + **({'process_flow_diagram': process_flow_diagram} if process_flow_diagram else {}), + **({'new_entrant_application': new_entrant_application} if new_entrant_application else {}) + ) return 200, OperationService.update_operation(get_current_user_guid(request), payload, operation_id) diff --git a/bc_obps/registration/api/operations.py b/bc_obps/registration/api/operations.py index 8d78b4e0f6..d8f6e28b22 100644 --- a/bc_obps/registration/api/operations.py +++ b/bc_obps/registration/api/operations.py @@ -4,18 +4,19 @@ from typing import Tuple from registration.schema import ( OperationCreateOut, - OperationInformationIn, Message, OperationTimelineFilterSchema, OperationTimelineListOut, ) +from registration.schema.operation import OperationRegistrationInWithDocuments +from registration.schema.operation import OperationRegistrationIn from service.operation_service import OperationService from common.permissions import authorize from django.http import HttpRequest from common.api.utils import get_current_user_guid from registration.api.router import router from service.error_service.custom_codes_4xx import custom_codes_4xx -from ninja import Query +from ninja import Query, File, Form, UploadedFile from django.db.models import QuerySet from ninja.pagination import paginate from registration.utils import CustomPagination @@ -75,6 +76,17 @@ def get_registration_purposes(request: HttpRequest) -> Tuple[Literal[200], List[ auth=authorize("approved_industry_user"), ) def register_create_operation_information( - request: HttpRequest, payload: OperationInformationIn + request: HttpRequest, + details: Form[OperationRegistrationIn], + boundary_map: UploadedFile = File(...), # File fields as separate params + process_flow_diagram: UploadedFile = File(...), ) -> Tuple[Literal[201], Operation]: - return 201, OperationService.register_operation_information(get_current_user_guid(request), None, payload) + payload = OperationRegistrationInWithDocuments( + **details.dict(by_alias=True), boundary_map=boundary_map, process_flow_diagram=process_flow_diagram + ) + + return 201, OperationService.register_operation_information( + get_current_user_guid(request), + None, + payload, + ) diff --git a/bc_obps/registration/schema/document.py b/bc_obps/registration/schema/document.py new file mode 100644 index 0000000000..2fb6274c54 --- /dev/null +++ b/bc_obps/registration/schema/document.py @@ -0,0 +1,7 @@ +from registration.models.document import Document + + +def resolve_document(document: Document | None) -> str | None: + if document is not None: + return str(document.file.url) + return None diff --git a/bc_obps/registration/schema/multiple_operator.py b/bc_obps/registration/schema/multiple_operator.py index 5402dc2c44..9c71a14cc0 100644 --- a/bc_obps/registration/schema/multiple_operator.py +++ b/bc_obps/registration/schema/multiple_operator.py @@ -4,7 +4,7 @@ from ninja import Field, ModelSchema from registration.constants import BC_CORPORATE_REGISTRY_REGEX from registration.models import MultipleOperator -from pydantic import field_validator +from pydantic import ConfigDict, field_validator class MultipleOperatorIn(ModelSchema): @@ -12,10 +12,11 @@ class MultipleOperatorIn(ModelSchema): Schema for the Multiple Operator part of the operation form """ + model_config = ConfigDict(arbitrary_types_allowed=True) id: Optional[int] = None legal_name: str = Field(..., alias="mo_legal_name") trade_name: str = Field(..., alias="mo_trade_name") - business_structure: str = Field(..., alias="mo_business_structure") + business_structure: str | BusinessStructure = Field(..., alias="mo_business_structure") cra_business_number: int = Field(..., alias="mo_cra_business_number") bc_corporate_registry_number: Optional[str] = Field( None, alias="mo_bc_corporate_registry_number", pattern=BC_CORPORATE_REGISTRY_REGEX @@ -33,7 +34,9 @@ def validate_cra_business_number(cls, value: int) -> Optional[int]: @field_validator("business_structure") @classmethod def validate_business_structure(cls, value: str) -> BusinessStructure: - return validate_business_structure(value) + if isinstance(value, str): + return validate_business_structure(value) + return value class Meta: model = MultipleOperator diff --git a/bc_obps/registration/schema/operation.py b/bc_obps/registration/schema/operation.py index bfd9cbb2e0..228fbdd1a6 100644 --- a/bc_obps/registration/schema/operation.py +++ b/bc_obps/registration/schema/operation.py @@ -5,41 +5,84 @@ from registration.models.contact import Contact from registration.schema import OperatorForOperationOut, MultipleOperatorIn, MultipleOperatorOut from ninja import Field, ModelSchema, Schema +from typing import Any +from ninja import UploadedFile, File from registration.models import MultipleOperator, Operation from registration.models.opted_in_operation_detail import OptedInOperationDetail -from pydantic import field_validator -from django.core.files.base import ContentFile -from registration.utils import data_url_to_file -from registration.utils import file_to_data_url from registration.models import Operator, User from ninja.types import DictStrAny +import json + +from registration.schema.document import resolve_document + #### Operation schemas +# Registration schemas +class OperationRegistrationIn(ModelSchema): + registration_purpose: Optional[Operation.Purposes] = None + regulated_products: Optional[List[int]] = None + activities: Optional[List[int]] = None + naics_code_id: Optional[int] = None + opt_in: Optional[bool] = False + secondary_naics_code_id: Optional[int] = None + tertiary_naics_code_id: Optional[int] = None + multiple_operators_array: Optional[List[MultipleOperatorIn]] = None + date_of_first_shipment: Optional[str] = None + + @staticmethod + def resolve_multiple_operators_array(obj: Operation) -> Any | None: + value = obj.get("multiple_operators_array", None) # type: ignore[attr-defined] + if not value or value == '[]': + return None + # If the multiple_operators_array comes directly from the frontend, it comes in as a string/json. If it comes from elsewhere (e.g., a backend service), it arrives as list + if isinstance(value[0], str): + return json.loads(value[0]) + else: + return value + + class Meta: + model = Operation + fields = ["name", 'type'] + + +class OperationRegistrationInWithDocuments(OperationRegistrationIn): + boundary_map: Optional[UploadedFile] = File(None) + process_flow_diagram: Optional[UploadedFile] = File(None) + new_entrant_application: Optional[UploadedFile] = File(None) + + +class EioOperationRegistrationIn(ModelSchema): + registration_purpose: Optional[Operation.Purposes] = None + + class Meta: + model = Operation + fields = ["name", 'type'] + class OperationRegistrationOut(ModelSchema): operation: UUID = Field(..., alias="id") naics_code_id: Optional[int] = Field(None, alias="naics_code.id") secondary_naics_code_id: Optional[int] = Field(None, alias="secondary_naics_code.id") tertiary_naics_code_id: Optional[int] = Field(None, alias="tertiary_naics_code.id") - multiple_operators_array: Optional[List[MultipleOperatorOut]] = [] + multiple_operators_array: Optional[List[MultipleOperatorOut]] = None operation_has_multiple_operators: Optional[bool] = False boundary_map: Optional[str] = None process_flow_diagram: Optional[str] = None @staticmethod def resolve_boundary_map(obj: Operation) -> Optional[str]: - boundary_map = obj.get_boundary_map() - if boundary_map: - return file_to_data_url(boundary_map) - return None + return resolve_document(obj.get_boundary_map()) @staticmethod def resolve_process_flow_diagram(obj: Operation) -> Optional[str]: - process_flow_diagram = obj.get_process_flow_diagram() - if process_flow_diagram: - return file_to_data_url(process_flow_diagram) - return None + return resolve_document(obj.get_process_flow_diagram()) + + @staticmethod + def resolve_operation_has_multiple_operators(obj: Operation) -> bool: + if obj.multiple_operators.exists(): + return True + return False @staticmethod def resolve_multiple_operators_array(obj: Operation) -> Optional[List[MultipleOperator]]: @@ -54,16 +97,25 @@ class Meta: fields = ["name", 'type', 'registration_purpose', 'regulated_products', 'activities'] -class OperationRepresentativeIn(ModelSchema): - existing_contact_id: Optional[int] = None - street_address: str - municipality: str - province: str - postal_code: str +class OperationCreateOut(ModelSchema): + bcghg_id: Optional[str] = Field(None, alias="bcghg_id.id") + + class Config: + model = Operation + model_fields = ['id', 'name', 'type', 'naics_code', 'opt_in', 'regulated_products'] + populate_by_name = True + + +class OperationUpdateOut(ModelSchema): + class Meta: + model = Operation + fields = ['id', 'name'] + +class OperationRepresentativeOut(ModelSchema): class Meta: model = Contact - fields = ['first_name', 'last_name', 'email', 'phone_number', 'position_title'] + fields = ['id'] class OperationRepresentativeRemove(ModelSchema): @@ -74,45 +126,16 @@ class Meta: fields = ['id'] -class OperationInformationIn(ModelSchema): - name: str - registration_purpose: Optional[Operation.Purposes] = None - regulated_products: Optional[List[int]] = None - activities: Optional[List[int]] = None - boundary_map: Optional[str] = None - process_flow_diagram: Optional[str] = None - naics_code_id: Optional[int] = None - opt_in: Optional[bool] = False - secondary_naics_code_id: Optional[int] = None - tertiary_naics_code_id: Optional[int] = None - multiple_operators_array: Optional[List[MultipleOperatorIn]] = None - date_of_first_shipment: Optional[str] = Field(None, alias="date_of_first_shipment") - new_entrant_application: Optional[str] = None - - @field_validator("boundary_map") - @classmethod - def validate_boundary_map(cls, value: str) -> ContentFile: - return data_url_to_file(value) - - @field_validator("process_flow_diagram") - @classmethod - def validate_process_flow_diagram(cls, value: str) -> ContentFile: - return data_url_to_file(value) - - @field_validator("new_entrant_application") - @classmethod - def validate_new_entrant_application(cls, value: Optional[str]) -> Optional[ContentFile]: - if value: - return data_url_to_file(value) - return None +class OperationRepresentativeIn(ModelSchema): + existing_contact_id: Optional[int] = None + street_address: str + municipality: str + province: str + postal_code: str class Meta: - model = Operation - fields = ["name", 'type'] - - -class OperationInformationInUpdate(OperationInformationIn): - operation_representatives: List[int] + model = Contact + fields = ['first_name', 'last_name', 'email', 'phone_number', 'position_title'] class OptedInOperationDetailOut(ModelSchema): @@ -141,22 +164,84 @@ class OptedInOperationDetailIn(OptedInOperationDetailOut): meets_notification_to_director_on_criteria_change: bool = Field(...) -class OperationOutV2(ModelSchema): +class OperationRegistrationSubmissionIn(Schema): + acknowledgement_of_review: bool + acknowledgement_of_records: bool + acknowledgement_of_information: bool + + +class OperationNewEntrantApplicationIn(Schema): + # not using model schema because I wanted to enforce the date_of_first_shipment to be not null and to be a specific value + date_of_first_shipment: Literal[ + Operation.DateOfFirstShipmentChoices.ON_OR_AFTER_APRIL_1_2024, + Operation.DateOfFirstShipmentChoices.ON_OR_BEFORE_MARCH_31_2024, + ] = Field(...) + + +class OperationNewEntrantApplicationInWithDocuments(OperationNewEntrantApplicationIn): + new_entrant_application: UploadedFile = File(None) + + +class OperationNewEntrantApplicationOut(ModelSchema): + new_entrant_application: Optional[str] = None + + @staticmethod + def resolve_new_entrant_application(obj: Operation) -> Optional[str]: + return resolve_document(obj.get_new_entrant_application()) + + class Meta: + model = Operation + fields = ['date_of_first_shipment'] + + +class OperationNewEntrantApplicationRemove(ModelSchema): + id: int + + class Meta: + model = Operation + fields = ['id'] + + +# Administration schemas + + +class OperationAdministrationIn(OperationRegistrationIn): + operation_representatives: List[int] + + +class OperationAdministrationInWithDocuments(OperationRegistrationInWithDocuments): + operation_representatives: List[int] + + +class OperationAdministrationOut(ModelSchema): + registration_purpose: Optional[str] = None + operation: Optional[UUID] = Field(None, alias="id") naics_code_id: Optional[int] = Field(None, alias="naics_code.id") secondary_naics_code_id: Optional[int] = Field(None, alias="secondary_naics_code.id") tertiary_naics_code_id: Optional[int] = Field(None, alias="tertiary_naics_code.id") bc_obps_regulated_operation: Optional[str] = Field(None, alias="bc_obps_regulated_operation.id") operator: Optional[OperatorForOperationOut] = None - boundary_map: Optional[str] = None - process_flow_diagram: Optional[str] = None - registration_purpose: Optional[str] = None - multiple_operators_array: Optional[List[MultipleOperatorOut]] = [] + multiple_operators_array: Optional[List[MultipleOperatorOut]] = None operation_has_multiple_operators: Optional[bool] = False opted_in_operation: Optional[OptedInOperationDetailOut] = None 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]] = [] + boundary_map: Optional[str] = None + process_flow_diagram: Optional[str] = None + new_entrant_application: Optional[str] = None + + @staticmethod + def resolve_boundary_map(obj: Operation) -> Optional[str]: + return resolve_document(obj.get_boundary_map()) + + @staticmethod + def resolve_process_flow_diagram(obj: Operation) -> Optional[str]: + return resolve_document(obj.get_process_flow_diagram()) + + @staticmethod + def resolve_new_entrant_application(obj: Operation) -> Optional[str]: + return resolve_document(obj.get_new_entrant_application()) @staticmethod def resolve_operation_representatives(obj: Operation) -> List[int]: @@ -197,42 +282,7 @@ class Meta: from_attributes = True -class OperationOutWithDocuments(OperationOutV2): - @staticmethod - def resolve_boundary_map(obj: Operation) -> Optional[str]: - boundary_map = obj.get_boundary_map() - if boundary_map: - return file_to_data_url(boundary_map) - return None - - @staticmethod - def resolve_process_flow_diagram(obj: Operation) -> Optional[str]: - process_flow_diagram = obj.get_process_flow_diagram() - if process_flow_diagram: - return file_to_data_url(process_flow_diagram) - return None - - @staticmethod - def resolve_new_entrant_application(obj: Operation) -> Optional[str]: - new_entrant_application = obj.get_new_entrant_application() - if new_entrant_application: - return file_to_data_url(new_entrant_application) - return None - - -class OperationCreateOut(ModelSchema): - bcghg_id: Optional[str] = Field(None, alias="bcghg_id.id") - - class Config: - model = Operation - model_fields = ['id', 'name', 'type', 'naics_code', 'opt_in', 'regulated_products'] - populate_by_name = True - - -class OperationUpdateOut(ModelSchema): - class Meta: - model = Operation - fields = ['id', 'name'] +# Other class OperationCurrentOut(ModelSchema): @@ -241,47 +291,6 @@ class Meta: fields = ["id", "name"] -class OperationRegistrationSubmissionIn(Schema): - acknowledgement_of_review: bool - acknowledgement_of_records: bool - acknowledgement_of_information: bool - - -class OperationNewEntrantApplicationIn(Schema): - new_entrant_application: str - # not using model schema because I wanted to enforce the date_of_first_shipment to be not null and to be a specific value - date_of_first_shipment: Literal[ - Operation.DateOfFirstShipmentChoices.ON_OR_AFTER_APRIL_1_2024, - Operation.DateOfFirstShipmentChoices.ON_OR_BEFORE_MARCH_31_2024, - ] = Field(...) - - @field_validator("new_entrant_application") - @classmethod - def validate_new_entrant_application(cls, value: str) -> ContentFile: - return data_url_to_file(value) - - -class OperationNewEntrantApplicationOut(ModelSchema): - new_entrant_application: Optional[str] = None - - @staticmethod - def resolve_new_entrant_application(obj: Operation) -> Optional[str]: - new_entrant_application = obj.get_new_entrant_application() - if new_entrant_application: - return file_to_data_url(new_entrant_application) - return None - - class Meta: - model = Operation - fields = ['date_of_first_shipment'] - - -class OperationRepresentativeOut(ModelSchema): - class Meta: - model = Contact - fields = ['id'] - - class OperationBoroIdOut(ModelSchema): class Meta: model = BcObpsRegulatedOperation diff --git a/bc_obps/registration/tests/constants.py b/bc_obps/registration/tests/constants.py index eca0ab50ea..6edd6ed5d9 100644 --- a/bc_obps/registration/tests/constants.py +++ b/bc_obps/registration/tests/constants.py @@ -1,3 +1,6 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.base import ContentFile + OPERATOR_FIXTURE = ("mock/operator.json",) USER_FIXTURE = ("mock/user.json",) ADDRESS_FIXTURE = ("mock/address.json",) @@ -12,11 +15,11 @@ CLOSURE_EVENT_FIXTURE = ("mock/closure_event.json",) TRANSFER_EVENT_FIXTURE = ("mock/transfer_event.json",) +MOCK_FILE = (ContentFile(bytes("testtesttesttest", encoding='utf-8'), "testfile.pdf"),) -MOCK_DATA_URL = "data:application/pdf;name=mock.pdf;base64,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CmVuZG9iagoKOSAwIG9iago8PC9UeXBlL0ZvbnQvU3VidHlwZS9UcnVlVHlwZS9CYXNlRm9udC9CQUFBQUErQXJpYWwtQm9sZE1UCi9GaXJzdENoYXIgMAovTGFzdENoYXIgMTEKL1dpZHRoc1s3NTAgNzIyIDYxMCA4ODkgNTU2IDI3NyA2NjYgNjEwIDMzMyAyNzcgMjc3IDU1NiBdCi9Gb250RGVzY3JpcHRvciA3IDAgUgovVG9Vbmljb2RlIDggMCBSCj4+CmVuZG9iagoKMTAgMCBvYmoKPDwKL0YxIDkgMCBSCj4+CmVuZG9iagoKMTEgMCBvYmoKPDwvRm9udCAxMCAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XT4+CmVuZG9iagoKMSAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDQgMCBSL1Jlc291cmNlcyAxMSAwIFIvTWVkaWFCb3hbMCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgoxMiAwIG9iago8PC9Db3VudCAxL0ZpcnN0IDEzIDAgUi9MYXN0IDEzIDAgUgo+PgplbmRvYmoKCjEzIDAgb2JqCjw8L1RpdGxlPEZFRkYwMDQ0MDA3NTAwNkQwMDZEMDA3OTAwMjAwMDUwMDA0NDAwNDYwMDIwMDA2NjAwNjkwMDZDMDA2NT4KL0Rlc3RbMSAwIFIvWFlaIDU2LjcgNzczLjMgMF0vUGFyZW50IDEyIDAgUj4+CmVuZG9iagoKNCAwIG9iago8PC9UeXBlL1BhZ2VzCi9SZXNvdXJjZXMgMTEgMCBSCi9NZWRpYUJveFsgMCAwIDU5NSA4NDIgXQovS2lkc1sgMSAwIFIgXQovQ291bnQgMT4+CmVuZG9iagoKMTQgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PdXRsaW5lcyAxMiAwIFIKPj4KZW5kb2JqCgoxNSAwIG9iago8PC9BdXRob3I8RkVGRjAwNDUwMDc2MDA2MTAwNkUwMDY3MDA2NTAwNkMwMDZGMDA3MzAwMjAwMDU2MDA2QzAwNjEwMDYzMDA2ODAwNkYwMDY3MDA2OTAwNjEwMDZFMDA2RTAwNjkwMDczPgovQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAwNEYwMDcwMDA2NTAwNkUwMDRGMDA2NjAwNjYwMDY5MDA2MzAwNjUwMDJFMDA2RjAwNzIwMDY3MDAyMDAwMzIwMDJFMDAzMT4KL0NyZWF0aW9uRGF0ZShEOjIwMDcwMjIzMTc1NjM3KzAyJzAwJyk+PgplbmRvYmoKCnhyZWYKMCAxNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMTE5OTcgMDAwMDAgbiAKMDAwMDAwMDAxOSAwMDAwMCBuIAowMDAwMDAwMjI0IDAwMDAwIG4gCjAwMDAwMTIzMzAgMDAwMDAgbiAKMDAwMDAwMDI0NCAwMDAwMCBuIAowMDAwMDExMTU0IDAwMDAwIG4gCjAwMDAwMTExNzYgMDAwMDAgbiAKMDAwMDAxMTM2OCAwMDAwMCBuIAowMDAwMDExNzA5IDAwMDAwIG4gCjAwMDAwMTE5MTAgMDAwMDAgbiAKMDAwMDAxMTk0MyAwMDAwMCBuIAowMDAwMDEyMTQwIDAwMDAwIG4gCjAwMDAwMTIxOTYgMDAwMDAgbiAKMDAwMDAxMjQyOSAwMDAwMCBuIAowMDAwMDEyNDk0IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSAxNi9Sb290IDE0IDAgUgovSW5mbyAxNSAwIFIKL0lEIFsgPEY3RDc3QjNEMjJCOUY5MjgyOUQ0OUZGNUQ3OEI4RjI4Pgo8RjdENzdCM0QyMkI5RjkyODI5RDQ5RkY1RDc4QjhGMjg+IF0KPj4Kc3RhcnR4cmVmCjEyNzg3CiUlRU9GCg==" - -MOCK_DATA_URL_2 = 'data:application/pdf;name=test.pdf;base64,JVBERi0xLjINJeLjz9MNCjMgMCBvYmoNPDwgDS9MaW5lYXJpemVkIDEgDS9PIDUgDS9IIFsgNzYwIDE1NyBdIA0vTCAzOTA4IA0vRSAzNjU4IA0vTiAxIA0vVCAzNzMxIA0+PiANZW5kb2JqDSAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB4cmVmDTMgMTUgDTAwMDAwMDAwMTYgMDAwMDAgbg0KMDAwMDAwMDY0NCAwMDAwMCBuDQowMDAwMDAwOTE3IDAwMDAwIG4NCjAwMDAwMDEwNjggMDAwMDAgbg0KMDAwMDAwMTIyNCAwMDAwMCBuDQowMDAwMDAxNDEwIDAwMDAwIG4NCjAwMDAwMDE1ODkgMDAwMDAgbg0KMDAwMDAwMTc2OCAwMDAwMCBuDQowMDAwMDAyMTk3IDAwMDAwIG4NCjAwMDAwMDIzODMgMDAwMDAgbg0KMDAwMDAwMjc2OSAwMDAwMCBuDQowMDAwMDAzMTcyIDAwMDAwIG4NCjAwMDAwMDMzNTEgMDAwMDAgbg0KMDAwMDAwMDc2MCAwMDAwMCBuDQowMDAwMDAwODk3IDAwMDAwIG4NCnRyYWlsZXINPDwNL1NpemUgMTgNL0luZm8gMSAwIFIgDS9Sb290IDQgMCBSIA0vUHJldiAzNzIyIA0vSURbPGQ3MGY0NmM1YmE0ZmU4YmQ0OWE5ZGQwNTk5YjBiMTUxPjxkNzBmNDZjNWJhNGZlOGJkNDlhOWRkMDU5OWIwYjE1MT5dDT4+DXN0YXJ0eHJlZg0wDSUlRU9GDSAgICAgIA00IDAgb2JqDTw8IA0vVHlwZSAvQ2F0YWxvZyANL1BhZ2VzIDIgMCBSIA0vT3BlbkFjdGlvbiBbIDUgMCBSIC9YWVogbnVsbCBudWxsIG51bGwgXSANL1BhZ2VNb2RlIC9Vc2VOb25lIA0+PiANZW5kb2JqDTE2IDAgb2JqDTw8IC9TIDM2IC9GaWx0ZXIgL0ZsYXRlRGVjb2RlIC9MZW5ndGggMTcgMCBSID4+IA1zdHJlYW0NCkiJYmBg4GVgYPrBAAScFxiwAQ4oLQDE3FDMwODHwKkyubctWLfmpsmimQ5AEYAAAwC3vwe0DWVuZHN0cmVhbQ1lbmRvYmoNMTcgMCBvYmoNNTMgDWVuZG9iag01IDAgb2JqDTw8IA0vVHlwZSAvUGFnZSANL1BhcmVudCAyIDAgUiANL1Jlc291cmNlcyA2IDAgUiANL0NvbnRlbnRzIDEwIDAgUiANL01lZGlhQm94IFsgMCAwIDYxMiA3OTIgXSANL0Nyb3BCb3ggWyAwIDAgNjEyIDc5MiBdIA0vUm90YXRlIDAgDT4+IA1lbmRvYmoNNiAwIG9iag08PCANL1Byb2NTZXQgWyAvUERGIC9UZXh0IF0gDS9Gb250IDw8IC9UVDIgOCAwIFIgL1RUNCAxMiAwIFIgL1RUNiAxMyAwIFIgPj4gDS9FeHRHU3RhdGUgPDwgL0dTMSAxNSAwIFIgPj4gDS9Db2xvclNwYWNlIDw8IC9DczUgOSAwIFIgPj4gDT4+IA1lbmRvYmoNNyAwIG9iag08PCANL1R5cGUgL0ZvbnREZXNjcmlwdG9yIA0vQXNjZW50IDg5MSANL0NhcEhlaWdodCAwIA0vRGVzY2VudCAtMjE2IA0vRmxhZ3MgMzQgDS9Gb250QkJveCBbIC01NjggLTMwNyAyMDI4IDEwMDcgXSANL0ZvbnROYW1lIC9UaW1lc05ld1JvbWFuIA0vSXRhbGljQW5nbGUgMCANL1N0ZW1WIDAgDT4+IA1lbmRvYmoNOCAwIG9iag08PCANL1R5cGUgL0ZvbnQgDS9TdWJ0eXBlIC9UcnVlVHlwZSANL0ZpcnN0Q2hhciAzMiANL0xhc3RDaGFyIDMyIA0vV2lkdGhzIFsgMjUwIF0gDS9FbmNvZGluZyAvV2luQW5zaUVuY29kaW5nIA0vQmFzZUZvbnQgL1RpbWVzTmV3Um9tYW4gDS9Gb250RGVzY3JpcHRvciA3IDAgUiANPj4gDWVuZG9iag05IDAgb2JqDVsgDS9DYWxSR0IgPDwgL1doaXRlUG9pbnQgWyAwLjk1MDUgMSAxLjA4OSBdIC9HYW1tYSBbIDIuMjIyMjEgMi4yMjIyMSAyLjIyMjIxIF0gDS9NYXRyaXggWyAwLjQxMjQgMC4yMTI2IDAuMDE5MyAwLjM1NzYgMC43MTUxOSAwLjExOTIgMC4xODA1IDAuMDcyMiAwLjk1MDUgXSA+PiANDV0NZW5kb2JqDTEwIDAgb2JqDTw8IC9MZW5ndGggMzU1IC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4+IA1zdHJlYW0NCkiJdJDBTsMwEETv/oo92ohuvXHsJEeggOCEwDfEIU1SCqIJIimIv2dthyJVQpGc0Xo88+xzL5beZ0DgN4IIq6oCzd8sK43amAyK3GKmTQV+J5YXo4VmjDYNYyOW1w8Ez6PQ4JuwfAkJyr+yXNgSSwt+NU+4Kp+rcg4uy9Q1a6MdarLcpgvUeUGh7RBFSLk1f1n+5FgsHJaZttFqA+tKLJhfZ3kEY+VcoHuUfvui2O3kCL9COSwk1Ok3deMEd6srUCVa2Q7Nftf1Ewar5a4nfxuu4v59NcLMGAKXlcjMLtwj1BsTQCITUSK52cC3IoNGDnto6l5VmEv4YAwjO8VWJ+s2DSeGttw/qmA/PZyLu3vY1p9p0MGZIs2iHdZxjwdNSkzedT0pJiW+CWl5H0O7uu2SB1JLn8rHlMkH2F+/xa20Rjp+nAQ39Ec8c1gz7KJ4T3H7uXnuwvSWl178CDAA/bGPlAplbmRzdHJlYW0NZW5kb2JqDTExIDAgb2JqDTw8IA0vVHlwZSAvRm9udERlc2NyaXB0b3IgDS9Bc2NlbnQgOTA1IA0vQ2FwSGVpZ2h0IDAgDS9EZXNjZW50IC0yMTEgDS9GbGFncyAzMiANL0ZvbnRCQm94IFsgLTYyOCAtMzc2IDIwMzQgMTA0OCBdIA0vRm9udE5hbWUgL0FyaWFsLEJvbGQgDS9JdGFsaWNBbmdsZSAwIA0vU3RlbVYgMTMzIA0+PiANZW5kb2JqDTEyIDAgb2JqDTw8IA0vVHlwZSAvRm9udCANL1N1YnR5cGUgL1RydWVUeXBlIA0vRmlyc3RDaGFyIDMyIA0vTGFzdENoYXIgMTE3IA0vV2lkdGhzIFsgMjc4IDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMjc4IDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgDTAgMCAwIDAgMCA3MjIgMCA2MTEgMCAwIDAgMCAwIDAgMCAwIDAgNjY3IDAgMCAwIDYxMSAwIDAgMCAwIDAgMCANMCAwIDAgMCAwIDAgNTU2IDAgNTU2IDYxMSA1NTYgMCAwIDYxMSAyNzggMCAwIDAgODg5IDYxMSA2MTEgMCAwIA0wIDU1NiAzMzMgNjExIF0gDS9FbmNvZGluZyAvV2luQW5zaUVuY29kaW5nIA0vQmFzZUZvbnQgL0FyaWFsLEJvbGQgDS9Gb250RGVzY3JpcHRvciAxMSAwIFIgDT4+IA1lbmRvYmoNMTMgMCBvYmoNPDwgDS9UeXBlIC9Gb250IA0vU3VidHlwZSAvVHJ1ZVR5cGUgDS9GaXJzdENoYXIgMzIgDS9MYXN0Q2hhciAxMjEgDS9XaWR0aHMgWyAyNzggMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDI3OCAwIDI3OCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCANMCAwIDAgNjY3IDAgMCAwIDAgMCAwIDAgMjc4IDAgMCAwIDAgMCAwIDAgMCA3MjIgMCAwIDAgMCAwIDAgMCAwIA0wIDAgMCAwIDAgMCA1NTYgNTU2IDUwMCA1NTYgNTU2IDI3OCAwIDU1NiAyMjIgMCAwIDIyMiA4MzMgNTU2IDU1NiANNTU2IDAgMzMzIDUwMCAyNzggNTU2IDUwMCAwIDAgNTAwIF0gDS9FbmNvZGluZyAvV2luQW5zaUVuY29kaW5nIA0vQmFzZUZvbnQgL0FyaWFsIA0vRm9udERlc2NyaXB0b3IgMTQgMCBSIA0+PiANZW5kb2JqDTE0IDAgb2JqDTw8IA0vVHlwZSAvRm9udERlc2NyaXB0b3IgDS9Bc2NlbnQgOTA1IA0vQ2FwSGVpZ2h0IDAgDS9EZXNjZW50IC0yMTEgDS9GbGFncyAzMiANL0ZvbnRCQm94IFsgLTY2NSAtMzI1IDIwMjggMTAzNyBdIA0vRm9udE5hbWUgL0FyaWFsIA0vSXRhbGljQW5nbGUgMCANL1N0ZW1WIDAgDT4+IA1lbmRvYmoNMTUgMCBvYmoNPDwgDS9UeXBlIC9FeHRHU3RhdGUgDS9TQSBmYWxzZSANL1NNIDAuMDIgDS9UUiAvSWRlbnRpdHkgDT4+IA1lbmRvYmoNMSAwIG9iag08PCANL1Byb2R1Y2VyIChBY3JvYmF0IERpc3RpbGxlciA0LjA1IGZvciBXaW5kb3dzKQ0vQ3JlYXRvciAoTWljcm9zb2Z0IFdvcmQgOS4wKQ0vTW9kRGF0ZSAoRDoyMDAxMDgyOTA5NTUwMS0wNycwMCcpDS9BdXRob3IgKEdlbmUgQnJ1bWJsYXkpDS9UaXRsZSAoVGhpcyBpcyBhIHRlc3QgUERGIGRvY3VtZW50KQ0vQ3JlYXRpb25EYXRlIChEOjIwMDEwODI5MDk1NDU3KQ0+PiANZW5kb2JqDTIgMCBvYmoNPDwgDS9UeXBlIC9QYWdlcyANL0tpZHMgWyA1IDAgUiBdIA0vQ291bnQgMSANPj4gDWVuZG9iag14cmVmDTAgMyANMDAwMDAwMDAwMCA2NTUzNSBmDQowMDAwMDAzNDI5IDAwMDAwIG4NCjAwMDAwMDM2NTggMDAwMDAgbg0KdHJhaWxlcg08PA0vU2l6ZSAzDS9JRFs8ZDcwZjQ2YzViYTRmZThiZDQ5YTlkZDA1OTliMGIxNTE+PGQ3MGY0NmM1YmE0ZmU4YmQ0OWE5ZGQwNTk5YjBiMTUxPl0NPj4Nc3RhcnR4cmVmDTE3Mw0lJUVPRg0=' +MOCK_UPLOADED_FILE = SimpleUploadedFile("test1.txt", b"Hello, world!", content_type="text/plain") +MOCK_UPLOADED_FILE_2 = SimpleUploadedFile("test2.txt", b"Hello, world!", content_type="text/plain") TIMESTAMP_COMMON_FIELDS = [ ("created_at", "created at", None, None), diff --git a/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_new_entrant_application.py b/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_new_entrant_application.py index c9a5159ad8..93bfdc14f7 100644 --- a/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_new_entrant_application.py +++ b/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_new_entrant_application.py @@ -1,11 +1,11 @@ import pytest from registration.models.operation import Operation +from registration.tests.constants import MOCK_UPLOADED_FILE from registration.tests.utils.bakers import operator_baker from registration.tests.utils.helpers import CommonTestSetup, TestUtils -from registration.utils import custom_reverse_lazy, data_url_to_file +from registration.utils import custom_reverse_lazy from model_bakery import baker -from registration.tests.constants import MOCK_DATA_URL from service.document_service import DocumentService @@ -30,9 +30,8 @@ def test_get_register_operation_new_entrant_application_endpoint_success(self): operator=approved_user_operator.operator, date_of_first_shipment=Operation.DateOfFirstShipmentChoices.ON_OR_AFTER_APRIL_1_2024, ) - file = data_url_to_file(MOCK_DATA_URL) - new_entrant_application_doc, created = DocumentService.create_or_replace_operation_document( - approved_user_operator.user_id, operation.id, file, "new_entrant_application" + new_entrant_application_doc = DocumentService.create_or_replace_operation_document( + operation.id, MOCK_UPLOADED_FILE, "new_entrant_application" ) operation.documents.add(new_entrant_application_doc) response = TestUtils.mock_get_with_auth_role( @@ -47,8 +46,8 @@ def test_get_register_operation_new_entrant_application_endpoint_success(self): # Additional Assertions assert response_json['date_of_first_shipment'] == "On or after April 1, 2024" # not testing `new_entrant_application` because resolver for a document doesn't work in CI - # MOCK_DATA_URL's filename is mock.pdf. When adding files to django, the name is appended, so we just check that 'mock' in the name - assert operation.documents.first().file.name.find("mock") != -1 + # MOCK_UPLOADED_FILE's filename is test1.pdf. When adding files to django, the name is appended, so we just check that 'test1' in the name + assert operation.documents.first().file.name.find("test1") != -1 class TestPutOperationNewEntrantApplicationSubmissionEndpoint(CommonTestSetup): @@ -70,7 +69,7 @@ def test_put_register_operation_new_entrant_application_submission_endpoint_user self.content_type, { "operation_id": operation.id, - "new_entrant_application": MOCK_DATA_URL, + "new_entrant_application": MOCK_UPLOADED_FILE, }, custom_reverse_lazy("create_or_replace_new_entrant_application", kwargs={'operation_id': operation.id}), ) @@ -86,7 +85,7 @@ def test_put_register_operation_new_entrant_application_submission_endpoint(self # Act payload_with_no_date_of_first_shipment = { - "new_entrant_application": MOCK_DATA_URL, + "new_entrant_application": MOCK_UPLOADED_FILE, } response_1 = TestUtils.mock_put_with_auth_role( self, @@ -103,7 +102,7 @@ def test_put_register_operation_new_entrant_application_submission_endpoint(self # we have specific date_of_first_shipment choices, so we can't just send a random date payload_with_random_date_of_first_shipment = { "date_of_first_shipment": "random", - "new_entrant_application": MOCK_DATA_URL, + "new_entrant_application": MOCK_UPLOADED_FILE, } response_2 = TestUtils.mock_put_with_auth_role( @@ -123,7 +122,7 @@ def test_put_register_operation_new_entrant_application_submission_endpoint(self # Act valid_payload = { "date_of_first_shipment": "On or after April 1, 2024", - "new_entrant_application": MOCK_DATA_URL, + "new_entrant_application": MOCK_UPLOADED_FILE, } response_3 = TestUtils.mock_put_with_auth_role( self, diff --git a/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_operation.py b/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_operation.py index ba98ba2fc3..f5839dbe74 100644 --- a/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_operation.py +++ b/bc_obps/registration/tests/endpoints/_operations/_operation_id/_registration/test_operation.py @@ -1,23 +1,22 @@ -from registration.tests.constants import MOCK_DATA_URL from registration.tests.utils.helpers import CommonTestSetup, TestUtils from registration.models import Operation from registration.utils import custom_reverse_lazy from model_bakery import baker -import json +from django.core.files.base import ContentFile -class TestPutOperationRegistrationInformationEndpoint(CommonTestSetup): +class TestPostOperationRegistrationInformationEndpoint(CommonTestSetup): + mock_payload = { - "registration_purpose": "Reporting Operation", - "regulated_products": [1], - "name": "op name", - "type": Operation.Types.SFO, - "naics_code_id": 1, - "secondary_naics_code_id": 2, - "tertiary_naics_code_id": 3, - "activities": [1], - "boundary_map": MOCK_DATA_URL, - "process_flow_diagram": MOCK_DATA_URL, + 'registration_purpose': ['Reporting Operation'], + 'operation': ['556ceeb0-7e24-4d89-b639-61f625f82084'], + 'activities': ['31'], + 'name': ['Barbie'], + 'type': [Operation.Types.SFO], + 'naics_code_id': ['20'], + 'operation_has_multiple_operators': ['false'], + 'process_flow_diagram': ContentFile(bytes("testtesttesttest", encoding='utf-8'), "testfile.pdf"), + 'boundary_map': ContentFile(bytes("testtesttesttest", encoding='utf-8'), "testfile.pdf"), } def test_users_cannot_update_other_users_operations(self): @@ -26,24 +25,21 @@ def test_users_cannot_update_other_users_operations(self): operation = baker.make_recipe( 'registration.tests.utils.operation', ) - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - json.dumps(self.mock_payload), + response = TestUtils.client.post( custom_reverse_lazy("register_edit_operation_information", kwargs={'operation_id': operation.id}), + data=self.mock_payload, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) + assert response.status_code == 401 def test_register_edit_operation_information_endpoint_success(self): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator', user=self.user) operation = baker.make_recipe('registration.tests.utils.operation', operator=approved_user_operator.operator) - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - json.dumps(self.mock_payload), + response = TestUtils.client.post( custom_reverse_lazy("register_edit_operation_information", kwargs={'operation_id': operation.id}), + data=self.mock_payload, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) response_json = response.json() @@ -55,14 +51,10 @@ def test_register_edit_operation_information_endpoint_success(self): def test_register_edit_operation_information_endpoint_fail(self): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator', user=self.user) operation = baker.make_recipe('registration.tests.utils.operation', operator=approved_user_operator.operator) - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - { - 'bad data': 'im bad', - }, + response = TestUtils.client.post( custom_reverse_lazy("register_edit_operation_information", kwargs={'operation_id': operation.id}), + data={'bad data': 'im bad'}, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) # Assert diff --git a/bc_obps/registration/tests/endpoints/_operations/test_operation_id.py b/bc_obps/registration/tests/endpoints/_operations/test_operation_id.py index f871bd4f34..071228bff4 100644 --- a/bc_obps/registration/tests/endpoints/_operations/test_operation_id.py +++ b/bc_obps/registration/tests/endpoints/_operations/test_operation_id.py @@ -3,27 +3,26 @@ UserOperator, ) from registration.models.operation import Operation -from registration.tests.constants import MOCK_DATA_URL +from registration.tests.constants import MOCK_FILE from registration.tests.utils.helpers import CommonTestSetup, TestUtils from registration.tests.utils.bakers import operation_baker, operator_baker from registration.utils import custom_reverse_lazy -import json class TestOperationIdEndpoint(CommonTestSetup): endpoint = CommonTestSetup.base_endpoint + "operations" test_payload = { - "registration_purpose": "Reporting Operation", + "registration_purpose": ["Reporting Operation"], "regulated_products": [1], - "name": "op name", - "type": Operation.Types.SFO, - "naics_code_id": 1, - "secondary_naics_code_id": 2, - "tertiary_naics_code_id": 3, + "name": ["op name"], + "type": [Operation.Types.SFO], + "naics_code_id": [1], + "secondary_naics_code_id": [2], + "tertiary_naics_code_id": [3], "activities": [1], - "boundary_map": MOCK_DATA_URL, - "process_flow_diagram": MOCK_DATA_URL, + "boundary_map": MOCK_FILE, + "process_flow_diagram": MOCK_FILE, } def test_industry_users_can_only_get_their_own_operations(self): @@ -105,7 +104,7 @@ def test_operations_with_documents_endpoint_get_success(self): response_data = response.json() assert response_data.get("id") == str(operation.id) - def test_operations_endpoint_put_success(self): + def test_operations_endpoint_post_success(self): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator', user=self.user) operation = baker.make_recipe( 'registration.tests.utils.operation', @@ -113,13 +112,11 @@ def test_operations_endpoint_put_success(self): status=Operation.Statuses.REGISTERED, ) contact = baker.make_recipe('registration.tests.utils.contact') - self.test_payload["operation_representatives"] = [contact.id] - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - json.dumps(self.test_payload), + self.test_payload.update({"operation_representatives": [contact.id]}) + response = TestUtils.client.post( custom_reverse_lazy("update_operation", kwargs={"operation_id": operation.id}), + data=self.test_payload, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) assert response.status_code == 200 response_data = response.json() diff --git a/bc_obps/registration/tests/endpoints/test_operations.py b/bc_obps/registration/tests/endpoints/test_operations.py index 91bbd048ef..d030b78c49 100644 --- a/bc_obps/registration/tests/endpoints/test_operations.py +++ b/bc_obps/registration/tests/endpoints/test_operations.py @@ -1,4 +1,4 @@ -from registration.tests.constants import MOCK_DATA_URL +from registration.tests.constants import MOCK_FILE from model_bakery import baker from localflavor.ca.models import CAPostalCodeField from registration.tests.utils.helpers import CommonTestSetup, TestUtils @@ -61,27 +61,25 @@ def test_returns_data_as_provided_by_the_service(self, mock_list_operations_time class TestPostOperationsEndpoint(CommonTestSetup): mock_payload = { - "registration_purpose": "Reporting Operation", + "registration_purpose": ["Reporting Operation"], "regulated_products": [1], - "name": "op name", - "type": Operation.Types.SFO, + "name": ["op name"], + "type": [Operation.Types.SFO], "naics_code_id": 1, "secondary_naics_code_id": 2, "tertiary_naics_code_id": 3, "activities": [1], - "boundary_map": MOCK_DATA_URL, - "process_flow_diagram": MOCK_DATA_URL, + "boundary_map": MOCK_FILE, + "process_flow_diagram": MOCK_FILE, } # GET def test_user_can_post_operation_success(self): baker.make_recipe('registration.tests.utils.approved_user_operator', user=self.user) - response = TestUtils.mock_post_with_auth_role( - self, - "industry_user", - self.content_type, - self.mock_payload, + response = TestUtils.client.post( custom_reverse_lazy("register_create_operation_information"), + data=self.mock_payload, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) assert response.status_code == 201 diff --git a/bc_obps/registration/tests/integration/test_changing_registration_purpose.py b/bc_obps/registration/tests/integration/test_changing_registration_purpose.py index 72fde0fd6d..e60268b22f 100644 --- a/bc_obps/registration/tests/integration/test_changing_registration_purpose.py +++ b/bc_obps/registration/tests/integration/test_changing_registration_purpose.py @@ -4,8 +4,8 @@ from registration.models import Operation, NaicsCode, DocumentType from registration.models.facility_designated_operation_timeline import FacilityDesignatedOperationTimeline from registration.models.opted_in_operation_detail import OptedInOperationDetail +from registration.tests.constants import MOCK_FILE from registration.tests.utils.helpers import CommonTestSetup, TestUtils -from registration.tests.constants import MOCK_DATA_URL from registration.utils import custom_reverse_lazy @@ -23,7 +23,10 @@ def _create_opted_in_detail_payload(self): } def _create_new_entrant_payload(self): - return {"date_of_first_shipment": "On or after April 1, 2024", "new_entrant_application": MOCK_DATA_URL} + return { + "date_of_first_shipment": ["On or after April 1, 2024"], + "new_entrant_application": MOCK_FILE, + } def _prepare_test_data(self, registration_purpose): self.approved_user_operator = baker.make_recipe( @@ -43,19 +46,19 @@ def _set_operation_information(self): naics_codes = NaicsCode.objects.all()[0:2] operation_information_payload = { # begin with most basic allowed payload - EIOs will only use this - "name": self.operation.name, - "type": self.operation.type, - "registration_purpose": self.operation.registration_purpose, + "name": [self.operation.name], + "type": [self.operation.type], + "registration_purpose": [self.operation.registration_purpose], "activities": [], # activities is required in payload so needs to be explicitly set, even if it's empty } if self.operation.registration_purpose != Operation.Purposes.ELECTRICITY_IMPORT_OPERATION: operation_information_payload.update( { - "boundary_map": MOCK_DATA_URL, - "process_flow_diagram": MOCK_DATA_URL, + "boundary_map": MOCK_FILE, + "process_flow_diagram": MOCK_FILE, "activities": [2, 3], - "naics_code_id": naics_codes[0].id, - "secondary_naics_code_id": naics_codes[1].id, + "naics_code_id": [naics_codes[0].id], + "secondary_naics_code_id": [naics_codes[1].id], } ) if self.operation.registration_purpose in [ @@ -64,13 +67,12 @@ def _set_operation_information(self): Operation.Purposes.NEW_ENTRANT_OPERATION, ]: operation_information_payload.update({"regulated_products": [1, 2]}) - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - operation_information_payload, + response = TestUtils.client.post( custom_reverse_lazy("register_edit_operation_information", kwargs={'operation_id': self.operation.id}), + data=operation_information_payload, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) + if response.status_code != 200: raise Exception(response.json()) self.operation.refresh_from_db() @@ -79,17 +81,17 @@ def _set_new_registration_purpose(self, new_purpose): naics_codes = NaicsCode.objects.all()[0:2] # begin with most basic allowed payload - EIOs will only use this operation_payload = { - "name": self.operation.name, - "type": self.operation.type, - "registration_purpose": new_purpose, + "name": [self.operation.name], + "type": [self.operation.type], + "registration_purpose": [new_purpose], "activities": [], # activities is required in payload so needs to be explicitly set, even if it's empty } if new_purpose != Operation.Purposes.ELECTRICITY_IMPORT_OPERATION: # any purpose other than EIO requires these additional fields operation_payload.update( { - "boundary_map": MOCK_DATA_URL, - "process_flow_diagram": MOCK_DATA_URL, + "boundary_map": MOCK_FILE, + "process_flow_diagram": MOCK_FILE, "activities": [2, 3], "naics_code_id": self.operation.naics_code_id or naics_codes[0].id, # if old purpose was EIO, operation won't have any NAICS codes @@ -103,13 +105,12 @@ def _set_new_registration_purpose(self, new_purpose): ]: # regulated operations need to report their operation_payload.update({"regulated_products": [1, 2]}) - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - operation_payload, + response = TestUtils.client.post( custom_reverse_lazy("register_edit_operation_information", kwargs={'operation_id': self.operation.id}), + data=operation_payload, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) + if response.status_code != 200: raise Exception(response.json()) self.operation.refresh_from_db() @@ -135,15 +136,14 @@ def _set_opted_in_operation_detail(self): self.operation.refresh_from_db() def _set_new_entrant_info(self): - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - self._create_new_entrant_payload(), + response = TestUtils.client.post( custom_reverse_lazy( "create_or_replace_new_entrant_application", kwargs={'operation_id': self.operation.id} ), + data=self._create_new_entrant_payload(), + HTTP_AUTHORIZATION=self.auth_header_dumps, ) + if response.status_code != 200: raise Exception(response.json()) self.operation.refresh_from_db() diff --git a/bc_obps/registration/tests/integration/test_operation_registration.py b/bc_obps/registration/tests/integration/test_operation_registration.py index b812377538..8730299ebd 100644 --- a/bc_obps/registration/tests/integration/test_operation_registration.py +++ b/bc_obps/registration/tests/integration/test_operation_registration.py @@ -3,7 +3,7 @@ from model_bakery import baker from registration.models import FacilityDesignatedOperationTimeline, Operation -from registration.tests.constants import MOCK_DATA_URL +from registration.tests.constants import MOCK_FILE from registration.tests.utils.helpers import CommonTestSetup, TestUtils from registration.utils import custom_reverse_lazy @@ -44,32 +44,31 @@ def _prepare_test_data(self, operation_type: Operation.Types): ] def _set_operation_information(self, purpose: Operation.Purposes, operation_type: Operation.Types): + if operation_type == Operation.Types.EIO: operation_information_payload = { - "registration_purpose": purpose, - "name": f"{purpose} name", - "type": operation_type.value, + "registration_purpose": [purpose], + "name": [f"{purpose} name"], + "type": [operation_type.value], } else: operation_information_payload = { - "registration_purpose": purpose, - "regulated_products": [] if purpose in self.purposes_with_no_regulated_products else [1, 2], - "name": f"{purpose} name", - "type": operation_type, - "naics_code_id": 1, - "secondary_naics_code_id": 2, - "tertiary_naics_code_id": 3, + "registration_purpose": [purpose], + **({"regulated_products": [1, 2]} if purpose not in self.purposes_with_no_regulated_products else {}), + "name": [f"{purpose} name"], + "type": [operation_type], + "naics_code_id": [1], + "secondary_naics_code_id": [2], + "tertiary_naics_code_id": [3], "activities": [1, 2], - "boundary_map": MOCK_DATA_URL, - "process_flow_diagram": MOCK_DATA_URL, + "boundary_map": MOCK_FILE, + "process_flow_diagram": MOCK_FILE, } - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - operation_information_payload, + response = TestUtils.client.post( custom_reverse_lazy("register_edit_operation_information", kwargs={'operation_id': self.operation.id}), + data=operation_information_payload, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) if response.status_code != 200: @@ -94,7 +93,7 @@ def _set_facilities(self): response = TestUtils.mock_post_with_auth_role( self, "industry_user", - self.content_type, + 'application/json', facility_payload, custom_reverse_lazy("create_facilities"), ) @@ -103,18 +102,17 @@ def _set_facilities(self): self.operation.refresh_from_db() def _set_new_entrant_application(self): - response = TestUtils.mock_put_with_auth_role( - self, - "industry_user", - self.content_type, - { - "date_of_first_shipment": "On or after April 1, 2024", - 'new_entrant_application': MOCK_DATA_URL, - }, + response = TestUtils.client.post( custom_reverse_lazy( "create_or_replace_new_entrant_application", kwargs={'operation_id': self.operation.id} ), + data={ + "date_of_first_shipment": ["On or after April 1, 2024"], + 'new_entrant_application': MOCK_FILE, + }, + HTTP_AUTHORIZATION=self.auth_header_dumps, ) + if response.status_code != 200: raise Exception(response.json()) self.operation.refresh_from_db() @@ -157,7 +155,7 @@ def _set_operation_representative(self): response = TestUtils.mock_post_with_auth_role( self, "industry_user", - self.content_type, + 'application/json', operation_representative_payload, custom_reverse_lazy("create_operation_representative", kwargs={'operation_id': self.operation.id}), ) diff --git a/bc_obps/registration/tests/test_utils.py b/bc_obps/registration/tests/test_utils.py index 3289a2a9d6..ebc5e61a43 100644 --- a/bc_obps/registration/tests/test_utils.py +++ b/bc_obps/registration/tests/test_utils.py @@ -9,14 +9,12 @@ from registration.utils import ( file_to_data_url, data_url_to_file, - files_have_same_hash, update_model_instance, generate_useful_error, ) from django.core.exceptions import ValidationError from ninja.errors import HttpError from django.test import RequestFactory, TestCase -from django.core.files.base import ContentFile from registration.tests.utils.bakers import document_baker, user_operator_baker import requests @@ -285,46 +283,6 @@ def test_data_url_to_file_invalid_base64(): data_url_to_file(data_url) -class TestFileHashComparison(TestCase): - def test_same_content(self): - """Tests if the function returns True for files with identical content.""" - content = b"This is some sample content." - file1 = ContentFile(content, "test_file1.txt") - file2 = ContentFile(content, "test_file2.txt") - - self.assertTrue(files_have_same_hash(file1, file2)) - - def test_different_content(self): - """Tests if the function returns False for files with different content.""" - content1 = b"This is some content." - content2 = b"This is different content." - file1 = ContentFile(content1, "test_file1.txt") - file2 = ContentFile(content2, "test_file2.txt") - - self.assertFalse(files_have_same_hash(file1, file2)) - - def test_empty_file(self): - """Tests if the function handles empty files.""" - empty_content = b"" - file1 = ContentFile(empty_content, "empty_file.txt") - file2 = ContentFile(empty_content, "empty_file2.txt") - - self.assertTrue(files_have_same_hash(file1, file2)) - - def test_none_values(self): - """Tests if the function raises a ValueError when None is passed as a file.""" - content = b"This is some sample content." - file1 = ContentFile(content, "test_file.txt") - file2 = ContentFile(content, "test_file2.txt") - with self.assertRaises(ValueError) as context: - files_have_same_hash(None, file2) - self.assertEqual(str(context.exception), "Both files must be provided to compare hashes.") - - with self.assertRaises(ValueError) as context: - files_have_same_hash(file1, None) - self.assertEqual(str(context.exception), "Both files must be provided to compare hashes.") - - class TestGenerateUniqueBcghgIdForOperationOrFacility(TestCase): def test_cannot_create_operation_with_duplicate_bcghg_id(self): bcghg_id_instance = baker.make(BcGreenhouseGasId, id='14121100001') diff --git a/bc_obps/registration/utils.py b/bc_obps/registration/utils.py index 47e97d6ce0..5819b1e980 100644 --- a/bc_obps/registration/utils.py +++ b/bc_obps/registration/utils.py @@ -8,7 +8,6 @@ import requests import base64 import re -import hashlib from django.core.files.base import ContentFile from registration.models import ( Document, @@ -126,42 +125,6 @@ def set_verification_columns(record: Union[UserOperator, Operator, Operation], u record.verified_by_id = user_guid -def files_have_same_hash(file1: Optional[ContentFile], file2: Optional[ContentFile]) -> bool: - """ - Compare the hash of two files to determine if they are the same. - this might miss formatting changes. - """ - - # If either file is None, raise an error - if not file1 or not file2: - raise ValueError("Both files must be provided to compare hashes.") - - hash1 = hashlib.sha256() - hash2 = hashlib.sha256() - - try: - # Handle ContentFile - if isinstance(file1, ContentFile): - hash1.update(file1.read()) - else: - # Handle FileField - with file1.open(mode='rb') as f1: - for chunk in iter(lambda: f1.read(4096), b''): - hash1.update(chunk) - - # Repeat for the second file - if isinstance(file2, ContentFile): - hash2.update(file2.read()) - else: - with file2.open(mode='rb') as f2: - for chunk in iter(lambda: f2.read(4096), b''): - hash2.update(chunk) - - return hash1.hexdigest() == hash2.hexdigest() - except Exception as e: - raise ValueError(f"Error comparing files: {e}") - - class CustomPagination(PageNumberPagination): """ Custom pagination class that allows for custom page sizes. diff --git a/bc_obps/service/data_access_service/document_service.py b/bc_obps/service/data_access_service/document_service.py index cfbf8c6faf..e8b3515b47 100644 --- a/bc_obps/service/data_access_service/document_service.py +++ b/bc_obps/service/data_access_service/document_service.py @@ -1,7 +1,8 @@ -from typing import Optional from uuid import UUID +from ninja import UploadedFile from registration.models import Document, DocumentType -from django.core.files.base import ContentFile +from reporting.constants import MAX_UPLOAD_SIZE +from pydantic import ValidationError class DocumentDataAccessService: @@ -17,13 +18,19 @@ def get_operation_document_by_type(cls, operation_id: UUID, document_type: str) @classmethod def create_document( - cls, user_guid: UUID, file_data: Optional[ContentFile], document_type_name: str, operation_id: UUID + cls, + operation_id: UUID, + type: str, + file: UploadedFile, ) -> Document: - document = Document.objects.create( - file=file_data, - type=DocumentType.objects.get(name=document_type_name), - created_by_id=user_guid, + if file.size and file.size > MAX_UPLOAD_SIZE: + raise ValidationError(f"File document cannot exceed {MAX_UPLOAD_SIZE} bytes.") + + document = Document( operation_id=operation_id, + file=file, + type=DocumentType.objects.get(name=type), ) + document.save() return document diff --git a/bc_obps/service/document_service.py b/bc_obps/service/document_service.py index cf0446b67f..96443b1ac6 100644 --- a/bc_obps/service/document_service.py +++ b/bc_obps/service/document_service.py @@ -1,9 +1,7 @@ -from typing import Tuple from uuid import UUID +from ninja import UploadedFile from service.data_access_service.document_service import DocumentDataAccessService from registration.models import Document, Operation -from registration.utils import files_have_same_hash -from django.core.files.base import ContentFile from service.data_access_service.operation_service import OperationDataAccessService @@ -18,26 +16,25 @@ def get_operation_document_by_type_if_authorized( return DocumentDataAccessService.get_operation_document_by_type(operation_id, document_type) @classmethod - def create_or_replace_operation_document( - cls, user_guid: UUID, operation_id: UUID, file_data: ContentFile, document_type: str - ) -> Tuple[Document, bool]: + def create_or_replace_operation_document(cls, operation_id: UUID, file: UploadedFile, type: str) -> Document | None: """ This function receives a document and operation id. Operations only have one of each type of document, so this function uses the type to check if an existing document needs to be replaced, or if no document exists and one must be created. This function does NOT set any m2m relationships. - :returns: Tuple[Document, bool] where the bool is True if a new document was created, False if an existing document was updated + """ - existing_document = cls.get_operation_document_by_type_if_authorized(user_guid, operation_id, document_type) - # if there is an existing document, check if the new one is different + existing_document = DocumentDataAccessService.get_operation_document_by_type(operation_id, type) + # if there is an existing document, delete it if existing_document: - # We need to check if the file has changed, if it has, we need to delete the old one and create a new one - if not files_have_same_hash(file_data, existing_document.file): - existing_document.delete() - else: - return existing_document, False - # if there is no existing document, create a new one - document = DocumentDataAccessService.create_document(user_guid, file_data, document_type, operation_id) - return document, True + existing_document.delete() + + # create the new document + document = DocumentDataAccessService.create_document( + operation_id=operation_id, + type=type, + file=file, + ) + return document @classmethod def archive_or_delete_operation_document(cls, user_guid: UUID, operation_id: UUID, document_type: str) -> bool: diff --git a/bc_obps/service/operation_service.py b/bc_obps/service/operation_service.py index 33d5467aa0..e520dab1aa 100644 --- a/bc_obps/service/operation_service.py +++ b/bc_obps/service/operation_service.py @@ -1,6 +1,11 @@ -from typing import List, Optional, Tuple, Callable, Generator, Union +from typing import List, Optional, Tuple, Callable, Generator from django.db.models import QuerySet from registration.models.facility import Facility +from registration.schema.operation import ( + OperationAdministrationInWithDocuments, + OperationNewEntrantApplicationInWithDocuments, + OperationRegistrationInWithDocuments, +) from service.contact_service import ContactService from service.data_access_service.document_service import DocumentDataAccessService from service.data_access_service.operation_designated_operator_timeline_service import ( @@ -28,11 +33,8 @@ from service.document_service import DocumentService from service.facility_service import FacilityService from registration.schema import ( - OperationInformationIn, - OperationInformationInUpdate, OperationRepresentativeRemove, OptedInOperationDetailIn, - OperationNewEntrantApplicationIn, OperationRepresentativeIn, FacilityIn, OperationTimelineFilterSchema, @@ -42,6 +44,7 @@ from datetime import datetime from zoneinfo import ZoneInfo from registration.models.operation_designated_operator_timeline import OperationDesignatedOperatorTimeline +from django.core.files.uploadedfile import UploadedFile class OperationService: @@ -119,21 +122,17 @@ def get_opted_in_operation_detail(cls, user_guid: UUID, operation_id: UUID) -> O @classmethod def create_or_replace_new_entrant_application( - cls, user_guid: UUID, operation_id: UUID, payload: OperationNewEntrantApplicationIn + cls, user_guid: UUID, operation_id: UUID, payload: OperationNewEntrantApplicationInWithDocuments ) -> Operation: operation = OperationService.get_if_authorized(user_guid, operation_id, ['id', 'operator_id']) - ( - new_entrant_application_document, - new_entrant_application_document_created, - ) = DocumentService.create_or_replace_operation_document( - user_guid, - operation_id, - payload.new_entrant_application, # type: ignore # mypy is not aware of the schema validator - "new_entrant_application", - ) - if new_entrant_application_document_created: - operation.documents.add(new_entrant_application_document) + if payload.new_entrant_application and isinstance(payload.new_entrant_application, UploadedFile): + DocumentService.create_or_replace_operation_document( + operation_id=operation.id, + type='new_entrant_application', + file=payload.new_entrant_application, + ) + operation.date_of_first_shipment = payload.date_of_first_shipment operation.save(update_fields=['date_of_first_shipment']) return operation @@ -165,9 +164,11 @@ def create_operation_representative( @classmethod @transaction.atomic() - def _create_or_update_eio(cls, user_guid: UUID, operation: Operation, payload: OperationInformationIn) -> None: + def _create_or_update_eio( + cls, user_guid: UUID, operation: Operation, payload: OperationRegistrationInWithDocuments + ) -> None: # EIO operations have a facility with the same data as the operation - eio_payload = FacilityIn(name=payload.name, type=Facility.Types.ELECTRICITY_IMPORT, operation_id=operation.id) + eio_payload = FacilityIn(name=payload.name, type=Facility.Types.ELECTRICITY_IMPORT, operation_id=operation.id) # type: ignore[attr-defined] # name is the fields section of the schema facility = operation.facilities.first() if not facility: FacilityService.create_facilities_with_designated_operations(user_guid, [eio_payload]) @@ -214,7 +215,7 @@ def remove_opted_in_operation_detail(cls, user_guid: UUID, operation_id: UUID) - def _create_operation( cls, user_guid: UUID, - payload: OperationInformationIn, + payload: OperationRegistrationInWithDocuments, ) -> Operation: operation_data = payload.dict( @@ -249,46 +250,19 @@ def _create_operation( ) # create documents - operation_documents = [ - doc - for doc in [ - *( - [ - DocumentDataAccessService.create_document( - user_guid, - payload.boundary_map, # type: ignore # mypy is not aware of the schema validator - 'boundary_map', - operation.id, - ) - ] - if payload.boundary_map - else [] - ), - *( - [ - DocumentDataAccessService.create_document( - user_guid, - payload.process_flow_diagram, # type: ignore # mypy is not aware of the schema validator - 'process_flow_diagram', - operation.id, - ) - ] - if payload.process_flow_diagram - else [] - ), - *( - DocumentDataAccessService.create_document( - user_guid, - payload.new_entrant_application, # type: ignore # mypy is not aware of the schema validator - 'new_entrant_application', - operation.id, - ) - if payload.new_entrant_application - else [] - ), - ] - ] - operation.documents.add(*operation_documents) + if payload.boundary_map and isinstance(payload.boundary_map, UploadedFile): + DocumentDataAccessService.create_document( + operation_id=operation.id, + type='boundary_map', + file=payload.boundary_map, + ) + + if payload.process_flow_diagram and isinstance(payload.process_flow_diagram, UploadedFile): + DocumentDataAccessService.create_document( + operation_id=operation.id, + type='process_flow_diagram', + file=payload.process_flow_diagram, + ) # handle multiple operators multiple_operators_data = payload.multiple_operators_array @@ -308,10 +282,9 @@ def register_operation_information( cls, user_guid: UUID, operation_id: UUID | None, - payload: Union[OperationInformationIn, OperationInformationInUpdate], + payload: OperationRegistrationInWithDocuments, ) -> Operation: - # can't optimize this much more without looking at files--the extra hits to operation are in the middleware, and the multi hits to document are from the resolvers operation: Operation if operation_id: operation = OperationService.get_if_authorized(user_guid, operation_id) @@ -376,7 +349,7 @@ def upsert_multiple_operators( def update_operation( cls, user_guid: UUID, - payload: OperationInformationIn, + payload: OperationRegistrationInWithDocuments | OperationAdministrationInWithDocuments, operation_id: UUID, ) -> Operation: @@ -416,7 +389,9 @@ def update_operation( else operation.regulated_products.clear() ) - if operation.status == Operation.Statuses.REGISTERED and isinstance(payload, OperationInformationInUpdate): + if operation.status == Operation.Statuses.REGISTERED and isinstance( + payload, OperationAdministrationInWithDocuments + ): # operation representatives are only mandatory to register (vs. simply update) and operation for contact_id in payload.operation_representatives: ContactService.raise_exception_if_contact_missing_address_information(contact_id) @@ -424,49 +399,25 @@ def update_operation( operation.contacts.set(payload.operation_representatives) # create or replace documents - operation_documents = [ - doc - for doc, created in [ - *( - [ - DocumentService.create_or_replace_operation_document( - user_guid, - operation.id, - payload.boundary_map, # type: ignore # mypy is not aware of the schema validator - 'boundary_map', - ) - ] - if payload.boundary_map - else [] - ), - *( - [ - DocumentService.create_or_replace_operation_document( - user_guid, - operation.id, - payload.process_flow_diagram, # type: ignore # mypy is not aware of the schema validator - 'process_flow_diagram', - ) - ] - if payload.process_flow_diagram - else [] - ), - *( - [ - DocumentService.create_or_replace_operation_document( - user_guid, - operation.id, - payload.new_entrant_application, # type: ignore # mypy is not aware of the schema validator - 'new_entrant_application', - ) - ] - if payload.new_entrant_application - else [] - ), - ] - if created - ] - operation.documents.add(*operation_documents) + if payload.boundary_map and isinstance(payload.boundary_map, UploadedFile): + DocumentService.create_or_replace_operation_document( + operation_id=operation.id, + file=payload.boundary_map, + type='boundary_map', + ) + if payload.process_flow_diagram and isinstance(payload.process_flow_diagram, UploadedFile): + DocumentService.create_or_replace_operation_document( + operation_id=operation.id, + type='process_flow_diagram', + file=payload.process_flow_diagram, + ) + + if payload.new_entrant_application and isinstance(payload.new_entrant_application, UploadedFile): + DocumentService.create_or_replace_operation_document( + operation_id=operation.id, + type='new_entrant_application', + file=payload.new_entrant_application, + ) # # this is not handled by changing registration purpose if ( @@ -635,8 +586,8 @@ def update_operator(cls, user_guid: UUID, operation: Operation, operator_id: UUI @classmethod def handle_change_of_registration_purpose( - cls, user_guid: UUID, operation: Operation, payload: OperationInformationIn - ) -> OperationInformationIn: + cls, user_guid: UUID, operation: Operation, payload: OperationRegistrationInWithDocuments + ) -> OperationRegistrationInWithDocuments: """ Logic to handle the situation when an industry user changes the selected registration purpose (RP) for their operation. Changing the RP can happen during or after submitting the operation's registration info. diff --git a/bc_obps/service/tests/test_document_service.py b/bc_obps/service/tests/test_document_service.py index 249a8d4c89..df6deea439 100644 --- a/bc_obps/service/tests/test_document_service.py +++ b/bc_obps/service/tests/test_document_service.py @@ -1,8 +1,7 @@ from service.data_access_service.document_service import DocumentDataAccessService -from registration.utils import data_url_to_file from registration.models.document import Document from registration.models.operation import Operation -from registration.tests.constants import MOCK_DATA_URL, MOCK_DATA_URL_2 +from registration.tests.constants import MOCK_UPLOADED_FILE, MOCK_UPLOADED_FILE_2 from service.document_service import DocumentService import pytest @@ -33,56 +32,39 @@ def test_cannot_get_operation_document_by_type_if_unauthorized(): @staticmethod def test_create_operation_document(): # the value received by the service is a File (transformed into this in the django ninja schema) - file = data_url_to_file(MOCK_DATA_URL) + file = MOCK_UPLOADED_FILE approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') operation = baker.make_recipe('registration.tests.utils.operation', operator=approved_user_operator.operator) - document, created = DocumentService.create_or_replace_operation_document( - approved_user_operator.user_id, operation.id, file, 'boundary_map' + document = DocumentService.create_or_replace_operation_document( + operation.id, + file, + 'boundary_map', ) assert Document.objects.count() == 1 assert document.type.name == 'boundary_map' - assert created is True - - @staticmethod - def test_do_not_update_duplicate_operation_document(): - approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') - operation = baker.make_recipe('registration.tests.utils.operation', operator=approved_user_operator.operator) - DocumentDataAccessService.create_document( - approved_user_operator.user_id, data_url_to_file(MOCK_DATA_URL), 'boundary_map', operation.id - ) - created_at = operation.documents.first().created_at - - updated_file = data_url_to_file(MOCK_DATA_URL) - document, created = DocumentService.create_or_replace_operation_document( - approved_user_operator.user_id, operation.id, updated_file, 'boundary_map' - ) - - assert Document.objects.count() == 1 - assert document.type.name == 'boundary_map' - # MOCK_DATA_URL's filename is mock.pdf. When adding files to django, the name is appended, so we just check that 'mock' in the name - assert document.file.name.find("mock") != -1 - assert document.created_at == created_at - assert created is False @staticmethod def test_update_operation_document(): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') operation = baker.make_recipe('registration.tests.utils.operation', operator=approved_user_operator.operator) + DocumentDataAccessService.create_document( - approved_user_operator.user_id, data_url_to_file(MOCK_DATA_URL), 'boundary_map', operation.id + operation.id, + 'boundary_map', + MOCK_UPLOADED_FILE, ) - updated_file = data_url_to_file(MOCK_DATA_URL_2) - document, created = DocumentService.create_or_replace_operation_document( - approved_user_operator.user_id, operation.id, updated_file, 'boundary_map' + document = DocumentService.create_or_replace_operation_document( + operation.id, + MOCK_UPLOADED_FILE_2, + 'boundary_map', ) assert Document.objects.count() == 1 assert document.type.name == 'boundary_map' - # MOCK_DATA_URL's filename is test.pdf - assert document.file.name.find("test") != -1 - assert created is True + # MOCK_UPLOADED_FILE_2's filename is test2.pdf + assert document.file.name.find("test2") != -1 @pytest.mark.parametrize("registration_status", [Operation.Statuses.REGISTERED, Operation.Statuses.DRAFT]) def test_archive_or_delete_operation_document(self, registration_status): @@ -92,11 +74,15 @@ def test_archive_or_delete_operation_document(self, registration_status): ) # boundary map b_map = DocumentDataAccessService.create_document( - approved_user_operator.user_id, data_url_to_file(MOCK_DATA_URL), 'boundary_map', operation.id + operation.id, + 'boundary_map', + MOCK_UPLOADED_FILE, ) # process flow diagram DocumentDataAccessService.create_document( - approved_user_operator.user_id, data_url_to_file(MOCK_DATA_URL), 'process_flow_diagram', operation.id + operation.id, + 'process_flow_diagram', + MOCK_UPLOADED_FILE, ) assert Document.objects.count() == 2 diff --git a/bc_obps/service/tests/test_operation_service.py b/bc_obps/service/tests/test_operation_service.py index 4be9cb2e17..6d1a81817a 100644 --- a/bc_obps/service/tests/test_operation_service.py +++ b/bc_obps/service/tests/test_operation_service.py @@ -14,26 +14,30 @@ from registration.models.opted_in_operation_detail import OptedInOperationDetail from registration.constants import UNAUTHORIZED_MESSAGE from registration.models.address import Address -from registration.schema import ( - FacilityIn, - OperationInformationInUpdate, +from registration.schema.facility import FacilityIn +from registration.schema.operation import ( OperationRepresentativeIn, - OperationNewEntrantApplicationIn, + OperationNewEntrantApplicationInWithDocuments, OperationRepresentativeRemove, - OperationTimelineFilterSchema, - MultipleOperatorIn, - OperationInformationIn, + OperationAdministrationInWithDocuments, ) +from registration.schema.operation_timeline import OperationTimelineFilterSchema from service.data_access_service.operation_service import OperationDataAccessService from service.operation_service import OperationService from registration.models.multiple_operator import MultipleOperator +from registration.schema.multiple_operator import MultipleOperatorIn from registration.models.operation import Operation -from registration.tests.constants import MOCK_DATA_URL +from registration.schema.operation import ( + OperationRegistrationInWithDocuments, +) from model_bakery import baker from registration.models.operation_designated_operator_timeline import OperationDesignatedOperatorTimeline +from django.core.files.uploadedfile import SimpleUploadedFile pytestmark = pytest.mark.django_db +mock_file = SimpleUploadedFile("test.txt", b"Hello, world!", content_type="text/plain") + def set_up_valid_mock_operation(purpose: Operation.Purposes): # create operation and purpose @@ -91,14 +95,14 @@ def test_assigns_single_selected_purpose(): operator=approved_user_operator.operator, registration_purpose='Potential Reporting Operation', ) - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Reporting Operation', name="string", type=Operation.Types.SFO, naics_code_id=1, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, ) OperationService.register_operation_information(approved_user_operator.user.user_guid, operation.id, payload) @@ -336,8 +340,8 @@ def test_create_or_replace_new_entrant_application(): operator=approved_user_operator.operator, created_by=approved_user_operator.user, ) - payload = OperationNewEntrantApplicationIn( - new_entrant_application=MOCK_DATA_URL, + payload = OperationNewEntrantApplicationInWithDocuments( + new_entrant_application=mock_file, date_of_first_shipment=Operation.DateOfFirstShipmentChoices.ON_OR_BEFORE_MARCH_31_2024, ) operation = OperationService.create_or_replace_new_entrant_application( @@ -354,7 +358,7 @@ class TestRegisterOperationInformation: @staticmethod def test_register_operation_information_new_eio(): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Electricity Import Operation', name="TestEIO", type=Operation.Types.EIO, @@ -384,7 +388,7 @@ def test_register_operation_information_existing_eio(): type=Operation.Types.EIO, registration_purpose='Electricity Import Operation', ) - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Electricity Import Operation', name="UpdatedEIO", type=Operation.Types.EIO, @@ -408,14 +412,14 @@ def test_register_operation_information_existing_eio(): def test_register_operation_information_new_operation(): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Reporting Operation', name="string", type=Operation.Types.SFO, naics_code_id=1, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, ) operation = OperationService.register_operation_information( approved_user_operator.user.user_guid, None, payload @@ -437,7 +441,7 @@ def test_register_operation_information_existing_operation(): operator=approved_user_operator.operator, created_by=approved_user_operator.user, ) - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Potential Reporting Operation', name="string", type=Operation.Types.SFO, @@ -445,8 +449,8 @@ def test_register_operation_information_existing_operation(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, ) # check operation updates operation = OperationService.register_operation_information( @@ -505,11 +509,11 @@ def test_is_operation_new_entrant_information_complete_no_application(): assert not OperationService.is_operation_new_entrant_information_complete(users_operation) -class TestOperationServiceV2CreateOperation: +class TestOperationServiceCreateOperation: @staticmethod def test_create_operation_without_multiple_operators(): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Reporting Operation', regulated_products=[1, 2], name="string", @@ -518,8 +522,8 @@ def test_create_operation_without_multiple_operators(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, ) operation = OperationService._create_operation(approved_user_operator.user.user_guid, payload) operation.refresh_from_db() @@ -539,7 +543,7 @@ def test_create_operation_without_multiple_operators(): @staticmethod def test_create_operation_with_multiple_operators(): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Reporting Operation', name="string", type=Operation.Types.SFO, @@ -547,8 +551,8 @@ def test_create_operation_with_multiple_operators(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, ) payload.multiple_operators_array = [ MultipleOperatorIn( @@ -584,14 +588,14 @@ def test_create_operation_with_multiple_operators(): @staticmethod def test_assigning_opted_in_operation_will_create_and_opted_in_operation_detail(): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose=Operation.Purposes.OPTED_IN_OPERATION, name="string", type=Operation.Types.SFO, naics_code_id=1, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, ) operation = OperationService._create_operation(approved_user_operator.user.user_guid, payload) @@ -602,7 +606,7 @@ def test_assigning_opted_in_operation_will_create_and_opted_in_operation_detail( @staticmethod def test_create_makes_eio_facility(): approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Electricity Import Operation', name="TestEIO", type=Operation.Types.EIO, @@ -619,7 +623,7 @@ def test_create_makes_eio_facility(): assert facilities[0].type == Facility.Types.ELECTRICITY_IMPORT -class TestOperationServiceV2UpdateOperation: +class TestOperationServiceUpdateOperation: @staticmethod def test_raises_error_if_operation_does_not_belong_to_user(): user = baker.make_recipe('registration.tests.utils.industry_operator_user') @@ -637,14 +641,14 @@ def test_raises_error_if_operation_does_not_belong_to_user(): registration_purpose='Potential Reporting Operation', ) - payload = OperationInformationIn( + payload = OperationRegistrationInWithDocuments( registration_purpose='Reporting Operation', name="string", type=Operation.Types.SFO, naics_code_id=1, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, ) with pytest.raises(Exception): OperationService.update_operation(user.user_guid, payload) @@ -658,7 +662,7 @@ def test_update_operation(): created_by=approved_user_operator.user, status=Operation.Statuses.REGISTERED, ) - payload = OperationInformationInUpdate( + payload = OperationRegistrationInWithDocuments( registration_purpose='Potential Reporting Operation', name="Test Update Operation Name", type=Operation.Types.SFO, @@ -666,8 +670,8 @@ def test_update_operation(): secondary_naics_code_id=1, tertiary_naics_code_id=2, activities=[2], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, operation_representatives=[baker.make_recipe('registration.tests.utils.contact').id], ) operation = OperationService.update_operation( @@ -688,7 +692,7 @@ def test_update_operation_with_no_regulated_products(): created_by=approved_user_operator.user, status=Operation.Statuses.REGISTERED, ) - payload = OperationInformationInUpdate( + payload = OperationRegistrationInWithDocuments( registration_purpose='OBPS Regulated Operation', name="Test Update Operation Name", type=Operation.Types.SFO, @@ -696,8 +700,8 @@ def test_update_operation_with_no_regulated_products(): secondary_naics_code_id=3, tertiary_naics_code_id=4, activities=[3], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, operation_representatives=[baker.make_recipe('registration.tests.utils.contact').id], ) operation = OperationService.update_operation( @@ -720,7 +724,7 @@ def test_update_operation_with_new_entrant_application_data(): date_of_first_shipment=Operation.DateOfFirstShipmentChoices.ON_OR_AFTER_APRIL_1_2024, status=Operation.Statuses.REGISTERED, ) - payload = OperationInformationInUpdate( + payload = OperationRegistrationInWithDocuments( registration_purpose='New Entrant Operation', name="Test Update Operation Name", type=Operation.Types.SFO, @@ -728,10 +732,10 @@ def test_update_operation_with_new_entrant_application_data(): secondary_naics_code_id=3, tertiary_naics_code_id=4, activities=[3], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, date_of_first_shipment=Operation.DateOfFirstShipmentChoices.ON_OR_BEFORE_MARCH_31_2024, - new_entrant_application=MOCK_DATA_URL, + new_entrant_application=mock_file, operation_representatives=[baker.make_recipe('registration.tests.utils.contact').id], ) operation = OperationService.update_operation( @@ -756,7 +760,7 @@ def test_update_operation_with_multiple_operators(): ) existing_operation.multiple_operators.set(multiple_operators) - payload = OperationInformationInUpdate( + payload = OperationRegistrationInWithDocuments( registration_purpose='Reporting Operation', regulated_products=[1], name="I am updated", @@ -765,8 +769,8 @@ def test_update_operation_with_multiple_operators(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, operation_representatives=[baker.make_recipe('registration.tests.utils.contact').id], ) payload.multiple_operators_array = [ @@ -819,7 +823,7 @@ def test_update_operation_archive_multiple_operators(): ) existing_operation.multiple_operators.set(multiple_operators) - payload = OperationInformationInUpdate( + payload = OperationRegistrationInWithDocuments( registration_purpose='Reporting Operation', regulated_products=[1], name="I am updated", @@ -828,8 +832,8 @@ def test_update_operation_archive_multiple_operators(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, operation_representatives=[baker.make_recipe('registration.tests.utils.contact').id], ) @@ -857,7 +861,7 @@ def test_update_operation_with_operation_representatives_with_address(): _quantity=3, ) - payload = OperationInformationInUpdate( + payload = OperationAdministrationInWithDocuments( registration_purpose='Reporting Operation', regulated_products=[1], name="I am updated", @@ -866,8 +870,8 @@ def test_update_operation_with_operation_representatives_with_address(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, operation_representatives=[contact.id for contact in contacts], ) @@ -892,7 +896,7 @@ def test_update_operation_with_eio(): _quantity=3, ) - payload = OperationInformationInUpdate( + payload = OperationRegistrationInWithDocuments( registration_purpose='Electricity Import Operation', regulated_products=[1], name="I am updated", @@ -901,8 +905,8 @@ def test_update_operation_with_eio(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, operation_representatives=[contact.id for contact in contacts], ) @@ -934,7 +938,7 @@ def test_update_opt_in_operation(): _quantity=3, ) - payload = OperationInformationInUpdate( + payload = OperationRegistrationInWithDocuments( registration_purpose='Opted-in Operation', regulated_products=[1], name="I am updated", @@ -943,8 +947,8 @@ def test_update_opt_in_operation(): secondary_naics_code_id=2, tertiary_naics_code_id=3, activities=[1], - process_flow_diagram=MOCK_DATA_URL, - boundary_map=MOCK_DATA_URL, + process_flow_diagram=mock_file, + boundary_map=mock_file, operation_representatives=[contact.id for contact in contacts], ) @@ -1019,7 +1023,7 @@ def test_update_eio(mock_update_facility): mock_update_facility.assert_called_once_with(approved_user_operator.user.user_guid, facility.id, payload) -class TestOperationServiceV2CheckCurrentUsersRegisteredOperation: +class TestOperationServiceCheckCurrentUsersRegisteredOperation: def test_check_current_users_registered_operation_returns_true(self): # Create a user operator and a registered operation approved_user_operator = baker.make_recipe('registration.tests.utils.approved_user_operator') @@ -1173,7 +1177,7 @@ class TestHandleChangeOfRegistrationPurpose: """ Note that these tests are different from the integration tests for handling change of registration purpose as these unit tests only go as far as confirming that the - OperationInformationIn payload is generated correctly. + OperationRegistrationInWithDocuments payload is generated correctly. """ @staticmethod @@ -1191,7 +1195,7 @@ def test_old_purpose_opted_in(): assert OptedInOperationDetail.objects.count() == 1 - submitted_payload = OperationInformationIn( + submitted_payload = OperationRegistrationInWithDocuments( registration_purpose=Operation.Purposes.REPORTING_OPERATION, name='Updated Operation', type=Operation.Types.SFO, @@ -1231,7 +1235,7 @@ def test_old_purpose_new_entrant(): assert Document.objects.count() == 3 assert operation.documents.count() == 3 - submitted_payload = OperationInformationIn( + submitted_payload = OperationRegistrationInWithDocuments( registration_purpose=Operation.Purposes.OBPS_REGULATED_OPERATION, name="Updated Operation", type=Operation.Types.SFO, @@ -1258,7 +1262,7 @@ def test_new_purpose_eio(): registration_purpose=Operation.Purposes.REPORTING_OPERATION, ) - submitted_payload = OperationInformationIn( + submitted_payload = OperationRegistrationInWithDocuments( registration_purpose=Operation.Purposes.ELECTRICITY_IMPORT_OPERATION, name="Updated Operation", type=Operation.Types.EIO, @@ -1267,8 +1271,8 @@ def test_new_purpose_eio(): regulated_products=[1, 2, 3], secondary_naics_code_id=2, tertiary_naics_code_id=3, - boundary_map=MOCK_DATA_URL, - process_flow_diagram=MOCK_DATA_URL, + boundary_map=mock_file, + process_flow_diagram=mock_file, ) returned_payload = OperationService.handle_change_of_registration_purpose( approved_user_operator.user.user_guid, operation, submitted_payload @@ -1296,7 +1300,7 @@ def test_new_purpose_reporting(): regulated_products=products, ) - submitted_payload = OperationInformationIn( + submitted_payload = OperationRegistrationInWithDocuments( registration_purpose=Operation.Purposes.REPORTING_OPERATION, name="Updated Operation", type=Operation.Types.SFO, diff --git a/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx b/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx index cc384c4122..c293cf4e2d 100644 --- a/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx +++ b/bciers/apps/administration/app/components/operations/OperationInformationForm.tsx @@ -22,6 +22,7 @@ import { useSessionRole } from "@bciers/utils/src/sessionUtils"; import Note from "@bciers/components/layout/Note"; import Link from "next/link"; import ConfirmChangeOfRegistrationPurposeModal from "@/registration/app/components/operations/registration/ConfirmChangeOfRegistrationPurposeModal"; +import { convertRjsfFormData } from "@/registration/app/components/operations/registration/OperationInformationForm"; const OperationInformationForm = ({ formData, @@ -41,6 +42,7 @@ const OperationInformationForm = ({ const [selectedPurpose, setSelectedPurpose] = useState( formData.registration_purpose || "", ); + console.log("generalSchema", generalSchema); const [ pendingChangeRegistrationPurpose, setPendingChangeRegistrationPurpose, @@ -67,13 +69,15 @@ const OperationInformationForm = ({ const handleSubmit = async (data: { formData?: OperationInformationFormData; }) => { + console.log("did i make it into handlesubmit"); setError(undefined); + const response = await actionHandler( `registration/operations/${operationId}`, - "PUT", + "POST", "", { - body: JSON.stringify(data.formData), + body: convertRjsfFormData(data.formData), }, ); @@ -155,6 +159,7 @@ const OperationInformationForm = ({ if (newSelectedPurpose !== selectedPurpose) { handleSelectedPurposeChange(newSelectedPurpose); } + console.log("e.formdata", e.formData); }} onCancel={() => router.push("/operations")} formContext={{ diff --git a/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts b/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts index 1a4d325d58..56d4fee472 100644 --- a/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts +++ b/bciers/apps/administration/app/data/jsonSchema/operationInformation/administrationRegistrationInformation.ts @@ -21,10 +21,11 @@ export const createAdministrationRegistrationInformationSchema = } = await getContacts(); if (contacts && "error" in contacts) throw new Error("Failed to retrieve contacts information"); - const registrationPurposes: { id: number; name: string }[] = - await getRegistrationPurposes(); - if (registrationPurposes && "error" in registrationPurposes) - throw new Error("Failed to retrieve registration purposes information"); + // const registrationPurposes: { id: number; name: string }[] = + // await getRegistrationPurposes(); + // console.log("registrationPurposes", registrationPurposes); + // if (registrationPurposes && "error" in registrationPurposes) + // throw new Error("Failed to retrieve registration purposes information"); const reportingActivities: { id: number; applicable_to: string; @@ -67,12 +68,20 @@ export const createAdministrationRegistrationInformationSchema = const registrationInformationSchema: RJSFSchema = { title: "Registration Information", type: "object", - required: ["operation_representatives"], + required: ["registration_purpose", "operation_representatives"], properties: { registration_purpose: { type: "string", title: "The purpose of this registration is to register as a:", - enum: registrationPurposes, + // enum: registrationPurposes, + enum: [ + RegistrationPurposes.ELECTRICITY_IMPORT_OPERATION, + RegistrationPurposes.NEW_ENTRANT_OPERATION, + RegistrationPurposes.OBPS_REGULATED_OPERATION, + RegistrationPurposes.OPTED_IN_OPERATION, + RegistrationPurposes.POTENTIAL_REPORTING_OPERATION, + RegistrationPurposes.REPORTING_OPERATION, + ], }, operation_representatives: { title: "Operation Representative(s)", @@ -147,11 +156,11 @@ export const createAdministrationRegistrationInformationSchema = registration_purpose: { const: RegistrationPurposes.NEW_ENTRANT_OPERATION, }, - new_entrant_preface: { - // Not an actual field, just used to display a message - type: "object", - readOnly: true, - }, + // new_entrant_preface: { + // // Not an actual field, just used to display a message + // type: "object", + // readOnly: true, + // }, regulated_products: { ...regulatedProductsSchema, }, @@ -189,11 +198,11 @@ export const createAdministrationRegistrationInformationSchema = activities: { ...reportingActivitiesSchema, }, - opted_in_preface: { - // Not an actual field, just used to display a message - type: "object", - readOnly: true, - }, + // opted_in_preface: { + // // Not an actual field, just used to display a message + // type: "object", + // readOnly: true, + // }, opted_in_operation: { type: "object", properties: { diff --git a/bciers/apps/administration/app/data/jsonSchema/operationInformation/operationInformation.ts b/bciers/apps/administration/app/data/jsonSchema/operationInformation/operationInformation.ts index 808d97ae1f..18622a9f5b 100644 --- a/bciers/apps/administration/app/data/jsonSchema/operationInformation/operationInformation.ts +++ b/bciers/apps/administration/app/data/jsonSchema/operationInformation/operationInformation.ts @@ -91,12 +91,10 @@ export const createOperationInformationSchema = async ( process_flow_diagram: { type: "string", title: "Process Flow Diagram", - format: "data-url", }, boundary_map: { type: "string", title: "Boundary Map", - format: "data-url", }, ...(app === Apps.ADMINISTRATION && { bc_obps_regulated_operation: { diff --git a/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx b/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx index 7d9cb9ce6f..d1f832232e 100644 --- a/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx +++ b/bciers/apps/administration/tests/components/operations/OperationInformationForm.test.tsx @@ -7,6 +7,11 @@ import { useSearchParams, useSession, } from "@bciers/testConfig/mocks"; +import { + downloadUrl, + downloadUrl2, + mockFile, +} from "libs/testConfig/src/constants"; import { createAdministrationOperationInformationSchema } from "apps/administration/app/data/jsonSchema/operationInformation/administrationOperationInformation"; import { Apps, FrontEndRoles, OperationStatus } from "@bciers/utils/src/enums"; @@ -26,8 +31,6 @@ useSearchParams.mockReturnValue({ get: vi.fn(), }); -const mockDataUri = "data:application/pdf;name=testpdf.pdf;base64,ZHVtbXk="; - // Just using a simple schema for testing purposes const testSchema: RJSFSchema = { type: "object", @@ -163,13 +166,16 @@ const newEntrantFormData = { name: "Operation 5", type: "Single Facility Operation", registration_purpose: RegistrationPurposes.NEW_ENTRANT_OPERATION, - new_entrant_application: mockDataUri, + new_entrant_application: downloadUrl, date_of_first_shipment: "On or before March 31, 2024", }; const operationId = "8be4c7aa-6ab3-4aad-9206-0ef914fea063"; describe("the OperationInformationForm component", () => { + global.URL.createObjectURL = vi.fn( + () => "this is the link to download the File", + ); beforeEach(() => { vi.clearAllMocks(); }); @@ -340,15 +346,15 @@ describe("the OperationInformationForm component", () => { expect(actionHandler).toHaveBeenCalledTimes(1); expect(actionHandler).toHaveBeenCalledWith( `registration/operations/${operationId}`, - "PUT", + "POST", "", { - body: JSON.stringify({ - name: "Operation 4", - type: "Single Facility Operation", - }), + body: expect.any(FormData), }, ); + const bodyFormData = actionHandler.mock.calls[0][3].body; + expect(bodyFormData.get("name")).toBe("Operation 4"); + expect(bodyFormData.get("type")).toBe("Single Facility Operation"); // Expect the form to be submitted expect(screen.getByText(/Operation 4/i)).toBeVisible(); @@ -744,12 +750,12 @@ describe("the OperationInformationForm component", () => { expect( screen.getByText(/new entrant application and statutory declaration/i), ).toBeVisible(); - expect(screen.getByText("testpdf.pdf")).toBeVisible(); + expect(screen.getByText("test.pdf")).toBeVisible(); expect( screen.getByRole("link", { name: /preview/i, }), - ).toHaveAttribute("href", mockDataUri); + ).toHaveAttribute("href", "this is the link to download the File"); }); it("should edit and submit the new entrant application form", async () => { @@ -804,7 +810,6 @@ describe("the OperationInformationForm component", () => { new_entrant_application: { type: "string", title: "New Entrant Application and Statutory Declaration", - format: "data-url", }, }, }, @@ -829,9 +834,7 @@ describe("the OperationInformationForm component", () => { ); await userEvent.click(afterAprilRadioButton); - const mockFile = new File(["test"], "mock_file.pdf", { - type: "application/pdf", - }); + const newEntrantApplicationDocument = screen.getByLabelText( /new entrant application and statutory declaration/i, ); @@ -844,19 +847,23 @@ describe("the OperationInformationForm component", () => { expect(actionHandler).toHaveBeenCalledTimes(1); expect(actionHandler).toHaveBeenCalledWith( `registration/operations/${operationId}`, - "PUT", + "POST", "", { - body: JSON.stringify({ - name: "Operation 5", - type: "Single Facility Operation", - registration_purpose: "New Entrant Operation", - date_of_first_shipment: "On or after April 1, 2024", - new_entrant_application: - "data:application/pdf;name=mock_file.pdf;base64,dGVzdA==", - }), + body: expect.any(FormData), }, ); + const bodyFormData = actionHandler.mock.calls[0][3].body; + + expect(bodyFormData.get("name ")).toBe("Operation 5"); + expect(bodyFormData.get("type ")).toBe("Single Facility Operation"); + expect(bodyFormData.get("registration_purpose ")).toBe( + "New Entrant Operation", + ); + expect(bodyFormData.get("date_of_first_shipment ")).toBe( + "On or after April 1, 2024", + ); + expect(bodyFormData.get("new_entrant_application")).toBe(mockFile); }); it("should not allow external users to remove their operation rep", async () => { @@ -912,8 +919,8 @@ describe("the OperationInformationForm component", () => { regulated_products: [1], opt_in: false, operation_representatives: [1], - boundary_map: mockDataUri, - process_flow_diagram: mockDataUri, + boundary_map: downloadUrl, + process_flow_diagram: downloadUrl2, }; useSession.mockReturnValue({ data: { @@ -964,23 +971,28 @@ describe("the OperationInformationForm component", () => { expect(actionHandler).toHaveBeenCalledTimes(1); expect(actionHandler).toHaveBeenCalledWith( `registration/operations/${operationId}`, - "PUT", + "POST", "", { - body: JSON.stringify({ - name: "Operation 3", - type: "Single Facility Operation", - naics_code_id: 1, - secondary_naics_code_id: 2, - process_flow_diagram: mockDataUri, - boundary_map: mockDataUri, - operation_has_multiple_operators: false, - registration_purpose: "Reporting Operation", - operation_representatives: [2], - activities: [1, 2], - }), + body: expect.any(FormData), }, ); + const bodyFormData = actionHandler.mock.calls[0][3].body; + + expect(bodyFormData.get("name")).toBe("Operation 3"); + expect(bodyFormData.get("type")).toBe("Single Facility Operation"); + expect(bodyFormData.get("naics_code_id")).toBe("1"); + expect(bodyFormData.get("secondary_naics_code_id")).toBe("2"); + expect(bodyFormData.get("boundary_map")).toBe(downloadUrl); + expect(bodyFormData.get("process_flow_diagram")).toBe(downloadUrl2); + expect(bodyFormData.get("operation_has_multiple_operators")).toBe( + "false", + ); + expect(bodyFormData.get("registration_purpose")).toBe( + "Reporting Operation", + ); + expect(bodyFormData.get("operation_representatives")).toBe("2"); + expect(bodyFormData.getAll("activities")).toStrictEqual(["1", "2"]); }, ); diff --git a/bciers/apps/registration/app/components/operations/registration/FacilityInformationForm.tsx b/bciers/apps/registration/app/components/operations/registration/FacilityInformationForm.tsx index 55fa90491c..74a9be1457 100644 --- a/bciers/apps/registration/app/components/operations/registration/FacilityInformationForm.tsx +++ b/bciers/apps/registration/app/components/operations/registration/FacilityInformationForm.tsx @@ -66,6 +66,7 @@ const FacilityInformationForm = ({ steps, }: FacilityInformationFormProps) => { const [formState, setFormState] = useState(formData ?? {}); + const [isSubmitting, setIsSubmitting] = useState(false); // Get the list of sections in the LFO schema - used to unnest the formData const formSectionListSfo = Object.keys( diff --git a/bciers/apps/registration/app/components/operations/registration/NewEntrantOperationForm.tsx b/bciers/apps/registration/app/components/operations/registration/NewEntrantOperationForm.tsx index 4ea510057d..2eb458b149 100644 --- a/bciers/apps/registration/app/components/operations/registration/NewEntrantOperationForm.tsx +++ b/bciers/apps/registration/app/components/operations/registration/NewEntrantOperationForm.tsx @@ -8,6 +8,7 @@ import { NewEntrantOperationFormData, OperationRegistrationFormProps, } from "apps/registration/app/components/operations/registration/types"; +import { convertRjsfFormData } from "./OperationInformationForm"; interface NewEntrantOperationFormProps extends OperationRegistrationFormProps { formData: NewEntrantOperationFormData | {}; @@ -24,11 +25,8 @@ const NewEntrantOperationForm = ({ const handleSubmit = async (e: IChangeEvent) => { const endpoint = `registration/operations/${operation}/registration/new-entrant-application`; // errors are handled in MultiStepBase - const response = await actionHandler(endpoint, "PUT", `${baseUrl}`, { - body: JSON.stringify({ - new_entrant_application: e.formData.new_entrant_application, - date_of_first_shipment: e.formData.date_of_first_shipment, - }), + const response = await actionHandler(endpoint, "POST", `${baseUrl}`, { + body: convertRjsfFormData(e.formData), }); return response; }; diff --git a/bciers/apps/registration/app/components/operations/registration/OperationInformationForm.tsx b/bciers/apps/registration/app/components/operations/registration/OperationInformationForm.tsx index 0f0d102ad3..6fbaa9ba5f 100644 --- a/bciers/apps/registration/app/components/operations/registration/OperationInformationForm.tsx +++ b/bciers/apps/registration/app/components/operations/registration/OperationInformationForm.tsx @@ -26,6 +26,45 @@ interface OperationInformationFormProps { steps: string[]; } +export const convertRjsfFormData = (rjsfFormData: { [key: string]: any }) => { + const formData = new FormData(); + for (const key in rjsfFormData) { + // this removes the file keys if no new file has been uploaded (a new file will be of type File) + if ( + (key === "boundary_map" || + key === "process_flow_diagram" || + key === "new_entrant_application") && + typeof rjsfFormData[key] === "string" + ) { + delete rjsfFormData[key]; + continue; + } + // this condition is to prevent sending '[]' if an RJSF array field is empty + if (Array.isArray(rjsfFormData[key]) && rjsfFormData[key].length === 0) { + continue; + } + + // this handles the multiple_operators_array, which is nested + if (key === "multiple_operators_array") { + formData.append(key, JSON.stringify(rjsfFormData[key])); + } + // this handles any other flat array + else if (Array.isArray(rjsfFormData[key])) { + for (const el of rjsfFormData[key]) { + formData.append(key, el); + } + // this handles all other non-array values + } else { + formData.append(key, rjsfFormData[key]); + } + } + for (let [key, value] of formData.entries()) { + console.log(key, value); + } + + return formData; +}; + const OperationInformationForm = ({ rawFormData, schema: initialSchema, @@ -125,21 +164,21 @@ const OperationInformationForm = ({ const handleSubmit = async (data: { formData?: any }) => { const isCreating = !data.formData?.section1?.operation; - const postEndpoint = `registration/operations`; - const putEndpoint = `registration/operations/${data.formData?.section1?.operation}/registration/operation`; - const body = JSON.stringify( - createUnnestedFormData(data.formData, [ - "section1", - "section2", - "section3", - ]), - ); + const createEndpoint = `registration/operations`; + const editEndpoint = `registration/operations/${data.formData?.section1?.operation}/registration/operation`; + const response = await actionHandler( - isCreating ? postEndpoint : putEndpoint, - isCreating ? "POST" : "PUT", + isCreating ? createEndpoint : editEndpoint, + "POST", "", { - body, + body: convertRjsfFormData( + createUnnestedFormData(data.formData, [ + "section1", + "section2", + "section3", + ]), + ), }, ).then((resolve) => { if (resolve?.error) { @@ -167,7 +206,6 @@ const OperationInformationForm = ({ setConfirmedFormState(combinedData); setKey(Math.random()); // NOSONAR }; - const handleSelectedPurposeChange = (data: any) => { const newSelectedPurpose: RegistrationPurposes = data.section1?.registration_purpose; diff --git a/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts b/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts index 0a78f5fe67..b704dd2d5d 100644 --- a/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts +++ b/bciers/apps/registration/app/data/jsonSchema/operationRegistration/newEntrantOperation.ts @@ -21,7 +21,6 @@ export const newEntrantOperationSchema: RJSFSchema = { new_entrant_application: { type: "string", title: "New Entrant Application and Statutory Declaration", - format: "data-url", }, }, dependencies: { 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 fa3d3cd93d..630b4284fb 100644 --- a/bciers/apps/registration/tests/components/operations/registration/NewEntrantOperationForm.test.tsx +++ b/bciers/apps/registration/tests/components/operations/registration/NewEntrantOperationForm.test.tsx @@ -12,6 +12,7 @@ import { newEntrantOperationSchema } from "@/registration/app/data/jsonSchema/op import NewEntrantOperationForm from "@/registration/app/components/operations/registration/NewEntrantOperationForm"; import { allOperationRegistrationSteps } from "@/registration/app/components/operations/registration/enums"; import { actionHandler, useRouter, useSession } from "@bciers/testConfig/mocks"; +import { downloadUrl, mockFile } from "libs/testConfig/src/constants"; useSession.mockReturnValue({ data: { @@ -27,13 +28,12 @@ useRouter.mockReturnValue({ push: mockPush, }); -export const mockDataUri = - "data:application/pdf;name=testpdf.pdf;base64,ZHVtbXk="; -const mockFile = new File(["test"], "test.pdf", { type: "application/pdf" }); - describe("the NewEntrantOperationForm component", () => { beforeEach(() => { vi.clearAllMocks(); + global.URL.createObjectURL = vi.fn( + () => "this is the link to download the File", + ); }); it("should render the NewEntrantOperationForm component", () => { @@ -67,7 +67,7 @@ describe("the NewEntrantOperationForm component", () => { render( { />, ); - expect(screen.getByText("testpdf.pdf")).toBeVisible(); + expect(screen.getByText("test.pdf")).toBeVisible(); }); it("should display the correct url and message for the default date choice", () => { @@ -193,12 +193,17 @@ describe("the NewEntrantOperationForm component", () => { expect(actionHandler).toHaveBeenCalledWith( "registration/operations/002d5a9e-32a6-4191-938c-2c02bfec592d/registration/new-entrant-application", - "PUT", + "POST", "/register-an-operation/002d5a9e-32a6-4191-938c-2c02bfec592d", { - body: '{"new_entrant_application":"data:application/pdf;name=test.pdf;base64,dGVzdA==","date_of_first_shipment":"On or before March 31, 2024"}', + body: expect.any(FormData), }, ); + const formData = actionHandler.mock.calls[0][3].body; + expect(formData.get("new_entrant_application")).toBe(mockFile); + expect(formData.get("date_of_first_shipment")).toBe( + "On or before March 31, 2024", + ); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith( "/register-an-operation/002d5a9e-32a6-4191-938c-2c02bfec592d/5", diff --git a/bciers/apps/registration/tests/components/operations/registration/OperationInformationForm.test.tsx b/bciers/apps/registration/tests/components/operations/registration/OperationInformationForm.test.tsx index 2f84ac49f4..1b85f96181 100644 --- a/bciers/apps/registration/tests/components/operations/registration/OperationInformationForm.test.tsx +++ b/bciers/apps/registration/tests/components/operations/registration/OperationInformationForm.test.tsx @@ -18,13 +18,22 @@ import { import userEvent from "@testing-library/user-event"; import { actionHandler } from "@bciers/testConfig/mocks"; import { createRegistrationOperationInformationSchema } from "@/registration/app/data/jsonSchema/operationInformation/registrationOperationInformation"; -import { mockDataUri } from "./NewEntrantOperationForm.test"; + import { fillComboboxWidgetField } from "@bciers/testConfig/helpers/helpers"; import fetchFormEnums from "@bciers/testConfig/helpers/fetchFormEnums"; import { Apps } from "@bciers/utils/src/enums"; +import { + downloadUrl, + downloadUrl2, + mockFile, + mockFile2, +} from "libs/testConfig/src/constants"; const mockPush = vi.fn(); -const mockFile = new File(["test"], "test.pdf", { type: "application/pdf" }); + +global.URL.createObjectURL = vi.fn( + () => "this is the link to download the File", +); describe("the OperationInformationForm component", () => { beforeEach(() => { @@ -100,8 +109,8 @@ describe("the OperationInformationForm component", () => { name: "Existing Operation", type: "Single Facility Operation", naics_code_id: 1, - boundary_map: mockDataUri, - process_flow_diagram: mockDataUri, + boundary_map: downloadUrl, + process_flow_diagram: downloadUrl2, }); // mock the GET from selecting an operation actionHandler.mockResolvedValueOnce({ @@ -152,7 +161,8 @@ describe("the OperationInformationForm component", () => { "211110 - Oil and gas extraction (except oil sands)", ); - expect(screen.getAllByText(/testpdf.pdf/i)).toHaveLength(2); + expect(screen.getByText(/test.pdf/i)).toBeVisible(); + expect(screen.getByText(/test2.pdf/i)).toBeVisible(); }); // edit one of the pre-filled values await userEvent.type( @@ -171,23 +181,25 @@ describe("the OperationInformationForm component", () => { // LastCalledWith because we mock the actionHandler multiple times to populate the dropdown options and operation info expect(actionHandler).toHaveBeenLastCalledWith( "registration/operations/b974a7fc-ff63-41aa-9d57-509ebe2553a4/registration/operation", - "PUT", + "POST", "", - { - body: JSON.stringify({ - registration_purpose: "Reporting Operation", - operation: "b974a7fc-ff63-41aa-9d57-509ebe2553a4", - activities: [1], - name: "Existing Operation edited", - type: "Single Facility Operation", - naics_code_id: 1, - process_flow_diagram: - "data:application/pdf;name=testpdf.pdf;base64,ZHVtbXk=", - boundary_map: - "data:application/pdf;name=testpdf.pdf;base64,ZHVtbXk=", - operation_has_multiple_operators: false, - }), - }, + { body: expect.any(FormData) }, + ); + const bodyFormData = actionHandler.mock.calls[1][3].body; + expect(bodyFormData.get("registration_purpose")).toBe( + "Reporting Operation", + ); + expect(bodyFormData.get("operation")).toBe( + "b974a7fc-ff63-41aa-9d57-509ebe2553a4", + ); + expect(bodyFormData.get("activities")).toBe("1"); + expect(bodyFormData.get("name")).toBe("Existing Operation edited"); + expect(bodyFormData.get("type")).toBe("Single Facility Operation"); + expect(bodyFormData.get("naics_code_id")).toBe("1"); + expect(bodyFormData.get("boundary_map")).toBe(downloadUrl); + expect(bodyFormData.get("process_flow_diagram")).toBe(downloadUrl2); + expect(bodyFormData.get("operation_has_multiple_operators")).toBe( + "false", ); expect(mockPush).toHaveBeenCalledWith( @@ -278,7 +290,7 @@ describe("the OperationInformationForm component", () => { await userEvent.upload(processFlowDiagramInput, mockFile); const boundaryMapInput = screen.getByLabelText(/boundary map+/i); - await userEvent.upload(boundaryMapInput, mockFile); + await userEvent.upload(boundaryMapInput, mockFile2); // add multiple operator await userEvent.click( @@ -329,37 +341,38 @@ describe("the OperationInformationForm component", () => { "registration/operations", "POST", "", - { - body: JSON.stringify({ - registration_purpose: "OBPS Regulated Operation", - regulated_products: [1, 2], - activities: [2], - name: "Op Name", - type: "Single Facility Operation", - naics_code_id: 1, - process_flow_diagram: - "data:application/pdf;name=test.pdf;base64,dGVzdA==", - boundary_map: - "data:application/pdf;name=test.pdf;base64,dGVzdA==", - operation_has_multiple_operators: true, - multiple_operators_array: [ - { - mo_is_extraprovincial_company: false, - mo_legal_name: "edit", - mo_trade_name: "edit", - mo_business_structure: "BC Corporation", - mo_cra_business_number: 999999999, - mo_attorney_street_address: "edit", - mo_municipality: "edit", - mo_province: "AB", - mo_postal_code: "A1B2C3", - mo_bc_corporate_registry_number: "zzz9999999", - }, - ], - }), - }, + { body: expect.any(FormData) }, ); }); + const formData = actionHandler.mock.calls[0][3].body; + expect(formData.get("registration_purpose")).toBe( + "OBPS Regulated Operation", + ); + // expect(formData.get("regulated_products")).toBe( [1, 2]) + expect(formData.get("activities")).toBe("2"); + expect(formData.get("name")).toBe("Op Name"); + expect(formData.get("type")).toBe("Single Facility Operation"); + expect(formData.get("naics_code_id")).toBe("1"); + expect(formData.get("process_flow_diagram")).toBe(mockFile); + expect(formData.get("boundary_map")).toBe(mockFile2); + expect(formData.get("operation_has_multiple_operators")).toBe("true"); + expect(formData.get("multiple_operators_array")).toBe( + JSON.stringify([ + { + mo_is_extraprovincial_company: false, + mo_legal_name: "edit", + mo_trade_name: "edit", + mo_business_structure: "BC Corporation", + mo_cra_business_number: 999999999, + mo_attorney_street_address: "edit", + mo_municipality: "edit", + mo_province: "AB", + mo_postal_code: "A1B2C3", + mo_bc_corporate_registry_number: "zzz9999999", + }, + ]), + ); + expect(mockPush).toHaveBeenCalledWith( "/register-an-operation/b974a7fc-ff63-41aa-9d57-509ebe2553a4/2?operations_title=Picklejuice", ); @@ -411,15 +424,15 @@ describe("the OperationInformationForm component", () => { "registration/operations", "POST", "", - { - body: JSON.stringify({ - registration_purpose: "Electricity Import Operation", - name: "EIO Op Name", - type: "Electricity Import Operation", - operation_has_multiple_operators: false, - }), - }, + { body: expect.any(FormData) }, + ); + const formData = actionHandler.mock.calls[0][3].body; + expect(formData.get("registration_purpose")).toBe( + "Electricity Import Operation", ); + expect(formData.get("name")).toBe("EIO Op Name"); + expect(formData.get("type")).toBe("Electricity Import Operation"); + expect(formData.get("operation_has_multiple_operators")).toBe("false"); }); expect(mockPush).toHaveBeenCalledWith( "/register-an-operation/b974a7fc-ff63-41aa-9d57-509ebe2553a4/2?operations_title=EIO%20Op%20Name", diff --git a/bciers/libs/components/src/form/SingleStepTaskListForm.tsx b/bciers/libs/components/src/form/SingleStepTaskListForm.tsx index bbdb295fef..6e191f0f1b 100644 --- a/bciers/libs/components/src/form/SingleStepTaskListForm.tsx +++ b/bciers/libs/components/src/form/SingleStepTaskListForm.tsx @@ -19,6 +19,7 @@ interface SingleStepTaskListFormProps { formData: { [key: string]: any }; onCancel: () => void; onChange?: (e: IChangeEvent) => void; + onSubmit: (e: IChangeEvent) => any; schema: RJSFSchema; uiSchema: UiSchema; diff --git a/bciers/libs/components/src/form/widgets/FileWidget.test.tsx b/bciers/libs/components/src/form/widgets/FileWidget.test.tsx index 13102977d9..6f939b69fc 100644 --- a/bciers/libs/components/src/form/widgets/FileWidget.test.tsx +++ b/bciers/libs/components/src/form/widgets/FileWidget.test.tsx @@ -5,22 +5,17 @@ import FormBase from "@bciers/components/form/FormBase"; import { useSession } from "@bciers/testConfig/mocks"; import { Session } from "@bciers/testConfig/types"; import { checkNoValidationErrorIsTriggered } from "@/tests/helpers/form"; +import { + downloadUrl, + downloadUrl2, + mock21MBFile, + mockFile, + mockFile2, + mockFileUnaccepted, +} from "@bciers/testConfig/constants"; const fileFieldLabel = "FileWidget test field"; const fileLabelRequired = `${fileFieldLabel}*`; -export const testDataUri = - "data:application/pdf;name=testpdf.pdf;base64,ZHVtbXk="; -export const testDataUri2 = - "data:application/pdf;name=testpdf2.pdf;base64,ZHVtbXk="; -const mockFile = new File(["test"], "test.pdf", { type: "application/pdf" }); -const mockFile2 = new File(["test2"], "test2.pdf", { type: "application/pdf" }); -const mockFileUnaccepted = new File(["test"], "test.txt", { - type: "text/plain", -}); - -const mock21MBFile = new File(["test".repeat(20000000)], "test.pdf", { - type: "application/pdf", -}); const alertMock = vi.spyOn(window, "alert"); @@ -30,7 +25,6 @@ export const fileFieldSchema = { properties: { fileTestField: { type: "string", - format: "data-url", title: fileFieldLabel, }, }, @@ -53,6 +47,9 @@ describe("RJSF FileWidget", () => { }, }, } as Session); + global.URL.createObjectURL = vi.fn( + () => "this is the link to download the File", + ); }); it("should render a file field", async () => { @@ -71,13 +68,13 @@ describe("RJSF FileWidget", () => { , ); - expect(screen.getByText("testpdf.pdf")).toBeVisible(); + expect(screen.getByText("test.pdf")).toBeVisible(); }); it("should allow uploading a file", async () => { @@ -176,7 +173,7 @@ describe("RJSF FileWidget", () => { , @@ -184,37 +181,29 @@ describe("RJSF FileWidget", () => { const input = screen.getByLabelText(fileLabelRequired); - expect(screen.getByText("testpdf.pdf")).toBeVisible(); + expect(screen.getByText("test.pdf")).toBeVisible(); await userEvent.upload(input, mockFile2); expect(screen.getByText("test2.pdf")).toBeVisible(); }); - it("should display the file preview link when a file is uploaded", async () => { + it("should have the file download link when a file is uploaded", async () => { render(); const input = screen.getByLabelText(fileLabelRequired); expect( - screen.queryByRole("link", { name: "Preview" }), + screen.queryByRole("link", { name: "test.pdf" }), ).not.toBeInTheDocument(); await userEvent.upload(input, mockFile); - expect(screen.getByRole("link", { name: "Preview" })).toBeVisible(); - }); - - it("should have the correct href for the preview link", async () => { - render( - , + const previewLink = screen.getByRole("link", { name: "test.pdf" }); + expect(previewLink).toBeVisible(); + // mocked + expect(previewLink).toHaveAttribute( + "href", + "this is the link to download the File", ); - const previewLink = screen.getByRole("link", { name: "Preview" }); - expect(previewLink).toHaveAttribute("href", testDataUri); }); it("should not render the upload button for internal users", () => { @@ -248,7 +237,7 @@ describe("RJSF FileWidget", () => { , @@ -270,13 +259,12 @@ describe("RJSF FileWidget", () => { title: "Multiple files", items: { type: "string", - format: "data-url", }, }, }, }} formData={{ - fileTestField: [testDataUri, testDataUri2], + fileTestField: [downloadUrl, downloadUrl2], }} uiSchema={fileFieldUiSchema} />, diff --git a/bciers/libs/components/src/form/widgets/FileWidget.tsx b/bciers/libs/components/src/form/widgets/FileWidget.tsx index 6ba79b44cf..b116333e3e 100644 --- a/bciers/libs/components/src/form/widgets/FileWidget.tsx +++ b/bciers/libs/components/src/form/widgets/FileWidget.tsx @@ -1,143 +1,26 @@ "use client"; -import { - ChangeEvent, - MutableRefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { - dataURItoBlob, - FormContextType, - Registry, - RJSFSchema, - StrictRJSFSchema, - TranslatableString, - WidgetProps, -} from "@rjsf/utils"; +import { ChangeEvent, MutableRefObject, useRef, useState } from "react"; +import { WidgetProps } from "@rjsf/utils"; import { useSession } from "next-auth/react"; +import Link from "next/link"; -const addNameToDataURL = (dataURL: string, name: string) => { - if (dataURL === null) { - return null; - } - return dataURL.replace(";base64", `;name=${encodeURIComponent(name)};base64`); -}; - -type FileInfoType = { - dataURL?: string | null; - name: string; - size: number; - type: string; -}; - -const processFile = (file: File): Promise => { - const { name, size, type } = file; - return new Promise((resolve, reject) => { - const reader = new window.FileReader(); - reader.onerror = reject; - reader.onload = (event) => { - if (typeof event.target?.result === "string") { - resolve({ - dataURL: addNameToDataURL(event.target.result, name), - name, - size, - type, - }); - } else { - resolve({ - dataURL: null, - name, - size, - type, - }); - } - }; - reader.readAsDataURL(file); - }); -}; - -const processFiles = (files: FileList) => { - return Promise.all(Array.from(files).map(processFile)); -}; +export const handleValue = (value: string | File) => { + let extractedFileName: string = ""; + let downloadUrl: string = ""; -function FileInfoPreview< - T = any, - S extends StrictRJSFSchema = RJSFSchema, - F extends FormContextType = any, ->({ - fileInfo, - registry, -}: { - readonly fileInfo: FileInfoType; - readonly registry: Registry; -}) { - const { translateString } = registry; - const { dataURL, name } = fileInfo; - if (!dataURL) { - return null; + if (typeof value === "string") { + downloadUrl = value; + const match = value.match(/\/documents\/([^?]+)/); + extractedFileName = match ? match[1] : ""; } - return ( - <> - {" "} - - {translateString(TranslatableString.PreviewLabel)} - - - ); -} - -export function FilesInfo< - T = any, - S extends StrictRJSFSchema = RJSFSchema, - F extends FormContextType = any, ->({ - filesInfo, - preview, - registry, -}: { - readonly filesInfo: FileInfoType[]; - readonly preview?: boolean; - readonly registry: Registry; -}) { - if (filesInfo.length === 0) { - return null; + if (value instanceof File) { + extractedFileName = value.name; + downloadUrl = URL.createObjectURL(value); } - return ( -
    - {filesInfo.map((fileInfo) => { - const { name } = fileInfo; - return ( -
  • - {name} - {preview && ( - - fileInfo={fileInfo} - registry={registry} - /> - )} -
  • - ); - })} -
- ); -} -export const extractFileInfo = (dataURLs: string[]): FileInfoType[] => { - return dataURLs - .filter((dataURL) => dataURL) - .map((dataURL) => { - const { blob, name } = dataURItoBlob(dataURL); - return { - dataURL, - name: name, - size: blob.size, - type: blob.type, - }; - }); + return { downloadUrl, extractedFileName }; }; const FileWidget = ({ @@ -145,25 +28,13 @@ const FileWidget = ({ disabled, readonly, required, - multiple, onChange, value, options, - registry, }: WidgetProps) => { - // We need to store the value in state to prevent loosing the value when user switches between tabs - const [localValue, setLocalValue] = useState(value); - const [filesInfo, setFilesInfo] = useState( - Array.isArray(localValue) - ? extractFileInfo(localValue) - : extractFileInfo([localValue]), - ); - // 🥷 Prevent resetting the value to null when user switch tabs - useEffect(() => { - if (localValue && !value) { - onChange(localValue); - } - }, [localValue, onChange, value]); + console.log("value", value); + const { downloadUrl, extractedFileName } = handleValue(value); + const [fileName, setFileName] = useState(extractedFileName); const { data: session } = useSession(); const isCasInternal = @@ -175,37 +46,23 @@ const FileWidget = ({ const handleClick = () => { hiddenFileInput.current.click(); }; - - const handleChange = useCallback( - (event: ChangeEvent) => { - event.preventDefault(); + const handleChange = async (evt: ChangeEvent) => { + if (!evt.target.files) { + return; + } + if (evt.target.files.length > 0) { + const file = evt.target.files[0]; const maxSize = 20000000; - const files = event.target.files; - if (!files) { + if (file.size > maxSize) { + alert("File size must be less than 20MB"); return; } - processFiles(files).then((filesInfoEvent) => { - const newValue = filesInfoEvent.map((fileInfo) => { - if (fileInfo.size > maxSize) { - alert("File size must be less than 20MB"); - return; - } - return fileInfo.dataURL; - }); - if (multiple) { - setFilesInfo(filesInfo.concat(filesInfoEvent[0])); - onChange(localValue.concat(newValue[0])); - setLocalValue(localValue.concat(newValue[0])); - } else { - setFilesInfo(filesInfoEvent); - onChange(newValue[0]); - setLocalValue(newValue[0]); - } - }); - }, - [multiple, localValue, filesInfo, onChange], - ); + onChange(file); + // using file.name instead of something from the response because 1) don't want to add useEffect, 2) we don't store filename separately from file in db and I don't want to retrieve the whole thing + setFileName(file.name); + } + }; const disabledColour = disabled || readonly ? "text-bc-bg-dark-grey" : "text-bc-link-blue"; @@ -219,7 +76,7 @@ const FileWidget = ({ onClick={handleClick} className={`p-0 decoration-solid border-0 text-lg bg-transparent cursor-pointer underline ${disabledColour}`} > - {localValue ? "Reupload attachment" : "Upload attachment"} + {fileName ? "Reupload attachment" : "Upload attachment"} )} - {localValue ? ( - + {fileName ? ( +
    +
  • + + {fileName} + +
  • +
) : ( No attachment was uploaded )} diff --git a/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.test.tsx b/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.test.tsx index b58a57a983..bffb6ee57f 100644 --- a/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.test.tsx +++ b/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.test.tsx @@ -1,26 +1,26 @@ import { render } from "@testing-library/react"; import FormBase from "@bciers/components/form/FormBase"; -import { - testDataUri, - fileFieldSchema, - fileFieldUiSchema, -} from "../FileWidget.test"; +import { fileFieldSchema, fileFieldUiSchema } from "../FileWidget.test"; +import { mockFile } from "@bciers/testConfig/constants"; describe("RJSF ReadOnlyFileWidget", () => { + global.URL.createObjectURL = vi.fn( + () => "this is the link to download the File", + ); it("should render a file field", async () => { const { container } = render( , ); const readOnlyFileWidget = container.querySelector("#root_fileTestField"); expect(readOnlyFileWidget).toBeVisible(); - expect(readOnlyFileWidget).toHaveTextContent("testpdf.pdf"); + expect(readOnlyFileWidget).toHaveTextContent("test.pdf"); }); it("should be have the correct message when no value is provided", async () => { diff --git a/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.tsx b/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.tsx index e8e9c64cfa..081c1391ae 100644 --- a/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.tsx +++ b/bciers/libs/components/src/form/widgets/readOnly/ReadOnlyFileWidget.tsx @@ -1,39 +1,28 @@ "use client"; import { WidgetProps } from "@rjsf/utils/lib/types"; -import { useEffect, useState } from "react"; -import { - extractFileInfo, - FilesInfo, -} from "@bciers/components/form/widgets/FileWidget"; +import { handleValue } from "../FileWidget"; -const ReadOnlyFileWidget: React.FC = ({ - id, - options, - registry, - value, -}) => { - const [fileInfo, setFileInfo] = useState([]); - const fileValue = Array.isArray(value) ? value : [value]; - - useEffect(() => { - // Fix for window not being defined on page load - if (fileValue.length) { - setFileInfo(fileValue); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); +const ReadOnlyFileWidget: React.FC = ({ id, value }) => { + const { downloadUrl, extractedFileName } = handleValue(value); return (
{value ? ( - + ) : ( - <>No attachment was uploaded + No attachment was uploaded )}
); diff --git a/bciers/libs/testConfig/src/constants.ts b/bciers/libs/testConfig/src/constants.ts new file mode 100644 index 0000000000..4ac5bd078e --- /dev/null +++ b/bciers/libs/testConfig/src/constants.ts @@ -0,0 +1,17 @@ +export const downloadUrl = + "https://storage.googleapis.com/test-registration-documents/documents/test.pdf?"; +export const downloadUrl2 = + "https://storage.googleapis.com/test-registration-documents/documents/test2.pdf?"; +export const mockFile = new File(["test"], "test.pdf", { + type: "application/pdf", +}); +export const mockFile2 = new File(["test2"], "test2.pdf", { + type: "application/pdf", +}); +export const mockFileUnaccepted = new File(["test"], "test.txt", { + type: "text/plain", +}); + +export const mock21MBFile = new File(["test".repeat(20000000)], "test.pdf", { + type: "application/pdf", +}); diff --git a/bciers/libs/utils/src/customTransformErrors.ts b/bciers/libs/utils/src/customTransformErrors.ts index 8583d77d2f..f87e0a6d13 100644 --- a/bciers/libs/utils/src/customTransformErrors.ts +++ b/bciers/libs/utils/src/customTransformErrors.ts @@ -4,6 +4,40 @@ const customTransformErrors = ( errors: RJSFValidationError[], customFormatsErrorMessages: { [key: string]: string }, ) => { + console.log("errors before filter", errors); + errors = errors.filter((error) => { + // in Administration boundary_map and process_flow_diagram are in section1, and in Registration they're in section2 + if (error?.property) { + if ( + [ + ".section1.boundary_map", + ".section1.process_flow_diagram", + ".section2.boundary_map", + ".section2.process_flow_diagram", + ".new_entrant_application", + ".section3.new_entrant_application", + ].includes(error.property) + ) { + if ( + // Sometimes these fields are a string, but sometimes they're a File + error.message === "must be string" + ) { + return false; + } + } + if ([".section3"].includes(error.property)) { + // The new entrant field can be either a File or string, and if it's a File it throws the following error + if (error.message === "must match exactly one schema in oneOf") { + return false; + } + } + // if (error.message === "must be equal to constant") { + // return false; // This will exclude the error from the array + // } + } + return true; // Keep all other errors + }); + console.log("errors", errors); return errors.map((error) => { if (error?.property) { if (error.message === "must be equal to constant") { @@ -17,6 +51,7 @@ const customTransformErrors = ( error.message = customFormatsErrorMessages.cra_business_number; return error; } + if ( ["statutory_declaration", "new_entrant_application"].includes( error.property, diff --git a/docs/file_uploads.md b/docs/file_uploads.md index 51eb41e013..08770ca99d 100644 --- a/docs/file_uploads.md +++ b/docs/file_uploads.md @@ -1,18 +1,32 @@ # File uploads in BCIERS app -Could use some optimization: https://github.com/bcgov/cas-registration/issues/2123 - Two methods are available: - with RJSF - without RJSF using `FormData` -## With RJSF (using data-urls) +#### Model + +The `FileField` is where django does the magic, along with the `STORAGES` configuration in settings.py. +More documentation [here](https://docs.djangoproject.com/en/5.1/ref/models/fields/#filefield) + +```python + class Document(TimeStampedModel): + file = models.FileField(upload_to="documents", db_comment="The file format, metadata, etc.") +``` + +#### Connection with GCS + +- Some setup is done in cas-registration/bc_obps/bc_obps/settings.py, will need env variables +- GCS is not set up in CI so we skip endpoint tests related to files, and we don't have any file stuff in our mock data + +## With RJSF + +Because files can be large and slow to process, we only pass the file from the front end to the back end when the user first uploads it. We never pass the file itself from back to front; instead we pass a url where the user can download it. ### Frontend -- RJSF supports file with data-urls: https://rjsf-team.github.io/react-jsonschema-form/docs/usage/widgets/#file-widgets -- `FileWidget`: this started as a copy/paste from RJSF's [FileWidget](https://github.com/rjsf-team/react-jsonschema-form/blob/main/packages/core/src/components/widgets/FileWidget.tsx) and @marcelmueller did some styling to match the designs. It now additionally includes a check for max file size, and possibly other stuff including state mgt. +We send files to the backend using FormData. Because RJSF stores data as json, in the handleSubmit, we have to convert to FormData (see `convertRjsfFormData` from "@/registration/app/components/operations/registration/OperationInformationForm"). The conversion happens in the form component (e.g. `OperationInformationForm`). The `FileWidget` handles previewing and downloading the file, and additionally includes a check for max file size. In the rjsf schema: @@ -21,14 +35,12 @@ In the rjsf schema: statutory_declaration: { type: "string", title: "Statutory Declaration", - format: "data-url", } # uiSchema statutory_declaration: { "ui:widget": "FileWidget", "ui:options": { - filePreview: true, accept: ".pdf", } } @@ -36,61 +48,86 @@ statutory_declaration: { ### Backend -#### Ninja field validator +#### Endpoints + +To make django ninja happy, we have to separate out form data and file data. There are a few ways to do this, see the docs, and we've chosen to go with this for a POST endpoint: + +```python +def update_operation( + request: HttpRequest, operation_id: UUID, + details: Form[OperationAdministrationIn], # OperationAdministrationIn is a ModelSchema or Schema + boundary_map: UploadedFile = File(None), + process_flow_diagram: UploadedFile = File(None), + new_entrant_application: UploadedFile = File(None) +) +``` -The field is declared as a string, and we validate that we can convert it to a file +Notes: -`In` schema: +- File is always optional because if the user hasn't changed the file, we don't send anything. +- Django ninja doesn't support files on PUT, so we have to use POST for anything file-related + +A GET endpoint requires a conversion from file to download link in the ninja schema: + +`Out` schema: ```python ... - boundary_map: str + class OutWithDocuments(ModelSchema): + boundary_map: Optional[str] = None - @field_validator("boundary_map") - @classmethod - def validate_boundary_map(cls, value: str) -> ContentFile: - return data_url_to_file(value) + @staticmethod + def resolve_boundary_map(obj: Operation) -> Optional[str]: + return str(obj.get_boundary_map().file.url) ``` -The reverse can be done for the `Out` schema if needed: +The `FileWidget` and `ReadOnlyFileWidget` can handle both File and string values. -```python - boundary_map: Optional[str] = None +#### Service - @staticmethod - def resolve_boundary_map(obj: Operation) -> Optional[str]: - boundary_map = obj.get_boundary_map() - if boundary_map: - return file_to_data_url(boundary_map) +We have two services for file uploads: - return None -``` +- DocumentServiceV2.create_or_replace_operation_document +- DocumentDataAccessServiceV2.create_document -#### Service +### Testing -```python - DocumentService.create_or_replace_operation_document( - user_guid, - operation.id, - payload.boundary_map, # type: ignore # mypy is not aware of the schema validator - 'boundary_map', - ), -``` +We have mock files in both our FE and BE constants. -#### Model +In vitests, we have to mock `createObjectURL`, which is used in the `FileWidget` (e.g. `global.URL.createObjectURL = vi.fn(() => "this is the link to download the File",);`). -The `FileField` is where django does the magic, along with the `STORAGES` configuration in settings.py. -More documentation [here](https://docs.djangoproject.com/en/5.1/ref/models/fields/#filefield) +We check mocked calls like this: -```python - class Document(TimeStampedModel): - file = models.FileField(upload_to="documents", db_comment="The file format, metadata, etc.") +``` +expect(actionHandler).toHaveBeenCalledWith( + "registration/operations/b974a7fc-ff63-41aa-9d57-509ebe2553a4/registration/operation", + "POST", + "", + { body: expect.any(FormData) }, + ); + const bodyFormData = actionHandler.mock.calls[1][3].body; + expect(bodyFormData.get("registration_purpose")).toBe( + "Reporting Operation", + ); + expect(bodyFormData.getAll("activities")).toStrictEqual(["1", "2"]); + ... ``` -#### Connection with GCS +When using pytests, we have to mock payloads that include files like this (note the array []): -- Some setup is done in cas-registration/bc_obps/bc_obps/settings.py, will need env variables -- GCS is not set up in CI so we skip endpoint tests related to files, and we don't have any file stuff in our mock data +```python +mock_payload = { + 'registration_purpose': ['Reporting Operation'], + 'operation': ['556ceeb0-7e24-4d89-b639-61f625f82084'], + 'activities': ['31'], + 'name': ['Barbie'], + 'type': [Operation.Types.SFO], + 'naics_code_id': ['20'], + 'operation_has_multiple_operators': ['false'], + 'process_flow_diagram': ContentFile(bytes("testtesttesttest", encoding='utf-8'), "testfile.pdf"), + 'boundary_map': ContentFile(bytes("testtesttesttest", encoding='utf-8'), "testfile.pdf"), + } +``` ## Without RJSF