From ac2be7237c8e62384bb1e4c862541dc5b35eb830 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 14 Jul 2026 13:04:45 -0400 Subject: [PATCH] Jul 14 - Using CDN - Phase 2b --- CLAUDE.md | 4 ++-- app/utils/pdf_export.py | 51 +++++++++++++++++++++++++++++++++++++---- app/utils/storage.py | 34 +++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6535e7b..a5ca5cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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] Routed photo **deletes** (issues.py, inspections.py) → `storage.delete(key)`; **fixed** the inspection-delete double-`static` bug in the process. -**2b — remaining before cutover:** -- [ ] 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. +**2b — delivered:** +- [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):** - [ ] Create R2 bucket (e.g. `jqc-media`), US region; create an R2 API token → Access Key + Secret + endpoint. diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py index 2533af2..5424eb3 100644 --- a/app/utils/pdf_export.py +++ b/app/utils/pdf_export.py @@ -657,6 +657,27 @@ def _notes_section(inspection): # ── 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, 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') 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( buf, pagesize=letter, @@ -723,8 +752,11 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues, ])) story.append(sig_tbl) - doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) - return buf.getvalue() + try: + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + 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') 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( buf, pagesize=letter, @@ -936,8 +976,11 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes: ])) story.append(v_tbl) - doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) - return buf.getvalue() + try: + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + finally: + _cleanup_media() # ══════════════════════════════════════════════════════════════════════════════ diff --git a/app/utils/storage.py b/app/utils/storage.py index 19dc328..26ffdc6 100644 --- a/app/utils/storage.py +++ b/app/utils/storage.py @@ -98,6 +98,10 @@ class LocalBackend: except OSError: 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 ───────────────────────────────────── @@ -201,6 +205,32 @@ class S3Backend: except Exception as 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 = { 'local': LocalBackend, @@ -247,3 +277,7 @@ def delete(key): def abs_local_path(key): return get_backend().abs_local_path(key) + + +def materialize_to_dir(keys): + return get_backend().materialize_to_dir(keys)