Skip to content
Open
Show file tree
Hide file tree
Changes from 21 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.

53 changes: 53 additions & 0 deletions care/emr/api/viewsets/file_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
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


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)
return storage_file_response(
storages[STORAGE_ALIAS],
object_key,
filename=object_key.rsplit("/", 1)[-1],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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)
126 changes: 102 additions & 24 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.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,50 @@ 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 —
this serializer does not restate those rules.
"""

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 +219,18 @@ 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()
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 +268,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")

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
serializer = FileUploadMultipartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data

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,20 +316,27 @@ 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()

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
Expand Down
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
5 changes: 4 additions & 1 deletion care/emr/reports/report_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from uuid import uuid4

from django.core.cache import cache
from django.core.files.base import ContentFile
from django.utils import timezone

from care.emr.models.report.report_upload import ReportUpload
Expand Down Expand Up @@ -121,8 +122,10 @@ def generate_and_upload_report( # noqa:PLR0915
report_upload.save(skip_internal_name=True)

try:
# output_bytes is already fully materialised by the renderer above; see
# docs/xii/architecture/inventory/storage-call-sites.md section 5.
report_upload.files_manager.put_object(
report_upload, output_bytes, ContentType=mime_type
report_upload, ContentFile(output_bytes), content_type=mime_type
)
report_upload.upload_completed = True
report_upload.save()
Expand Down
Loading