Jul 14 - Using CDN - Phase 4a
This commit is contained in:
@@ -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.
|
- [ ] 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
|
### Phase 4 — Cutover + iOS
|
||||||
- [ ] Maintenance window: final incremental sync → set `STORAGE_BACKEND=s3` → `systemctl restart janitorial-qc`.
|
**4a — server photo URLs — ✅ delivered:**
|
||||||
- [ ] Smoke test: existing web issue/inspection photos load via presigned URLs; new upload from **web** and **iPad** lands in R2.
|
- [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.
|
||||||
- [ ] 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.
|
- [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()`.
|
||||||
- [ ] Keep `static/uploads/` for 30 days as backup. Snapshot, then reclaim disk as a **separate, deliberate** step — never part of the migration.
|
- [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)
|
### 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.
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ def _parse_datetime(value):
|
|||||||
return None
|
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):
|
def _inspection_payload(inspection):
|
||||||
"""Serialize an Inspection to the dict returned in API responses."""
|
"""Serialize an Inspection to the dict returned in API responses."""
|
||||||
# Extract form responses from the notes JSON blob.
|
# Extract form responses from the notes JSON blob.
|
||||||
@@ -136,6 +143,14 @@ def _inspection_payload(inspection):
|
|||||||
if inspection.completed_at else None,
|
if inspection.completed_at else None,
|
||||||
'mobile_local_id': inspection.mobile_local_id,
|
'mobile_local_id': inspection.mobile_local_id,
|
||||||
'form_data': form_data,
|
'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,
|
'form_schema': form_schema,
|
||||||
'inspector_notes': inspector_notes,
|
'inspector_notes': inspector_notes,
|
||||||
# ── Follow-up / re-inspection fields ──────────────────────────────
|
# ── Follow-up / re-inspection fields ──────────────────────────────
|
||||||
|
|||||||
@@ -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):
|
def _issue_payload(issue):
|
||||||
"""Serialise an Issue to the dict returned in list/detail responses."""
|
"""Serialise an Issue to the dict returned in list/detail responses."""
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
@@ -71,6 +78,14 @@ def _issue_payload(issue):
|
|||||||
'photo_path': issue.photo_path or None,
|
'photo_path': issue.photo_path or None,
|
||||||
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
||||||
'result_photos': issue.result_photos 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.
|
# Resolution details — set by web staff after fixing the issue.
|
||||||
'result_notes': issue.result_notes or None,
|
'result_notes': issue.result_notes or None,
|
||||||
# Verification fields — set after a director/admin confirms fix.
|
# Verification fields — set after a director/admin confirms fix.
|
||||||
|
|||||||
@@ -76,10 +76,12 @@ class LocalBackend:
|
|||||||
def abs_local_path(self, key):
|
def abs_local_path(self, key):
|
||||||
return os.path.normpath(os.path.join(self._static_folder(), 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:
|
if not key:
|
||||||
return ''
|
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):
|
def read(self, key):
|
||||||
with open(self.abs_local_path(key), 'rb') as fh:
|
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.
|
# not exist) so transition code / callers can still reference it.
|
||||||
return self._local.abs_local_path(key)
|
return self._local.abs_local_path(key)
|
||||||
|
|
||||||
def media_url(self, key):
|
def media_url(self, key, external=False):
|
||||||
if not key:
|
if not key:
|
||||||
return ''
|
return ''
|
||||||
if self.fallback and not self._head(key):
|
if self.fallback and not self._head(key):
|
||||||
# Object not in R2 yet — serve the local copy during transition.
|
# Object not in R2 yet — serve the local copy during transition.
|
||||||
if self._local.exists(key):
|
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(
|
return self._client.generate_presigned_url(
|
||||||
'get_object',
|
'get_object',
|
||||||
Params={'Bucket': self.bucket, 'Key': key},
|
Params={'Bucket': self.bucket, 'Key': key},
|
||||||
@@ -259,8 +262,8 @@ def save(file_obj, subfolder):
|
|||||||
return get_backend().save(file_obj, subfolder)
|
return get_backend().save(file_obj, subfolder)
|
||||||
|
|
||||||
|
|
||||||
def media_url(key):
|
def media_url(key, external=False):
|
||||||
return get_backend().media_url(key)
|
return get_backend().media_url(key, external=external)
|
||||||
|
|
||||||
|
|
||||||
def read(key):
|
def read(key):
|
||||||
|
|||||||
Reference in New Issue
Block a user