Jul 29 - Photo stamping files

This commit is contained in:
2026-07-29 17:35:54 -04:00
parent 7c064b6ca1
commit bd994e75e6
3 changed files with 411 additions and 5 deletions
+46 -5
View File
@@ -47,15 +47,31 @@ def upload_photo():
---------------------
file — binary image data (jpg / png / gif)
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
------------
{
"ok": true,
"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"
}
}
The three stamp keys are additive — an iPad build that predates them decodes
explicit CodingKeys and ignores what it doesn't know.
"""
user = g.api_user
@@ -85,12 +101,37 @@ def upload_photo():
else:
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
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends. The
# stamped FileStorage keeps the original filename, so the derived key — and
# the tenant prefix applied inside S3Backend — are unaffected.
from app.utils import storage
server_path = storage.save(file_obj, subfolder)
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
entity_type, server_path, user.username)
logger.info(
'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'),
})
+357
View File
@@ -0,0 +1,357 @@
"""
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.
MULTI-TENANT NOTES (MT-12)
--------------------------
Ported from the single-tenant tree. This module is tenant-agnostic by
construction and needs no tenancy awareness:
- It only transforms bytes. It never touches the DB, ``g.tenant``, or a
storage key, so there is nothing here that could leak across tenants.
- ``stamp_file_storage()`` returns a FileStorage carrying the ORIGINAL
``filename`` and ``content_type``, so ``storage.save()`` derives exactly the
same ``uploads/<subfolder>/<uuid>.<ext>`` key it would have derived for the
unstamped upload. The per-tenant ``t<id>/`` prefix is applied further down,
inside ``S3Backend._object_key`` — stamping happens strictly upstream of
that and cannot interfere with it.
- Because the bytes are stamped BEFORE ``storage.save()``, exactly one
already-stamped object is written per upload and nothing is ever read back
out of R2 to be re-encoded.
Scope matches the single-tenant tree deliberately: only the mobile API upload
path (``app/api/photos.py``) stamps. Web uploads through
``routes/inspections._save_photo`` are NOT stamped, there or here — a desk user
attaching a file has no capture-time or GPS to burn in, and the server receipt
time would be misleading.
"""
import io
import logging
from datetime import datetime
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(11, int(base * 0.020))
pad = max(6, int(base * 0.012))
font = _load_font(font_size)
measure = ImageDraw.Draw(img)
# Measure the block.
heights, widths = [], []
for line in lines:
box = measure.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
# Draw the whole overlay on a transparent layer so both the bar AND the
# text carry alpha, then composite once. Keeps the photo readable through
# the stamp instead of masking it behind a solid strip.
layer = Image.new('RGBA', (width, bar_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
draw.rectangle((0, 0, width, bar_h), fill=(0, 0, 0, 80))
y = pad
for line, h in zip(lines, heights):
# Faint dark outline still 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, 90))
draw.text((pad, y), line, font=font, fill=(255, 255, 255, 165))
y += h + line_gap
if img.mode == 'RGBA':
img.alpha_composite(layer, (0, height - bar_h))
else:
img.paste(Image.alpha_composite(
img.crop((0, height - bar_h, width, height)).convert('RGBA'), layer
).convert('RGB'), (0, height - bar_h))
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
+8
View File
@@ -80,6 +80,14 @@ class Config:
# full verification, so objects exist.
R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true'
# ── Photo capture-time / geo overlay (MT-12) ────────────────────────────
# 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.
# Global, not per-tenant: the overlay is evidence provenance, which every
# tenant wants and none should be able to switch off from the UI. Promote to
# a TenantSettings column only if a tenant ever has a real reason to opt out.
PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true'
# ── Session / cookies ───────────────────────────────────────────────────
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
# Secure by default — subclasses must explicitly opt out for local dev.