Jul 20 - Update codes to comply with some framework (SOC 2 TYPE 2, ISO, etc)
This commit is contained in:
+8
-1
@@ -1,4 +1,4 @@
|
|||||||
from flask import Flask
|
from flask import Flask, request
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
@@ -251,6 +251,13 @@ def create_app(config_name='default'):
|
|||||||
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||||
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
|
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
|
||||||
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
# Only asserted over an actual HTTPS request — ProxyFix (x_proto=1) makes
|
||||||
|
# request.is_secure reflect the real client-facing scheme behind Nginx,
|
||||||
|
# so this never fires for plain-HTTP local/dev requests.
|
||||||
|
if request.is_secure:
|
||||||
|
response.headers.setdefault(
|
||||||
|
'Strict-Transport-Security', 'max-age=31536000; includeSubDomains'
|
||||||
|
)
|
||||||
response.headers.setdefault(
|
response.headers.setdefault(
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
|
|||||||
+17
-8
@@ -96,24 +96,29 @@ def view(log_id):
|
|||||||
|
|
||||||
# ── Purge old logs ────────────────────────────────────────────────────────────
|
# ── Purge old logs ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Minimum floor of 1 year is deliberate: audit trails are the primary control
|
||||||
|
# evidence for SOC 2 / ISO 27001 access-monitoring, so shorter windows (the old
|
||||||
|
# 7/30/60/90/180-day options) are no longer offered — a purge can only ever
|
||||||
|
# remove entries old enough that they're outside any plausible audit lookback.
|
||||||
PURGE_OPTIONS = {
|
PURGE_OPTIONS = {
|
||||||
7: '7 days',
|
|
||||||
30: '30 days',
|
|
||||||
60: '60 days',
|
|
||||||
90: '90 days',
|
|
||||||
180: '180 days',
|
|
||||||
365: '1 year',
|
365: '1 year',
|
||||||
|
730: '2 years',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PURGE_CONFIRM_PHRASE = 'PURGE'
|
||||||
|
|
||||||
@bp.route('/purge', methods=['POST'])
|
@bp.route('/purge', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def purge():
|
def purge():
|
||||||
"""Delete audit log entries older than the selected threshold.
|
"""Delete audit log entries older than the selected threshold.
|
||||||
|
|
||||||
Accepts a POST form field `older_than` (integer days).
|
Accepts POST form fields `older_than` (integer days, >= 1 year) and
|
||||||
The purge itself is recorded as a new audit log entry so there is
|
`confirm_phrase` (must exactly equal PURGE_CONFIRM_PHRASE) — the typed
|
||||||
always a traceable record of who purged what and when.
|
confirmation is extra friction against an accidental click on an
|
||||||
|
otherwise-irreversible action. The purge itself is recorded as a new
|
||||||
|
audit log entry so there is always a traceable record of who purged
|
||||||
|
what and when.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
older_than = int(request.form.get('older_than', 0))
|
older_than = int(request.form.get('older_than', 0))
|
||||||
@@ -124,6 +129,10 @@ def purge():
|
|||||||
flash('Invalid purge threshold selected.', 'danger')
|
flash('Invalid purge threshold selected.', 'danger')
|
||||||
return redirect(url_for('audit.index'))
|
return redirect(url_for('audit.index'))
|
||||||
|
|
||||||
|
if request.form.get('confirm_phrase', '').strip() != PURGE_CONFIRM_PHRASE:
|
||||||
|
flash(f'You must type "{PURGE_CONFIRM_PHRASE}" to confirm this action.', 'danger')
|
||||||
|
return redirect(url_for('audit.index'))
|
||||||
|
|
||||||
cutoff = now_eastern() - timedelta(days=older_than)
|
cutoff = now_eastern() - timedelta(days=older_than)
|
||||||
deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete()
|
deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete()
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.models.user import User
|
|||||||
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
|
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
|
||||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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')
|
@bp.route('/users')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -305,4 +305,94 @@ def check_score_trends():
|
|||||||
sent = send_score_alerts(**kwargs)
|
sent = send_score_alerts(**kwargs)
|
||||||
|
|
||||||
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
|
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})
|
||||||
+32
-3
@@ -123,6 +123,33 @@ FAQS = [
|
|||||||
_KB_MAX_CHARS = 6000
|
_KB_MAX_CHARS = 6000
|
||||||
|
|
||||||
|
|
||||||
|
# ── PII redaction for the outbound Groq payload ───────────────────────────────
|
||||||
|
# Groq is a third-party processor. Customers may type identifying details
|
||||||
|
# (their own email/phone, or a coworker's) into a support question; there is
|
||||||
|
# no need for that 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 text is still saved as-is in support_chat_messages
|
||||||
|
# so the customer's own conversation history reads normally in the app.
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def _system_prompt_with_kb():
|
def _system_prompt_with_kb():
|
||||||
"""Return the base system prompt plus all ACTIVE admin knowledge entries
|
"""Return the base system prompt plus all ACTIVE admin knowledge entries
|
||||||
(phase38), so staff can curate the chatbot's knowledge without code changes.
|
(phase38), so staff can curate the chatbot's knowledge without code changes.
|
||||||
@@ -214,11 +241,13 @@ def chat_message():
|
|||||||
client = Groq(api_key=api_key)
|
client = Groq(api_key=api_key)
|
||||||
|
|
||||||
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
|
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
|
||||||
# Append prior conversation (cap at last 20 turns to control token usage)
|
# Append prior conversation (cap at last 20 turns to control token usage).
|
||||||
|
# Redact PII-shaped text before it leaves the app for the Groq API —
|
||||||
|
# the unredacted originals stay in support_chat_messages below.
|
||||||
for m in history[-20:]:
|
for m in history[-20:]:
|
||||||
if m.get('role') in ('user', 'assistant') and m.get('content'):
|
if m.get('role') in ('user', 'assistant') and m.get('content'):
|
||||||
messages.append({'role': m['role'], 'content': m['content']})
|
messages.append({'role': m['role'], 'content': _redact_pii(m['content'])})
|
||||||
messages.append({'role': 'user', 'content': user_message})
|
messages.append({'role': 'user', 'content': _redact_pii(user_message)})
|
||||||
|
|
||||||
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||||
completion = client.chat.completions.create(
|
completion = client.chat.completions.create(
|
||||||
|
|||||||
@@ -221,13 +221,20 @@
|
|||||||
<label class="form-label fw-semibold">Delete logs older than</label>
|
<label class="form-label fw-semibold">Delete logs older than</label>
|
||||||
<select name="older_than" class="form-select" id="purgeOlderThan" required>
|
<select name="older_than" class="form-select" id="purgeOlderThan" required>
|
||||||
<option value="">— Select a threshold —</option>
|
<option value="">— Select a threshold —</option>
|
||||||
<option value="7">7 days</option>
|
|
||||||
<option value="30">30 days</option>
|
|
||||||
<option value="60">60 days</option>
|
|
||||||
<option value="90">90 days</option>
|
|
||||||
<option value="180">180 days</option>
|
|
||||||
<option value="365">1 year</option>
|
<option value="365">1 year</option>
|
||||||
|
<option value="730">2 years</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Minimum retention is 1 year, so audit history stays available for
|
||||||
|
a full compliance lookback window.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">
|
||||||
|
Type <code>PURGE</code> to confirm
|
||||||
|
</label>
|
||||||
|
<input type="text" name="confirm_phrase" class="form-control"
|
||||||
|
id="purgeConfirmPhrase" autocomplete="off" required>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small mb-0">
|
<p class="text-muted small mb-0">
|
||||||
All audit log entries created before the selected threshold will be
|
All audit log entries created before the selected threshold will be
|
||||||
@@ -247,8 +254,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('purgeOlderThan').addEventListener('change', function () {
|
function updatePurgeSubmitState() {
|
||||||
document.getElementById('purgeSubmitBtn').disabled = !this.value;
|
var thresholdOk = !!document.getElementById('purgeOlderThan').value;
|
||||||
});
|
var confirmOk = document.getElementById('purgeConfirmPhrase').value === 'PURGE';
|
||||||
|
document.getElementById('purgeSubmitBtn').disabled = !(thresholdOk && confirmOk);
|
||||||
|
}
|
||||||
|
document.getElementById('purgeOlderThan').addEventListener('change', updatePurgeSubmitState);
|
||||||
|
document.getElementById('purgeConfirmPhrase').addEventListener('input', updatePurgeSubmitState);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -81,6 +81,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- My Data & Privacy Card -->
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="mb-0 fw-semibold"><i class="bi bi-shield-lock me-1"></i>My Data & Privacy</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Download a copy of the data tied to your account, or request its
|
||||||
|
erasure.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('auth.export_my_data') }}" class="btn btn-outline-secondary btn-sm w-100 mb-2">
|
||||||
|
<i class="bi bi-download me-1"></i>Export My Data
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm w-100"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#deleteMyDataModal">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete My Account & Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Right column: edit form + recent inspections ──────────────── -->
|
<!-- ── Right column: edit form + recent inspections ──────────────── -->
|
||||||
@@ -221,4 +241,38 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Delete My Data Modal ─────────────────────────────────────────────── -->
|
||||||
|
<div class="modal fade" id="deleteMyDataModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Delete My Account & Data</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('auth.request_my_data_deletion') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
This action is <strong>permanent</strong> and logs you out immediately.
|
||||||
|
</div>
|
||||||
|
<p class="mb-0">
|
||||||
|
If you have no inspection or issue history tied to your account, it will be
|
||||||
|
<strong>permanently deleted</strong>. If you do have history (common for staff
|
||||||
|
accounts), your name, username, and email will be replaced with a
|
||||||
|
non-identifying placeholder and the account deactivated — historical records
|
||||||
|
stay intact for audit continuity but will no longer identify you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Confirm Deletion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -68,6 +68,13 @@ class Config:
|
|||||||
# ── Digest email secret token (used to authenticate cron trigger) ────────
|
# ── Digest email secret token (used to authenticate cron trigger) ────────
|
||||||
DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
|
DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
|
||||||
|
|
||||||
|
# ── Photo retention (data minimization — GDPR Art. 5(1)(e)) ─────────────
|
||||||
|
# Unset (None) by default: no automatic photo deletion happens unless the
|
||||||
|
# operator opts in. When set, /notifications/purge-old-photos deletes
|
||||||
|
# photo files (not the issue record) for RESOLVED issues older than this
|
||||||
|
# many days.
|
||||||
|
PHOTO_RETENTION_DAYS = int(os.environ['PHOTO_RETENTION_DAYS']) if os.environ.get('PHOTO_RETENTION_DAYS') else None
|
||||||
|
|
||||||
# ── Google Maps (used for GPS map on inspection view) ────────────────────
|
# ── Google Maps (used for GPS map on inspection view) ────────────────────
|
||||||
GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '')
|
GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user