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)
|
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
||||||
21. [Change Philosophy](#21-change-philosophy)
|
21. [Change Philosophy](#21-change-philosophy)
|
||||||
22. [Object Storage Migration (R2)](#22-object-storage-migration-r2)
|
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. |
|
| `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_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`. |
|
| `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
|
### 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` |
|
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
|
||||||
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) |
|
| `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/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
|
### 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. |
|
| 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. |
|
| 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. |
|
| 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)
|
### Rollback (any phase after cutover)
|
||||||
- [ ] `STORAGE_BACKEND=local` → restart. Instant revert; local files were never touched.
|
- [ ] `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.
|
||||||
|
|||||||
+40
-4
@@ -47,13 +47,26 @@ def upload_photo():
|
|||||||
---------------------
|
---------------------
|
||||||
file — binary image data (jpg / png / gif)
|
file — binary image data (jpg / png / gif)
|
||||||
entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
|
entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
|
||||||
|
captured_at — OPTIONAL ISO-8601 capture time (e.g. 2026-07-20T09:14:22-04:00)
|
||||||
|
latitude — OPTIONAL decimal degrees at capture
|
||||||
|
longitude — OPTIONAL decimal degrees at capture
|
||||||
|
|
||||||
|
A capture-time + geo overlay is burned into the image before it is stored
|
||||||
|
(see app/utils/photo_stamp.py). Metadata is taken from the client fields
|
||||||
|
above, falling back to the image's EXIF, then to server receipt time.
|
||||||
|
Sending captured_at/latitude/longitude is strongly preferred for an
|
||||||
|
offline-first client: a photo taken at 09:14 but synced at 16:00 would
|
||||||
|
otherwise be stamped with the sync time.
|
||||||
|
|
||||||
Response 200
|
Response 200
|
||||||
------------
|
------------
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"data": {
|
"data": {
|
||||||
"server_path": "uploads/inspection_photos/abc123.jpg"
|
"server_path": "uploads/inspection_photos/abc123.jpg",
|
||||||
|
"stamped": true,
|
||||||
|
"captured_at": "2026-07-20T09:14:22",
|
||||||
|
"capture_source": "client"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@@ -85,12 +98,35 @@ def upload_photo():
|
|||||||
else:
|
else:
|
||||||
subfolder = 'inspection_photos'
|
subfolder = 'inspection_photos'
|
||||||
|
|
||||||
|
# Burn the capture-time + geo overlay before the bytes are ever stored, so
|
||||||
|
# exactly one (already-stamped) object is written and nothing has to be
|
||||||
|
# read back out of R2. Any stamping failure returns the original bytes.
|
||||||
|
meta = {'stamped': False, 'captured_at': None, 'source': None}
|
||||||
|
if current_app.config.get('PHOTO_STAMP_ENABLED', True):
|
||||||
|
from app.utils.photo_stamp import stamp_file_storage
|
||||||
|
file_obj, meta = stamp_file_storage(
|
||||||
|
file_obj,
|
||||||
|
captured_at = request.form.get('captured_at'),
|
||||||
|
latitude = request.form.get('latitude'),
|
||||||
|
longitude = request.form.get('longitude'),
|
||||||
|
)
|
||||||
|
|
||||||
# Write via the active storage backend (local disk or R2). Key format
|
# Write via the active storage backend (local disk or R2). Key format
|
||||||
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
|
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
|
||||||
from app.utils import storage
|
from app.utils import storage
|
||||||
server_path = storage.save(file_obj, subfolder)
|
server_path = storage.save(file_obj, subfolder)
|
||||||
|
|
||||||
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
|
logger.info(
|
||||||
entity_type, server_path, user.username)
|
'API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s | '
|
||||||
|
'stamped=%s | capture_source=%s',
|
||||||
|
entity_type, server_path, user.username,
|
||||||
|
meta.get('stamped'), meta.get('source'),
|
||||||
|
)
|
||||||
|
|
||||||
return api_ok({'server_path': server_path})
|
captured_at = meta.get('captured_at')
|
||||||
|
return api_ok({
|
||||||
|
'server_path': server_path,
|
||||||
|
'stamped': bool(meta.get('stamped')),
|
||||||
|
'captured_at': captured_at.isoformat() if captured_at else None,
|
||||||
|
'capture_source': meta.get('source'),
|
||||||
|
})
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
app/utils/photo_stamp.py
|
||||||
|
------------------------
|
||||||
|
Burn a capture-time + geolocation overlay into uploaded evidence photos.
|
||||||
|
|
||||||
|
Applied at UPLOAD time (``app/api/photos.py``) rather than on
|
||||||
|
``PATCH /issues/<id>/photos``. At upload the raw bytes and any camera EXIF are
|
||||||
|
in hand, so nothing has to be read back out of R2, and each upload writes
|
||||||
|
exactly one already-stamped object. That also keeps stamping clear of the
|
||||||
|
retry/double-burn hazard the PATCH endpoint would have — it is deliberately
|
||||||
|
idempotent and re-runnable (CLAUDE.md rule 45), so burning there could stack a
|
||||||
|
second bar onto an already-stamped image.
|
||||||
|
|
||||||
|
Metadata resolution order
|
||||||
|
-------------------------
|
||||||
|
1. Client-supplied ``captured_at`` / ``latitude`` / ``longitude`` — most
|
||||||
|
reliable for an offline-first app: the iPad knows when and where the shot
|
||||||
|
was taken even if it syncs hours later.
|
||||||
|
2. The image's own EXIF ``DateTimeOriginal`` / ``GPSInfo``.
|
||||||
|
3. Server receipt time (last resort; no geo).
|
||||||
|
|
||||||
|
FAILURE POLICY
|
||||||
|
--------------
|
||||||
|
Stamping must never cost us the photo. Every failure path falls back to
|
||||||
|
storing the original bytes unmodified — an unstamped photo beats a lost one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from app.utils.time_utils import EASTERN, now_eastern
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Only these are stamped. GIF (possibly animated) and anything exotic passes
|
||||||
|
# through untouched rather than risking a broken re-encode.
|
||||||
|
_STAMPABLE_FORMATS = {'JPEG', 'PNG'}
|
||||||
|
|
||||||
|
# Candidate TrueType fonts, in preference order. Pillow does not reliably ship
|
||||||
|
# a TTF, and the bitmap default is unreadably small on a 4000px photo, so we
|
||||||
|
# probe the usual Linux locations and degrade gracefully.
|
||||||
|
_FONT_CANDIDATES = (
|
||||||
|
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||||
|
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||||
|
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
||||||
|
'/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
|
||||||
|
'C:/Windows/Fonts/arialbd.ttf',
|
||||||
|
'C:/Windows/Fonts/arial.ttf',
|
||||||
|
)
|
||||||
|
|
||||||
|
_EXIF_DATETIME_ORIGINAL = 36867 # 0x9003
|
||||||
|
_EXIF_DATETIME_DIGITIZED = 36868 # 0x9004
|
||||||
|
_EXIF_DATETIME = 306 # 0x0132
|
||||||
|
_EXIF_GPS_IFD = 34853 # 0x8825
|
||||||
|
|
||||||
|
|
||||||
|
# ── Font ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_font(size):
|
||||||
|
"""Return a TrueType font at *size*, or Pillow's bitmap default."""
|
||||||
|
from PIL import ImageFont
|
||||||
|
for path in _FONT_CANDIDATES:
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# Pillow >= 9.2 can scale the default font.
|
||||||
|
return ImageFont.load_default(size=size)
|
||||||
|
except Exception:
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Metadata extraction ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse_client_datetime(value):
|
||||||
|
"""Parse a client ISO-8601 timestamp into naive Eastern, or None.
|
||||||
|
|
||||||
|
Accepts offsets and a trailing 'Z'. An offset-aware value is converted to
|
||||||
|
Eastern; a naive value is taken as already-Eastern wall time.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith(('Z', 'z')):
|
||||||
|
text = text[:-1] + '+00:00'
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone(EASTERN).replace(tzinfo=None)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exif_datetime(raw):
|
||||||
|
"""Parse an EXIF 'YYYY:MM:DD HH:MM:SS' string into a naive datetime."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(raw).strip(), '%Y:%m:%d %H:%M:%S')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _exif_datetime(exif):
|
||||||
|
"""Best available capture time from EXIF, or None."""
|
||||||
|
if not exif:
|
||||||
|
return None
|
||||||
|
for tag in (_EXIF_DATETIME_ORIGINAL, _EXIF_DATETIME_DIGITIZED, _EXIF_DATETIME):
|
||||||
|
dt = _parse_exif_datetime(exif.get(tag))
|
||||||
|
if dt:
|
||||||
|
return dt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dms_to_decimal(dms, ref):
|
||||||
|
"""Convert EXIF degrees/minutes/seconds rationals to signed decimal."""
|
||||||
|
try:
|
||||||
|
deg, minutes, seconds = (float(x) for x in dms)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
value = deg + minutes / 60.0 + seconds / 3600.0
|
||||||
|
if str(ref).upper().strip() in ('S', 'W'):
|
||||||
|
value = -value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _exif_gps(exif):
|
||||||
|
"""Return (lat, lng) decimal degrees from EXIF GPSInfo, or (None, None)."""
|
||||||
|
if not exif:
|
||||||
|
return (None, None)
|
||||||
|
try:
|
||||||
|
gps = exif.get_ifd(_EXIF_GPS_IFD)
|
||||||
|
except Exception:
|
||||||
|
gps = None
|
||||||
|
if not gps:
|
||||||
|
return (None, None)
|
||||||
|
# 1/2 = LatitudeRef/Latitude, 3/4 = LongitudeRef/Longitude
|
||||||
|
lat = _dms_to_decimal(gps.get(2), gps.get(1)) if gps.get(2) and gps.get(1) else None
|
||||||
|
lng = _dms_to_decimal(gps.get(4), gps.get(3)) if gps.get(4) and gps.get(3) else None
|
||||||
|
return (lat, lng)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_coord(value):
|
||||||
|
"""Parse a coordinate to float, rejecting out-of-range/garbage values."""
|
||||||
|
if value is None or value == '':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
num = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if num != num or abs(num) > 180: # NaN or impossible
|
||||||
|
return None
|
||||||
|
return num
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_metadata(exif, captured_at=None, latitude=None, longitude=None):
|
||||||
|
"""Resolve (capture_dt, lat, lng, source) from client fields then EXIF.
|
||||||
|
|
||||||
|
``source`` is one of 'client', 'exif', or 'server' and describes where the
|
||||||
|
*timestamp* came from — useful for logging and for judging trust later.
|
||||||
|
"""
|
||||||
|
dt = _parse_client_datetime(captured_at)
|
||||||
|
source = 'client' if dt else None
|
||||||
|
|
||||||
|
lat = _coerce_coord(latitude)
|
||||||
|
lng = _coerce_coord(longitude)
|
||||||
|
|
||||||
|
if dt is None:
|
||||||
|
dt = _exif_datetime(exif)
|
||||||
|
source = 'exif' if dt else None
|
||||||
|
|
||||||
|
if lat is None or lng is None:
|
||||||
|
ex_lat, ex_lng = _exif_gps(exif)
|
||||||
|
lat = lat if lat is not None else ex_lat
|
||||||
|
lng = lng if lng is not None else ex_lng
|
||||||
|
|
||||||
|
if dt is None:
|
||||||
|
dt = now_eastern()
|
||||||
|
source = 'server'
|
||||||
|
|
||||||
|
return dt, lat, lng, source
|
||||||
|
|
||||||
|
|
||||||
|
# ── Overlay rendering ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tz_abbrev(dt):
|
||||||
|
"""EDT/EST label for a naive Eastern datetime."""
|
||||||
|
try:
|
||||||
|
return EASTERN.localize(dt).strftime('%Z')
|
||||||
|
except Exception:
|
||||||
|
return 'ET'
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_lines(dt, lat, lng):
|
||||||
|
"""Text lines for the overlay bar."""
|
||||||
|
lines = [f"{dt.strftime('%Y-%m-%d %H:%M:%S')} {_tz_abbrev(dt)}"]
|
||||||
|
if lat is not None and lng is not None:
|
||||||
|
lines.append(f'{lat:.5f}, {lng:.5f}')
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_overlay(img, lines):
|
||||||
|
"""Draw a translucent bar with *lines* across the bottom of *img*."""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
if img.mode not in ('RGB', 'RGBA'):
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
width, height = img.size
|
||||||
|
# Scale everything off the short edge so portrait and landscape match.
|
||||||
|
base = min(width, height)
|
||||||
|
font_size = max(14, int(base * 0.035))
|
||||||
|
pad = max(8, int(base * 0.018))
|
||||||
|
font = _load_font(font_size)
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Measure the block.
|
||||||
|
heights, widths = [], []
|
||||||
|
for line in lines:
|
||||||
|
box = draw.textbbox((0, 0), line, font=font)
|
||||||
|
widths.append(box[2] - box[0])
|
||||||
|
heights.append(box[3] - box[1])
|
||||||
|
line_gap = max(2, int(font_size * 0.25))
|
||||||
|
text_h = sum(heights) + line_gap * (len(lines) - 1)
|
||||||
|
bar_h = text_h + pad * 2
|
||||||
|
|
||||||
|
# Translucent black bar, composited so it works on RGB too.
|
||||||
|
bar = Image.new('RGBA', (width, bar_h), (0, 0, 0, 150))
|
||||||
|
if img.mode == 'RGBA':
|
||||||
|
img.alpha_composite(bar, (0, height - bar_h))
|
||||||
|
else:
|
||||||
|
img.paste(Image.alpha_composite(
|
||||||
|
img.crop((0, height - bar_h, width, height)).convert('RGBA'), bar
|
||||||
|
).convert('RGB'), (0, height - bar_h))
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
y = height - bar_h + pad
|
||||||
|
for line, h in zip(lines, heights):
|
||||||
|
# Thin dark outline keeps the text legible over a bright photo.
|
||||||
|
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||||
|
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0))
|
||||||
|
draw.text((pad, y), line, font=font, fill=(255, 255, 255))
|
||||||
|
y += h + line_gap
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def stamp_image_bytes(data, captured_at=None, latitude=None, longitude=None):
|
||||||
|
"""Burn the capture-time/geo overlay into *data*.
|
||||||
|
|
||||||
|
Returns ``(out_bytes, meta)``. On any failure — unsupported format, corrupt
|
||||||
|
image, missing Pillow — returns the ORIGINAL bytes with ``meta['stamped']``
|
||||||
|
False rather than raising, so an upload is never lost to a stamping bug.
|
||||||
|
"""
|
||||||
|
meta = {'stamped': False, 'captured_at': None, 'latitude': None,
|
||||||
|
'longitude': None, 'source': None}
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
fmt = (img.format or '').upper()
|
||||||
|
|
||||||
|
try:
|
||||||
|
exif = img.getexif()
|
||||||
|
except Exception:
|
||||||
|
exif = None
|
||||||
|
|
||||||
|
dt, lat, lng, source = resolve_metadata(exif, captured_at, latitude, longitude)
|
||||||
|
meta.update({'captured_at': dt, 'latitude': lat,
|
||||||
|
'longitude': lng, 'source': source})
|
||||||
|
|
||||||
|
if fmt not in _STAMPABLE_FORMATS:
|
||||||
|
logger.info('PHOTO STAMP | skipped unsupported format=%s', fmt or '?')
|
||||||
|
return data, meta
|
||||||
|
|
||||||
|
# Honour the camera's EXIF orientation BEFORE drawing, otherwise the
|
||||||
|
# bar lands on a rotated edge and the re-encode (which drops EXIF)
|
||||||
|
# would leave the photo visibly rotated versus the original.
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
|
||||||
|
img = _draw_overlay(img, _overlay_lines(dt, lat, lng))
|
||||||
|
|
||||||
|
out = io.BytesIO()
|
||||||
|
if fmt == 'JPEG':
|
||||||
|
if img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
img.save(out, format='JPEG', quality=88, optimize=True)
|
||||||
|
else:
|
||||||
|
img.save(out, format='PNG', optimize=True)
|
||||||
|
|
||||||
|
meta['stamped'] = True
|
||||||
|
return out.getvalue(), meta
|
||||||
|
|
||||||
|
except Exception as exc: # never lose a photo
|
||||||
|
logger.warning('PHOTO STAMP | failed, storing original: %s', exc)
|
||||||
|
return data, meta
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_file_storage(file_obj, captured_at=None, latitude=None, longitude=None):
|
||||||
|
"""Return a FileStorage of the stamped image, plus the resolved metadata.
|
||||||
|
|
||||||
|
The result is a drop-in replacement for the incoming upload: it keeps the
|
||||||
|
original ``filename``/``content_type``, so ``storage.save()`` derives the
|
||||||
|
same key and works unchanged on both the local and s3 backends.
|
||||||
|
"""
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
|
file_obj.stream.seek(0)
|
||||||
|
original = file_obj.stream.read()
|
||||||
|
|
||||||
|
out_bytes, meta = stamp_image_bytes(
|
||||||
|
original, captured_at=captured_at, latitude=latitude, longitude=longitude
|
||||||
|
)
|
||||||
|
if not meta['stamped']:
|
||||||
|
file_obj.stream.seek(0) # hand back the untouched upload
|
||||||
|
return file_obj, meta
|
||||||
|
|
||||||
|
return FileStorage(
|
||||||
|
stream=io.BytesIO(out_bytes),
|
||||||
|
filename=file_obj.filename,
|
||||||
|
content_type=file_obj.content_type,
|
||||||
|
), meta
|
||||||
@@ -53,6 +53,11 @@ class Config:
|
|||||||
# Off by default — cutover is gated on full verification, so objects exist.
|
# Off by default — cutover is gated on full verification, so objects exist.
|
||||||
R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true'
|
R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
# ── Photo capture-time / geo overlay ────────────────────────────────────
|
||||||
|
# When true (default), POST /api/v1/photos/upload burns a timestamp + GPS
|
||||||
|
# bar into the image before storing it. Set false to store raw uploads.
|
||||||
|
PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true'
|
||||||
|
|
||||||
# ── Session / cookies ───────────────────────────────────────────────────
|
# ── Session / cookies ───────────────────────────────────────────────────
|
||||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||||||
# Secure by default — subclasses must explicitly opt out for local dev.
|
# Secure by default — subclasses must explicitly opt out for local dev.
|
||||||
|
|||||||
Reference in New Issue
Block a user