Jul 20 - Update codes to comply with some framework (SOC 2 TYPE 2, ISO, etc)
This commit is contained in:
@@ -5,6 +5,7 @@ from app.models.user import User
|
||||
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
import logging
|
||||
import secrets
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -109,6 +110,135 @@ 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 — does not touch other users' data even where it references
|
||||
this user (e.g. an issue this user commented on is included, but the
|
||||
facility/other participants' details are not expanded)."""
|
||||
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.
|
||||
|
||||
If the account has no records that would be orphaned by a hard delete
|
||||
(same guard rails as the admin delete_user route), it is deleted outright.
|
||||
Otherwise — the common case, since inspectors/staff usually have
|
||||
inspection or issue history that must be kept for business/audit
|
||||
continuity — the account is anonymized in place: name/email/username are
|
||||
replaced with a non-identifying placeholder, the password hash is
|
||||
invalidated, and the account is deactivated. Historical records (which
|
||||
reference the user id, not the PII) are preserved unchanged."""
|
||||
user = current_user
|
||||
|
||||
from app.models.issue import Issue as _Issue, IssueComment as _IssueComment
|
||||
from app.models.inspection import InspectionTemplate as _InspectionTemplate
|
||||
|
||||
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
|
||||
user.set_password(secrets.token_hex(32)) # invalidate — no one can log in as this account again
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user