Jul 21 - Update uploaded photos location/timestamp
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
||||
21. [Change Philosophy](#21-change-philosophy)
|
||||
22. [Object Storage Migration (R2)](#22-object-storage-migration-r2)
|
||||
23. [Photo Capture-Time / Geo Overlay](#23-photo-capture-time--geo-overlay)
|
||||
|
||||
---
|
||||
|
||||
@@ -163,6 +164,7 @@ part of the tree — see §7. Device registration on the API side lives in
|
||||
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
||||
| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
|
||||
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. |
|
||||
| `PHOTO_STAMP_ENABLED` | Optional, default `true`. Burns a capture-time + geo overlay into photos uploaded via `POST /api/v1/photos/upload`. Set `false` to store raw uploads. |
|
||||
|
||||
### Email SSL Auto-Detection
|
||||
|
||||
@@ -577,7 +579,7 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa
|
||||
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
|
||||
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) |
|
||||
| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id`; accepts `result_photos` list stored in `mobile_photo_paths` |
|
||||
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` |
|
||||
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path`, `stamped`, `captured_at`, `capture_source`. Optional form fields `captured_at` (ISO-8601), `latitude`, `longitude` drive the burned-in timestamp/geo overlay — see §23. |
|
||||
|
||||
### Phase C Endpoints
|
||||
|
||||
@@ -1351,6 +1353,7 @@ timeout = 30
|
||||
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`, `internal_handler_name`, `internal_handler_contact`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
|
||||
| 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. |
|
||||
| 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. |
|
||||
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
|
||||
|
||||
---
|
||||
|
||||
@@ -1455,3 +1458,35 @@ timeout = 30
|
||||
|
||||
### Rollback (any phase after cutover)
|
||||
- [ ] `STORAGE_BACKEND=local` → restart. Instant revert; local files were never touched.
|
||||
|
||||
---
|
||||
|
||||
## 23. Photo Capture-Time / Geo Overlay
|
||||
|
||||
**Goal:** evidence photos carry a visible, tamper-evident record of *when* and *where* they were taken. Implemented in `app/utils/photo_stamp.py`, applied in `POST /api/v1/photos/upload`.
|
||||
|
||||
**Why upload-time and not `PATCH /issues/<id>/photos`** (rule 81): that PATCH only receives path strings — the bytes are already stored and it carries no capture metadata. Stamping there would require a read-modify-write per key, a new overwrite-in-place storage primitive (`storage.save()` mints a new uuid key; §22 requires key == DB path), and would risk a **double burn** on retry since the endpoint is intentionally idempotent (rule 45). At upload the raw bytes and camera EXIF are in hand and exactly one already-stamped object is written.
|
||||
|
||||
### Metadata resolution order
|
||||
1. **Client fields** — `captured_at` (ISO-8601, offsets and `Z` accepted), `latitude`, `longitude` multipart form fields. Preferred: the app is offline-first, so a photo taken at 09:14 may not sync until 16:00 — only the client knows the true capture moment.
|
||||
2. **EXIF** — `DateTimeOriginal` → `DateTimeDigitized` → `DateTime`; GPS from the GPS IFD (DMS rationals → signed decimal, honouring N/S/E/W refs).
|
||||
3. **Server receipt time** — last resort, no geo.
|
||||
|
||||
`resolve_metadata()` returns `(dt, lat, lng, source)` where `source` ∈ `client|exif|server`; it is echoed back as `capture_source` in the response and logged, so you can tell how much to trust a given stamp.
|
||||
|
||||
### Rendering
|
||||
- Translucent black bar across the bottom; line 1 `YYYY-MM-DD HH:MM:SS EDT`, line 2 `lat, lng` (omitted when unknown).
|
||||
- Font/padding scale off the image's **short edge**, so portrait and landscape look the same. TrueType is probed at the usual Linux/Windows paths with a graceful fall back to Pillow's default.
|
||||
- White text with a 1px dark outline stays legible over bright surfaces.
|
||||
- **`ImageOps.exif_transpose()` runs before drawing** — the re-encode drops EXIF, so without it an iPhone photo would come out visibly rotated and the bar would land on the wrong edge.
|
||||
- JPEG (q88) and PNG are stamped; **GIF and anything else passes through untouched** rather than risking a broken re-encode.
|
||||
|
||||
### Hard guarantees
|
||||
- **Never lose a photo.** Every failure path (corrupt bytes, unsupported format, missing Pillow, font problems) returns the ORIGINAL bytes with `stamped: False` and logs a warning — it never raises.
|
||||
- **No storage/schema change.** `stamp_file_storage()` returns a `werkzeug` `FileStorage` with the same filename/content-type, so `storage.save()` derives the same key and both the `local` and `s3` backends work unchanged.
|
||||
- Toggle with `PHOTO_STAMP_ENABLED=false` (default `true`) to store raw uploads.
|
||||
|
||||
### Not covered (deliberate)
|
||||
- Web-form uploads (`_save_photo` in `routes/inspections.py`) are **not** stamped — browsers rarely supply reliable capture/GPS metadata. The helper is reusable if that changes.
|
||||
- Only the stamped image is stored; no pristine original is retained. Since the burn happens *before* the first write, nothing stored is ever destroyed.
|
||||
- EXIF is not re-written into the output (the overlay is the record). Add it here if a machine-readable copy is ever needed.
|
||||
|
||||
Reference in New Issue
Block a user