Aug 11 - Update photo storage using r2

This commit is contained in:
2026-08-11 11:30:57 -04:00
parent 3c35835505
commit c9984e7ae6
6 changed files with 999 additions and 1 deletions
+55
View File
@@ -227,6 +227,61 @@ Stripe per-plan subscription, fully implemented in `app/billing/` (`routes.py`,
**MT-9 — iOS multi-tenant. 🔲 PENDING (server + client both unbuilt).**
Planned server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — to be exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. **Neither endpoint exists in the code yet** — no `api_discovery` blueprint is registered. iOS side: also pending (web-first priority).
**MT-20 — R2 photo storage cutover. ✅ CODE DONE / 🔲 CUTOVER PENDING.**
No schema change, no migration. The data-plane code was already fully on the
storage seam (`storage.save()` / `media_url()` / `materialize_to_dir()` /
`delete()` at every inspection and issue photo site) — MT served local purely
because `STORAGE_BACKEND` had never been flipped, and it **could not** be:
- `boto3` was missing from `requirements.txt`, so `S3Backend.__init__`'s lazy
import would have raised `ModuleNotFoundError` on the first upload after the
flip — at request time, not at boot. Now pinned (`boto3>=1.34`, matching ST).
- there was no cutover tooling, and ST's could not be reused (below).
**Why ST's sync script does not port.** The local backend deliberately does not
prefix keys, so one shared `app/static/uploads/` holds every tenant's photos and
a file on disk carries no ownership marker. ST's script walks the disk and
uploads everything, which on MT writes objects with no tenant prefix — keys
`S3Backend._object_key()` will never read. Ownership must instead be derived
from each tenant DB's references, then written under `t<tenant_id>/`.
| Layer | Value |
|---|---|
| DB | `uploads/issue_photos/abc.jpg` |
| Disk (local backend) | `app/static/uploads/issue_photos/abc.jpg` |
| R2 object (s3 backend) | `t3/uploads/issue_photos/abc.jpg` |
Delivered:
- `scripts/audit_photos.py` — read-only. Per tenant, collects every key its DB
references (5 sources: `issues.photo_path`, `.mobile_photo_paths[]`,
`.result_photos[]`, `inspections.form_data`, `inspection_results.photo_path`)
and reconciles against disk. Emits the baseline count that must still resolve
after cutover, plus orphans and any key claimed by more than one tenant.
- `scripts/migrate_photos_to_r2.py` — copy-only, idempotent, resumable,
MD5+size verified, tenant-prefixed. Exit 0 only on full verification.
Orphans are **not** uploaded: no prefix could legitimately claim them.
- `tests/test_storage_backend.py` — pins the prefix arithmetic, the bare key
returned to the DB, the refusal to write unprefixed with no tenant bound, and
the legacy-unprefixed read/delete fallback.
Both scripts read the control DB then each tenant DB via raw SQL (no Flask app
context — the ORM's default bind is the wrong database for every tenant), and
the sync imports `collect_referenced` from the audit script so the two key sets
can never diverge. Neither writes to any database.
`STORAGE_BACKEND` is process-wide, so cutover is all-tenants-at-once; isolation
comes from the prefix, not from separate backends. Per-tenant backend selection
would require the resolver to carry a storage selector and `get_backend()` to
cache per tenant rather than per app — do not half-build it.
Rollback is one env var (`STORAGE_BACKEND=local` + restart); the sync never
deletes local files.
**Open, deliberately not done in MT-20:** `routes/tenant_settings.py::_save_logo`
still writes tenant logos directly to `static/uploads/logos/` with `os.path.join`,
bypassing the seam. After cutover it is the only remaining local-disk writer, so
logos would sit outside whatever backs up R2.
---
## 8. Tenant-zero (LT Services) migration