Jul 14 - Using CDN - Phase 2b
This commit is contained in:
@@ -1366,8 +1366,8 @@ timeout = 30
|
|||||||
- [x] Transition fallback: `read()` falls back to the local copy on a missing object; `media_url()` falls back to a local static URL **only when `R2_MEDIA_FALLBACK=true`** (off by default — avoids a HEAD per image; cutover is gated on full verification anyway).
|
- [x] Transition fallback: `read()` falls back to the local copy on a missing object; `media_url()` falls back to a local static URL **only when `R2_MEDIA_FALLBACK=true`** (off by default — avoids a HEAD per image; cutover is gated on full verification anyway).
|
||||||
- [x] Routed photo **deletes** (issues.py, inspections.py) → `storage.delete(key)`; **fixed** the inspection-delete double-`static` bug in the process.
|
- [x] Routed photo **deletes** (issues.py, inspections.py) → `storage.delete(key)`; **fixed** the inspection-delete double-`static` bug in the process.
|
||||||
|
|
||||||
**2b — remaining before cutover:**
|
**2b — delivered:**
|
||||||
- [ ] Route PDF-export image **reads** through storage. `pdf_export.py` resolves `os.path.join(static_folder, key)` at several sites (`_form_fields_section`, `generate_issue_pdf`, `_compress_image`). Lowest-risk approach: a `storage.materialize_to_dir(keys)` helper — local backend returns the real static folder (no-op); s3 downloads referenced keys to a temp dir mirroring `key` layout, and the two entry points (`generate_inspection_pdf`, `generate_issue_pdf`) point `static_folder` at it (try/finally cleanup). Leaves all render sites untouched.
|
- [x] PDF-export image reads routed through storage via `storage.materialize_to_dir(keys)`. Local backend returns the real static folder (no-op); s3 downloads the referenced keys to a temp dir mirroring the `key` layout. The two entry points (`generate_inspection_pdf` — keys from `_collect_media_keys(form_data)`; `generate_issue_pdf` — `photo_path` + `mobile_photo_paths` + `result_photos`) point `static_folder` at it and clean up in `finally`. All render sites (`_form_fields_section`, `_photo_grid`, `_compress_image`) are **unchanged** — they still do `os.path.join(static_folder, key)`, which now resolves under the temp dir on s3. Only the inspection + issue PDFs embed photos; list/scheduled/facility PDFs don't take `static_folder`.
|
||||||
|
|
||||||
**Operator setup (before flip, not code):**
|
**Operator setup (before flip, not code):**
|
||||||
- [ ] Create R2 bucket (e.g. `jqc-media`), US region; create an R2 API token → Access Key + Secret + endpoint.
|
- [ ] Create R2 bucket (e.g. `jqc-media`), US region; create an R2 API token → Access Key + Secret + endpoint.
|
||||||
|
|||||||
@@ -657,6 +657,27 @@ def _notes_section(inspection):
|
|||||||
|
|
||||||
# ── Public entry point ────────────────────────────────────────────────────────
|
# ── Public entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _collect_media_keys(form_data):
|
||||||
|
"""Return the set of 'uploads/...' storage keys referenced in form_data
|
||||||
|
(image field values, including any nested in lists/dicts). Signature values
|
||||||
|
are inline 'data:' base64 and are naturally excluded."""
|
||||||
|
keys = set()
|
||||||
|
|
||||||
|
def _walk(v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
if v.startswith('uploads/'):
|
||||||
|
keys.add(v)
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
for x in v.values():
|
||||||
|
_walk(x)
|
||||||
|
elif isinstance(v, (list, tuple)):
|
||||||
|
for x in v:
|
||||||
|
_walk(x)
|
||||||
|
|
||||||
|
_walk(form_data or {})
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
||||||
static_folder) -> bytes:
|
static_folder) -> bytes:
|
||||||
"""
|
"""
|
||||||
@@ -674,6 +695,14 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
|||||||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||||
report_title = f'Inspection Report — {inspection.template.name}'
|
report_title = f'Inspection Report — {inspection.template.name}'
|
||||||
|
|
||||||
|
# Make referenced photos available as local file paths for ReportLab/PIL.
|
||||||
|
# local backend: no-op (returns the real static folder); s3 backend:
|
||||||
|
# downloads the form's image keys to a temp dir. Cleaned up after build.
|
||||||
|
from app.utils import storage
|
||||||
|
static_folder, _cleanup_media = storage.materialize_to_dir(
|
||||||
|
_collect_media_keys(form_data)
|
||||||
|
)
|
||||||
|
|
||||||
doc = SimpleDocTemplate(
|
doc = SimpleDocTemplate(
|
||||||
buf,
|
buf,
|
||||||
pagesize=letter,
|
pagesize=letter,
|
||||||
@@ -723,8 +752,11 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
|||||||
]))
|
]))
|
||||||
story.append(sig_tbl)
|
story.append(sig_tbl)
|
||||||
|
|
||||||
|
try:
|
||||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
finally:
|
||||||
|
_cleanup_media()
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -743,6 +775,14 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
|
|||||||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||||
report_title = f'Issue Report — Issue #{issue.id}'
|
report_title = f'Issue Report — Issue #{issue.id}'
|
||||||
|
|
||||||
|
# Make referenced photos available as local file paths for ReportLab/PIL.
|
||||||
|
# local backend: no-op; s3 backend: downloads the issue's photo keys to a
|
||||||
|
# temp dir. Cleaned up after build.
|
||||||
|
from app.utils import storage
|
||||||
|
_issue_keys = [issue.photo_path] + list(issue.mobile_photo_paths or []) \
|
||||||
|
+ list(issue.result_photos or [])
|
||||||
|
static_folder, _cleanup_media = storage.materialize_to_dir(_issue_keys)
|
||||||
|
|
||||||
doc = SimpleDocTemplate(
|
doc = SimpleDocTemplate(
|
||||||
buf,
|
buf,
|
||||||
pagesize=letter,
|
pagesize=letter,
|
||||||
@@ -936,8 +976,11 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
|
|||||||
]))
|
]))
|
||||||
story.append(v_tbl)
|
story.append(v_tbl)
|
||||||
|
|
||||||
|
try:
|
||||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
finally:
|
||||||
|
_cleanup_media()
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ class LocalBackend:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def materialize_to_dir(self, keys):
|
||||||
|
# Files already live under the static folder — no copy needed.
|
||||||
|
return self._static_folder(), (lambda: None)
|
||||||
|
|
||||||
|
|
||||||
# ── Cloudflare R2 / S3-compatible backend ─────────────────────────────────────
|
# ── Cloudflare R2 / S3-compatible backend ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -201,6 +205,32 @@ class S3Backend:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('S3 delete failed for %s: %s', key, e)
|
logger.warning('S3 delete failed for %s: %s', key, e)
|
||||||
|
|
||||||
|
def materialize_to_dir(self, keys):
|
||||||
|
"""Download the given keys to a temp dir mirroring the key layout.
|
||||||
|
|
||||||
|
Returns (base_dir, cleanup); each key resolves at base_dir/key so tools
|
||||||
|
that need real file paths (ReportLab/PIL) work unchanged. Missing or
|
||||||
|
unreadable keys are skipped — callers already guard with os.path.exists.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
base = tempfile.mkdtemp(prefix='jqc_media_')
|
||||||
|
for key in {k for k in keys if k}:
|
||||||
|
try:
|
||||||
|
data = self.read(key)
|
||||||
|
except Exception:
|
||||||
|
continue # skip missing; caller guards existence
|
||||||
|
dest = os.path.join(base, key)
|
||||||
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||||
|
with open(dest, 'wb') as fh:
|
||||||
|
fh.write(data)
|
||||||
|
|
||||||
|
def _cleanup():
|
||||||
|
shutil.rmtree(base, ignore_errors=True)
|
||||||
|
|
||||||
|
return base, _cleanup
|
||||||
|
|
||||||
|
|
||||||
_BACKENDS = {
|
_BACKENDS = {
|
||||||
'local': LocalBackend,
|
'local': LocalBackend,
|
||||||
@@ -247,3 +277,7 @@ def delete(key):
|
|||||||
|
|
||||||
def abs_local_path(key):
|
def abs_local_path(key):
|
||||||
return get_backend().abs_local_path(key)
|
return get_backend().abs_local_path(key)
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_to_dir(keys):
|
||||||
|
return get_backend().materialize_to_dir(keys)
|
||||||
|
|||||||
Reference in New Issue
Block a user