Jul 20 - Update codes to comply with some framework (SOC 2 TYPE 2, ISO, etc)

This commit is contained in:
2026-07-20 21:10:47 -04:00
parent 66f9fc30f4
commit 65666d19e8
8 changed files with 358 additions and 21 deletions
+91 -1
View File
@@ -305,4 +305,94 @@ def check_score_trends():
sent = send_score_alerts(**kwargs)
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
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). The issue record itself, its description,
and its audit trail are untouched — only the photo bytes are removed.
Recommended cron schedule — nightly is sufficient:
0 4 * * * curl -s -X POST https://yourdomain.com/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 [])
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})