Skip to content

Commit 0eaf0b3

Browse files
authored
Merge pull request #4777 from bcgov/chore/2123-refactor-registration-file-uploads
chore: registration file upload
2 parents abbd262 + a49c797 commit 0eaf0b3

46 files changed

Lines changed: 1386 additions & 1154 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .asdict import asdict # noqa: F401
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import dataclasses
2+
from typing import Any, Optional
3+
from .dict_factory import dict_factory
4+
5+
6+
def asdict(obj: Any, *, include: Optional[set] = None, exclude_none: bool = False) -> dict:
7+
"""
8+
Providing an alternative to dataclasses.asdict to give an API similar to pydantic's dict method.
9+
"""
10+
11+
return dataclasses.asdict(obj, dict_factory=dict_factory(include=include, exclude_none=exclude_none))
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import Any, Callable, Iterable, List, Optional, Tuple
2+
3+
4+
def _field_filter(
5+
obj: List[Tuple[str, Any]], include: Optional[set] = None, exclude_none: bool = False
6+
) -> Iterable[Tuple[str, Any]]:
7+
"""
8+
Helper function to include only specified fields from a dataclass in the output dict.
9+
The field information passed to the factory by dataclasses.asdict is a list of tuples in the form (field_name, field_value).
10+
"""
11+
if include is not None and not isinstance(include, set):
12+
raise ValueError("The 'include' parameter must be a set of field names.")
13+
14+
for field in obj:
15+
if include is not None and field[0] not in include:
16+
continue
17+
if exclude_none and field[1] is None:
18+
continue
19+
yield field
20+
21+
22+
def dict_factory(include: Optional[set] = None, exclude_none: bool = False) -> Callable[[List[Tuple[str, Any]]], dict]:
23+
"""
24+
Factory function that returns a function to include only specified fields in the output dict.
25+
"""
26+
27+
def factory_func(obj: Any) -> dict:
28+
return dict(_field_filter(obj, include=include, exclude_none=exclude_none))
29+
30+
return factory_func

bc_obps/common/tests/endpoints/auth/constants.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@
236236
"kwargs": {"version_id": MOCK_INT},
237237
},
238238
{
239-
"method": "put",
239+
"method": "post",
240240
"endpoint_name": "register_edit_operation_information",
241241
"kwargs": {"operation_id": MOCK_UUID},
242242
},
@@ -266,7 +266,7 @@
266266
"kwargs": {"version_id": MOCK_INT},
267267
},
268268
{
269-
"method": "put",
269+
"method": "post",
270270
"endpoint_name": "update_operation",
271271
"kwargs": {"operation_id": MOCK_UUID},
272272
},
@@ -290,7 +290,7 @@
290290
"kwargs": {"operation_id": MOCK_UUID},
291291
},
292292
{
293-
"method": "put",
293+
"method": "post",
294294
"endpoint_name": "create_or_replace_new_entrant_application",
295295
"kwargs": {"operation_id": MOCK_UUID},
296296
},
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from dataclasses import dataclass
2+
3+
from common.lib.dataclasses.asdict import asdict
4+
5+
6+
@dataclass
7+
class Child:
8+
value: int
9+
optional_value: str | None = None
10+
11+
12+
@dataclass
13+
class Parent:
14+
name: str
15+
child: Child
16+
optional_field: str | None = None
17+
18+
19+
class TestAsdict:
20+
def test_returns_nested_dataclass_as_dict(self):
21+
subject = Parent(name="parent", child=Child(value=1, optional_value="child"))
22+
23+
assert asdict(subject) == {
24+
"name": "parent",
25+
"child": {"value": 1, "optional_value": "child"},
26+
"optional_field": None,
27+
}
28+
29+
def test_include_limits_top_level_fields(self):
30+
subject = Parent(name="parent", child=Child(value=1), optional_field="kept")
31+
32+
assert asdict(subject, include={"name", "optional_field"}) == {
33+
"name": "parent",
34+
"optional_field": "kept",
35+
}
36+
37+
def test_exclude_none_removes_none_values(self):
38+
subject = Parent(name="parent", child=Child(value=1), optional_field=None)
39+
40+
assert asdict(subject, exclude_none=True) == {
41+
"name": "parent",
42+
"child": {"value": 1},
43+
}
44+
45+
def test_include_and_exclude_none_together(self):
46+
subject = Parent(name="parent", child=Child(value=1), optional_field=None)
47+
48+
assert asdict(subject, include={"name", "optional_field"}, exclude_none=True) == {
49+
"name": "parent",
50+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.core.files.base import ContentFile
2+
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
3+
4+
5+
def create_test_file(name: str) -> UploadedFile:
6+
return InMemoryUploadedFile(ContentFile(b"file_content", name=name), None, name, None, 12, "utf-8")

bc_obps/registration/api/_operations/_operation_id/_registration/new_entrant_application.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from typing import Literal, Tuple
22
from uuid import UUID
33
from django.http import HttpRequest
4+
from ninja import File, UploadedFile
45
from registration.constants import OPERATION_TAGS
56
from service.error_service.custom_codes_4xx import custom_codes_4xx
67
from registration.schema import (
78
OperationUpdateOut,
8-
OperationNewEntrantApplicationIn,
99
OperationNewEntrantApplicationOut,
1010
Message,
1111
)
@@ -15,7 +15,6 @@
1515
from registration.models import Operation
1616
from registration.api.router import router
1717

18-
1918
##### GET #####
2019

2120

@@ -32,16 +31,18 @@ def get_operation_new_entrant_application(request: HttpRequest, operation_id: UU
3231
return 200, OperationService.get_if_authorized(get_current_user_guid(request), operation_id, ['id', 'operator_id'])
3332

3433

35-
@router.put(
34+
@router.post(
3635
"/operations/{uuid:operation_id}/registration/new-entrant-application",
3736
response={200: OperationUpdateOut, custom_codes_4xx: Message},
3837
tags=OPERATION_TAGS,
3938
description="Creates or replaces a new entrant application document for an Operation",
4039
auth=authorize("approved_industry_user"),
4140
)
4241
def create_or_replace_new_entrant_application(
43-
request: HttpRequest, operation_id: UUID, payload: OperationNewEntrantApplicationIn
42+
request: HttpRequest,
43+
operation_id: UUID,
44+
new_entrant_application: File[UploadedFile],
4445
) -> Tuple[Literal[200], Operation]:
4546
return 200, OperationService.create_or_replace_new_entrant_application(
46-
get_current_user_guid(request), operation_id, payload
47+
get_current_user_guid(request), operation_id, new_entrant_application
4748
)

bc_obps/registration/api/_operations/_operation_id/_registration/operation.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from typing import Literal, Tuple
22
from uuid import UUID
33
from django.http import HttpRequest
4+
from ninja import File, UploadedFile
45
from registration.schema import OperationInformationIn, OperationUpdateOut, OperationRegistrationOut, Message
5-
from service.operation_service import OperationService
6+
from service.operation_service import OperationData, OperationService, MultipleOperatorData
67
from registration.constants import OPERATION_TAGS
78
from common.permissions import authorize
89
from common.api.utils import get_current_user_guid
@@ -25,10 +26,7 @@ def register_get_operation_information(request: HttpRequest, operation_id: UUID)
2526
return 200, OperationService.get_if_authorized(get_current_user_guid(request), operation_id)
2627

2728

28-
##### PUT #####
29-
30-
31-
@router.put(
29+
@router.post(
3230
"/operations/{uuid:operation_id}/registration/operation",
3331
response={200: OperationUpdateOut, custom_codes_4xx: Message},
3432
tags=OPERATION_TAGS,
@@ -37,6 +35,29 @@ def register_get_operation_information(request: HttpRequest, operation_id: UUID)
3735
auth=authorize('approved_industry_user'),
3836
)
3937
def register_edit_operation_information(
40-
request: HttpRequest, operation_id: UUID, payload: OperationInformationIn
38+
request: HttpRequest,
39+
operation_id: UUID,
40+
payload: OperationInformationIn,
41+
# django-ninja doesn't parse multipart requests properly if the type is marked Optional
42+
boundary_map: File[UploadedFile] = None, # type: ignore
43+
process_flow_diagram: File[UploadedFile] = None, # type: ignore
44+
new_entrant_application: File[UploadedFile] = None, # type: ignore
4145
) -> Tuple[Literal[200], Operation]:
42-
return 200, OperationService.register_operation_information(get_current_user_guid(request), operation_id, payload)
46+
47+
data = OperationData(
48+
boundary_map=boundary_map,
49+
process_flow_diagram=process_flow_diagram,
50+
new_entrant_application=new_entrant_application,
51+
**payload.model_dump(exclude={'multiple_operators_array'}),
52+
multiple_operators_array=(
53+
[
54+
MultipleOperatorData(
55+
**op.model_dump(exclude={'business_structure'}), business_structure_id=op.business_structure.name # type: ignore
56+
)
57+
for op in payload.multiple_operators_array or []
58+
]
59+
),
60+
)
61+
operation = OperationService.register_operation_information(get_current_user_guid(request), operation_id, data)
62+
63+
return 200, operation

bc_obps/registration/api/_operations/_operation_id/_registration/operation_representative.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,10 @@ def create_operation_representative(
4141
def remove_operation_representative(
4242
request: HttpRequest, operation_id: UUID, payload: OperationRepresentativeRemove
4343
) -> Tuple[Literal[200], OperationRepresentativeRemove]:
44-
return 200, OperationService.remove_operation_representative(get_current_user_guid(request), operation_id, payload)
44+
removed_id = OperationService.remove_operation_representative(
45+
get_current_user_guid(request),
46+
operation_id,
47+
payload.id, # type: ignore
48+
)
49+
50+
return 200, OperationRepresentativeRemove(id=removed_id)

bc_obps/registration/api/_operations/_operation_id/_registration/opted_in_operation.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.http import HttpRequest
44
from registration.models.opted_in_operation_detail import OptedInOperationDetail
55
from registration.schema import OptedInOperationDetailIn, OptedInOperationDetailOut, OptedOutOperationDetailIn, Message
6+
from service.data_types.operation_service import OptedInOperationDetailData
67
from service.operation_service import OperationService
78
from registration.constants import OPERATION_TAGS
89
from common.permissions import authorize
@@ -36,7 +37,13 @@ def operation_registration_get_opted_in_operation_detail(
3637
def operation_registration_update_opted_in_operation_detail(
3738
request: HttpRequest, operation_id: UUID, payload: OptedInOperationDetailIn
3839
) -> Tuple[Literal[200, 400], OptedInOperationDetail]:
39-
return 200, OperationService.update_opted_in_operation_detail(get_current_user_guid(request), operation_id, payload)
40+
41+
opted_in_operation_detail_data = OptedInOperationDetailData(**payload.dict())
42+
operation = OperationService.update_opted_in_operation_detail(
43+
get_current_user_guid(request), operation_id, opted_in_operation_detail_data
44+
)
45+
46+
return 200, operation
4047

4148

4249
@router.put(

0 commit comments

Comments
 (0)