Aug 5 - Update code to follow up ST - MT14c
This commit is contained in:
@@ -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
@@ -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 13–19 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(
|
||||
|
||||
Reference in New Issue
Block a user