Skip to content

Commit 8685fc4

Browse files
authored
Merge pull request #2892 from bcgov/DEP-277-formio-fileupload-backend
DEP-277: Add CHEFS + Object storage file upload functionality to DEP (+fix survey listing)
2 parents a8980ab + ae31e02 commit 8685fc4

49 files changed

Lines changed: 1031 additions & 417 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.MD

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
## Jul 7, 2026
1+
## July 07, 2026
2+
3+
- **Feature** Added ability to upload files to Object Storage during survey submission [🎟️ DEP-277](https://citz-gdx.atlassian.net/browse/DEP-277)
4+
- Added a new public document endpoint to the API that generates pre-signed URLs for uploading files to Object Storage. This endpoint takes the file name and content type as input and returns a pre-signed URL that can be used to upload the file directly to Object Storage.
5+
- The new API can also be used to generate pre-signed URLs for downloading files from Object Storage, as well as deleting files from Object Storage.
6+
- Updated the Formio options to include callbacks for handling file uploads, downloads, and deletions. These callbacks use the new API to generate URLs and perform privileged operations on Object Storage, allowing users to upload files during survey submission without needing high-level access to the storage service.
7+
- Cleaned up survey-related component code
8+
- Updated the survey pages to use React Router loaders to fetch the survey data instead of using useState and useEffect, as well as making sure the loader returns promises (as opposed to awaiting the data) so that the survey page can be rendered while the data is being fetched. This improves the user experience by allowing the page to render faster and show a loading state while the survey data is being retrieved.
9+
- Fixed authorization bug that did not allow Super Admins to view surveys
210

311
- **Feature** Integrated new landing hero section and introduction [🎟️ DEP-315](https://citz-gdx.atlassian.net/browse/DEP-315)
412
- Refactored existing landing code for structural simplicity

api/src/api/models/submission.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55
from __future__ import annotations
66

7-
from typing import List
7+
from typing import List, Optional
88

99
from sqlalchemy import ForeignKey
1010
from sqlalchemy.dialects import postgresql
@@ -26,20 +26,27 @@ class Submission(BaseModel): # pylint: disable=too-few-public-methods
2626
__tablename__ = 'submission'
2727

2828
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
29-
submission_json = db.Column(postgresql.JSONB(astext_type=db.Text()), nullable=False, server_default='{}')
30-
survey_id = db.Column(db.Integer, ForeignKey('survey.id', ondelete='CASCADE'), nullable=False)
31-
engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=False)
32-
participant_id = db.Column(db.Integer, ForeignKey('participant.id'), nullable=True)
29+
submission_json = db.Column(postgresql.JSONB(
30+
astext_type=db.Text()), nullable=False, server_default='{}')
31+
survey_id = db.Column(db.Integer, ForeignKey(
32+
'survey.id', ondelete='CASCADE'), nullable=False)
33+
engagement_id = db.Column(db.Integer, ForeignKey(
34+
'engagement.id', ondelete='CASCADE'), nullable=False)
35+
participant_id = db.Column(
36+
db.Integer, ForeignKey('participant.id'), nullable=True)
3337
reviewed_by = db.Column(db.String(50))
3438
review_date = db.Column(db.DateTime)
35-
comment_status_id = db.Column(db.Integer, ForeignKey('comment_status.id', ondelete='SET NULL'))
39+
comment_status_id = db.Column(db.Integer, ForeignKey(
40+
'comment_status.id', ondelete='SET NULL'))
3641
has_personal_info = db.Column(db.Boolean, nullable=True)
3742
has_profanity = db.Column(db.Boolean, nullable=True)
3843
rejected_reason_other = db.Column(db.String(500), nullable=True)
3944
has_threat = db.Column(db.Boolean, nullable=True)
4045
notify_email = db.Column(db.Boolean(), default=True)
41-
comments = db.relationship('Comment', backref='submission', cascade='all, delete')
42-
staff_note = db.relationship('StaffNote', backref='submission', cascade='all, delete')
46+
comments = db.relationship(
47+
'Comment', backref='submission', cascade='all, delete')
48+
staff_note = db.relationship(
49+
'StaffNote', backref='submission', cascade='all, delete')
4350

4451
@classmethod
4552
def get_by_survey_id(cls, survey_id) -> List[SubmissionSchema]:
@@ -118,7 +125,7 @@ def update(cls, submission: SubmissionSchema, session=None) -> Submission:
118125
return query.first()
119126

120127
@classmethod
121-
def update_comment_status(cls, submission_id, comment: dict, session=None) -> Submission:
128+
def update_comment_status(cls, submission_id, comment: dict, session=None) -> Optional[Submission]:
122129
"""Update comment status."""
123130
status_id = comment.get('status_id', None)
124131
has_personal_info = comment.get('has_personal_info', None)

api/src/api/resources/document.py

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,47 @@
1414
"""API endpoints for managing documents resource."""
1515

1616
from http import HTTPStatus
17+
from typing import Any, cast
1718

1819
from flask import jsonify, request
1920
from flask_cors import cross_origin
2021
from flask_restx import Namespace, Resource
2122

23+
from api.models import Engagement as EngagementModel
24+
from api.models import Survey as SurveyModel
2225
from api.schemas.document import Document
26+
from api.schemas.public_upload import PublicObjectAccessRequestSchema, PublicUploadAuthorizationRequestSchema
27+
from api.services.email_verification_service import EmailVerificationService
2328
from api.services.object_storage_service import ObjectStorageService
2429
from api.utils.roles import Role
2530
from api.utils.tenant_validator import require_role
2631
from api.utils.util import allowedorigins, cors_preflight
2732

2833

29-
API = Namespace('document', description='Endpoints for Document Storage Management')
34+
API = Namespace(
35+
'document', description='Endpoints for Document Storage Management')
3036
"""Custom exception messages"""
3137

3238

39+
def _get_public_upload_scope(verification_token: str):
40+
"""Return the validated public upload scope for the provided verification token."""
41+
verification = cast(
42+
dict[str, Any], EmailVerificationService().get_active(verification_token))
43+
survey_id = verification.get('survey_id')
44+
if not survey_id:
45+
raise ValueError('Survey not found.')
46+
47+
survey = SurveyModel.get_open(survey_id)
48+
if not survey:
49+
raise PermissionError('Engagement not open to submissions.')
50+
51+
engagement = EngagementModel.find_by_id(survey.engagement_id)
52+
if not engagement:
53+
raise ValueError('Engagement not found.')
54+
55+
return verification, survey, engagement
56+
57+
3358
@cors_preflight('GET,OPTIONS')
3459
@API.route('/')
3560
class DocumentStorage(Resource):
@@ -42,9 +67,133 @@ def post():
4267
"""Retrieve authentication properties for document storage."""
4368
try:
4469
requestfilejson = request.get_json()
45-
documents = Document().load(requestfilejson, many=True)
70+
documents = cast(list[dict[str, Any]],
71+
Document().load(requestfilejson, many=True))
4672
return jsonify(ObjectStorageService().get_auth_headers(documents)), HTTPStatus.OK
4773
except KeyError as err:
4874
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
4975
except ValueError as err:
5076
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
77+
78+
79+
@cors_preflight('POST,OPTIONS')
80+
@API.route('/public')
81+
class PublicDocumentUploadAuthorization(Resource):
82+
"""Token-scoped public upload/download/delete authorization controller."""
83+
84+
@staticmethod
85+
@cross_origin(origins=allowedorigins())
86+
def post():
87+
"""Retrieve upload authorization for a public survey file upload."""
88+
try:
89+
request_json = request.get_json()
90+
upload_request = cast(
91+
dict[str, Any],
92+
PublicUploadAuthorizationRequestSchema().load(request_json),
93+
)
94+
95+
verification, survey, _ = _get_public_upload_scope(
96+
upload_request['verification_token'])
97+
98+
object_storage_service = ObjectStorageService()
99+
object_key, unique_filename = object_storage_service.build_public_upload_key(
100+
tenant_id=survey.tenant_id,
101+
survey_id=survey.id,
102+
verification_id=verification['id'],
103+
filename=upload_request['filename'],
104+
)
105+
signed_upload = object_storage_service.get_signed_upload_details(
106+
object_key=object_key,
107+
content_type=upload_request.get('content_type'),
108+
)
109+
110+
return {
111+
**signed_upload,
112+
'uniquefilename': unique_filename,
113+
'filename': upload_request['filename'],
114+
'content_type': upload_request['content_type'],
115+
'size': upload_request['size'],
116+
}, HTTPStatus.OK
117+
except KeyError as err:
118+
return str(err), HTTPStatus.BAD_REQUEST
119+
except PermissionError as err:
120+
return {'message': str(err)}, HTTPStatus.FORBIDDEN
121+
except ValueError as err:
122+
return {'message': str(err)}, HTTPStatus.BAD_REQUEST
123+
124+
@staticmethod
125+
@cross_origin(origins=allowedorigins())
126+
def get():
127+
"""Retrieve download authorization for a public survey file upload."""
128+
try:
129+
file_id = request.args.get('file_id')
130+
verification_token = request.headers.get('Verification-Token')
131+
if not file_id or not verification_token:
132+
return {'message': 'Missing required parameters.'}, HTTPStatus.BAD_REQUEST
133+
access_request = cast(
134+
dict[str, Any],
135+
PublicObjectAccessRequestSchema().load({
136+
'file_id': request.args.get('file_id'),
137+
'verification_token': request.headers.get('Verification-Token'),
138+
}),
139+
)
140+
verification, survey, _ = _get_public_upload_scope(
141+
access_request['verification_token'])
142+
143+
object_storage_service = ObjectStorageService()
144+
object_key = object_storage_service.get_object_key(
145+
access_request['file_id'])
146+
expected_prefix = object_storage_service.build_public_upload_prefix(
147+
tenant_id=survey.tenant_id,
148+
survey_id=survey.id,
149+
verification_id=verification['id'],
150+
)
151+
if not object_key.startswith(expected_prefix):
152+
return {'message': 'Invalid file requested.'}, HTTPStatus.FORBIDDEN
153+
154+
return object_storage_service.get_signed_request_details('GET', object_key), HTTPStatus.OK
155+
except KeyError as err:
156+
return str(err), HTTPStatus.BAD_REQUEST
157+
except PermissionError as err:
158+
return {'message': str(err)}, HTTPStatus.FORBIDDEN
159+
except ValueError as err:
160+
return {'message': str(err)}, HTTPStatus.BAD_REQUEST
161+
162+
@staticmethod
163+
@cross_origin(origins=allowedorigins())
164+
def delete():
165+
"""Delete a public survey upload for a valid verification token."""
166+
try:
167+
headers = request.headers
168+
args = request.args
169+
if not args.get('file_id') or not headers.get('Verification-Token'):
170+
return {'message': 'Missing required parameters.'}, HTTPStatus.BAD_REQUEST
171+
access_request = cast(
172+
dict[str, Any],
173+
PublicObjectAccessRequestSchema().load({
174+
'file_id': args.get('file_id'),
175+
'verification_token': headers.get('Verification-Token'),
176+
}),
177+
)
178+
verification, survey, _ = _get_public_upload_scope(
179+
access_request['verification_token'])
180+
181+
object_storage_service = ObjectStorageService()
182+
object_key = object_storage_service.get_object_key(
183+
access_request['file_id'])
184+
expected_prefix = object_storage_service.build_public_upload_prefix(
185+
tenant_id=survey.tenant_id,
186+
survey_id=survey.id,
187+
verification_id=verification['id'],
188+
)
189+
if not object_key.startswith(expected_prefix):
190+
return {'message': 'Invalid file requested.'}, HTTPStatus.FORBIDDEN
191+
192+
object_storage_service.delete_file(object_key)
193+
return {}, HTTPStatus.NO_CONTENT
194+
except KeyError as err:
195+
return str(err), HTTPStatus.BAD_REQUEST
196+
except PermissionError as err:
197+
return {'message': str(err)}, HTTPStatus.FORBIDDEN
198+
except ValueError as err:
199+
return {'message': str(err)}, HTTPStatus.BAD_REQUEST

api/src/api/resources/survey.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,21 @@ def get():
8787
exclude_hidden=args.get('exclude_hidden', False, bool),
8888
exclude_template=args.get('exclude_template', False, bool),
8989
search_text=args.get('search_text', '', str),
90-
is_unlinked=args.get('is_unlinked', default=False, type=lambda v: v.lower() == 'true'),
91-
is_linked=args.get('is_linked', default=False, type=lambda v: v.lower() == 'true'),
92-
is_hidden=args.get('is_hidden', default=False, type=lambda v: v.lower() == 'true'),
93-
is_template=args.get('is_template', default=False, type=lambda v: v.lower() == 'true'),
94-
created_date_from=args.get('created_date_from', None, type=str),
90+
is_unlinked=args.get(
91+
'is_unlinked', default=False, type=lambda v: v.lower() == 'true'),
92+
is_linked=args.get('is_linked', default=False,
93+
type=lambda v: v.lower() == 'true'),
94+
is_hidden=args.get('is_hidden', default=False,
95+
type=lambda v: v.lower() == 'true'),
96+
is_template=args.get(
97+
'is_template', default=False, type=lambda v: v.lower() == 'true'),
98+
created_date_from=args.get(
99+
'created_date_from', None, type=str),
95100
created_date_to=args.get('created_date_to', None, type=str),
96-
published_date_from=args.get('published_date_from', None, type=str),
97-
published_date_to=args.get('published_date_to', None, type=str),
101+
published_date_from=args.get(
102+
'published_date_from', None, type=str),
103+
published_date_to=args.get(
104+
'published_date_to', None, type=str),
98105
)
99106

100107
survey_records = SurveyService()\
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Schemas for public object-storage upload authorization."""
2+
3+
from marshmallow import EXCLUDE, Schema, fields, validate
4+
5+
6+
class PublicUploadAuthorizationRequestSchema(Schema):
7+
"""Request schema for token-scoped public upload authorization."""
8+
9+
class Meta: # pylint: disable=too-few-public-methods
10+
"""Exclude unknown fields in the deserialized output."""
11+
12+
unknown = EXCLUDE
13+
14+
filename = fields.Str(
15+
data_key='filename',
16+
required=True,
17+
validate=validate.Length(min=1, max=2048),
18+
)
19+
content_type = fields.Str(
20+
data_key='content_type',
21+
required=True,
22+
validate=validate.Length(min=1, max=255),
23+
)
24+
size = fields.Int(
25+
data_key='size',
26+
required=True,
27+
validate=validate.Range(min=1),
28+
)
29+
verification_token = fields.Str(
30+
data_key='verification_token',
31+
required=True,
32+
validate=validate.Length(min=1, max=255),
33+
)
34+
35+
36+
class PublicObjectAccessRequestSchema(Schema):
37+
"""Request schema for token-scoped access to a previously uploaded object."""
38+
39+
class Meta: # pylint: disable=too-few-public-methods
40+
"""Exclude unknown fields in the deserialized output."""
41+
42+
unknown = EXCLUDE
43+
44+
file_id = fields.Str(
45+
data_key='file_id',
46+
required=True,
47+
validate=validate.Length(min=1, max=4096),
48+
)
49+
verification_token = fields.Str(
50+
data_key='verification_token',
51+
required=True,
52+
validate=validate.Length(min=1, max=255),
53+
)

0 commit comments

Comments
 (0)