diff --git a/app/api/inspections.py b/app/api/inspections.py index 7fd44c0..e14ad8a 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -96,6 +96,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. @@ -137,6 +144,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 ────────────────────────────── diff --git a/app/api/issues.py b/app/api/issues.py index 9286daf..37e8702 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -52,6 +52,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 @@ -72,6 +79,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. diff --git a/app/routes/notifications.py b/app/routes/notifications.py index efd504b..0f08774 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -308,6 +308,118 @@ def check_score_trends(): return jsonify({'ok': True, 'alerts_sent': sent}) +# ── Photo retention purge (called by cron) ──────────────────────────────────── + +@bp.route('/purge-old-photos', methods=['POST']) +@csrf.exempt +def purge_old_photos(): + """Delete photo FILES (not the issue records) for issues resolved longer + ago than PHOTO_RETENTION_DAYS, addressing GDPR Art. 5(1)(e) storage + limitation — evidence photos otherwise persist forever. + + Disabled by default (no-op) unless PHOTO_RETENTION_DAYS is set in config/ + env — this is a data-minimization policy the operator opts into, not a + forced deletion, since some deployments may have a longer required + retention for their own contractual/audit reasons. + + Only touches RESOLVED issues whose resolved_at predates the cutoff. + Clears photo_path / mobile_photo_paths / result_photos to null/empty and + deletes the underlying files via the storage abstraction (safe on both + the local and R2 backends, and tenant-prefix aware — storage.delete() + resolves the same key the write used). + + PER-TENANT, deliberately. Unlike /trial-reminders and /dunning-reminders + (which walk the control DB), this operates on `issues` in whichever tenant + DB the request resolves to, and the retention window is that tenant's own + policy. So it must be invoked once per tenant Host, exactly like + /check-sla and /check-score-trends. A cross-tenant variant would have to + read each tenant's own retention setting, which does not exist yet. + + Recommended cron schedule — nightly is sufficient, per tenant host: + + 0 4 * * * curl -s -X POST https://lts.jqc.app/notifications/purge-old-photos \\ + -d "token=YOUR_DIGEST_SECRET" + """ + token = request.form.get('token') or request.args.get('token') + expected = current_app.config.get('DIGEST_SECRET') + + if not expected or token != expected: + logger.warning('PHOTO PURGE REJECTED | bad or missing token') + abort(403) + + retention_days = current_app.config.get('PHOTO_RETENTION_DAYS') + if not retention_days: + return jsonify({'ok': True, 'skipped': 'PHOTO_RETENTION_DAYS not configured', + 'issues_purged': 0}) + + from datetime import timedelta + from app.models.issue import Issue + from app.utils.time_utils import now_eastern + from app.utils.audit import log_action, ACTION_UPDATE + from app.utils import storage + + cutoff = now_eastern() - timedelta(days=int(retention_days)) + candidates = ( + Issue.query + .filter(Issue.status == 'resolved') + .filter(Issue.resolved_at.isnot(None)) + .filter(Issue.resolved_at < cutoff) + .filter( + db.or_( + Issue.photo_path.isnot(None), + Issue.mobile_photo_paths.isnot(None), + Issue.result_photos.isnot(None), + ) + ) + .all() + ) + + purged_count = 0 + for issue in candidates: + keys = [] + if issue.photo_path: + keys.append(issue.photo_path) + keys.extend(issue.mobile_photo_paths or []) + keys.extend(issue.result_photos or []) + + # Skip rows that hold no actual photo keys. The or_() above cannot do + # this on its own: db.JSON defaults to none_as_null=False, so a Python + # None assigned to mobile_photo_paths / result_photos is persisted as + # the JSON scalar `null` — which is NOT SQL NULL and therefore still + # satisfies isnot(None). Without this guard the endpoint would (a) count + # and rewrite every old resolved issue even when it has no photos, and + # (b) never become idempotent: clearing the fields writes JSON `null` + # again, so the next nightly run would re-select the very same rows + # forever, churning UPDATEs and logging a purge that did nothing. + if not keys: + continue + + for key in keys: + try: + storage.delete(key) + except Exception as exc: + logger.warning('PHOTO PURGE | failed to delete key=%s issue_id=%s: %s', + key, issue.id, exc) + issue.photo_path = None + issue.mobile_photo_paths = None + issue.result_photos = None + purged_count += 1 + + db.session.commit() + + if purged_count: + log_action( + ACTION_UPDATE, 'Issue', None, + f'Photo retention purge — {purged_count} resolved issue(s)', + f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")}; retention_days={retention_days}', + ) + + logger.info('PHOTO PURGE TRIGGERED | issues_purged=%s | retention_days=%s', + purged_count, retention_days) + return jsonify({'ok': True, 'issues_purged': purged_count, + 'retention_days': retention_days}) + + # ── Trial-ending reminder (called by cron) ──────────────────────────────────── @bp.route('/trial-reminders', methods=['POST']) diff --git a/config.py b/config.py index d801262..447caa7 100644 --- a/config.py +++ b/config.py @@ -88,6 +88,18 @@ class Config: # a TenantSettings column only if a tenant ever has a real reason to opt out. PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true' + # ── Photo retention (MT-13) ───────────────────────────────────────────── + # Days after an issue is RESOLVED before its photo FILES are deleted by + # POST /notifications/purge-old-photos. Unset (None) = the purge is a no-op. + # Deliberately opt-in, not a default: this is a data-minimization policy the + # operator chooses (GDPR Art. 5(1)(e) storage limitation), and some tenants + # have longer contractual/audit retention of their own. Applies per tenant — + # the cron is invoked once per tenant Host, like check-sla. + PHOTO_RETENTION_DAYS = ( + int(os.environ['PHOTO_RETENTION_DAYS']) + if os.environ.get('PHOTO_RETENTION_DAYS') else None + ) + # ── Session / cookies ─────────────────────────────────────────────────── PERMANENT_SESSION_LIFETIME = timedelta(hours=24) # Secure by default — subclasses must explicitly opt out for local dev.