Aug 5 - Update code to follow up ST - MT14c

This commit is contained in:
2026-08-05 10:02:12 -04:00
parent be5484e1fb
commit 9bd6b364b7
6 changed files with 667 additions and 5 deletions
+146
View File
@@ -241,6 +241,152 @@ def profile():
)
# ── Self-service data export (GDPR Art. 15/20, CCPA right-to-know) ────────────
@bp.route('/my-data/export')
@login_required
def export_my_data():
"""Download a JSON snapshot of everything this account's own records hold:
profile fields, inspections performed, issues reported/assigned/commented
on, and the audit log entries recorded against this user id.
Read-only, and scoped to the caller. Records that merely *reference* this
user are included only as the user's own row — related entities are NOT
expanded, so an issue this user commented on contributes the comment, not
the facility details or the other participants. That keeps a subject-access
request from becoming a data leak about everyone else.
Multi-tenant note: this runs against the caller's own tenant DB via the
normal request routing, so it can only ever see that tenant's data.
"""
from flask import Response
import json
from app.models.inspection import Inspection
from app.models.issue import Issue, IssueComment
from app.models.audit import AuditLog
from app.utils.time_utils import now_eastern
user = current_user
payload = {
'exported_at': now_eastern().isoformat(),
'profile': {
'id': user.id,
'username': user.username,
'full_name': user.full_name,
'email': user.email,
'role': user.role,
'created_at': user.created_at.isoformat() if user.created_at else None,
'active': user.active,
},
'inspections_performed': [
{'id': i.id, 'facility_id': i.facility_id,
'inspection_date': i.inspection_date.isoformat() if i.inspection_date else None,
'overall_score': i.overall_score, 'status': i.status}
for i in Inspection.query.filter_by(inspector_id=user.id).all()
],
'issues_reported': [
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
'status': iss.status, 'severity': iss.severity,
'reported_at': iss.reported_at.isoformat() if iss.reported_at else None}
for iss in Issue.query.filter_by(reported_by=user.id).all()
],
'issues_assigned': [
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
'status': iss.status, 'severity': iss.severity}
for iss in Issue.query.filter_by(assigned_to=user.id).all()
],
'issue_comments_authored': [
{'id': c.id, 'issue_id': c.issue_id, 'body': c.body,
'created_at': c.created_at.isoformat() if c.created_at else None}
for c in IssueComment.query.filter_by(user_id=user.id).all()
],
'audit_log_entries': [
{'id': a.id, 'action': a.action, 'entity_type': a.entity_type,
'entity_id': a.entity_id, 'entity_label': a.entity_label,
'created_at': a.created_at.isoformat() if a.created_at else None}
for a in AuditLog.query.filter_by(user_id=user.id).all()
],
}
log_action(ACTION_UPDATE, 'User', user.id, user.username, 'self-service data export')
logger.info('AUTH | export_my_data | user_id=%s username=%s', user.id, user.username)
body = json.dumps(payload, indent=2, default=str)
return Response(
body,
mimetype='application/json',
headers={'Content-Disposition': f'attachment; filename=jqc_my_data_{user.id}.json'},
)
# ── Self-service erasure request (GDPR Art. 17, CCPA right-to-delete) ─────────
@bp.route('/my-data/delete-request', methods=['POST'])
@login_required
def request_my_data_deletion():
"""Erase this account's PII on request.
Two outcomes, chosen automatically:
* No records that a hard delete would orphan (same guard rails as the admin
delete_user route) → the account is deleted outright.
* Otherwise — the common case, since staff usually have inspection or issue
history that must be kept for business and audit continuity — the account
is ANONYMIZED in place: name/email/username replaced with a
non-identifying placeholder, the password hash invalidated so nobody can
ever log in as it again, and the account deactivated.
Historical records reference the user *id*, not the PII, so they survive the
anonymization unchanged and the audit trail stays intact. This is the
balance the regulations expect: erase the identity, keep the ledger.
"""
import secrets
from app.models.issue import Issue as _Issue, IssueComment as _IssueComment
from app.models.inspection import InspectionTemplate as _InspectionTemplate
user = current_user
blocking = (
user.inspections.count() > 0
or _Issue.query.filter_by(assigned_to=user.id).count() > 0
or _IssueComment.query.filter_by(user_id=user.id).count() > 0
or _InspectionTemplate.query.filter_by(created_by=user.id).count() > 0
)
username = user.username
user_id = user.id
if not blocking:
db.session.delete(user)
db.session.commit()
logout_user()
logger.info('AUTH | self_delete | user_id=%s username=%s', user_id, username)
log_action(ACTION_DELETE, 'User', user_id, username,
'self-service account deletion')
flash('Your account and data have been permanently deleted.', 'success')
return redirect(url_for('auth.login'))
placeholder = f'deleted_user_{user_id}'
user.full_name = None
user.email = f'{placeholder}@deleted.local'
user.username = placeholder
# Random hash nobody holds — the account can never be logged into again.
user.set_password(secrets.token_hex(32))
user.active = False
db.session.commit()
logger.info('AUTH | self_anonymize | user_id=%s '
'(had blocking records, hard delete not possible)', user_id)
log_action(ACTION_UPDATE, 'User', user_id, placeholder,
'self-service erasure request — anonymized '
'(blocking records retained for audit/business continuity)')
logout_user()
flash('Your personal information has been removed and your account '
'deactivated. Historical records tied to your account id are retained '
'for audit continuity but no longer identify you.', 'success')
return redirect(url_for('auth.login'))
@bp.route('/users')
@login_required
@admin_required