Jul 29 - Photo absolute media URLs for mobile; add photo retention purge
This commit is contained in:
@@ -96,6 +96,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.
|
||||||
@@ -137,6 +144,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 ──────────────────────────────
|
||||||
|
|||||||
@@ -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):
|
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
|
||||||
@@ -72,6 +79,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.
|
||||||
|
|||||||
@@ -308,6 +308,118 @@ def check_score_trends():
|
|||||||
return jsonify({'ok': True, 'alerts_sent': sent})
|
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) ────────────────────────────────────
|
# ── Trial-ending reminder (called by cron) ────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/trial-reminders', methods=['POST'])
|
@bp.route('/trial-reminders', methods=['POST'])
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ class Config:
|
|||||||
# a TenantSettings column only if a tenant ever has a real reason to opt out.
|
# 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_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 ───────────────────────────────────────────────────
|
# ── Session / cookies ───────────────────────────────────────────────────
|
||||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||||||
# Secure by default — subclasses must explicitly opt out for local dev.
|
# Secure by default — subclasses must explicitly opt out for local dev.
|
||||||
|
|||||||
Reference in New Issue
Block a user