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
+36 -2
View File
@@ -20,6 +20,38 @@ from app.utils.notifications import notify
bp = Blueprint('support', __name__, url_prefix='/support')
logger = logging.getLogger(__name__)
# ── Outbound PII redaction ────────────────────────────────────────────────────
# Groq is a THIRD PARTY. Customers routinely paste contact details (their own,
# or a coworker's) into a support question, and none of that needs to leave the
# app to get a helpful, generic answer. This scrubs a best-effort set of PII
# patterns from the copy of the text sent to Groq ONLY — the original is still
# stored verbatim in support_chat_messages, so the customer's own conversation
# history reads normally in the app and staff see what was actually said.
#
# Best-effort by design: over-redacting a support question costs nothing, while
# under-redacting leaks a real address. Order matters — the 1319 digit card
# pattern runs before the phone pattern so a card number is not partly consumed
# as a phone number first.
import re as _re
_PII_PATTERNS = [
(_re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'), '[redacted-email]'),
(_re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[redacted-ssn]'),
(_re.compile(r'\b(?:\d[ -]?){13,19}\b'), '[redacted-number]'),
(_re.compile(r'\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b'), '[redacted-phone]'),
]
def _redact_pii(text):
"""Best-effort scrub of email/phone/SSN/card-like sequences from outbound text."""
if not text:
return text
redacted = text
for pattern, placeholder in _PII_PATTERNS:
redacted = pattern.sub(placeholder, redacted)
return redacted
# ── Groq system prompt ────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """\
@@ -159,9 +191,11 @@ def chat_message():
client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
# Redact before the text leaves the app for Groq. The unredacted
# originals are persisted below, so nothing is lost in-app.
for m in prior[-20:]:
messages.append({'role': m.role, 'content': m.content})
messages.append({'role': 'user', 'content': user_message})
messages.append({'role': m.role, 'content': _redact_pii(m.content)})
messages.append({'role': 'user', 'content': _redact_pii(user_message)})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
completion = client.chat.completions.create(