Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e280e0f
docs: add scope and goals document for GCP deployment
cesarbenjamindotnet Aug 5, 2026
6a2976d
docs(gcp): complete runtime call-site inventory
cesarbenjamindotnet Aug 5, 2026
2fe40cd
docs(xii): complete runtime call-site inventory
cesarbenjamindotnet Aug 6, 2026
755e8cb
docs(gcp): record local runtime baseline
cesarbenjamindotnet Aug 6, 2026
d98a189
Merge branch 'feature/gcp-phase-0-inventory' into gcp
cesarbenjamindotnet Aug 6, 2026
30da93e
docs(gcp): complete runtime call-site inventory
cesarbenjamindotnet Aug 6, 2026
26b4f2e
chore(storage): add django-storages with s3 and google backends
cesarbenjamindotnet Aug 6, 2026
a9e3fce
feat(storage): configure portable storage aliases
cesarbenjamindotnet Aug 6, 2026
c587c5f
refactor(storage): route file persistence through Django Storage
cesarbenjamindotnet Aug 6, 2026
e52ffbf
test(storage): cover aliases, object names and MinIO integration
cesarbenjamindotnet Aug 6, 2026
86d0038
fix(storage): name the GCS project setting per the configuration refe…
cesarbenjamindotnet Aug 6, 2026
bdf212c
docs(storage): record the IS-01 storage modernization
cesarbenjamindotnet Aug 6, 2026
49a9e58
refactor(storage): remove signed URL transport, serve objects through…
cesarbenjamindotnet Aug 7, 2026
f4875d0
refactor(storage): keep S3FilesManager as a deprecated plugin shim
cesarbenjamindotnet Aug 7, 2026
12b4e02
test(storage): verify provider-neutral transport
cesarbenjamindotnet Aug 7, 2026
0714146
docs(storage): finalize ADR-0001
cesarbenjamindotnet Aug 7, 2026
50783d2
docs(adr): add ADR-0007 for Terraform infrastructure on GCP
cesarbenjamindotnet Aug 7, 2026
4898f12
refactor(files): replace base64 upload with multipart transport
cesarbenjamindotnet Aug 7, 2026
98cfbfa
test(files): cover multipart upload and provider-neutral round trips
cesarbenjamindotnet Aug 7, 2026
bfa85df
docs(files): complete file transport modernization
cesarbenjamindotnet Aug 7, 2026
e6d928f
Merge branch 'ohcnetwork:develop' into gcp
cesarbenjamindotnet Aug 7, 2026
672c532
fix(storage): address review findings on transport and inventories
cesarbenjamindotnet Aug 7, 2026
97058e9
fix(storage): address review findings on transport and inventories
cesarbenjamindotnet Aug 7, 2026
0bde688
fix(files): update file transport documentation and improve upload ha…
cesarbenjamindotnet Aug 7, 2026
b1e32bc
test(files): share the streaming-response helper
cesarbenjamindotnet Aug 7, 2026
3582f7e
Merge branch 'fix/coderabbit-file-transport' into feature/django-stor…
cesarbenjamindotnet Aug 7, 2026
402ca6e
docs(inventory): correct report_utils line references and retry analysis
cesarbenjamindotnet Aug 7, 2026
699b936
docs: reconcile the IS-01 documents with the delivered ES-02 state
cesarbenjamindotnet Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ evalidate = "==2.1.3"
weasyprint = "==68.0"
pip = "==26.0"
urllib3 = "==2.7.0"
django-storages = {extras = ["s3", "google"], version = "==1.14.6"}

[dev-packages]
boto3-stubs = { extras = ["s3", "boto3"], version = "==1.43.6" }
Expand Down
682 changes: 398 additions & 284 deletions Pipfile.lock

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions care/emr/api/viewsets/file_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Public asset delivery for facility cover images and user avatars.

ADR-0001: a client never receives a storage-provider URL. These two objects were
already world-readable directly from the bucket, so the routes stay
unauthenticated — who can see the image is unchanged. What changes is that CARE
serves the bytes, which lets the bucket become private and keeps the provider
interchangeable.

They are separate views rather than actions on FacilityViewSet / UserViewSet
because those viewsets filter their querysets by ``request.user`` and cannot
serve an anonymous request.
"""

from django.core.files.storage import storages
from rest_framework.exceptions import NotFound
from rest_framework.views import APIView

from care.emr.utils.file_download import storage_file_response
from care.facility.models.facility import Facility
from care.users.models import User
from care.utils.file_uploads.cover_image import STORAGE_ALIAS
from care.utils.shortcuts import get_object_or_404

#: ``upload_cover_image`` mints a fresh key containing a random token for every
#: upload, so the bytes behind a given key never change -- replacing an image
#: produces a different key. The response is therefore immutable per key and
#: safe for anonymous browser, CDN and reverse-proxy caches, which is what CARE
#: serving the bytes would otherwise cost us over reading the bucket directly.
PUBLIC_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"


class PublicAssetView(APIView):
"""Unauthenticated read-only delivery of a public image."""

authentication_classes = ()
permission_classes = ()

@staticmethod
def serve(object_key: str | None):
if not object_key:
msg = "No image set"
raise NotFound(msg)
response = storage_file_response(
storages[STORAGE_ALIAS],
object_key,
filename=object_key.rsplit("/", 1)[-1],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
response.headers["Cache-Control"] = PUBLIC_ASSET_CACHE_CONTROL
return response


class FacilityCoverImageView(PublicAssetView):
def get(self, request, external_id):
facility = get_object_or_404(Facility, external_id=external_id)
return self.serve(facility.cover_image_url)


class UserProfilePictureView(PublicAssetView):
def get(self, request, username):
user = get_object_or_404(User, username=username, deleted=False)
return self.serve(user.profile_picture_url)
147 changes: 119 additions & 28 deletions care/emr/api/viewsets/file_upload.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import base64

import magic
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django_filters import rest_framework as filters
from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_field
from pydantic import BaseModel
from rest_framework import filters as rest_framework_filters
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response

from care.emr.api.viewsets.base import (
Expand All @@ -25,12 +25,14 @@
from care.emr.models.diagnostic_report import DiagnosticReport
from care.emr.models.service_request import ServiceRequest
from care.emr.resources.file_upload.spec import (
FileCategoryChoices,
FileTypeChoices,
FileUploadCreateSpec,
FileUploadListSpec,
FileUploadRetrieveSpec,
FileUploadUpdateSpec,
)
from care.emr.utils.file_download import file_object_response
from care.security.authorization import AuthorizationController
from care.utils.shortcuts import get_object_or_404

Expand Down Expand Up @@ -116,6 +118,55 @@ class FileUploadFilter(filters.FilterSet):
name = filters.CharFilter(field_name="name", lookup_expr="icontains")


@extend_schema_field(OpenApiTypes.BINARY)
class BinaryFileField(serializers.FileField):
"""
A `FileField` that documents itself as binary.

drf-spectacular renders `FileField` as `format: uri` by default, because
DRF serialises it to a URL on *output*. In a multipart request body it is
raw bytes, so the annotation is applied here rather than by flipping
COMPONENT_SPLIT_REQUEST, which would reshape every schema in the project.
"""


class FileUploadMultipartSerializer(serializers.Serializer):
"""
`multipart/form-data` upload request (ADR-0002).

Declares the transport contract and the binary field. The metadata fields
are carried through to `FileUploadCreateSpec`, which remains the
authoritative validator for the logical file type, category and filename.

`file_type` and `file_category` are `ChoiceField`s here as well, so the
choices appear in the generated schema and a bad value is rejected before
the file is read. That duplicates the spec's enums deliberately: both are
generated from the same `FileTypeChoices` / `FileCategoryChoices`, so adding
a member updates both. No rule is restated by hand.
"""

file = BinaryFileField(
help_text="The file itself, sent as a normal multipart file part."
)
name = serializers.CharField(help_text="Display name for the file.")
associating_id = serializers.CharField(
help_text="External id of the object the file belongs to."
)
file_type = serializers.ChoiceField(
choices=[choice.value for choice in FileTypeChoices]
)
file_category = serializers.ChoiceField(
choices=[choice.value for choice in FileCategoryChoices]
)
original_name = serializers.CharField(
required=False,
help_text=(
"Original filename. Defaults to the uploaded part's filename; "
"supply it only to override."
),
)


class FileUploadViewSet(
EMRCreateMixin, EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet
):
Expand Down Expand Up @@ -173,6 +224,21 @@ def get_queryset(self):
file_authorizer(self.request.user, obj.file_type, obj.associating_id, "read")
return super().get_queryset()

@extend_schema(
description="Download the file through CARE. Reads through Django Storage; "
"no storage-provider URL is exposed.",
responses={(200, "application/octet-stream"): OpenApiTypes.BINARY},
)
@action(detail=True, methods=["GET"])
def download(self, request, *args, **kwargs):
# get_object() -> get_queryset(), which runs file_authorizer(..., "read")
# for every detail action.
obj = self.get_object()
if not obj.upload_completed:
msg = "File upload is not complete"
raise NotFound(msg)
return file_object_response(obj)

@extend_schema(responses={200: FileUploadListSpec})
@action(detail=True, methods=["POST"])
def mark_upload_completed(self, request, *args, **kwargs):
Expand Down Expand Up @@ -210,31 +276,44 @@ def archive(self, request, *args, **kwargs):
)
return Response(FileUploadListSpec.serialize(obj).to_json())

@action(detail=False, methods=["POST"], url_path="upload-file")
@extend_schema(
description=(
"Upload a file through CARE using multipart/form-data. The bytes "
"are streamed to the configured storage backend through Django "
"Storage; no storage-provider URL is involved."
),
request={"multipart/form-data": FileUploadMultipartSerializer},
responses={200: FileUploadRetrieveSpec},
)
@action(
detail=False,
methods=["POST"],
url_path="upload-file",
parser_classes=[MultiPartParser],
)
def upload_file(self, request, *args, **kwargs):
file_name = request.data.get("original_name")
file_data = request.data.get("file_data")
serializer = FileUploadMultipartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data

if not file_name or not file_data:
raise ValidationError(
"Missing required fields: 'original_name' or 'file_data'"
)

try:
file_content = base64.b64decode(file_data)
except Exception as e:
error = "Invalid base64-encoded file data"
raise ValidationError(error) from e

uploaded_file = ContentFile(file_content, name=file_name)
# Django's upload handlers have already decided whether this is an
# InMemoryUploadedFile or a TemporaryUploadedFile, based on
# FILE_UPLOAD_MAX_MEMORY_SIZE. Either way it is a file-like object and
# is never fully materialised here.
uploaded_file = payload["file"]
file_name = payload.get("original_name") or uploaded_file.name

max_file_size = settings.MAX_FILE_UPLOAD_SIZE * 1024 * 1024
if uploaded_file.size > max_file_size:
error = f"File size exceeds the limit of {max_file_size / (1024 * 1024)}MB"
raise ValidationError(error)

# Sniff the declared type from the leading bytes rather than trusting
# the part's Content-Type header, matching the previous behaviour.
try:
mime_type = magic.from_buffer(file_content[:2048], mime=True)
header = uploaded_file.read(2048)
uploaded_file.seek(0)
mime_type = magic.from_buffer(header, mime=True)
except Exception as e:
error = "Error detecting file type."
raise ValidationError(error) from e
Expand All @@ -245,26 +324,38 @@ def upload_file(self, request, *args, **kwargs):

request_data = {
"original_name": file_name,
"name": request.data.get("name"),
"associating_id": request.data.get("associating_id"),
"file_type": request.data.get("file_type"),
"file_category": request.data.get("file_category"),
"name": payload["name"],
"associating_id": payload["associating_id"],
"file_type": payload["file_type"],
"file_category": payload["file_category"],
"mime_type": mime_type,
}

# The row is written first and the object second, both inside one
# transaction: a storage failure rolls the row back, so no completed
# record can exist without its object. The reverse gap — object written,
# commit fails — leaves an orphan object, which is pre-existing
# behaviour recorded as B8 in unresolved-items.md.
with transaction.atomic():
file_upload = FileUploadCreateSpec(**request_data).de_serialize()
file_upload._just_created = False # noqa SLF001
self.authorize_create(file_upload)
file_upload.save()

# Only the storage write is translated into "failed to upload to
# storage". The save below is deliberately outside the block: a
# database failure there is not a storage failure, and reporting it
# as one sends whoever reads the log to the wrong system.
try:
# The UploadedFile is handed straight to Django Storage; it is
# not read into memory first.
file_upload.files_manager.put_object(file_upload, uploaded_file)
file_upload.upload_completed = True
file_upload.updated_by = request.user
file_upload.save(skip_internal_name=True)
except Exception as e:
error_msg = "Failed to upload file to storage"
raise ValidationError(error_msg) from e

file_upload.upload_completed = True
file_upload.updated_by = request.user
file_upload.save(skip_internal_name=True)

return Response(FileUploadRetrieveSpec.serialize(file_upload).to_json())
16 changes: 16 additions & 0 deletions care/emr/api/viewsets/report/report_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.utils import timezone
from django_filters import BooleanFilter, CharFilter, FilterSet
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from pydantic import UUID4, BaseModel, field_validator
from rest_framework import status
Expand All @@ -26,6 +27,7 @@
ReportUploadRetrieveSpec,
)
from care.emr.tasks.report_generation import generate_report_task
from care.emr.utils.file_download import file_object_response
from care.security.authorization.base import AuthorizationController
from care.utils.shortcuts import get_object_or_404

Expand Down Expand Up @@ -94,6 +96,20 @@ def authorize_update(self, request_obj, model_instance):
self.request.user, model_instance.report_type, model_instance.associating_id
)

@extend_schema(
description="Download the report through CARE. Reads through Django Storage; "
"no storage-provider URL is exposed.",
responses={(200, "application/octet-stream"): OpenApiTypes.BINARY},
tags=["report"],
)
@action(detail=True, methods=["GET"])
def download(self, request, *args, **kwargs):
obj = self.get_object()
# get_queryset() only authorizes the list action, so a detail action
# must authorize explicitly or it would serve any report to any user.
read_report_authorizer(request.user, obj.report_type, obj.associating_id)
return file_object_response(obj)

@extend_schema(
description="Generate a report from a template with patient/encounter data",
request=GenerateReportRequest,
Expand Down
5 changes: 2 additions & 3 deletions care/emr/models/file_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
from django.db import models

from care.emr.models import EMRBaseModel
from care.emr.utils.file_manager import S3FilesManager
from care.emr.utils.file_manager import FilesManager
from care.users.models import User
from care.utils.csp.config import BucketType
from care.utils.models.validators import parse_file_extension


Expand All @@ -30,7 +29,7 @@ class FileUpload(EMRBaseModel):
related_name="archived_files",
)

files_manager = S3FilesManager(BucketType.PATIENT)
files_manager = FilesManager("patient")

def get_extension(self):
extensions = parse_file_extension(self.internal_name)
Expand Down
7 changes: 3 additions & 4 deletions care/emr/models/report/report_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
from django.db import models

from care.emr.models import EMRBaseModel
from care.emr.utils.file_manager import S3FilesManager
from care.emr.utils.file_manager import FilesManager
from care.users.models import User
from care.utils.csp.config import BucketType
from care.utils.models.validators import parse_file_extension


Expand All @@ -31,11 +30,11 @@ class ReportUpload(EMRBaseModel):
related_name="archived_reports",
)

files_manager = S3FilesManager(BucketType.REPORT)
files_manager = FilesManager("report")

@property
def file_type(self):
"""Alias for report_type to maintain compatibility with S3FilesManager"""
"""Alias for report_type, so reports share the storage-name convention"""
return self.report_type

def get_extension(self):
Expand Down
5 changes: 2 additions & 3 deletions care/emr/reports/context_builder/data_points/fileupload.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Field,
QuerysetContextBuilder,
)
from care.emr.utils.file_download import file_download_url


class FileUploadReportFilter(filters.FilterSet):
Expand All @@ -26,9 +27,7 @@ class FileUploadContextBuilder(QuerysetContextBuilder):
url = Field(
display="File URL",
preview_value="https://s3.amazonaws.com/bucket/patient/12345/file.pdf",
mapping=lambda f: f.files_manager.read_signed_url(f)
if f.upload_completed
else None,
mapping=lambda f: file_download_url(f) if f.upload_completed else None,
description="URL to access the uploaded file",
)

Expand Down
Loading