Jul 14 - Using CDN - Phase 4a

This commit is contained in:
2026-07-14 13:35:34 -04:00
parent 9d5a280ca6
commit 2d87a15b0d
4 changed files with 56 additions and 10 deletions
+17 -4
View File
@@ -1383,10 +1383,23 @@ timeout = 30
- [ ] Gate: script exits 0 → every local file verified in R2. Cross-check `verified >= referenced-present` from the Phase 0 baseline. Only then flip in Phase 4.
### Phase 4 — Cutover + iOS
- [ ] Maintenance window: final incremental sync → set `STORAGE_BACKEND=s3``systemctl restart janitorial-qc`.
- [ ] Smoke test: existing web issue/inspection photos load via presigned URLs; new upload from **web** and **iPad** lands in R2.
- [ ] iOS: add absolute `photo_url` to issue/inspection payloads; iPad prefers `photo_url`, falls back to `ServerConfig.current + "/static/" + path` (backward-compatible). Record as an iOS CLAUDE.md rule + `APIAssignedIssue`/`APIIssueDetail` field.
- [ ] Keep `static/uploads/` for 30 days as backup. Snapshot, then reclaim disk as a **separate, deliberate** step — never part of the migration.
**4a — server photo URLs — ✅ delivered:**
- [x] `storage.media_url(key, external=False)``external=True` yields an absolute URL for off-origin (iPad) consumers: local backend uses `url_for('static', ..., _external=True)`; s3 returns the (already absolute) presigned URL. Templates call with `external=False` → unchanged relative `/static/` URLs.
- [x] Issue payload (`api/issues.py` `_issue_payload`) adds `photo_urls` (absolute; order = `[photo_path] + mobile_photo_paths`, matching the iPad's evidence merge) and `result_photo_urls`. Relative keys stay as keys (they're still submission values). Helper `_photo_urls()`.
- [x] Inspection detail payload (`api/inspections.py`) adds `form_media` = `{field_id: absolute_url}` for `uploads/...` image field values. Helper `_media()`.
- These are additive — the iPad ignores them until 4b ships, and on `local` they're just absolute static URLs, so nothing breaks.
**4b — iOS: consume the URLs (do before new post-cutover photos appear):**
- [ ] Add `photoUrls` / `resultPhotoUrls` to `APIAssignedIssue`; `resultPhotoUrls` to `APIIssueDetail`; `formMedia` to the inspection detail model.
- [ ] Store parallel URL arrays on `LocalIssue` (`photoServerUrls`, `resultPhotoServerUrls`), mapped in `pullAssignedIssues` + `refreshStatusFromServer`.
- [ ] A resolver that prefers the absolute URL and falls back to `ServerConfig.current + "/static/" + path` (older server / empty URL). Wire the 4 display sites: `IssuesView` (evidence ×1, result ×1), `InspectionHistoryView` (form image; use `formMedia[fieldId]`).
- Grace period: local files are kept 30 days post-cutover, and the iPad caches downloaded photos, so old `/static/` URLs keep working during rollout; 4b must land before the 30-day local cleanup and before users need *new* post-cutover photos.
**Operator cutover runbook (after 4b ships + Phase 3 sync exits clean):**
- [ ] Confirm `python scripts/migrate_photos_to_r2.py` prints "✅ SAFE" (0 mismatches) and verified count ≥ Phase 0 baseline present-count.
- [ ] Maintenance window: run the sync once more (delta) → set `STORAGE_BACKEND=s3` in `.env``systemctl restart janitorial-qc`.
- [ ] Smoke test: existing web issue/inspection photos load; existing iPad issue photos load; a **new** upload from web and from iPad lands in R2 and renders; generate an inspection PDF + issue PDF with photos.
- [ ] Keep `static/uploads/` for 30 days as backup (untouched). Snapshot, then reclaim disk as a **separate, deliberate** step.
### Rollback (any phase after cutover)
- [ ] `STORAGE_BACKEND=local` → restart. Instant revert; local files were never touched.
+15
View File
@@ -95,6 +95,13 @@ def _parse_datetime(value):
return None
def _media(key):
"""Absolute display URL for a storage key (presigned on R2, absolute-static
on local). '' for falsy keys. Used for iPad image rendering."""
from app.utils import storage
return storage.media_url(key, external=True) if key else ''
def _inspection_payload(inspection):
"""Serialize an Inspection to the dict returned in API responses."""
# Extract form responses from the notes JSON blob.
@@ -136,6 +143,14 @@ def _inspection_payload(inspection):
if inspection.completed_at else None,
'mobile_local_id': inspection.mobile_local_id,
'form_data': form_data,
# Absolute display URLs for image form fields (presigned on R2,
# absolute-static on local): {field_id: url}. The iPad prefers this
# over building ServerConfig + /static/ + value.
'form_media': {
fid: _media(v)
for fid, v in (form_data or {}).items()
if isinstance(v, str) and v.startswith('uploads/')
},
'form_schema': form_schema,
'inspector_notes': inspector_notes,
# ── Follow-up / re-inspection fields ──────────────────────────────
+15
View File
@@ -51,6 +51,13 @@ _UUID_RE = re.compile(
)
def _photo_urls(keys):
"""Map storage keys to absolute display URLs (presigned on R2, absolute-static
on local). Falsy keys are skipped. Used for iPad photo rendering."""
from app.utils import storage
return [storage.media_url(k, external=True) for k in keys if k]
def _issue_payload(issue):
"""Serialise an Issue to the dict returned in list/detail responses."""
facility = issue.resolved_facility
@@ -71,6 +78,14 @@ def _issue_payload(issue):
'photo_path': issue.photo_path or None,
'mobile_photo_paths': issue.mobile_photo_paths or [],
'result_photos': issue.result_photos or [],
# Absolute display URLs (presigned on R2, absolute-static on local) for
# the iPad, which loads photos off-origin. Relative keys above stay as
# keys. photo_urls order mirrors the iPad's evidence merge:
# [photo_path] + mobile_photo_paths.
'photo_urls': _photo_urls(
([issue.photo_path] if issue.photo_path else [])
+ (issue.mobile_photo_paths or [])),
'result_photo_urls': _photo_urls(issue.result_photos or []),
# Resolution details — set by web staff after fixing the issue.
'result_notes': issue.result_notes or None,
# Verification fields — set after a director/admin confirms fix.
+9 -6
View File
@@ -76,10 +76,12 @@ class LocalBackend:
def abs_local_path(self, key):
return os.path.normpath(os.path.join(self._static_folder(), key))
def media_url(self, key):
def media_url(self, key, external=False):
if not key:
return ''
return url_for('static', filename=key)
# external=True yields an absolute URL (scheme+host) for API responses
# consumed off-origin (the iPad); templates call with external=False.
return url_for('static', filename=key, _external=external)
def read(self, key):
with open(self.abs_local_path(key), 'rb') as fh:
@@ -164,13 +166,14 @@ class S3Backend:
# not exist) so transition code / callers can still reference it.
return self._local.abs_local_path(key)
def media_url(self, key):
def media_url(self, key, external=False):
if not key:
return ''
if self.fallback and not self._head(key):
# Object not in R2 yet — serve the local copy during transition.
if self._local.exists(key):
return self._local.media_url(key)
return self._local.media_url(key, external=external)
# Presigned R2 URLs are always absolute; the external flag is moot.
return self._client.generate_presigned_url(
'get_object',
Params={'Bucket': self.bucket, 'Key': key},
@@ -259,8 +262,8 @@ def save(file_obj, subfolder):
return get_backend().save(file_obj, subfolder)
def media_url(key):
return get_backend().media_url(key)
def media_url(key, external=False):
return get_backend().media_url(key, external=external)
def read(key):