Skip to content

Modernize file storage and transport using Django Storage API - #3732

Closed
cesarbenjamindotnet wants to merge 21 commits into
ohcnetwork:developfrom
xiidigital:gcp
Closed

Modernize file storage and transport using Django Storage API#3732
cesarbenjamindotnet wants to merge 21 commits into
ohcnetwork:developfrom
xiidigital:gcp

Conversation

@cesarbenjamindotnet

@cesarbenjamindotnet cesarbenjamindotnet commented Aug 7, 2026

Copy link
Copy Markdown

This PR modernizes CARE's file storage implementation by adopting Django's Storage API and simplifying the HTTP file transport contract, according to #3731

The objective is to reduce provider-specific code while preserving local MinIO compatibility and enabling additional storage backends through configuration.

What changed

Storage

  • migrate object persistence to Django Storage API
  • introduce django-storages
  • preserve MinIO as the default local backend
  • support generic S3-compatible providers
  • support Google Cloud Storage through Django Storage
  • remove provider-specific persistence logic
  • centralize storage configuration

File transport

  • remove provider-generated signed upload URLs
  • remove provider-generated signed download URLs
  • replace base64 uploads with multipart uploads
  • route uploads through CARE
  • route downloads through CARE using Django FileResponse
  • remove provider-specific bucket URLs from the public API

Benefits

  • provider-neutral storage layer
  • frontend no longer depends on storage implementation
  • easier support for additional storage providers
  • native Django upload handling
  • lower upload overhead
  • simpler storage architecture
  • centralized authorization
  • improved maintainability

Local development

The existing Docker development workflow continues using MinIO through S3Storage.

No cloud provider is required for local development.

Breaking changes

This PR changes the file API contract.

Removed:

  • signed_url
  • read_signed_url
  • JSON/base64 upload transport

Added:

  • multipart uploads
  • CARE download endpoints (download_url)

The frontend must be updated accordingly.

Out of scope

This PR intentionally does not include:

  • asynchronous runtime changes;
  • Cloud Tasks;
  • Redis changes;
  • cache redesign;
  • distributed locking;
  • deployment infrastructure.

Those are planned as separate architectural phases.

Testing

The implementation has been validated with:

  • local MinIO integration
  • Django Storage tests
  • multipart upload tests
  • provider-neutral storage tests
  • full regression suite
  • end-to-end HTTP upload/download verification

Notes

The architectural work was intentionally split into independent phases.

This PR contains only the storage and file-transport modernization to keep review focused.

Merge Checklist

  • Tests added/fixed
  • Update docs in /docs
  • Linting Complete
  • Any other necessary step

Summary by CodeRabbit

  • New Features

    • Added multipart binary file uploads with filename, size, type, and authorization validation.
    • Added CARE-mediated downloads for uploaded files and reports, including streaming responses and appropriate inline or attachment handling.
    • Added public routes for facility cover images and user profile pictures.
    • Added support for configurable S3-compatible and Google Cloud storage backends.
  • Improvements

    • File responses now use provider-neutral download URLs rather than direct storage links.
    • Added configurable upload transport limits and updated storage architecture documentation.

cesarbenjamindotnet and others added 21 commits August 5, 2026 15:32
Phase 0 inventory of CARE's runtime, verified against the code at
6a2976d. Documentation only; no application behavior changed.

Adds docs/xii/architecture/inventory/:
  storage-call-sites.md    19 object-storage call sites
  task-call-sites.md       8 task definitions, 4 async dispatch sites
  cache-and-redis.md       69 cache/Redis call sites, 3 hard couplings
  frontend-file-flow.md    file API contract and required changes
  runtime-and-deployment.md process/image/CI facts, baseline blockers
  plugin-impact.md         plugin loading; no plugins bundled
  unresolved-items.md      12 defects, 10 open questions, 8 unknowns

Corrections to 01-current-runtime.md:
  - remove stray ````markdown fence that made the frontmatter and
    sections 1-2 render as literal code
  - section 38: patient files do not use FILE_UPLOAD_REGION/KEY/SECRET;
    get_patient_bucket_config reads FACILITY_S3_* instead, leaving
    those three settings dead
  - section 40: note reports inherit the same FACILITY_S3_* credentials
  - section 26: add the three task definitions outside care/emr/tasks/
  - fix two wrong paths to 02-target-runtime.md
  - record verified_against_commit

Baseline commands could not run: Docker daemon down, make absent, no
Python 3.13 environment, Redis not running. Blockers documented in
runtime-and-deployment.md section 11.

All 84 cited paths and 46 cited symbols verified to exist.

Co-Authored-By: Claude <noreply@anthropic.com>
Record the Phase 0 green baseline for the current upstream-compatible
CARE runtime. Documentation only; no runtime, settings, model, migration
or dependency change.

runtime-and-deployment.md
- Replace the BLOCKED section 11 with the recorded baseline: environment,
  environment files, translated Makefile commands, build result, service
  health, startup sequence, migration and sync results, fixtures, tests.
- 312 migrations applied; makemigrations --check clean; 115 permissions,
  10 roles, 546 role-permissions, 30 valuesets; fixtures loaded.
- 1912 tests, 0 skipped, green on 2 of 3 --parallel --shuffle runs.

unresolved-items.md
- Mark Part E resolved and record the disposition of E1-E6.
- Withdraw E6: no root .env is required. docker/.local.env and
  docker/.prebuilt.env are tracked in git and have no .example variants.
- Add E7 (flaky test_password_request_rate_limiting under --parallel) and
  E8 (transient BuildKit pip cache wheel corruption).
- Resolve D8.

Co-Authored-By: Claude <noreply@anthropic.com>
ADR-0001 / ES-01 (IS-01) milestone 1.

Adds django-storages 1.14.6 with the s3 and google extras so that
object persistence can move onto Django's Storage API. No application
code uses it yet.

Resolution notes:
- A full `pipenv lock` moved 59 unrelated packages, which ES-01 section 8
  forbids. Used `pipenv upgrade` instead, which merges only the resolved
  subtree into Pipfile.lock.
- All 44 direct [packages] pins are unchanged, including boto3 ==1.43.6
  (retained: SNS and the legacy signed-URL path still need it) and
  django ==6.0.
- dev-packages and docs categories are untouched.
- 12 packages added: django-storages plus the google-cloud-storage tree.
- 8 transitive packages moved as an unavoidable consequence of resolving
  the google extra: asgiref, botocore, certifi, cffi, charset-normalizer,
  cryptography, idna, s3transfer. None is a direct dependency.

django-storages 1.14.6 advertises Django support only through 5.1, so
compatibility with Django 6.0 was verified by rebuilding the image and
importing both S3Storage and GoogleCloudStorage.

Co-Authored-By: Claude <noreply@anthropic.com>
ADR-0001 / ES-01 (IS-01) milestone 2. Configuration only; no application
code is routed through the new aliases yet.

Adds CARE_STORAGE_BACKEND (s3 default, gcs supported). An unsupported
value raises ImproperlyConfigured naming the supported values.

Defines the logical aliases patient, facility and report in STORAGES,
built by config/storage.py. staticfiles is untouched and stays on
WhiteNoise. `default` is deliberately still absent: Django 6.0 does not
merge STORAGES with its global defaults and nothing in CARE resolves that
alias, so adding it would change behaviour rather than preserve it.

Bucket names come from provider-neutral CARE_*_STORAGE_BUCKET variables,
each defaulting to the existing setting, so no local or deployed
configuration has to be rewritten. report shares the patient bucket, as
before, but is a separate alias so it can be repointed without touching
application code.

Behaviour change, per ES-01 section 26 (do not preserve a verified
settings bug): the patient and report aliases now read FILE_UPLOAD_REGION,
FILE_UPLOAD_KEY and FILE_UPLOAD_SECRET. get_patient_bucket_config and
get_report_bucket_config read the FACILITY_S3_* credentials instead,
leaving those three settings dead. Locally all of them resolve to the same
MinIO values, so there is no local change; a deployment that sets
FACILITY_S3_KEY to something other than BUCKET_KEY without also setting
FILE_UPLOAD_KEY will need to set it.

file_overwrite is set explicitly because CARE generates a unique
internal_name and the boto3 put_object being replaced overwrote
unconditionally; Django must not rename on collision. Verified against
MinIO: re-saving returns the same name and replaces the content.

BUCKET_HAS_FINE_ACL is honoured at the facility alias rather than per
object. The facility bucket's only writer is cover_image.py, so this is
equivalent. It is ignored under GCS, which requires uniform bucket-level
access.

Verified: all four aliases resolve; a MinIO save/exists/size/open/delete
round trip succeeds; open of a missing object raises FileNotFoundError;
GCS aliases construct with no credentials present; collectstatic and
manage.py check pass.

Co-Authored-By: Claude <noreply@anthropic.com>
ADR-0001 / ES-01 (IS-01) milestone 3. Ordinary object persistence now
goes through Django Storage aliases. No provider client is constructed
for persistence anywhere in CARE.

file_manager.py replaces S3FilesManager with FilesManager, a transitional
wrapper that resolves a logical alias and delegates to Django Storage. It
imports no provider SDK and holds no provider branch. Retained rather
than removed because files_manager is a class attribute on FileUpload and
ReportUpload and is used by viewsets, tasks and report generation;
rewriting every caller was wider than IS-01 warrants.

get_storage_name() is the provider-neutral name helper. It preserves the
verified <file_type>/<internal_name> convention exactly, returns a
relative name with no bucket, URL or endpoint, and rejects traversal.
Both components are already server-controlled (FileTypeChoices, the
report-type registry, and a UUID internal_name), so the guard is defence
in depth.

Signed URLs move to the new legacy_signed_urls.py, isolated from ordinary
CRUD per ES-01 section 21 and listing its exact callers. It is the only
remaining provider SDK use for files and is S3-only; IS-02 removes it.

Migrated call sites:
- file_upload viewset put_object -> Storage.save (unchanged signature)
- report generation -> Storage.save, wrapping the already-materialised
  bytes in ContentFile
- cleanup task -> Storage.delete
- cover_image.py -> storages[facility] directly, keeping its own
  <folder>/<id>_<token>.<ext> key convention, and now streaming the
  uploaded file instead of passing raw bytes

delete_object no longer takes `quiet`. Django Storage delete is
idempotent on both backends, and so was the boto3 call it replaces: S3
delete_object does not raise NoSuchKey, so the old quiet=False branch
never fired.

report generation keeps an explicit content type. S3Storage prefers
content.content_type; GoogleCloudStorage derives it from the name, so the
extension in internal_name stays the portable signal. The only registered
generator is html/.html, which guesses identically.

test_file_upload_api no longer asserts botocore ClientError/NoSuchKey. It
now asserts the object does not exist and that opening it raises
FileNotFoundError -- provider-neutral and one assertion stronger.

care/emr/tasks/report_generation.py keeps autoretry_for=(ClientError,)
deliberately. It imports an exception class for retry configuration and
never instantiates boto3; under the default s3 profile django-storages
raises ClientError from inside Storage.save, so retry behaviour is
unchanged. It would not retry under gcs; recorded as a known gap rather
than changing Celery retry semantics, which ES-01 section 31 excludes.

Tests: 102 pass across file upload, facility, user and diagnostic report
suites, including real MinIO round trips.

Co-Authored-By: Claude <noreply@anthropic.com>
ADR-0001 / ES-01 (IS-01) milestone 4. 50 new tests.

care/utils/tests/test_storage_config.py -- construction only, no provider
contact and no Google credentials:
- s3 is the default backend
- patient, facility and report resolve to S3Storage under s3
- staticfiles stays on WhiteNoise
- an invalid backend raises ImproperlyConfigured naming s3 and gcs
- report shares the patient bucket while staying a distinct alias
- alias names contain no provider name
- GCS variables are not required under s3, and S3 options are not leaked
  into a GCS alias
- GCS never uses object ACLs, and GoogleCloudStorage constructs with no
  credentials present
- the endpoint is omitted for AWS and included for S3-compatible

care/emr/tests/test_storage.py:
- the <file_type>/<internal_name> convention, including Unicode,
  multi-part extensions and unusual names, all preserved byte-for-byte
- names are relative: no bucket, URL or endpoint
- traversal, absolute paths and empty components raise
  SuspiciousFileOperation
- models are bound to logical aliases, cover images to facility
- delegation proven by substituting InMemoryStorage at the Django Storage
  boundary; no provider SDK is mocked
- MinIO integration on all three aliases: save, exists, size, open,
  delete, missing-object FileNotFoundError, idempotent delete, and
  overwrite keeping the name

Writing the delegation tests surfaced a real distinction worth recording:
overwrite-on-collision comes from the file_overwrite backend option, not
from Django Storage. InMemoryStorage renames instead. So overwrite is
asserted against the configured backends in the MinIO tests and the
option itself in the config tests, while the delegation test asserts only
that FilesManager reports back what the backend did.

Integration test names are UUID-based because the 16 parallel workers
share one MinIO.

Co-Authored-By: Claude <noreply@anthropic.com>
…rence

07-configuration-reference.md section 12.4 specifies GCS_PROJECT_ID. The
initial implementation used CARE_GCS_PROJECT_ID. The documentation is
authoritative, so the code is aligned to it rather than the reverse.

Optional either way: when unset, GoogleCloudStorage uses Application
Default Credentials and no project_id option is emitted.

Co-Authored-By: Claude <noreply@anthropic.com>
Documentation only.

inventory/storage-call-sites.md
- New section 11 marks the migration status of all 19 call sites:
  9 migrated_to_django_storage, 7 legacy_signed_url_only,
  2 temporary_wrapper, 1 removed, 1 not_storage_persistence, 0 blocked.
- Records the alias table, the object-name helper, the corrected
  credential defect from section 2.1 and its behaviour change, the two
  remaining provider SDK uses, and the whole-file reads that remain.
- Sections 1-8 are left as the Phase 0 snapshot; section 11 is current
  where they disagree.

inventory/frontend-file-flow.md
- New section 11: no route, request field or response field changed.
- Maps the file and line references IS-01 relocated.
- Records which persistence now uses Django Storage, and which signed
  upload, signed download and base64 flows remain for IS-02.
- Marks D3 partly done and C3 resolved at the settings layer; the rest of
  the section 9 change list is unchanged.
- Records that legacy_signed_urls is S3-only, so IS-02 is a prerequisite
  for a real GCS deployment.

inventory/unresolved-items.md
- New Part B2 with S1 (GCS cannot serve files end to end), S2 (report
  retry does not fire under GCS), S3 (overwrite safety depends on a
  backend option) and S4 (items unchanged by IS-01).
- B10 resolved: delete_objects removed.
- B1 partly resolved: the aliases read the correct credentials; the two
  csp/config.py resolvers still carry the defect but are now reached only
  by the legacy signed-URL path.
- E7 scope corrected. It is not one flaky test but a defect class: six
  tests across rate limiting and favorites, all caused by a single Redis
  cache shared by 16 parallel workers while three modules call
  cache.clear() in setUp. Measured: full suite serial 1962/1962 OK; full
  suite parallel 1 of 6 green; the three cache-touching modules alone
  fail 4 of 4 in parallel with no storage code involved. Includes the
  recommended per-worker KEY_PREFIX fix.

02-target-runtime.md section 11
- States the four required claims explicitly: Django Storage API is the
  architecture; MinIO through S3Storage is the default local profile;
  generic S3-compatible storage remains supported; GCS is the initial GCP
  profile, not the only supported provider.
- Adds the post-IS-01 status of the three SHALL NOT clauses, naming the
  two transitional exceptions.

07-configuration-reference.md
- 11.2: records CARE_STORAGE_BACKEND as implemented, s3 default, and that
  filesystem is not a supported value per ES-01 section 9.
- 12.8: corrects the suggestion that GCS_FILE_OVERWRITE=false may suit
  unique immutable names. false does not reject duplicates, it silently
  renames, which is exactly the database-reference hazard the section
  warns about. Overwrite is enabled on every alias, with the tests that
  demonstrate both behaviours cited.

Co-Authored-By: Claude <noreply@anthropic.com>
… Django

ADR-0001 completion. Object transport is now entirely mediated by CARE:
no application code generates a storage-provider URL, and no client ever
receives one.

Removed:
- care/emr/utils/legacy_signed_urls.py (presigned PUT and GET generation)
- care/utils/csp/ entirely: BucketType, CSProvider, ClientConfig and
  get_client_config existed only to resolve provider credentials and
  external endpoints for those signed URLs
- the signed_url response field on FileUploadRetrieveSpec and
  ReportUploadRetrieveSpec, and the _just_created write branch
- FACILITY_CDN, BUCKET_HAS_FINE_ACL, BUCKET_EXTERNAL_ENDPOINT,
  FILE_UPLOAD_BUCKET_EXTERNAL_ENDPOINT and
  FACILITY_S3_BUCKET_EXTERNAL_ENDPOINT, which had no consumer left
- the public-read ACL on the facility alias; every bucket is now private

Added care/emr/utils/file_download.py: a provider-neutral FileResponse
helper that streams through Django Storage. It carries the
inline-vs-attachment decision the presigned ResponseContentDisposition
used to make, so browser handling of patient documents is unchanged.

Download routes:
- GET /api/v1/files/{external_id}/download/ -- authorized by the existing
  get_queryset file_authorizer, which runs for every detail action
- GET /api/v1/template_reports/{external_id}/download/ -- authorizes
  explicitly, because ReportUploadViewSet.get_queryset only guards list
- GET /api/v1/assets/facility/{external_id}/cover_image/
- GET /api/v1/assets/user/{username}/profile_picture/

read_signed_url is replaced by download_url, a CARE route.

The two asset routes are unauthenticated, and deliberately so. Cover
images and avatars are already world-readable: AllFacilityViewSet and
FacilitySchedulableUsersViewSet are anonymous and expose them. Who can
see an image is unchanged; what changes is that CARE serves the bytes,
so the bucket becomes private. They are separate views because
FacilityViewSet and UserViewSet filter their querysets by request.user
and cannot serve an anonymous request.

Facility.read_cover_image_url and User.read_profile_picture_url now
reverse to those routes instead of concatenating a bucket URL, which also
removes the bucket-name divergence between persistence and URL building
that the review found.

BUCKET_PROVIDER survives as the credential-source switch, now compared
against a literal in config/storage.py rather than the deleted enum.

Tests: the presigned PUT round trip in test_upload_patient_file is
replaced by the Django upload endpoint plus a download through CARE,
asserting the route is a CARE URL and that Content-Type and
Content-Disposition are preserved. test_cleanup_incomplete_file_uploads
writes its object through Django Storage. The
FILE_UPLOAD_BUCKET_EXTERNAL_ENDPOINT override, which existed only for
signed URLs, is gone. 12 file API tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
plugin-impact.md records that S3FilesManager is importable from
care.emr.utils.file_manager and that any plugin may use it. Renaming it
to FilesManager in the previous commit was therefore a silent breaking
change for external plugins. The name is restored as a thin subclass.

Despite the name it is not S3-specific: it is FilesManager, delegating to
whichever provider the alias is configured for. It emits a
DeprecationWarning, accepts the historical positional argument that used
to be a BucketType, and maps PATIENT/FACILITY/REPORT onto the logical
aliases. An unrecognised value raises rather than silently writing to the
wrong bucket.

It deliberately exposes no signed_url or read_signed_url method. Those
were removed with the provider-specific transport, and a plugin must not
be able to mint a bucket URL that bypasses CARE.

Also updates the module docstring, which still pointed at the deleted
legacy_signed_urls module.

Co-Authored-By: Claude <noreply@anthropic.com>
New care/emr/tests/test_storage_transport.py (25 tests) asserts the
property the architecture depends on rather than the mechanics:

- the base64 upload endpoint still works and returns no provider URL
- no response offers signed_url or read_signed_url
- download_url is a CARE route, matched against reverse()
- detail and list responses are scanned for provider markers (minio,
  amazonaws, storage.googleapis, X-Amz-Signature, GoogleAccessId, :9100)
- downloading through Django returns the stored bytes
- download enforces authorization; a different user gets 403
- a missing object yields 404, not a provider error
- facility cover image and user avatar URLs are CARE routes that serve
  the stored bytes
- the cover-image route stays anonymous, matching the world-readable
  bucket objects it replaces, and 404s when no image is set
- every alias round-trips through Django Storage
- no manager exposes a signed-URL helper

Plugin compatibility: S3FilesManager imports, warns, delegates to Django
Storage, maps each legacy bucket name, rejects unknown aliases, and
exposes no signed-URL method.

Updated test_storage_config for the removed default_acl parameter and
added a test that no configured alias can be public.

68 storage tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
ADR-0001
- Implementation checklist: IS-01 complete, legacy storage removed,
  legacy signed URL flows removed.
- Records what IS-01 delivered and that only the base64 upload transport
  remains for IS-02.

storage-call-sites.md
- Final per-call-site status: 11 migrated, 2 temporary wrapper, 6
  removed. Nothing is legacy_signed_url_only; nothing is blocked.
- New 11.1a: the four CARE routes that now mediate transport, their
  authorization, and why the two asset routes are anonymous.
- New 11.3a: one source of truth per logical bucket. Records that this
  closes a defect introduced earlier in IS-01, where setting
  CARE_PATIENT_STORAGE_BUCKET moved persistence but not the signed URLs,
  so uploads and downloads silently addressed different buckets on the
  default s3 profile.
- 11.5 rewritten: no storage module imports a provider SDK. Lists the
  deleted settings and the csp package.
- New 11.8: the two remaining compatibility layers.

frontend-file-flow.md
- 11.3 rewritten: signed upload, signed download and unsigned bucket URLs
  are all gone; base64 upload is all that remains for IS-02.
- 11.4: 13 of the 18 section 9 change items are now done.
- 11.5: contract impact as delivered, including that the four URL fields
  are now relative paths where they used to be absolute.
- 11.6: GCS is no longer blocked on transport.

plugin-impact.md
- Corrects the row claiming a plugin can mint presigned URLs through
  S3FilesManager.
- New section 9: deprecation notice with a before/after table of every
  behavioural change, the DeprecationWarning, the migration target, and
  the fact that the no-signed-URL guarantee is CARE's rather than
  enforced against plugin code.

07-configuration-reference.md
- Section 13 corrected. It specified S3_ACCESS_KEY, S3_SECRET_KEY,
  S3_ENDPOINT_URL, S3_REGION_NAME, S3_ADDRESSING_STYLE and
  S3_SIGNATURE_VERSION; none exists. Replaced with the actual per-alias
  credential and endpoint variables, BUCKET_PROVIDER as the credential
  source, and the bucket variables as the single point of resolution.
- Records that addressing_style and signature_version are not
  configurable, and what that rules out.

02-target-runtime.md
- Section 11 status: all three SHALL NOT clauses now hold for persistence
  and transport. Records that transport is entirely CARE-mediated and
  that csp/ is deleted.

unresolved-items.md
- S1 resolved: the GCS profile serves files end to end; IS-02 is no
  longer a prerequisite for a GCS deployment.
- S2 is now the only item blocking that profile.
- B9 largely defused: no client can write to the bucket, so an
  unverified mark_upload_completed is bookkeeping rather than an
  unverified external write.

Co-Authored-By: Claude <noreply@anthropic.com>
ADR-0002 / ES-02. The last remaining transport issue: the Django-mediated
upload still carried complete file contents as base64 inside JSON.

POST /api/v1/files/upload-file/ now accepts multipart/form-data. The
binary part is `file`; the base64 `file_data` field is removed with no
fallback, per ES-02 section 23 (greenfield, no production client).

FileUploadMultipartSerializer declares the transport contract and the
binary field. It deliberately does not restate the domain rules:
FileUploadCreateSpec remains the authoritative validator for file type,
category and filename, so there is no second source of truth.
`original_name` becomes optional and defaults to the uploaded part's
filename, which multipart supplies and base64 could not.

The file is never materialised:
- Django's upload handlers decide InMemoryUploadedFile vs
  TemporaryUploadedFile before the view runs;
- size is read from UploadedFile.size, not by reading content;
- MIME sniffing reads only the leading 2048 bytes and seeks back, as
  before, so a browser-declared Content-Type is still not trusted;
- the UploadedFile is handed straight to Storage.save() with no
  ContentFile round trip.

Upload limits are now explicit rather than implicit, per ADR-0002. The
added FILE_UPLOAD_MAX_MEMORY_SIZE and DATA_UPLOAD_MAX_MEMORY_SIZE are set
to Django's own defaults, so behaviour is unchanged: anything over 2.5 MB
is temp-file backed, and MAX_FILE_UPLOAD_SIZE still caps the total at
5 MB. DATA_UPLOAD_MAX_MEMORY_SIZE does not bound the file, because
multipart file parts are exempt from it.

Worth noting: base64 inflated a 5 MB file to roughly 6.7 MB of JSON body,
which exceeds Django's 2.5 MB DATA_UPLOAD_MAX_MEMORY_SIZE. The old
endpoint could not actually accept a file near its own documented limit
over JSON. Multipart removes that.

Persistence, object naming, alias selection and authorization are
untouched: still Storage.save() through the patient alias, still
get_storage_name, still authorize_create -> file_authorizer. The DB row
is still written first inside the transaction so a storage failure rolls
it back.

Existing tests are updated to send multipart. test_upload_patient_file
also stops conflating two rows: it previously created a row via
POST /files/, uploaded a different row, then marked the first complete.

30 upload and transport tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
39 focused tests in care/emr/tests/test_file_upload_multipart.py,
covering ES-02 sections 31, 33 and 34.

Successful upload: record contents, patient alias, the ES-01 object name
unchanged, stored bytes match, download works, response carries no
provider marker and no bucket or endpoint key.

Rejection: missing file, missing metadata, oversized file (and that an
oversized file persists nothing), and that the old base64 `file_data`
body no longer works even by accident.

Validation: the declared part Content-Type is not trusted. A shell script
sent as image/jpeg is rejected on sniffed content; a real JPEG sent as
application/x-sh is accepted and recorded as image/jpeg. Blocked,
uppercase and double extensions behave as before, as do unknown file
type and category.

Authorization: unauthenticated and unauthorized uploads are rejected and
persist nothing.

Upload handlers: with a lowered FILE_UPLOAD_MAX_MEMORY_SIZE, the view is
verified to receive an InMemoryUploadedFile below the threshold and a
TemporaryUploadedFile above it, and the temp-file path round-trips.

Failure consistency: a storage failure reports failure and leaves no row;
a database failure after the object is written also rolls the row back.
The surviving gap is the orphan object, which is the pre-existing B8.

Provider neutrality: the whole upload and download flow is exercised
against InMemoryStorage substituted at the Django Storage boundary, which
would break on any surviving S3 assumption. A further test parses the AST
of the three transport modules to assert none imports a provider SDK and
none reads CARE_STORAGE_BACKEND. It inspects the AST rather than the text
because those modules legitimately mention boto3 and the backend classes
in prose describing what they replaced.

Schema: the request is multipart/form-data only, `file` is string/binary,
`file_data` is absent, and `original_name` is not required.

Also adds BinaryFileField. drf-spectacular renders a plain FileField as
format: uri, because DRF serialises it to a URL on output; in a multipart
request body it is raw bytes. Annotating the field is preferable to
enabling COMPONENT_SPLIT_REQUEST, which would reshape every schema in the
project.

Co-Authored-By: Claude <noreply@anthropic.com>
ADR-0002
- Implementation checklist complete: base64 removed, multipart
  implemented, upload handlers verified, size limits configured, schema
  implemented, authorization tested, MinIO round trip verified,
  provider-neutral behaviour verified, ES-02 complete.
- Notes that CARE never materialises the file, that range requests remain
  undesigned despite FileResponse advertising them, and that
  POST /files/ plus mark_upload_completed are now vestigial.

frontend-file-flow.md
- 11.3: base64 upload marked removed.
- New section 12: flow status table (signed upload, signed download,
  unsigned bucket URLs, base64 all removed; multipart upload and
  server-mediated download implemented), the multipart request contract
  with each part, the FormData migration snippet for the external
  frontend repository, and the memory/size table.
- Records that base64 inflated a 5 MB file past
  DATA_UPLOAD_MAX_MEMORY_SIZE, so the old endpoint could not accept a
  file at its own documented limit over JSON.
- Records what ES-02 did not change: cover images and avatars already
  used ordinary multipart and never touched the base64 endpoint;
  authorization, naming, alias selection and persistence are untouched.

storage-call-sites.md
- Call site 12 now covers transport as well as persistence.
- The base64 whole-file read is marked gone.
- Removes the note describing base64 as a remaining transport.

07-configuration-reference.md
- Section 15 corrected. It specified CARE_MAX_UPLOAD_SIZE,
  CARE_ALLOWED_UPLOAD_MIME_TYPES, CARE_ALLOWED_UPLOAD_EXTENSIONS and
  CARE_BLOCKED_UPLOAD_EXTENSIONS; none exists. Replaced with the real
  MAX_FILE_UPLOAD_SIZE (megabytes, not bytes), the two explicit Django
  memory settings, ALLOWED_MIME_TYPES with the note that the value is
  sniffed rather than taken from the request, and a pointer to
  FileNameValidator for extension policy.
- 16.1 corrected: inline behaviour comes from SAFE_INLINE_FORMATS in
  code, not a CARE_INLINE_MIME_TYPES setting.

Co-Authored-By: Claude <noreply@anthropic.com>
@cesarbenjamindotnet
cesarbenjamindotnet requested a review from a team as a code owner August 7, 2026 07:10
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Portable storage and file transport

Layer / File(s) Summary
Storage foundation
config/storage.py, config/settings/*, care/emr/utils/file_manager.py, care/utils/file_uploads/*, care/emr/models/*, care/emr/tests/test_storage.py
Django Storage aliases now replace provider-specific storage operations. Compatibility support remains through S3FilesManager.
Multipart upload and mediated download
care/emr/api/viewsets/*, care/emr/utils/file_download.py, care/emr/resources/*, care/emr/tests/test_file_upload*, care/emr/tests/test_storage_transport.py
Uploads now use multipart binary files. CARE routes stream authenticated downloads and public image assets without exposing provider URLs.
Architecture and migration records
docs/xii/adr/*, docs/xii/architecture/*, docs/xii/implementation/*, docs/xii/prompts/*
The repository adds storage, transport, runtime, deployment, testing, operations, and migration documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the PR's main changes to file storage and transport using Django Storage API.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Tools execution failed with the following error:

Failed to run tools: 14 UNAVAILABLE: read ECONNRESET


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Warning

⚠️ This pull request shows signs of AI-generated slop (phantom_api). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (17)
docs/xii/architecture/inventory/cache-and-redis.md-491-491 (1)

491-491: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the excluded-call total.

Line 491 says that test-only calls are excluded from 67. Section 5 totals 69 at Line 470. State the correct total or show the calculation that produces 67. The inventory count is otherwise not auditable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/cache-and-redis.md` at line 491, Correct the
excluded-call total in the “verified” statement near Section 5 so it matches the
documented total of 69, or add the explicit calculation showing how exclusions
produce 67. Ensure the inventory count is internally consistent and auditable.
docs/xii/prompts/00-Complete-CARE-Runtime-Inventory-41-52 (1)

41-52: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the prompt to use the active documentation paths.

This prompt still directs work to docs/gcp/. The architecture documents and inventories now live under docs/xii/architecture/. Replace the old root in the reading list, required outputs, contradiction record, and baseline destination. Otherwise a future run will create duplicate obsolete documents.

unresolved-items.md already records the corrected architecture path.

Also applies to: 90-101, 310-335, 368-372

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/prompts/00-Complete-CARE-Runtime-Inventory` around lines 41 - 52,
Update the prompt’s documentation references from docs/gcp/ to
docs/xii/architecture/ throughout the reading list, required outputs,
contradiction record, and baseline destination, including the sections around
the identified ranges. Preserve the existing filenames and use the
already-corrected architecture path recorded in unresolved-items.md.
docs/xii/architecture/inventory/plugin-impact.md-120-124 (1)

120-124: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Describe the ADDITIONAL_PLUGS mismatch direction correctly.

A build-time plugin set that is a superset of the runtime set does not cause ModuleNotFoundError. The failure occurs when the runtime set contains a plugin that the image did not install. Keep equality as a reproducibility requirement, but document the actual directional startup failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/plugin-impact.md` around lines 120 - 124,
Update the `ADDITIONAL_PLUGS` explanation to state that `ModuleNotFoundError`
occurs when the runtime plugin set includes a plugin absent from the build-time
installed set; do not claim that build-time-only plugins cause this failure.
Retain the requirement that build-time and runtime values be identical for
reproducibility.
docs/xii/architecture/00-scope-and-goals.md-633-649 (1)

633-649: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make the signed-URL rule conditional.

The target runtime removes signed upload and download URLs and requires CARE-mediated transport. Keep this requirement only if an exceptional signed-URL flow remains. Otherwise, remove it to avoid directing implementers toward the retired transport.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/00-scope-and-goals.md` around lines 633 - 649, Update
the Security Scope signed-URL requirement to be conditional on an exceptional
signed-URL flow remaining; otherwise remove the “Signed URLs MUST use short,
documented expiration periods” rule so the scope reflects CARE-mediated
transport.
docs/xii/architecture/00-scope-and-goals.md-475-480 (1)

475-480: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Identify the component that scales to zero correctly.

Cloud Tasks is a managed queue. The private Cloud Run worker is the component that scales to zero. Replace Cloud Tasks consumers with private Cloud Run worker in this cost classification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/00-scope-and-goals.md` around lines 475 - 480, Update
the “Services expected to scale to zero” list to replace “Cloud Tasks consumers”
with “private Cloud Run worker,” while preserving the other entries unchanged.
docs/xii/architecture/03-migration-plan.md-777-786 (1)

777-786: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Name the Pipenv files in the dependency step.

State that this phase updates Pipfile and Pipfile.lock. “Update the repository lockfile” is ambiguous and does not identify the package-management files that CI and contributors must change.

As per coding guidelines, use Pipenv with Pipfile and Pipfile.lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/03-migration-plan.md` around lines 777 - 786, Update
the “40. Dependencies” section to explicitly state that adding django-storages
requires updating both Pipfile and Pipfile.lock, replacing the ambiguous
“repository lockfile” wording while preserving the listed backends.

Source: Coding guidelines

docs/xii/architecture/00-scope-and-goals.md-845-851 (1)

845-851: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use one valid path for all architecture-document references.

The supplied documents are under docs/xii/architecture/, but the references use both docs/xii/gcp/ and docs/gcp/. These paths will break navigation and dependency metadata unless duplicate documents exist.

  • docs/xii/architecture/00-scope-and-goals.md#L845-L851: point the next-document link to docs/xii/architecture/01-current-runtime.md.
  • docs/xii/architecture/02-target-runtime.md#L17-L34: update the current-runtime and migration-plan references.
  • docs/xii/architecture/02-target-runtime.md#L1874-L1880: update the migration-plan next-document link.
  • docs/xii/architecture/03-migration-plan.md#L9-L12: update all dependency paths.
  • docs/xii/architecture/03-migration-plan.md#L2008-L2014: update the testing-strategy next-document link.
  • docs/xii/architecture/04-testing.md#L9-L13: update all dependency paths.
  • docs/xii/architecture/04-testing.md#L1960-L1966: update the upstream-sync next-document link.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/00-scope-and-goals.md` around lines 845 - 851, Use the
canonical docs/xii/architecture/ path for every architecture-document reference:
update the next-document link in
docs/xii/architecture/00-scope-and-goals.md:845-851 to 01-current-runtime.md;
update current-runtime and migration-plan references in
docs/xii/architecture/02-target-runtime.md:17-34 and its migration-plan link at
1874-1880; update all dependency paths in
docs/xii/architecture/03-migration-plan.md:9-12 and its testing-strategy link at
2008-2014; and update all dependency paths in
docs/xii/architecture/04-testing.md:9-13 and its upstream-sync link at
1960-1966. Remove inconsistent docs/xii/gcp/ and docs/gcp/ references without
changing document ordering or link targets.
docs/xii/architecture/04-testing.md-81-92 (1)

81-92: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Define distinct GCP test profiles.

The target document defines the default GCP profile with PostgreSQL cache. Therefore, GCP default profile and GCP PostgreSQL-cache profile currently describe the same configuration. Rename one profile or define a distinct backend combination, such as a LocMem-cache profile.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/04-testing.md` around lines 81 - 92, Update the
supported deployment profile list in “Test supported deployment profiles” so the
default GCP profile and GCP PostgreSQL-cache profile represent distinct
configurations: either rename one appropriately or define the default GCP
profile with a different backend combination such as LocMem cache. Keep the
PostgreSQL-cache, optional Redis, and conditionally accepted PostgreSQL queue
coverage requirements intact.
docs/xii/implementation/ES-02-file-transport-modernization.md-4-6 (1)

4-6: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Set the ES status to completed.

Line 4 marks ES-02 as Draft. ADR-0002 records ES-02 as complete on August 7, 2026. The frontend inventory also records the implementation as complete. Update this status to prevent conflicting delivery documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/implementation/ES-02-file-transport-modernization.md` around lines 4
- 6, Update the ES-02 status in the implementation document from Draft to
Completed, leaving the related ADR and dependency metadata unchanged.
docs/xii/adr/ADR-0002-file-transport.md-1-1 (1)

1-1: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the outer Markdown fences.

Both files are Markdown documents. The opening ```markdown fence and final closing fence make the complete document render as code instead of Markdown.

  • docs/xii/adr/ADR-0002-file-transport.md#L1-L1: remove the opening and closing outer fence.
  • docs/xii/implementation/ES-02-file-transport-modernization.md#L1-L1: remove the opening and closing outer fence.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/adr/ADR-0002-file-transport.md` at line 1, Remove the outer Markdown
opening and closing fences from docs/xii/adr/ADR-0002-file-transport.md (line 1)
and docs/xii/implementation/ES-02-file-transport-modernization.md (line 1),
leaving each document’s inner Markdown content unchanged.
docs/xii/adr/ADR-0002-file-transport.md-242-250 (1)

242-250: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the configured upload-limit setting name.

Both documents name CARE_MAX_UPLOAD_SIZE, but config/settings/config.py reads MAX_FILE_UPLOAD_SIZE. An operator who sets the documented variable will silently retain the 5 MB default.

  • docs/xii/adr/ADR-0002-file-transport.md#L242-L250: replace CARE_MAX_UPLOAD_SIZE with MAX_FILE_UPLOAD_SIZE.
  • docs/xii/implementation/ES-02-file-transport-modernization.md#L419-L427: replace CARE_MAX_UPLOAD_SIZE with MAX_FILE_UPLOAD_SIZE.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/adr/ADR-0002-file-transport.md` around lines 242 - 250, Replace
CARE_MAX_UPLOAD_SIZE with MAX_FILE_UPLOAD_SIZE in the examples at
docs/xii/adr/ADR-0002-file-transport.md:242-250 and
docs/xii/implementation/ES-02-file-transport-modernization.md:419-427, ensuring
both documents use the setting name read by config/settings/config.py.
docs/xii/architecture/inventory/frontend-file-flow.md-387-466 (1)

387-466: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the ES-01 inventory chronology.

Section 11 is dated August 6, 2026 and says that no transport field changed. It then records download_url, removed signed URLs, removed base64 upload, and completed ES-02 work. Section 12 records those ES-02 results on August 7, 2026. Keep Section 11 limited to ES-01 results, or move the later claims into Section 12.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/frontend-file-flow.md` around lines 387 -
466, Correct Section 11’s chronology by removing or relocating claims about
ES-02 outcomes, including multipart upload replacing base64, removed signed
upload/download fields, and completed ES-02 items such as U2, U3, U4, and S1.
Keep Section 11 limited to ES-01 changes dated August 6, and place later
transport and upload results in Section 12 dated August 7.
docs/xii/architecture/inventory/storage-call-sites.md-368-371 (1)

368-371: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The totals do not reconcile with the table above.

Counting the call sites marked removed in §11.1 gives 8, not 6: sites 1, 2, 7, 19, plus the four sites collapsed into the 13-16 row. The migrated count of 11 also includes the two URL builders (facility.py, users/models.py), which are not part of the 19 sites enumerated in §3, so 11 + 2 + 6 = 19 only looks right by coincidence. Please restate the totals against a fixed denominator, for example 21 tracked sites = 11 migrated + 2 temporary wrapper + 8 removed.

Separately, line 510 writes unresolved-items.md S2 where the rest of the document uses §2.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/storage-call-sites.md` around lines 368 -
371, The summary totals must reconcile with the documented call-site inventory:
update the totals to use a fixed denominator of 21 tracked sites, comprising 11
migrated, 2 temporary wrapper, and 8 removed, while clarifying that the two URL
builders are included in the migrated count. Also correct the unresolved-items
reference near `unresolved-items.md` from `S2` to `§2`.
care/emr/tests/test_file_upload_multipart.py-299-305 (1)

299-305: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test does not assert what its name and comment claim.

test_incomplete_rows_remain_cleanable never produces an incomplete row and never invokes cleanup_incomplete_file_uploads. It asserts upload_completed is True and that the object exists — the successful path, already covered by test_record_is_correct and test_uses_the_patient_alias_and_es01_object_name.

The invariant in the comment is that the multipart path sets upload_completed=False before writing the object. That is the part worth protecting: if a future change sets the flag to True up front, storage failures would leave orphaned objects that the cleanup task can no longer find, and this test would still pass. Consider asserting the intermediate state instead, for example by capturing upload_completed inside a put_object spy.

💚 Sketch
     def test_incomplete_rows_remain_cleanable(self):
-        # cleanup_incomplete_file_uploads keys off upload_completed=False, which
-        # the multipart path still sets before the object is written.
-        response = self.upload()
-        file_obj = FileUpload.objects.get(external_id=response.data["id"])
-        self.assertTrue(file_obj.upload_completed)
-        self.assertTrue(file_obj.files_manager.exists(file_obj))
+        # cleanup_incomplete_file_uploads keys off upload_completed=False, so
+        # the row must be incomplete while the object is being written.
+        seen = {}
+        original = FileUpload.files_manager.__class__.put_object
+
+        def spy(manager, file_obj, file, content_type=None):
+            seen["completed_during_write"] = FileUpload.objects.get(
+                id=file_obj.id
+            ).upload_completed
+            return original(manager, file_obj, file, content_type=content_type)
+
+        with patch.object(FileUpload.files_manager.__class__, "put_object", spy):
+            response = self.upload()
+        self.assertFalse(seen["completed_during_write"])
+        file_obj = FileUpload.objects.get(external_id=response.data["id"])
+        self.assertTrue(file_obj.upload_completed)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/tests/test_file_upload_multipart.py` around lines 299 - 305, Update
test_incomplete_rows_remain_cleanable to observe the FileUpload state during the
files_manager.put_object operation, using a spy or mock that captures
upload_completed before the object is written. Assert the captured intermediate
value is False, and remove the existing successful-upload assertions that
duplicate other tests.
care/emr/resources/file_upload/spec.py-112-115 (1)

112-115: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return download_url only for completed objects.

Both retrieve serializers assign a CARE download route while the model can still have upload_completed=False. Clients then receive a usable-looking URL for an object that is not available.

  • care/emr/resources/file_upload/spec.py#L112-L115: Set mapping["download_url"] to file_download_url(obj) only when obj.upload_completed is true.
  • care/emr/resources/report/report_upload/spec.py#L49-L52: Set mapping["download_url"] to report_download_url(obj) only when obj.upload_completed is true.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/resources/file_upload/spec.py` around lines 112 - 115, Update
perform_extra_serialization in care/emr/resources/file_upload/spec.py at lines
112-115 to assign download_url only when obj.upload_completed is true; leave it
absent or unchanged for incomplete uploads. Apply the same conditional behavior
in care/emr/resources/report/report_upload/spec.py at lines 49-52, using
report_download_url for completed report uploads.
docs/xii/adr/ADR-0001-django-storage.md-319-346 (1)

319-346: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Separate IS-01 status from IS-02 transport delivery.

Lines 178-195 define file transport as outside ADR-0001. Lines 326-339 then list signed URL removal and CARE-mediated transport as IS-01 delivery while Line 323 says IS-02 is incomplete.

Move the transport statements to ADR-0002, or identify them as partial IS-02 delivery. Keep IS-01 status limited to storage persistence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/adr/ADR-0001-django-storage.md` around lines 319 - 346, The ADR-0001
implementation status incorrectly attributes file transport changes to IS-01
while IS-02 remains incomplete. Update the status and “What IS-01 delivered”
section to cover only Django Storage persistence and provider configuration, and
move the signed-URL removal, CARE-mediated object serving, and
base64-to-multipart transport scope to ADR-0002 or explicitly mark them as
partial IS-02 delivery.
docs/xii/architecture/05-upstream-sync.md-10-15 (1)

10-15: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use one consistent documentation path namespace.

All three documents reference docs/gcp/..., but the supplied files use docs/xii/architecture/.... Update every affected dependency, next-document link, runbook path, and command example.

  • docs/xii/architecture/05-upstream-sync.md#L10-L15: update depends_on and remaining docs/gcp references.
  • docs/xii/architecture/06-operations.md#L9-L15: update depends_on, the next-document link, and runbook paths.
  • docs/xii/architecture/07-configuration-reference.md#L9-L16: update depends_on and the next-document link.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/05-upstream-sync.md` around lines 10 - 15, Use the
docs/xii/architecture namespace consistently: in
docs/xii/architecture/05-upstream-sync.md lines 10-15, update depends_on and all
remaining docs/gcp references; in docs/xii/architecture/06-operations.md lines
9-15, update depends_on, the next-document link, and runbook paths; in
docs/xii/architecture/07-configuration-reference.md lines 9-16, update
depends_on and the next-document link.
🧹 Nitpick comments (5)
care/emr/tests/test_file_upload_api.py (1)

75-114: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

test_direct_file_upload now duplicates test_upload_patient_file.

Both tests post self.upload_payload() to files-upload-file and then download the result. The first one already asserts everything the second does, plus the route shape and the headers. When the two upload paths were distinct the split made sense; after the multipart migration there is only one. Consider dropping test_direct_file_upload or narrowing it to the one thing it uniquely covers, which is reading download_url straight off the upload response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/tests/test_file_upload_api.py` around lines 75 - 114, Remove the
duplicated download/content assertions from test_direct_file_upload, since
test_upload_patient_file already covers the upload and download behavior. Either
delete test_direct_file_upload or narrow it to validating only that the upload
response’s download_url can be used directly.
care/emr/tests/test_file_upload_multipart.py (2)

42-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Run ruff format . over this file.

Line 42 is about 102 characters, past Ruff's default 88-column limit; lines 90-92 and 334 look similar. The formatter would rewrap them. It is the sort of thing CI notices.

As per coding guidelines: "After code changes, run 'ruff check --fix .' and 'ruff format .' to lint and format Python code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/tests/test_file_upload_multipart.py` at line 42, Run Ruff formatting
on the entire test file, using ruff format ., so the upload method signature and
other overlong lines are wrapped to the configured style; also apply the
project’s required ruff check --fix . command.

Source: Coding guidelines


387-396: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Generate the schema once per class.

upload_operation() calls schema(), and three of the four tests call both, so each of those tests builds the full OpenAPI document twice. Over CARE's router that is not cheap. Caching it on the class removes eight generations, and the schema does not vary between these tests anyway.

While you are in here: "/api/v1/files/upload-file/" could be reverse("files-upload-file"), which keeps the key in sync if the route ever moves.

♻️ Proposed refactor
 class UploadSchemaTests(CareAPITestBase):
     """The generated OpenAPI schema must describe multipart, not JSON."""
 
-    def schema(self):
-        from drf_spectacular.generators import SchemaGenerator
-
-        return SchemaGenerator().get_schema(request=None, public=True)
+    `@classmethod`
+    def setUpClass(cls):
+        super().setUpClass()
+        from drf_spectacular.generators import SchemaGenerator
+
+        cls._schema = SchemaGenerator().get_schema(request=None, public=True)
+
+    def schema(self):
+        return self._schema
 
     def upload_operation(self):
-        return self.schema()["paths"]["/api/v1/files/upload-file/"]["post"]
+        return self.schema()["paths"][reverse("files-upload-file")]["post"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/tests/test_file_upload_multipart.py` around lines 387 - 396, Update
UploadSchemaTests.schema to cache the generated OpenAPI document once on the
test class and reuse it across all tests, avoiding repeated
SchemaGenerator().get_schema calls. Update upload_operation to derive the path
key with reverse("files-upload-file") instead of the hard-coded URL while
preserving the existing POST operation lookup.
care/emr/tests/test_storage.py (1)

186-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard MinioIntegrationTests against non-MinIO configurations.

These tests need a live MinIO on the configured endpoint, and test_aliases_are_s3_storage hard-asserts the backend class name. Both fail whenever CARE_STORAGE_BACKEND=gcs, or in any environment that runs the suite without the compose service. The PR itself makes the backend configurable, so pinning the test suite to one provider works against that. A skip condition keeps the local profile mandatory without breaking the others.

♻️ Suggested guard
 class MinioIntegrationTests(SimpleTestCase):
+    def setUp(self):
+        super().setUp()
+        if settings.CARE_STORAGE_BACKEND != "s3":
+            self.skipTest("MinIO integration requires the s3 backend")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/tests/test_storage.py` around lines 186 - 199, Guard
MinioIntegrationTests with a skip condition that runs only when the configured
storage backend is MinIO and the required local service is available. Apply the
guard to the class or test setup so test_aliases_are_s3_storage and related
integration tests are skipped for configurations such as
CARE_STORAGE_BACKEND=gcs or unavailable compose services, while preserving
mandatory execution for the local MinIO profile.
care/emr/api/viewsets/file_assets.py (1)

26-53: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add caching headers, and consider throttling, on these anonymous routes.

Every cover image and avatar render now costs CARE one database query plus one object-storage GET, where the bucket or CDN previously absorbed that traffic. The routes are unauthenticated, so the cost is open to anyone. Two things would help:

  • Set Cache-Control on the response. Object keys already carry a random token (<folder>/<external_id>_<token>.<ext>), so the content at a given key is immutable and can be cached hard by browsers and any proxy in front of CARE.
  • Apply a throttle class, since permission_classes = () also removes the default throttling that other views inherit.

Neither is a correctness problem today. Both matter once these routes carry real render traffic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/api/viewsets/file_assets.py` around lines 26 - 53, Update
PublicAssetView.serve to add a long-lived immutable Cache-Control header to the
response returned by storage_file_response, leveraging the existing tokenized
object keys. Add an appropriate throttle class to PublicAssetView so both
FacilityCoverImageView and UserProfilePictureView retain rate limiting despite
using empty permission_classes; reuse the project’s established throttle symbol
rather than defining a new policy.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@care/emr/utils/file_download.py`:
- Around line 34-56: Update storage_file_response to set the
X-Content-Type-Options response header to nosniff on every returned
FileResponse, while preserving the existing MIME type and attachment behavior.
Use the framework’s standard response-header mechanism and verify any shared
security setting remains enabled if this helper relies on it.

In `@care/facility/models/facility.py`:
- Around line 208-219: The four file URL fields have inconsistent or
undocumented relative-versus-absolute behavior. Decide on one URL convention for
facility.read_cover_image_url, users.read_profile_picture_url, and
emr.utils.file_download.file_download_url/report_download_url; either join
reversed paths with the configured API/site base to preserve absolute URLs, or
update the frontend and both architecture documents to explicitly support
relative paths, ensuring all four fields follow the same convention.

In `@docs/xii/adr/ADR-0003-asynchronous-execution.md`:
- Around line 122-128: Update ADR-0003's database-dependent asynchronous
dispatch requirement to use SHALL instead of SHOULD, making post-commit dispatch
mandatory. Require transaction.on_commit(...) or an equivalent transactional
outbox for every such dispatch, while preserving the requirement that tasks
cannot observe state from a transaction that later rolls back.
- Around line 199-214: Update the “Idempotency” section to require every
asynchronous task handler to make an explicit idempotency decision, replacing
the vague “where required” wording. Require each handler to declare an
idempotency key, unique constraint, conditional update, execution record, or
explicit proof that the operation is intrinsically idempotent.

In `@docs/xii/adr/ADR-0004-configurable-application-cache.md`:
- Around line 13-26: Update the ADR’s responsibility classification to separate
rate-limit counters from ordinary cache behavior, and define their contract
covering scope, atomic updates, expiration, and failure behavior. Ensure the
decision and related configuration guidance identify the required consistency
guarantees when using LocMem or another shared cache profile.

In `@docs/xii/adr/ADR-0005-distributed-locking.md`:
- Around line 88-105: Update the “Redis locks” requirements in ADR-0005 to
address stale owners after lease expiration: require owner-token validation when
releasing locks and fencing tokens or conditional writes for lock-protected
operations, or explicitly restrict Redis locking to operations whose correctness
is enforced by the database.

In `@docs/xii/adr/ADR-0007-terraform-for-GCP.md`:
- Around line 99-111: Update the “Plans and review” section of ADR-0007 to make
reviewed Terraform plans mandatory for production applies: require the exact
approved plan artifact to be consumed and explicitly reject direct applies or
unreviewed/different plans, aligning the requirement with ADR-0008’s controlled
delivery process.

In `@docs/xii/adr/ADR-0008-automated-continuous-integration.md`:
- Around line 118-130: Update the ADR’s Rollback section to define schema
compatibility after migrations: require forward-compatible database migrations
and a tested application rollback procedure, or require verification of schema
compatibility before deploying a previous immutable image. Preserve the existing
metadata requirements and the rule that database rollback is not automatic.
- Around line 75-98: Update the deployment requirements in the controlled
deployment sequence and Migrations section to mandate backward-compatible
migrations while older API and worker versions remain active. Make
worker-before-API deployment mandatory, and require workers to accept both
queued and retried payload formats before the API emits new task payloads; make
expand-and-contract migration techniques required where schema changes affect
multiple deployed versions.

In `@docs/xii/architecture/02-target-runtime.md`:
- Around line 529-533: Update the report-generation retry flow in
care/emr/tasks/report_generation.py to use a storage-provider-independent retry
contract, or translate GCS backend failures into the existing retryable error
type so transient uploads retry under GCS as well as AWS. Add a test covering a
transient GCS upload failure and verify the configured retry behavior, then
update the target-runtime documentation and inventory entry to reflect the
implemented contract.

In `@docs/xii/architecture/07-configuration-reference.md`:
- Around line 301-316: Update the DJANGO_ALLOWED_HOSTS production example to
list only the exact CARE Cloud Run service hostname and approved custom domains,
removing the broad “.run.app” entry. If the exact hostnames are unavailable,
revise the documentation to require verification that the ingress layer rejects
all other Host values before presenting the configuration as the normal
production setup.
- Around line 2341-2350: Update the local profile configuration near
CARE_TRANSIENT_STATE_BACKEND to remove the obsolete S3_ENDPOINT_URL,
S3_ACCESS_KEY, and S3_SECRET_KEY variables, replacing them with the documented
BUCKET_* fallback or appropriate FILE_UPLOAD_* and FACILITY_S3_* variables from
Section 13.1 so MinIO remains configurable.
- Around line 2414-2430: Update the “Test Profile” configuration to use only
backend values supported by the documented contracts, especially replacing or
removing CARE_STORAGE_BACKEND=filesystem and CARE_TASK_BACKEND=fake. If these
are intended as test-only parser values, document them explicitly in the
corresponding backend sections and ensure startup validation accepts them;
otherwise move the choices into the test settings using the established
InMemoryStorage and supported task-backend configuration.

In `@docs/xii/architecture/inventory/runtime-and-deployment.md`:
- Around line 325-354: Update the dependency and GCP inventory section to
reflect the PR’s addition of django-storages and provider-neutral Django Storage
configuration, including the dependency table and repository search results.
Revise the “GCP support does not exist” conclusion to distinguish the new
configuration from any still-absent GCP infrastructure; if retaining the prior
findings, label them explicitly as the historical Phase 0 baseline.

In `@docs/xii/architecture/inventory/task-call-sites.md`:
- Around line 275-279: Update the recursive call in handle_cascade so it passes
child.pk when the function uses the value as parent_id, or change the query to
use the parent relation with the FacilityLocation instance. Add a regression
test covering cascading through a two-level FacilityLocation hierarchy.
- Around line 35-37: Update the asynchronous task count in the inventory
statement from 3 to 4, while retaining the existing list of four tasks and the
recursive-tail qualification for summarise_monetary_components.

In `@docs/xii/architecture/inventory/unresolved-items.md`:
- Around line 96-104: Update the resolved file-transport entries, including the
sections corresponding to the signed-URL, base64-upload, direct bucket URL, and
legacy bucket-config paths, to mark replaced behavior as resolved and document
the multipart upload plus CARE-mediated download/image routes. Remove pending
work that targets the retired contracts, retaining only migration questions for
clients still using the legacy contract.

---

Minor comments:
In `@care/emr/resources/file_upload/spec.py`:
- Around line 112-115: Update perform_extra_serialization in
care/emr/resources/file_upload/spec.py at lines 112-115 to assign download_url
only when obj.upload_completed is true; leave it absent or unchanged for
incomplete uploads. Apply the same conditional behavior in
care/emr/resources/report/report_upload/spec.py at lines 49-52, using
report_download_url for completed report uploads.

In `@care/emr/tests/test_file_upload_multipart.py`:
- Around line 299-305: Update test_incomplete_rows_remain_cleanable to observe
the FileUpload state during the files_manager.put_object operation, using a spy
or mock that captures upload_completed before the object is written. Assert the
captured intermediate value is False, and remove the existing successful-upload
assertions that duplicate other tests.

In `@docs/xii/adr/ADR-0001-django-storage.md`:
- Around line 319-346: The ADR-0001 implementation status incorrectly attributes
file transport changes to IS-01 while IS-02 remains incomplete. Update the
status and “What IS-01 delivered” section to cover only Django Storage
persistence and provider configuration, and move the signed-URL removal,
CARE-mediated object serving, and base64-to-multipart transport scope to
ADR-0002 or explicitly mark them as partial IS-02 delivery.

In `@docs/xii/adr/ADR-0002-file-transport.md`:
- Line 1: Remove the outer Markdown opening and closing fences from
docs/xii/adr/ADR-0002-file-transport.md (line 1) and
docs/xii/implementation/ES-02-file-transport-modernization.md (line 1), leaving
each document’s inner Markdown content unchanged.
- Around line 242-250: Replace CARE_MAX_UPLOAD_SIZE with MAX_FILE_UPLOAD_SIZE in
the examples at docs/xii/adr/ADR-0002-file-transport.md:242-250 and
docs/xii/implementation/ES-02-file-transport-modernization.md:419-427, ensuring
both documents use the setting name read by config/settings/config.py.

In `@docs/xii/architecture/00-scope-and-goals.md`:
- Around line 633-649: Update the Security Scope signed-URL requirement to be
conditional on an exceptional signed-URL flow remaining; otherwise remove the
“Signed URLs MUST use short, documented expiration periods” rule so the scope
reflects CARE-mediated transport.
- Around line 475-480: Update the “Services expected to scale to zero” list to
replace “Cloud Tasks consumers” with “private Cloud Run worker,” while
preserving the other entries unchanged.
- Around line 845-851: Use the canonical docs/xii/architecture/ path for every
architecture-document reference: update the next-document link in
docs/xii/architecture/00-scope-and-goals.md:845-851 to 01-current-runtime.md;
update current-runtime and migration-plan references in
docs/xii/architecture/02-target-runtime.md:17-34 and its migration-plan link at
1874-1880; update all dependency paths in
docs/xii/architecture/03-migration-plan.md:9-12 and its testing-strategy link at
2008-2014; and update all dependency paths in
docs/xii/architecture/04-testing.md:9-13 and its upstream-sync link at
1960-1966. Remove inconsistent docs/xii/gcp/ and docs/gcp/ references without
changing document ordering or link targets.

In `@docs/xii/architecture/03-migration-plan.md`:
- Around line 777-786: Update the “40. Dependencies” section to explicitly state
that adding django-storages requires updating both Pipfile and Pipfile.lock,
replacing the ambiguous “repository lockfile” wording while preserving the
listed backends.

In `@docs/xii/architecture/04-testing.md`:
- Around line 81-92: Update the supported deployment profile list in “Test
supported deployment profiles” so the default GCP profile and GCP
PostgreSQL-cache profile represent distinct configurations: either rename one
appropriately or define the default GCP profile with a different backend
combination such as LocMem cache. Keep the PostgreSQL-cache, optional Redis, and
conditionally accepted PostgreSQL queue coverage requirements intact.

In `@docs/xii/architecture/05-upstream-sync.md`:
- Around line 10-15: Use the docs/xii/architecture namespace consistently: in
docs/xii/architecture/05-upstream-sync.md lines 10-15, update depends_on and all
remaining docs/gcp references; in docs/xii/architecture/06-operations.md lines
9-15, update depends_on, the next-document link, and runbook paths; in
docs/xii/architecture/07-configuration-reference.md lines 9-16, update
depends_on and the next-document link.

In `@docs/xii/architecture/inventory/cache-and-redis.md`:
- Line 491: Correct the excluded-call total in the “verified” statement near
Section 5 so it matches the documented total of 69, or add the explicit
calculation showing how exclusions produce 67. Ensure the inventory count is
internally consistent and auditable.

In `@docs/xii/architecture/inventory/frontend-file-flow.md`:
- Around line 387-466: Correct Section 11’s chronology by removing or relocating
claims about ES-02 outcomes, including multipart upload replacing base64,
removed signed upload/download fields, and completed ES-02 items such as U2, U3,
U4, and S1. Keep Section 11 limited to ES-01 changes dated August 6, and place
later transport and upload results in Section 12 dated August 7.

In `@docs/xii/architecture/inventory/plugin-impact.md`:
- Around line 120-124: Update the `ADDITIONAL_PLUGS` explanation to state that
`ModuleNotFoundError` occurs when the runtime plugin set includes a plugin
absent from the build-time installed set; do not claim that build-time-only
plugins cause this failure. Retain the requirement that build-time and runtime
values be identical for reproducibility.

In `@docs/xii/architecture/inventory/storage-call-sites.md`:
- Around line 368-371: The summary totals must reconcile with the documented
call-site inventory: update the totals to use a fixed denominator of 21 tracked
sites, comprising 11 migrated, 2 temporary wrapper, and 8 removed, while
clarifying that the two URL builders are included in the migrated count. Also
correct the unresolved-items reference near `unresolved-items.md` from `S2` to
`§2`.

In `@docs/xii/implementation/ES-02-file-transport-modernization.md`:
- Around line 4-6: Update the ES-02 status in the implementation document from
Draft to Completed, leaving the related ADR and dependency metadata unchanged.

In `@docs/xii/prompts/00-Complete-CARE-Runtime-Inventory`:
- Around line 41-52: Update the prompt’s documentation references from docs/gcp/
to docs/xii/architecture/ throughout the reading list, required outputs,
contradiction record, and baseline destination, including the sections around
the identified ranges. Preserve the existing filenames and use the
already-corrected architecture path recorded in unresolved-items.md.

---

Nitpick comments:
In `@care/emr/api/viewsets/file_assets.py`:
- Around line 26-53: Update PublicAssetView.serve to add a long-lived immutable
Cache-Control header to the response returned by storage_file_response,
leveraging the existing tokenized object keys. Add an appropriate throttle class
to PublicAssetView so both FacilityCoverImageView and UserProfilePictureView
retain rate limiting despite using empty permission_classes; reuse the project’s
established throttle symbol rather than defining a new policy.

In `@care/emr/tests/test_file_upload_api.py`:
- Around line 75-114: Remove the duplicated download/content assertions from
test_direct_file_upload, since test_upload_patient_file already covers the
upload and download behavior. Either delete test_direct_file_upload or narrow it
to validating only that the upload response’s download_url can be used directly.

In `@care/emr/tests/test_file_upload_multipart.py`:
- Line 42: Run Ruff formatting on the entire test file, using ruff format ., so
the upload method signature and other overlong lines are wrapped to the
configured style; also apply the project’s required ruff check --fix . command.
- Around line 387-396: Update UploadSchemaTests.schema to cache the generated
OpenAPI document once on the test class and reuse it across all tests, avoiding
repeated SchemaGenerator().get_schema calls. Update upload_operation to derive
the path key with reverse("files-upload-file") instead of the hard-coded URL
while preserving the existing POST operation lookup.

In `@care/emr/tests/test_storage.py`:
- Around line 186-199: Guard MinioIntegrationTests with a skip condition that
runs only when the configured storage backend is MinIO and the required local
service is available. Apply the guard to the class or test setup so
test_aliases_are_s3_storage and related integration tests are skipped for
configurations such as CARE_STORAGE_BACKEND=gcs or unavailable compose services,
while preserving mandatory execution for the local MinIO profile.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dec447aa-0fc4-4c29-b3f3-78c68b81c288

📥 Commits

Reviewing files that changed from the base of the PR and between 81149a3 and e6d928f.

⛔ Files ignored due to path filters (1)
  • Pipfile.lock is excluded by !**/*.lock
📒 Files selected for processing (53)
  • Pipfile
  • care/emr/api/viewsets/file_assets.py
  • care/emr/api/viewsets/file_upload.py
  • care/emr/api/viewsets/report/report_upload.py
  • care/emr/models/file_upload.py
  • care/emr/models/report/report_upload.py
  • care/emr/reports/context_builder/data_points/fileupload.py
  • care/emr/reports/report_utils.py
  • care/emr/resources/file_upload/spec.py
  • care/emr/resources/report/report_upload/spec.py
  • care/emr/tasks/cleanup_incomplete_file_uploads.py
  • care/emr/tests/test_file_upload_api.py
  • care/emr/tests/test_file_upload_multipart.py
  • care/emr/tests/test_storage.py
  • care/emr/tests/test_storage_transport.py
  • care/emr/utils/file_download.py
  • care/emr/utils/file_manager.py
  • care/facility/models/facility.py
  • care/users/models.py
  • care/utils/csp/__init__.py
  • care/utils/csp/config.py
  • care/utils/file_uploads/cover_image.py
  • care/utils/tests/test_storage_config.py
  • config/api_router.py
  • config/settings/base.py
  • config/settings/config.py
  • config/storage.py
  • docs/xii/adr/ADR-0001-django-storage.md
  • docs/xii/adr/ADR-0002-file-transport.md
  • docs/xii/adr/ADR-0003-asynchronous-execution.md
  • docs/xii/adr/ADR-0004-configurable-application-cache.md
  • docs/xii/adr/ADR-0005-distributed-locking.md
  • docs/xii/adr/ADR-0006-portable-runtime-profiles.md
  • docs/xii/adr/ADR-0007-terraform-for-GCP.md
  • docs/xii/adr/ADR-0008-automated-continuous-integration.md
  • docs/xii/architecture/00-scope-and-goals.md
  • docs/xii/architecture/01-current-runtime.md
  • docs/xii/architecture/02-target-runtime.md
  • docs/xii/architecture/03-migration-plan.md
  • docs/xii/architecture/04-testing.md
  • docs/xii/architecture/05-upstream-sync.md
  • docs/xii/architecture/06-operations.md
  • docs/xii/architecture/07-configuration-reference.md
  • docs/xii/architecture/inventory/cache-and-redis.md
  • docs/xii/architecture/inventory/frontend-file-flow.md
  • docs/xii/architecture/inventory/plugin-impact.md
  • docs/xii/architecture/inventory/runtime-and-deployment.md
  • docs/xii/architecture/inventory/storage-call-sites.md
  • docs/xii/architecture/inventory/task-call-sites.md
  • docs/xii/architecture/inventory/unresolved-items.md
  • docs/xii/implementation/ES-01-storage.md
  • docs/xii/implementation/ES-02-file-transport-modernization.md
  • docs/xii/prompts/00-Complete-CARE-Runtime-Inventory
💤 Files with no reviewable changes (1)
  • care/utils/csp/config.py

Comment on lines +34 to +56
def storage_file_response(storage, name, *, filename, mime_type=None):
"""
Stream ``name`` from ``storage`` as an HTTP response.

``FileResponse`` streams in chunks and closes the handle when the response
is finished, so the object is never fully buffered in memory.
"""
if not mime_type:
mime_type = mimetypes.guess_type(filename or name)[0]
mime_type = mime_type or "application/octet-stream"

try:
handle = storage.open(name, "rb")
except FileNotFoundError as e:
msg = "File not found in storage"
raise NotFound(msg) from e

return FileResponse(
handle,
as_attachment=mime_type not in SAFE_INLINE_FORMATS,
filename=filename,
content_type=mime_type,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set X-Content-Type-Options: nosniff on this response.

as_attachment is False for every type in SAFE_INLINE_FORMATS, so those objects render inline on the CARE origin — the same origin that serves the API and its session cookie. The presigned URLs this replaces rendered inline on the bucket origin, so the blast radius of a content-type mismatch just changed.

The declared type is not always derived from the bytes. file_object_response trusts file_obj.meta["mime_type"], and rows created through POST /api/v1/files/ carry a client-supplied mime_type that is validated against the allowlist but never compared with what is later stored. A client can therefore store arbitrary bytes and have CARE serve them inline as image/png. nosniff stops a browser from reinterpreting them.

🔒️ Proposed fix
-    return FileResponse(
+    response = FileResponse(
         handle,
         as_attachment=mime_type not in SAFE_INLINE_FORMATS,
         filename=filename,
         content_type=mime_type,
     )
+    response.headers["X-Content-Type-Options"] = "nosniff"
+    return response

Related: the change details for config/settings/base.py line 18 say the CSP configuration import was replaced with the storage constants. If that removed a Content-Security-Policy header that used to cover these responses, the second layer of defence went with it. Worth confirming.

#!/bin/bash
# Check whether CSP configuration survives elsewhere in settings.
rg -n -C3 'csp|CONTENT_SECURITY_POLICY|SECURE_CONTENT_TYPE_NOSNIFF' --iglob '*.py' config/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/utils/file_download.py` around lines 34 - 56, Update
storage_file_response to set the X-Content-Type-Options response header to
nosniff on every returned FileResponse, while preserving the existing MIME type
and attachment behavior. Use the framework’s standard response-header mechanism
and verify any shared security setting remains enabled if this helper relies on
it.

Comment on lines 208 to +219
def read_cover_image_url(self):
"""
CARE route serving the cover image (ADR-0001).

Never a storage-provider URL: the bytes are read through Django Storage
so the bucket can stay private and the provider interchangeable.
"""
if self.cover_image_url:
if settings.FACILITY_CDN:
return f"{settings.FACILITY_CDN}/{self.cover_image_url}"
return f"{settings.FACILITY_S3_BUCKET_EXTERNAL_ENDPOINT}/{settings.FACILITY_S3_BUCKET}/{self.cover_image_url}"
return reverse(
"facility-cover-image-asset",
kwargs={"external_id": self.external_id},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Both public URL fields changed from absolute to relative, which is an unannounced API contract break. reverse returns a path such as /api/v1/assets/facility/<external_id>/cover_image/. The values these replace were absolute URLs built from the bucket external endpoint. A frontend served from a different origin than the API, or any consumer that used the field directly as an image src without a base, will not resolve the new value. The PR objectives state the frontend must migrate to the new download contract, but the relative-versus-absolute distinction is not recorded in docs/xii/architecture/inventory/frontend-file-flow.md or in §11.1a of docs/xii/architecture/inventory/storage-call-sites.md.

  • care/facility/models/facility.py#L208-L219: return an absolute URL by joining reverse(...) with the configured site or API base, or document that the field is now relative and confirm the frontend resolves it against the API origin.
  • care/users/models.py#L201-L212: apply the same decision to read_profile_picture_url so both fields keep one shape.

Note that care/emr/utils/file_download.py file_download_url and report_download_url return relative paths too, so whichever convention you pick should cover all four.

📍 Affects 2 files
  • care/facility/models/facility.py#L208-L219 (this comment)
  • care/users/models.py#L201-L212
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/facility/models/facility.py` around lines 208 - 219, The four file URL
fields have inconsistent or undocumented relative-versus-absolute behavior.
Decide on one URL convention for facility.read_cover_image_url,
users.read_profile_picture_url, and
emr.utils.file_download.file_download_url/report_download_url; either join
reversed paths with the configured API/site base to preserve absolute URLs, or
update the frontend and both architecture documents to explicitly support
relative paths, ensuring all four fields follow the same convention.

Comment on lines +122 to +128
When a task depends on a database change, dispatch SHOULD occur after successful commit using Django's transaction facilities, such as:

```python
transaction.on_commit(...)
```

A task SHALL not observe state that was subsequently rolled back.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make post-commit dispatch mandatory.

Line 122 uses SHOULD, but Line 128 requires that tasks never observe rolled-back state. A task can run before commit, observe old or missing state, and continue after the transaction rolls back. Change this requirement to SHALL and require transaction.on_commit(...) or an equivalent transactional outbox for every database-dependent asynchronous dispatch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/adr/ADR-0003-asynchronous-execution.md` around lines 122 - 128,
Update ADR-0003's database-dependent asynchronous dispatch requirement to use
SHALL instead of SHOULD, making post-commit dispatch mandatory. Require
transaction.on_commit(...) or an equivalent transactional outbox for every such
dispatch, while preserving the requirement that tasks cannot observe state from
a transaction that later rolls back.

Comment on lines +199 to +214
## Idempotency

Task execution SHALL be treated as at-least-once.

Handlers SHALL tolerate duplicate delivery where required.

Idempotency SHOULD rely on:

- existing database state;
- unique constraints;
- conditional updates;
- explicit idempotency keys;
- execution records;
- object existence where appropriate.

Redis SHALL not be the sole correctness mechanism.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Require an idempotency decision for every asynchronous task.

At-least-once delivery makes duplicate execution normal. The phrase “where required” does not define an approval rule. Require every handler to declare an idempotency key, unique constraint, conditional update, execution record, or an explicit proof that the operation is intrinsically idempotent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/adr/ADR-0003-asynchronous-execution.md` around lines 199 - 214,
Update the “Idempotency” section to require every asynchronous task handler to
make an explicit idempotency decision, replacing the vague “where required”
wording. Require each handler to declare an idempotency key, unique constraint,
conditional update, execution record, or explicit proof that the operation is
intrinsically idempotent.

Comment on lines +13 to +26
Repository inspection identified multiple operations that appear related to Redis but do not all represent ordinary cache behavior:

- standard cache reads and writes;
- report-progress values;
- rate-limit counters;
- `cache.set(..., nx=True)` used as lock-like behavior;
- `cache.delete_pattern(...)`;
- direct `get_redis_connection()` access;
- Celery broker and result storage;
- health checks.

A backend swap from Redis to PostgreSQL or LocMem cannot safely replace all these responsibilities.

The existing LocMem shim accepts an `nx` argument while always returning success, silently removing mutual exclusion. This demonstrates that cache configuration and distributed locking must be separated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Classify rate-limit counters outside ordinary cache semantics.

Line 17 lists rate-limit counters as a Redis responsibility, but the decision does not define their required consistency or atomicity. If a rate limit is a security control, a switch to LocMem or another shared cache profile can weaken enforcement. Define a separate rate-limit contract for scope, atomic updates, expiration, and failure behavior.

Also applies to: 54-60

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/adr/ADR-0004-configurable-application-cache.md` around lines 13 -
26, Update the ADR’s responsibility classification to separate rate-limit
counters from ordinary cache behavior, and define their contract covering scope,
atomic updates, expiration, and failure behavior. Ensure the decision and
related configuration guidance identify the required consistency guarantees when
using LocMem or another shared cache profile.

Comment on lines +2414 to +2430
# 48. Test Profile

Conceptual fast test configuration:

```text
CARE_TASK_BACKEND=fake
CARE_CACHE_BACKEND=dummy
CARE_RATE_LIMIT_BACKEND=postgres
CARE_TRANSIENT_STATE_BACKEND=postgres
CARE_STORAGE_BACKEND=filesystem
```

A `fake` task backend MAY exist only in test settings.

Production settings SHALL reject it.

Provider integration tests SHALL explicitly override the fake backends.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align the test profile with the backend contracts.

CARE_STORAGE_BACKEND=filesystem conflicts with Section 11.2, which says that filesystem is not a supported value and that tests use InMemoryStorage through override_settings. CARE_TASK_BACKEND=fake is also absent from the supported backend list in Section 17.1. Move these choices into test settings, or document them explicitly as parser-supported test-only values. As written, this profile can fail startup validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/07-configuration-reference.md` around lines 2414 -
2430, Update the “Test Profile” configuration to use only backend values
supported by the documented contracts, especially replacing or removing
CARE_STORAGE_BACKEND=filesystem and CARE_TASK_BACKEND=fake. If these are
intended as test-only parser values, document them explicitly in the
corresponding backend sections and ensure startup validation accepts them;
otherwise move the choices into the test settings using the established
InMemoryStorage and supported task-backend configuration.

Comment on lines +325 to +354
**verified** **Absent** from `Pipfile`: `django-storages`, any
`google-cloud-*` package, `django-celery-beat`, `django-celery-results`.

**verified** `django-anymail` is installed with the `amazon-ses` extra.
**inferred** email delivery is AWS SES today; GCP has no drop-in equivalent, so
this needs an explicit decision.

---

## 10. Existing GCP-related code

**verified** A repository-wide search for `gcp`, `google.cloud`, `cloud run`,
`cloudsql`, `cloud_tasks`, `django-storages` and `django_storages` across
`*.py`, `*.yml`, `*.yaml`, `*.sh`, `Pipfile` and `*.toml`, excluding
`docs/xii/`, returns exactly **two** matches:

| File | Line | Content |
| --- | --- | --- |
| `care/utils/csp/config.py` | 20 | `GCP = "GCP"` — a `CSProvider` enum member |
| `care/emr/utils/file_manager.py` | 130 | `# bulk delete is not supported by some providers: GCP` |

**verified** The `CSProvider.GCP` member is **never branched on**.
`BUCKET_PROVIDER` is only ever compared against `CSProvider.AWS_ROLE_BASED`
(`care/utils/csp/config.py:35, 48, 62`).

**verified** There is no Terraform, no Cloud Build config, no `app.yaml`, no
`service.yaml`, and no GCP credentials handling anywhere in the repository.

**Conclusion (verified):** GCP support does not exist. This is genuinely
greenfield.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Refresh the dependency and GCP inventory.

These lines state that django-storages is absent and that GCP support has no storage configuration. This PR adds django-storages and provider-neutral Django Storage configuration. Update the dependency table, the GCP search result, and the greenfield conclusion. If this section must preserve the Phase 0 state, label it explicitly as a historical baseline.

The PR objective confirms the new storage configuration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/runtime-and-deployment.md` around lines 325 -
354, Update the dependency and GCP inventory section to reflect the PR’s
addition of django-storages and provider-neutral Django Storage configuration,
including the dependency table and repository search results. Revise the “GCP
support does not exist” conclusion to distinguish the new configuration from any
still-absent GCP infrastructure; if retaining the prior findings, label them
explicitly as the historical Phase 0 baseline.

Comment on lines +35 to +37
**verified** Only **3 tasks are ever dispatched asynchronously** in non-test code:
`generate_report_task`, `send_totp_enabled_email`, `send_totp_disabled_email`, plus
`summarise_monetary_components` in its own recursive tail.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Correct the asynchronous task count.

Lines [35-37] say that only 3 tasks run asynchronously, but the sentence lists summarise_monetary_components as a fourth task. Lines [383-389] confirm 4 asynchronous call sites across 4 distinct tasks. Update this count to 4 to avoid an incomplete migration inventory.

Proposed wording
-**verified** Only **3 tasks are ever dispatched asynchronously** in non-test code:
+**verified** Only **4 tasks are ever dispatched asynchronously** in non-test code:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**verified** Only **3 tasks are ever dispatched asynchronously** in non-test code:
`generate_report_task`, `send_totp_enabled_email`, `send_totp_disabled_email`, plus
`summarise_monetary_components` in its own recursive tail.
**verified** Only **4 tasks are ever dispatched asynchronously** in non-test code:
`generate_report_task`, `send_totp_enabled_email`, `send_totp_disabled_email`, plus
`summarise_monetary_components` in its own recursive tail.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/task-call-sites.md` around lines 35 - 37,
Update the asynchronous task count in the inventory statement from 3 to 4, while
retaining the existing list of four tasks and the recursive-tail qualification
for summarise_monetary_components.

Comment on lines +275 to +279
**verified** The recursion at `:167` passes `child`, a **`FacilityLocation`
instance**, while the entry point at `:128` passes `self.id`, an **int**. The
function body at `:165` uses the argument as `parent_id=base_location`, which
works for an int; passing a model instance relies on Django coercing the instance
to its PK in the filter. Inconsistent, but functional today.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fix the recursive handle_cascade argument contract.

Lines [275-279] state that recursion passes a FacilityLocation instance into a query using parent_id=base_location, then classify the behavior as functional. In Django, ForeignKey.get_prep_value() delegates to the target field, and IntegerField.get_prep_value() converts the value with int(value). A model instance is therefore not a safe scalar parent_id value for this path. (raw.githubusercontent.com)

Pass child.pk, or use the relation lookup parent=base_location. Add a regression test for a two-level hierarchy.

#!/bin/bash
set -euo pipefail

rg -n -C 8 'def handle_cascade|handle_cascade\(|parent_id\s*=' care config --glob '*.py'
fd -a '^(Pipfile|Pipfile.lock|pyproject.toml|setup.cfg|setup.py)$' \
  | xargs -r rg -n 'django'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/task-call-sites.md` around lines 275 - 279,
Update the recursive call in handle_cascade so it passes child.pk when the
function uses the value as parent_id, or change the query to use the parent
relation with the FacilityLocation instance. Add a regression test covering
cascading through a two-level FacilityLocation hierarchy.

Comment on lines +96 to +104
### A6. "All uploads pass through Django" is partly already true

**verified** `POST /api/v1/files/upload-file/`
(`care/emr/api/viewsets/file_upload.py:213-270`) already proxies uploads through
Django as base64.

**inferred** Any document describing the Django-proxied upload as new work should
account for this endpoint — the task is to replace a base64 path with a streaming
one and to remove the presigned alternative, not to build from nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update the resolved file-transport entries.

These sections describe the signed-URL, base64-upload, direct bucket URL, and legacy bucket-config paths as current or pending work. This PR replaces those paths with multipart uploads and CARE-mediated download and image routes. Mark resolved items as resolved, and retain only migration questions about clients that used the legacy contract. Otherwise this inventory directs frontend and deployment work toward removed behavior.

The PR objective confirms the new file transport contract.

Also applies to: 130-135, 223-229, 312-322, 338-346, 412-418

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/xii/architecture/inventory/unresolved-items.md` around lines 96 - 104,
Update the resolved file-transport entries, including the sections corresponding
to the signed-URL, base64-upload, direct bucket URL, and legacy bucket-config
paths, to mark replaced behavior as resolved and document the multipart upload
plus CARE-mediated download/image routes. Remove pending work that targets the
retired contracts, retaining only migration questions for clients still using
the legacy contract.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant