Skip to content

Commit 06b749b

Browse files
committed
docs: explain file upload strategy
1 parent 173be36 commit 06b749b

File tree

1 file changed

+80
-43
lines changed

1 file changed

+80
-43
lines changed

docs/file_uploads.md

Lines changed: 80 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
11
# File uploads in BCIERS app
22

3-
Could use some optimization: https://github.com/bcgov/cas-registration/issues/2123
4-
53
Two methods are available:
64

75
- with RJSF
86
- without RJSF using `FormData`
97

10-
## With RJSF (using data-urls)
8+
#### Model
9+
10+
The `FileField` is where django does the magic, along with the `STORAGES` configuration in settings.py.
11+
More documentation [here](https://docs.djangoproject.com/en/5.1/ref/models/fields/#filefield)
12+
13+
```python
14+
class Document(TimeStampedModel):
15+
file = models.FileField(upload_to="documents", db_comment="The file format, metadata, etc.")
16+
```
17+
18+
#### Connection with GCS
19+
20+
- Some setup is done in cas-registration/bc_obps/bc_obps/settings.py, will need env variables
21+
- 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
22+
23+
## With RJSF
24+
25+
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.
1126

1227
### Frontend
1328

14-
- RJSF supports file with data-urls: https://rjsf-team.github.io/react-jsonschema-form/docs/usage/widgets/#file-widgets
15-
- `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.
29+
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.
1630

1731
In the rjsf schema:
1832

@@ -21,76 +35,99 @@ In the rjsf schema:
2135
statutory_declaration: {
2236
type: "string",
2337
title: "Statutory Declaration",
24-
format: "data-url",
2538
}
2639

2740
# uiSchema
2841
statutory_declaration: {
2942
"ui:widget": "FileWidget",
3043
"ui:options": {
31-
filePreview: true,
3244
accept: ".pdf",
3345
}
3446
}
3547
```
3648

3749
### Backend
3850

39-
#### Ninja field validator
51+
#### Endpoints
52+
53+
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:
54+
55+
```python
56+
def update_operation(
57+
request: HttpRequest, operation_id: UUID,
58+
details: Form[OperationAdminstrationIn], # OperationAdminstrationIn is a ModelSchema or Schema
59+
boundary_map: UploadedFile = File(None),
60+
process_flow_diagram: UploadedFile = File(None),
61+
new_entrant_application: UploadedFile = File(None)
62+
)
63+
```
4064

41-
The field is declared as a string, and we validate that we can convert it to a file
65+
Notes:
4266

43-
`In` schema:
67+
- File is always optional because if the user hasn't changed the file, we don't send anything.
68+
- Django ninja doesn't support files on PUT, so we have to use POST for anything file-related
69+
70+
A GET endpoint requires a conversion from file to download link in the ninja schema:
71+
72+
`Out` schema:
4473

4574
```python
4675
...
47-
boundary_map: str
76+
class OutWithDocuments(ModelSchema):
77+
boundary_map: Optional[str] = None
4878

49-
@field_validator("boundary_map")
50-
@classmethod
51-
def validate_boundary_map(cls, value: str) -> ContentFile:
52-
return data_url_to_file(value)
79+
@staticmethod
80+
def resolve_boundary_map(obj: Operation) -> Optional[str]:
81+
return str(obj.get_boundary_map().file.url)
5382
```
5483

55-
The reverse can be done for the `Out` schema if needed:
84+
The `FileWidget` and `ReadOnlyFileWidget` can handle both File and string values.
5685

57-
```python
58-
boundary_map: Optional[str] = None
86+
#### Service
5987

60-
@staticmethod
61-
def resolve_boundary_map(obj: Operation) -> Optional[str]:
62-
boundary_map = obj.get_boundary_map()
63-
if boundary_map:
64-
return file_to_data_url(boundary_map)
88+
We have two services for file uploads:
6589

66-
return None
67-
```
90+
- DocumentServiceV2.create_or_replace_operation_document
91+
- DocumentDataAccessServiceV2.create_document
6892

69-
#### Service
93+
### Testing
7094

71-
```python
72-
DocumentService.create_or_replace_operation_document(
73-
user_guid,
74-
operation.id,
75-
payload.boundary_map, # type: ignore # mypy is not aware of the schema validator
76-
'boundary_map',
77-
),
78-
```
95+
We have mock files in both our FE and BE constants.
7996

80-
#### Model
97+
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",);`).
8198

82-
The `FileField` is where django does the magic, along with the `STORAGES` configuration in settings.py.
83-
More documentation [here](https://docs.djangoproject.com/en/5.1/ref/models/fields/#filefield)
99+
We check mocked calls like this:
84100

85-
```python
86-
class Document(TimeStampedModel):
87-
file = models.FileField(upload_to="documents", db_comment="The file format, metadata, etc.")
101+
```
102+
expect(actionHandler).toHaveBeenCalledWith(
103+
"registration/operations/b974a7fc-ff63-41aa-9d57-509ebe2553a4/registration/operation",
104+
"POST",
105+
"",
106+
{ body: expect.any(FormData) },
107+
);
108+
const bodyFormData = actionHandler.mock.calls[1][3].body;
109+
expect(bodyFormData.get("registration_purpose")).toBe(
110+
"Reporting Operation",
111+
);
112+
expect(bodyFormData.getAll("activities")).toStrictEqual(["1", "2"]);
113+
...
88114
```
89115

90-
#### Connection with GCS
116+
When using pytests, we have to mock payloads that include files like this (note the array []):
91117

92-
- Some setup is done in cas-registration/bc_obps/bc_obps/settings.py, will need env variables
93-
- 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
118+
```python
119+
mock_payload = {
120+
'registration_purpose': ['Reporting Operation'],
121+
'operation': ['556ceeb0-7e24-4d89-b639-61f625f82084'],
122+
'activities': ['31'],
123+
'name': ['Barbie'],
124+
'type': [Operation.Types.SFO],
125+
'naics_code_id': ['20'],
126+
'operation_has_multiple_operators': ['false'],
127+
'process_flow_diagram': ContentFile(bytes("testtesttesttest", encoding='utf-8'), "testfile.pdf"),
128+
'boundary_map': ContentFile(bytes("testtesttesttest", encoding='utf-8'), "testfile.pdf"),
129+
}
130+
```
94131

95132
## Without RJSF
96133

0 commit comments

Comments
 (0)