Skip to content

Let a phone upload captures straight to a station's storage - #1409

Open
mihow wants to merge 7 commits into
mainfrom
feat/mobile-upload-api-a1-a5
Open

Let a phone upload captures straight to a station's storage#1409
mihow wants to merge 7 commits into
mainfrom
feat/mobile-upload-api-a1-a5

Conversation

@mihow

@mihow mihow commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

A phone in the field holds a night of captures and needs somewhere to put them. Routing that traffic through the platform would mean every image crossing the web server twice, so instead the client asks the platform for permission and uploads straight to the station's own storage.

A client calls POST /api/v2/deployments/{id}/upload-request/ with the files it wants to send, gets back a short-lived signed PUT URL for each one, uploads each file directly to the station's storage source, and then calls the existing sync action to ingest them. The key each URL is minted for is exactly the object key the subsequent sync stores, so re-requesting and re-uploading the same file is idempotent rather than producing a duplicate.

Alongside that, the endpoints a mobile client needs in order to find where to upload now answer the questions it actually asks: which stations belong to a given research site or device, and which projects the signed-in user may write to.

This is the first of the three contracts in #1379 (A1 and A5, with A2 covered by documentation only). No migration: the new action reuses the permission that already governs syncing a station.

List of Changes

Change (what it does) How Notes
A client can upload captures straight to a station's storage POST /api/v2/deployments/{id}/upload-request/ mints short-lived presigned PUT URLs Validated per file; rejected files come back in errors with no URL minted
Re-uploading the same file does not duplicate it derive_upload_key() builds the object key the same way sync_captures will store it Deliberately not key_with_prefix, whose deduplication heuristic mangles filenames that contain the subdirectory name
Uploading needs no new permission The upload_request action maps to SYNC_DEPLOYMENT Without the mapping every non-superuser would be refused, because a detail action probes a permission that does not exist
A client can find the station for a site or device research_site_id and device_id filters on the station list The parameter names the client already sends
A client can find the projects it may write to ?role=manager / ?writable=true on the project list Covers project managers and owners; superusers see all, anonymous sees none
A client knows when its uploads have been ingested Schema documentation on the existing sync action: the response, the polling endpoint, and the three terminal states Documentation only — the auto-sync cadence is a separate change

Worth a reviewer's attention

The checksum header is suppressed for non-AWS storage. x-amz-checksum-sha256 is only emitted when there is no custom endpoint, because Swift's S3 API rejects it. The consequence is that the end-to-end test against MinIO never exercises the checksum branch — that path is only live against real AWS.

HEIC is not an accepted image extension. IMAGE_FILE_EXTENSIONS covers jpg/jpeg/png/gif/webp/svg/bmp/ico/tiff/tif. A client uploading HEIC is rejected at the request stage, and a sync would drop it too. If phone uploads need HEIC, the extension list is the change to make and it is not made here.

Fixed while bringing the branch up to date with main

Six weeks of drift, and running the suite against a live stack turned up three problems in the branch's own code and tests:

  • ?role=manager and ?writable=true answered with every project. A role group carries the model-level permission as well as the per-project one, so anyone who manages a single project holds update_project globally, and guardian accepts a global permission by default. Asking for object-level permissions only fixes it — for exactly the users the filter exists to serve.
  • The site and device scoping tests asked for paths that do not exist. Both lists live under the stations route (/api/v2/deployments/sites/, /deployments/devices/), so the tests were asserting on a 404. They now also assert what scoping promises — this project's rows present, another project's absent — rather than an exact set that breaks as soon as the project has any other site.
  • The key-length test never reached the guard. It sent a 270-character filename, which the request serializer rejects with a 400 before the view builds a key. The filename is now legal on its own and only exceeds the limit once the storage prefix and the station's subdirectory are in front of it, which is the case the guard exists for.

One thing to watch at merge time

#1408 (station status) also adds check_custom_permission to Deployment. The two definitions merge without a conflict, and Python keeps the last one — so whichever lands second silently removes the other's permission mapping and starts refusing every non-superuser. Whoever merges second should fold both actions into one method; the mapping is a one-line set membership.

Testing

  • ami/tests/test_storage.py — key derivation, including the filename-matching-subdirectory regression, and the presigned URL headers with checksum gating. All offline: signing is local.
  • ami/main/tests.pyTestDeploymentUploadRequest covers the permission matrix, a station with no storage source, each per-file validation error, deterministic keys, and the file-count limit. TestDeploymentUploadRequestE2E mints a URL, uploads through it and asserts the synced capture's path matches the minted key; it needs MinIO. TestDeploymentAndProjectFilters and TestProjectWritableFilter cover the filters.

Refs #1379.

🤖 Generated with Claude Code

https://claude.ai/code/session_019Ej7dJGDozxeVg6AhfTkYo

Mike's Bot and others added 7 commits July 23, 2026 18:28
Introduces get_presigned_put_url (per-file, uncached PUT URL; AWS
flexible-checksum header gated to real AWS since Swift's s3api rejects it)
and derive_upload_key, which builds the full object key directly rather than
via key_with_prefix (whose split() dedup mangles filenames containing the
subdir string). The derived key matches exactly what sync_captures stores as
SourceImage.path, making re-upload idempotent.

Unit tests (offline, local signing) cover key derivation edge cases and the
checksum-gating behaviour. Part of #1379.

Co-Authored-By: Claude <noreply@anthropic.com>
A1: POST /api/v2/deployments/{id}/upload-request/ mints presigned PUT URLs for
direct-to-storage capture uploads, with full per-file validation (parseable
timestamp, image extension, path-traversal, key length <= 255, regex, size)
returning rejected files in errors[] rather than minting a URL. Adds
Deployment.check_custom_permission mapping "upload_request" to SYNC_DEPLOYMENT
so the detail action's object-permission check passes (reuses an existing
guardian perm; no migration).

A5: DeploymentFilterSet exposes research_site_id/device_id (the exact param
names the mobile client sends); ProjectViewSet gains ?role=manager /
?writable=true via get_objects_for_user on update_project (covers managers and
owners). Sites/devices ?project_id scoping confirmed by test.

A2: @extend_schema on the existing sync action documents the {job_id} response,
the job-polling endpoint, and the three terminal states (SUCCESS, FAILURE,
REVOKED). The auto-sync cadence is a separate follow-up.

Tests: permission matrix, each validation error path, deterministic key, and an
E2E flagship (mint PUT -> requests.put -> sync_captures -> assert SourceImage
path) against live MinIO. Part of #1379.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
A role group carries the model-level permission as well as the per-project one,
so a user who manages any single project holds `update_project` globally. Guardian
accepts a global permission by default, so `?role=manager` and `?writable=true`
answered with the entire project list for exactly the users the filter exists to
serve — anyone who manages at least one project.

Asking guardian for object-level permissions only fixes it. Superusers are still
short-circuited above and continue to see everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ej7dJGDozxeVg6AhfTkYo
Both lists live under the stations route — /api/v2/deployments/sites/ and
/api/v2/deployments/devices/ — so the tests were asking for paths that answer 404
and asserting on a response that never arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ej7dJGDozxeVg6AhfTkYo
…romise

The key-length test sent a 270-character filename, which the request serializer
rejects with a 400 before the view ever builds a key, so the guard it meant to
exercise never ran. The filename is now legal on its own and only exceeds the
limit once the storage prefix and the station's subdirectory are in front of it,
which is the case the guard exists for.

The site and device scoping tests asserted an exact set, which fails as soon as
the project carries any other site or device. They now assert what scoping
actually promises: this project's rows are present and another project's are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ej7dJGDozxeVg6AhfTkYo
Copilot AI lite review requested due to automatic review settings September 4, 2026 23:15
@netlify

netlify Bot commented Sep 4, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-ssec canceled.

Name Link
🔨 Latest commit 32f9612
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/6a9b5124471f8e000839f486

@netlify

netlify Bot commented Sep 4, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview canceled.

Name Link
🔨 Latest commit 32f9612
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6a9b5124aeed9b0007a90532

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 39 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 062a1e4c-f6de-4088-a381-5811bf55bfa4

📥 Commits

Reviewing files that changed from the base of the PR and between 498e62f and 32f9612.

📒 Files selected for processing (8)
  • ami/main/api/serializers.py
  • ami/main/api/views.py
  • ami/main/models.py
  • ami/main/tests.py
  • ami/tests/test_storage.py
  • ami/utils/s3.py
  • docs/claude/INDEX.md
  • docs/claude/planning/2026-07-23-mobile-upload-api.md

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.

Copilot AI 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.

🟡 Changes recommended

A couple of newly introduced API validation/messages are misleading or under-validated (docstring/error detail correctness and sha256 format validation), and should be corrected before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a direct-to-storage mobile upload contract for deployments by minting short-lived presigned PUT URLs, plus related query filters and schema docs to support the mobile client’s upload → sync workflow.

Changes:

  • Add POST /api/v2/deployments/{id}/upload-request/ to mint presigned PUT URLs with deterministic object keys that match subsequent sync_captures ingestion.
  • Add deployment list filters (research_site_id, device_id) and project list “writable” filters (?role=manager / ?writable=true) aligned with the mobile client’s needs.
  • Add unit + E2E tests for key derivation, presigned PUT signing behavior, endpoint validation/permissions, and filter scoping; add planning/index documentation.
File summaries
File Description
docs/claude/planning/2026-07-23-mobile-upload-api.md Planning/notes for A1/A5 and sync-completion docs (A2 docs).
docs/claude/INDEX.md Adds the planning doc to the Claude docs index.
ami/utils/s3.py Introduces presigned PUT URL generation + deterministic upload key derivation helper.
ami/tests/test_storage.py Adds unit tests for upload key derivation and checksum-header gating in presigned PUT URLs.
ami/main/tests.py Adds API tests for upload-request permissions/validation and for the new deployment/project filters, plus an E2E MinIO flow.
ami/main/models.py Maps upload_request action permission to existing SYNC_DEPLOYMENT permission on Deployment.
ami/main/api/views.py Implements upload-request action, adds deployment filterset, and documents sync completion response shape.
ami/main/api/serializers.py Adds request/response serializers for upload-request.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +899 to +900
# base64-encoded raw SHA-256 digest; optional and only honoured against real AWS.
sha256 = serializers.CharField(required=False, allow_blank=True, default="")
Comment thread ami/main/api/views.py
Comment on lines +357 to +359
These mirror the constraints ``sync_captures`` later imposes so we never
mint a URL for a file the sync would silently drop or the DB would reject.
"""
Comment thread ami/main/api/views.py
if get_image_timestamp_from_filename(filename) is None:
return {
"code": "unparseable_timestamp",
"detail": "Filename has no parseable timestamp; sync would drop it.",
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.

2 participants