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
+23
View File
@@ -18,6 +18,29 @@ class SupportChatSession(db.Model):
order_by='SupportChatMessage.created_at',
)
@property
def message_count(self):
"""Number of turns in this session.
`messages` is a plain list relationship here (ST's is lazy='dynamic'),
so this is len() rather than .count(). It is already loaded whenever the
session is, so this costs no extra query.
"""
return len(self.messages)
@property
def preview(self):
"""First user message, for list views.
Scans the loaded list instead of ST's .filter_by(role='user').first(),
for the same reason. `messages` is ordered by created_at, so the first
match is the opening question.
"""
for m in self.messages:
if m.role == 'user':
return m.content
return '(no messages)'
def __repr__(self):
return f'<SupportChatSession {self.id}>'
+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(
+55
View File
@@ -81,6 +81,27 @@
</div>
</div>
<!-- ── My Data & Privacy (phase51) ───────────────────────────── -->
<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 &amp; 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 &amp; Data
</button>
</div>
</div>
</div>
<!-- ── Right column: edit form + recent inspections ──────────────── -->
@@ -263,4 +284,38 @@
</div>
</div>
<!-- ── Delete My Data Modal (phase51) ──────────────────────────────────── -->
<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 &amp; 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 %}
+32 -3
View File
@@ -595,6 +595,23 @@ def notify_by_matrix(
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
event_type, role_key, [u.username for u in users])
# Scope the inspector role for "inspection_completed" to the inspection's
# OWN inspector — the person who did the work — not the whole inspector
# pool. Without this, switching the Inspector column on for this event
# notifies EVERY active inspector on EVERY submitted inspection, which on
# a tenant with a dozen inspectors is a mail storm and trains people to
# ignore notifications. Falls back to notifying nobody when the
# inspection cannot be resolved, rather than notifying everybody.
if role_key == 'inspector' and event_type == 'inspection_completed':
target_id = None
if inspection_id:
from app.models.inspection import Inspection
insp = db.session.get(Inspection, inspection_id)
target_id = insp.inspector_id if insp else None
users = [u for u in users if u.id == target_id] if target_id else []
logger.info('MATRIX NOTIFY | event=%s | role=inspector scoped to '
'submitting inspector_id=%s', event_type, target_id)
# Scope customer role to facility if provided
if role_key == 'customer' and facility_id:
from app.utils.notifications import notify_customers_for_facility
@@ -625,10 +642,22 @@ def notify_by_matrix(
)
notified.add(user.id)
# ── Custom email recipients ───────────────────────────────────────────
# ── Custom email recipients (global, per-event) ───────────────────────
# Deduplicated on the normalised address. The matrix stores this list as
# free text, so the same person can appear twice with different casing or
# stray whitespace ("Ops@x.com" and "ops@x.com "), which previously produced
# two identical emails. `sent_emails` is built AS WE SEND, so it reflects
# what actually went out — blank entries are skipped rather than being
# counted as sent — and is then handed to the per-contract pass below so a
# recipient listed both globally and on the contract is contacted once.
custom_emails = get_custom_emails_for(event_type)
sent_emails = set()
for email in custom_emails:
norm = (email or '').strip().lower()
if not norm or norm in sent_emails:
continue
_send_custom_email(email, title, body, link)
sent_emails.add(norm)
# ── Per-contract additional recipients (phase37) ──────────────────────
_notify_project_recipients(
@@ -641,12 +670,12 @@ def notify_by_matrix(
facility_id = facility_id,
exclude_user_ids = exclude,
already_notified = notified,
already_emailed = {e.strip().lower() for e in custom_emails},
already_emailed = sent_emails,
)
logger.info(
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
event_type, len(notified), len(custom_emails),
event_type, len(notified), len(sent_emails),
)
+375
View File
@@ -0,0 +1,375 @@
"""
tests/test_privacy_and_routing.py
----------------------------------
Behaviour tests for MT-14c the non-schema ST catch-ups. No migrations.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* notify_by_matrix() scopes the INSPECTOR role for 'inspection_completed' to
the inspection's own inspector, instead of the whole inspector pool
* custom-email recipients are deduplicated on the normalised address, and
blank entries are skipped
* support._redact_pii() scrubs email/phone/SSN/card patterns
* /auth/my-data/export returns the caller's own records and nobody else's
* /auth/my-data/delete-request hard-deletes a clean account, and ANONYMIZES
one with history rather than orphaning records
* SupportChatSession.message_count / .preview
"""
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
with app.app_context():
from app import db
from app.models import inspector_assignment # noqa: F401
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _user(username, role):
from app import db
from app.models.user import User
u = User(username=username, full_name=username.title(), role=role,
email=f'{username}@example.com', active=True)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _facility(name='Main Office'):
from app import db
from app.models.facility import Facility
f = Facility(name=name, active=True)
db.session.add(f)
db.session.commit()
return f
def _template():
from app import db
from app.models.inspection import InspectionTemplate
t = InspectionTemplate(name='Restroom Check', active=True,
form_schema=[{'id': 'f1', 'type': 'rating_5',
'label': 'Clean', 'row': 0, 'col': 0,
'rowSpan': 1, 'colSpan': 1}])
db.session.add(t)
db.session.commit()
return t
def _login(client, user):
return client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
# ── notify_by_matrix inspector scoping ───────────────────────────────────────
def test_inspection_completed_notifies_only_the_submitting_inspector(app, client):
"""Without the scoping, switching the Inspector column on for this event
mails EVERY active inspector on EVERY submitted inspection."""
from app import db
from app.models.inspection import Inspection
from app.models.notification import Notification
from app.models.notification_matrix import NotificationMatrix
from app.utils.notifications import notify_by_matrix
from app.utils.time_utils import now_eastern
tmpl = _template()
fac = _facility()
doer = _user('ivy', 'inspector')
other1 = _user('otto', 'inspector')
other2 = _user('opal', 'inspector')
db.session.add(NotificationMatrix(event_type='inspection_completed',
role_key='inspector', enabled=True))
insp = Inspection(template_id=tmpl.id, facility_id=fac.id,
inspector_id=doer.id, inspection_date=now_eastern(),
status='completed', completed_at=now_eastern())
db.session.add(insp)
db.session.commit()
notify_by_matrix(event_type='inspection_completed', title='Done',
body='An inspection was submitted.',
inspection_id=insp.id, facility_id=fac.id)
recipients = {n.user_id for n in
Notification.query.filter_by(event_type='inspection_completed').all()}
assert doer.id in recipients
assert other1.id not in recipients
assert other2.id not in recipients
def test_unresolvable_inspection_notifies_no_inspector(app, client):
"""Fail closed: notify nobody rather than everybody."""
from app import db
from app.models.notification import Notification
from app.models.notification_matrix import NotificationMatrix
from app.utils.notifications import notify_by_matrix
_user('ivy', 'inspector')
_user('otto', 'inspector')
db.session.add(NotificationMatrix(event_type='inspection_completed',
role_key='inspector', enabled=True))
db.session.commit()
# No inspection_id at all.
notify_by_matrix(event_type='inspection_completed', title='Done',
body='An inspection was submitted.')
assert Notification.query.filter_by(event_type='inspection_completed').count() == 0
def test_other_events_still_notify_the_whole_inspector_role(app, client):
"""The scoping is specific to inspection_completed — it must not silently
narrow every other event."""
from app import db
from app.models.notification import Notification
from app.models.notification_matrix import NotificationMatrix
from app.utils.notifications import notify_by_matrix
a = _user('ivy', 'inspector')
b = _user('otto', 'inspector')
db.session.add(NotificationMatrix(event_type='issue_created',
role_key='inspector', enabled=True))
db.session.commit()
notify_by_matrix(event_type='issue_created', title='New issue',
body='Something broke.')
recipients = {n.user_id for n in
Notification.query.filter_by(event_type='issue_created').all()}
assert {a.id, b.id} <= recipients
# ── Custom-email dedupe ──────────────────────────────────────────────────────
def test_custom_emails_are_deduplicated_and_blanks_skipped(app, client, monkeypatch):
"""The matrix stores this list as free text, so the same person can appear
twice with different casing or stray whitespace."""
from app import db
from app.models.notification_matrix import NotificationMatrix
from app.utils import notifications as notif
import json
row = NotificationMatrix(
event_type='issue_created', role_key='custom', enabled=True,
# Stored as a JSON list; MT has no setter, only get_custom_emails().
custom_emails=json.dumps(['Ops@x.com', 'ops@x.com ', ' ', 'other@x.com']),
)
db.session.add(row)
db.session.commit()
sent = []
monkeypatch.setattr(notif, '_send_custom_email',
lambda email, *a, **kw: sent.append(email))
notif.notify_by_matrix(event_type='issue_created', title='T', body='B')
assert len(sent) == 2
assert {e.strip().lower() for e in sent} == {'ops@x.com', 'other@x.com'}
# ── PII redaction ────────────────────────────────────────────────────────────
@pytest.mark.parametrize('raw, expected_marker', [
('reach me at ops@lts.com', '[redacted-email]'),
('call 703-555-0142 please', '[redacted-phone]'),
('ssn is 123-45-6789', '[redacted-ssn]'),
('card 4111 1111 1111 1111 on file', '[redacted-number]'),
])
def test_redact_pii_scrubs_known_shapes(raw, expected_marker):
from app.routes.support import _redact_pii
out = _redact_pii(raw)
assert expected_marker in out
def test_redact_pii_leaves_ordinary_text_alone():
from app.routes.support import _redact_pii
text = 'The third floor restroom needs restocking before Monday.'
assert _redact_pii(text) == text
def test_redact_pii_handles_empty_input():
from app.routes.support import _redact_pii
assert _redact_pii('') == ''
assert _redact_pii(None) is None
# ── Self-service data export ─────────────────────────────────────────────────
def test_export_returns_only_the_callers_own_records(app, client):
import json
from app import db
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
tmpl = _template()
fac = _facility()
me = _user('ivy', 'inspector')
other = _user('otto', 'inspector')
mine = Inspection(template_id=tmpl.id, facility_id=fac.id,
inspector_id=me.id, inspection_date=now_eastern(),
status='completed')
theirs = Inspection(template_id=tmpl.id, facility_id=fac.id,
inspector_id=other.id, inspection_date=now_eastern(),
status='completed')
db.session.add_all([mine, theirs])
db.session.commit()
mine_id, theirs_id = mine.id, theirs.id
_login(client, me)
resp = client.get('/auth/my-data/export')
assert resp.status_code == 200
assert 'application/json' in resp.headers['Content-Type']
assert 'attachment' in resp.headers['Content-Disposition']
data = json.loads(resp.get_data(as_text=True))
assert data['profile']['username'] == 'ivy'
ids = {i['id'] for i in data['inspections_performed']}
assert mine_id in ids
assert theirs_id not in ids # nobody else's work
def test_export_requires_login(client):
resp = client.get('/auth/my-data/export', follow_redirects=False)
assert resp.status_code == 302
assert '/auth/login' in resp.headers['Location']
# ── Self-service erasure ─────────────────────────────────────────────────────
def test_clean_account_is_hard_deleted(app, client):
from app import db
from app.models.user import User
me = _user('ivy', 'inspector')
uid = me.id
_login(client, me)
resp = client.post('/auth/my-data/delete-request', follow_redirects=False)
assert resp.status_code == 302
db.session.expire_all()
assert db.session.get(User, uid) is None
def test_account_with_history_is_anonymized_not_deleted(app, client):
"""Hard-deleting would orphan inspection history that must be kept for
audit continuity so erase the identity and keep the ledger."""
from app import db
from app.models.user import User
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
tmpl = _template()
fac = _facility()
me = _user('ivy', 'inspector')
uid = me.id
insp = Inspection(template_id=tmpl.id, facility_id=fac.id,
inspector_id=me.id, inspection_date=now_eastern(),
status='completed')
db.session.add(insp)
db.session.commit()
insp_id = insp.id
_login(client, me)
client.post('/auth/my-data/delete-request', follow_redirects=False)
db.session.expire_all()
u = db.session.get(User, uid)
assert u is not None # kept, so the FK is not orphaned
assert u.username == f'deleted_user_{uid}'
assert u.full_name is None
assert u.email == f'deleted_user_{uid}@deleted.local'
assert u.active is False
# The old password must no longer work — the hash was replaced with a
# random secret nobody holds.
assert u.check_password('pw-correct1') is False
# The history survives intact.
assert db.session.get(Inspection, insp_id) is not None
def test_anonymized_account_cannot_log_back_in(app, client):
from app import db
tmpl = _template()
fac = _facility()
me = _user('ivy', 'inspector')
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
db.session.add(Inspection(template_id=tmpl.id, facility_id=fac.id,
inspector_id=me.id, inspection_date=now_eastern(),
status='completed'))
db.session.commit()
_login(client, me)
client.post('/auth/my-data/delete-request', follow_redirects=True)
resp = client.post('/auth/login',
data={'username': 'ivy', 'password': 'pw-correct1'},
follow_redirects=False)
assert resp.status_code == 200 # re-rendered form, not a redirect
assert client.get('/dashboard', follow_redirects=False).status_code == 302
def test_deletion_requires_login(client):
resp = client.post('/auth/my-data/delete-request', follow_redirects=False)
assert resp.status_code == 302
assert '/auth/login' in resp.headers['Location']
# ── SupportChatSession helpers ───────────────────────────────────────────────
def test_support_session_count_and_preview(app, client):
from app import db
from app.models.support import SupportChatSession, SupportChatMessage
cust = _user('cara', 'customer')
sess = SupportChatSession(customer_id=cust.id)
db.session.add(sess)
db.session.commit()
assert sess.message_count == 0
assert sess.preview == '(no messages)'
db.session.add_all([
SupportChatMessage(session_id=sess.id, role='user',
content='How do I export a report?'),
SupportChatMessage(session_id=sess.id, role='assistant',
content='Open Reports, then Export.'),
])
db.session.commit()
db.session.refresh(sess)
assert sess.message_count == 2
# The opening question, not the assistant's reply.
assert sess.preview == 'How do I export a report?'
def test_preview_skips_a_leading_assistant_message(app, client):
from app import db
from app.models.support import SupportChatSession, SupportChatMessage
cust = _user('cara', 'customer')
sess = SupportChatSession(customer_id=cust.id)
db.session.add(sess)
db.session.commit()
db.session.add_all([
SupportChatMessage(session_id=sess.id, role='assistant',
content='Hi! How can I help?'),
SupportChatMessage(session_id=sess.id, role='user',
content='My badge scanner is broken.'),
])
db.session.commit()
db.session.refresh(sess)
assert sess.preview == 'My badge scanner is broken.'