Aug 19 - Update code to catch up with ST

This commit is contained in:
2026-08-19 14:05:18 -04:00
parent c9984e7ae6
commit 12141c2f75
52 changed files with 3321 additions and 342 deletions
+50 -41
View File
@@ -391,10 +391,13 @@ def request_my_data_deletion():
@login_required
@admin_required
def list_users():
# Exclude customer accounts — those are managed exclusively via /customers
# Exclude customer-side accounts — Customer Director AND Customer Inspector
# are both managed exclusively via /customers (phase51). Using
# User.CUSTOMER_ROLES rather than != 'customer' is what moves the customer
# inspectors off this page.
users = (
User.query
.filter(User.role != 'customer')
.filter(~User.role.in_(User.CUSTOMER_ROLES))
.order_by(User.created_at.desc())
.all()
)
@@ -418,6 +421,25 @@ def list_users():
inspector_contract_counts=inspector_contract_counts)
def _redirect_if_customer_account(user):
"""Send customer-side accounts back to Customer Management.
phase51 moved Customer Director + Customer Inspector wholly under
/customers. These accounts are no longer listed here, but the /auth/users
URLs are still reachable by hand — and editing one through UserForm would
fail anyway ('external_inspector' is no longer an offered role choice, so
SelectField would reject the existing value). Redirect instead of 404 so an
old bookmark lands on the page that now owns the account.
Returns a response to return, or None to continue.
"""
if user is not None and user.is_customer_account:
flash(f'{user.display_name} is a {user.role_label} account and is '
f'managed in Customer Management.', 'info')
return redirect(url_for('customers.manage', customer_id=user.id))
return None
@bp.route('/users/new', methods=['GET', 'POST'])
@login_required
@admin_required
@@ -432,20 +454,17 @@ def create_user():
if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data
# MT-15 — an external inspector works for the customer or a third
# party, so we never set a password on their behalf. They are invited
# exactly like a customer: created with password_set=False (which the
# login route refuses until they finish), given a one-time token, and
# emailed a link to choose their own password.
invite = (role == 'external_inspector')
# phase51 — the invitation branch that used to live here moved to
# Customer Management along with the Customer Inspector role. Every
# role this form still offers is OUR OWN staff, created with an
# admin-set password. Customer-side accounts are invited (they choose
# their own username and password) via customers.create().
#
# UserForm.password is Optional() because the same form is used for
# EDIT, where blank means "keep current". On CREATE a blank password
# would otherwise store the hash of an empty string, so require one
# unless the account is being invited to choose their own.
if not invite and not form.password.data:
flash('Please set a password, or choose the External Inspector role '
'to send an invitation instead.', 'danger')
# would otherwise store the hash of an empty string, so require one.
if not form.password.data:
flash('Please set a password for the new user.', 'danger')
return render_template('auth/user_form.html', form=form, user=None,
title='Create User',
director_editing=director_editing)
@@ -455,40 +474,19 @@ def create_user():
full_name=form.full_name.data.strip() or None,
email=form.email.data.strip().lower(),
role=role,
password_set=not invite,
password_set=True,
)
if invite:
# A random unguessable placeholder — password_set=False already
# blocks login, but never leave an account holding a known or
# empty-string hash.
import secrets
user.set_password(secrets.token_hex(32))
else:
user.set_password(form.password.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.flush() # need user.id before minting the token
token = user.generate_set_password_token(expires_hours=72) if invite else None
db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s invite=%s',
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
current_user.id, current_user.username, user.username,
user.role, invite)
user.role)
log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}; invite_sent={invite}')
f'role={user.role}; email={user.email}')
if invite:
# Reuses the customer invitation email — the copy ("an account has
# been created for you… set your password") is already correct for
# any invited account. Imported inside the function to keep the
# auth ↔ customers import graph acyclic.
from app.routes.customers import _send_invite_email
_send_invite_email(user, token, base_url=request.host_url)
flash(f'External inspector {user.display_name} created. An invitation '
f'email has been sent to {user.email} with a link to set their '
f'password.', 'success')
else:
flash(f'User {user.username} created successfully.', 'success')
flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User',
@@ -502,6 +500,9 @@ def edit_user(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
moved = _redirect_if_customer_account(user)
if moved:
return moved
form = UserForm(user=user, obj=user)
# Directors may not change another user's role — that privilege is admin-only.
@@ -546,6 +547,9 @@ def resend_invite(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
moved = _redirect_if_customer_account(user)
if moved:
return moved
if user.password_set:
flash(f'{user.display_name} has already completed their account setup.',
'info')
@@ -576,6 +580,11 @@ def assign_inspector_contracts(user_id):
# rows, so this page must accept them too.
if user is None or not user.is_inspector:
abort(404)
# A Customer Inspector is scoped by exactly these rows, but the page that
# owns them is now customers.manage — one editor per account, not two.
moved = _redirect_if_customer_account(user)
if moved:
return moved
from app.models.project import Project
from app.models.inspector_assignment import InspectorAssignment
+1 -1
View File
@@ -27,7 +27,7 @@ BROADCAST_ROLES = ['inspector', 'external_inspector', 'project_manager',
ROLE_LABELS = {
'inspector': 'Inspectors',
'external_inspector': 'External Inspectors',
'external_inspector': 'Customer Inspectors',
'project_manager': 'Project Managers',
'director': 'Directors',
'admin': 'Admins',
+401 -69
View File
@@ -3,13 +3,26 @@ app/routes/customers.py
-----------------------
Customer Management — admin-only consolidated view.
Owns BOTH customer-side roles (phase51 — see User.CUSTOMER_ROLES):
Customer Director role='customer' portal access, read-mostly,
scoped by CustomerAssignment
Customer Inspector role='external_inspector' full inspector capabilities,
scoped by InspectorAssignment
They are two seats of the same customer organisation, so they are listed,
invited, edited, assigned, switched and disabled here rather than in User
Management — which now excludes both.
Provides a single screen to:
- List all customer-role users with their assignment summary
- Create a new customer account
- Edit an existing customer (username / email / password / active)
- Manage assignments for a customer (add / remove)
- Quick-disable / enable a customer account
- View a customer's scoped facility access at a glance
- List all customer-side users with their assignment summary
- Invite a new customer account in either role
- Edit an existing account (username / email / password / active)
- Manage assignments (contracts/facilities for a director, contracts for an
inspector)
- Switch an account between the two roles
- Quick-disable / enable an account
- View the account's scoped facility access at a glance
"""
import logging
@@ -18,6 +31,7 @@ from flask_login import login_required, current_user
from app import db
from app.models.user import User
from app.models.project import Project, CustomerAssignment
from app.models.inspector_assignment import InspectorAssignment
from app.models.facility import Facility
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
@@ -29,16 +43,46 @@ logger = logging.getLogger(__name__)
bp = Blueprint('customers', __name__, url_prefix='/customers')
def _get_customer_or_redirect(customer_id):
"""Load a customer-side account, or return a redirect response.
Returns (account, None) on success and (None, response) when the id is not
a customer-side account. Every route here used to test
`customer.role != 'customer'`, which would now reject the Customer
Inspectors this page owns — the check is CUSTOMER_ROLES, once, here.
"""
account = db.session.get(User, customer_id)
if account is None:
abort(404)
if not account.is_customer_account:
flash('This page is only for customer accounts.', 'warning')
return None, redirect(url_for('customers.index'))
return account, None
def _inspector_scope_ids(user, project_facilities_map):
"""Facility IDs a Customer Inspector reaches, from its contract rows.
Mirrors get_inspector_scope() but reuses the caller's already-loaded
project → facilities map so the list view stays free of N+1 queries
(rule 13).
"""
ids = set()
for a in InspectorAssignment.query.filter_by(user_id=user.id).all():
ids.update(project_facilities_map.get(a.project_id, []))
return ids
# ── List ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
@supervisor_required
def index():
"""Consolidated customer management dashboard."""
"""Consolidated customer management dashboard — both customer roles."""
customers = (
User.query
.filter_by(role='customer')
.filter(User.role.in_(User.CUSTOMER_ROLES))
.order_by(User.username)
.all()
)
@@ -57,10 +101,27 @@ def index():
for a in all_assignments:
assignment_map[a.user_id].append(a)
# ── Bulk query for the inspector-side assignments ─────────────────────
# Customer Inspectors are scoped by InspectorAssignment, not
# CustomerAssignment — the two roles read different tables for the same
# question ("which facilities does this account see?").
all_inspector_assignments = (
InspectorAssignment.query
.filter(InspectorAssignment.user_id.in_(customer_ids))
.all()
) if customer_ids else []
inspector_assignment_map = {c.id: [] for c in customers}
for a in all_inspector_assignments:
inspector_assignment_map[a.user_id].append(a)
# ── Single bulk query for all active facilities in assigned projects ──
# Resolves facility scope for every customer without repeated DB round-trips.
from collections import defaultdict
assigned_project_ids = {a.project_id for a in all_assignments}
assigned_project_ids = (
{a.project_id for a in all_assignments}
| {a.project_id for a in all_inspector_assignments}
)
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
if assigned_project_ids:
@@ -78,11 +139,17 @@ def index():
scope_map = {} # user_id → sorted list[int] facility IDs
for customer in customers:
ids = set()
for a in assignment_map[customer.id]:
if a.facility_id:
ids.add(a.facility_id)
else:
if customer.is_inspector:
# Customer Inspector — contract-level rows only, no facility-level
# narrowing exists for inspectors (rule 57: no rows = sees nothing).
for a in inspector_assignment_map[customer.id]:
ids.update(project_facilities_map.get(a.project_id, []))
else:
for a in assignment_map[customer.id]:
if a.facility_id:
ids.add(a.facility_id)
else:
ids.update(project_facilities_map.get(a.project_id, []))
scope_map[customer.id] = sorted(ids)
# All active projects for the assignment modal
@@ -101,11 +168,12 @@ def index():
return render_template(
'customers/index.html',
customers = customers,
assignment_map = assignment_map,
scope_map = scope_map,
projects = projects,
expired_invitations = expired_invitations,
customers = customers,
assignment_map = assignment_map,
inspector_assignment_map = inspector_assignment_map,
scope_map = scope_map,
projects = projects,
expired_invitations = expired_invitations,
)
@@ -115,12 +183,16 @@ def index():
@login_required
@supervisor_required
def create():
"""Create a customer account via email invitation.
"""Create a customer-side account via email invitation.
Admin enters Full Name and Email only. A temporary username is
auto-generated from the email address. A one-time set-password link
is emailed; the customer chooses their own username and password when
they click it. The account is activated on completion.
Admin enters Full Name, Email and the role (Customer Director or Customer
Inspector). A temporary username is auto-generated from the email address.
A one-time set-password link is emailed; the invitee chooses their own
username and password when they click it. The account is activated on
completion.
Both roles take this identical path — an account that belongs to the
customer is never given a password we chose.
"""
form = CustomerInviteForm()
@@ -129,6 +201,11 @@ def create():
full_name = form.full_name.data.strip()
email = form.email.data.strip().lower()
role = form.role.data
# Defence in depth: never let a crafted POST mint a staff role through
# the customer invitation form, which sets no password.
if role not in User.CUSTOMER_ROLES:
role = 'customer'
# Auto-generate a temporary username from the email local part.
# The customer replaces this with their preferred username when
@@ -146,7 +223,7 @@ def create():
username = username,
full_name = full_name,
email = email,
role = 'customer',
role = role,
active = True,
password_set = False,
)
@@ -157,15 +234,15 @@ def create():
token = user.generate_set_password_token(expires_hours=72)
db.session.commit()
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
current_user.username, user.username, user.email)
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s role=%s email=%s',
current_user.username, user.username, user.role, user.email)
log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role=customer; email={user.email}; invite_sent=True')
f'role={user.role}; email={user.email}; invite_sent=True')
_send_invite_email(user, token, base_url=request.host_url)
flash(
f'Customer account created for {full_name}. '
f'{user.role_label} account created for {full_name}. '
f'An invitation email has been sent to {email} with a link to set their username and password.',
'success'
)
@@ -259,12 +336,9 @@ def _send_invite_email(user, token, base_url=None):
@supervisor_required
def resend_invite(customer_id):
"""Generate a fresh token and resend the set-password invitation email."""
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
token = customer.generate_set_password_token(expires_hours=72)
customer.password_set = False
@@ -321,12 +395,9 @@ def set_password(token):
@login_required
@supervisor_required
def edit(customer_id):
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
form = CustomerUserForm(user=customer, obj=customer)
@@ -354,36 +425,78 @@ def edit(customer_id):
@login_required
@supervisor_required
def manage(customer_id):
"""Single-customer detail page: profile + all assignments."""
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
"""Single-account detail page: profile + assignments + notification matrix.
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
facility_ids = get_customer_scope(customer) or []
facilities = (
The assignment editor differs by role. A Customer Director gets the
contract/facility assignment list (CustomerAssignment); a Customer
Inspector gets the contract checkbox set (InspectorAssignment) that used to
live on /auth/users/<id>/assign-contracts.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
# Resolve the account's facility scope through the SAME helper the app uses
# at request time, so this page can never disagree with what the account
# actually sees.
if customer.is_inspector:
from app.utils.scope import get_inspector_scope
facility_ids = get_inspector_scope(customer) or []
else:
facility_ids = get_customer_scope(customer) or []
facilities = (
Facility.query
.filter(Facility.id.in_(facility_ids), Facility.active == True)
.order_by(Facility.name)
.all()
) if facility_ids else []
# Assignment form (populated here so it can be rendered inline)
aform = CustomerAssignmentForm()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
assignments = []
assigned_pids = set()
if customer.is_inspector:
assigned_pids = {
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=customer_id).all()
}
else:
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
# Assignment form (populated here so it can be rendered inline)
aform = CustomerAssignmentForm()
aform.user_id.choices = [(customer.id, customer.username)]
aform.facility_id.choices = [(0, '— All facilities in contract —')]
# ── Per-account notification matrix ───────────────────────────────────
# For each event: what the global matrix would do for this account's role,
# and whether the account overrides it. The template renders a tri-state
# (Inherit / On / Off) so "inherit" stays visibly distinct from "explicitly
# set to the same value the global happens to have today".
from app.models.notification_matrix import MATRIX_EVENTS, is_enabled
from app.models.user_notification_matrix import overrides_for_user
overrides = overrides_for_user(customer.id)
matrix_rows = [
{
'event': event_key,
'label': label,
'global': is_enabled(event_key, customer.role),
'override': overrides.get(event_key), # True / False / None
}
for event_key, label in MATRIX_EVENTS.items()
]
return render_template(
'customers/manage.html',
customer = customer,
assignments = assignments,
facilities = facilities,
aform = aform,
projects = projects,
customer = customer,
assignments = assignments,
assigned_pids = assigned_pids,
facilities = facilities,
aform = aform,
projects = projects,
matrix_rows = matrix_rows,
)
@@ -393,12 +506,16 @@ def manage(customer_id):
@login_required
@supervisor_required
def add_assignment(customer_id):
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('Assignments are only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
if customer.is_inspector:
# A Customer Inspector is scoped by InspectorAssignment — writing a
# CustomerAssignment row for them would grant nothing while looking
# like it had.
flash('Customer Inspectors are assigned whole contracts — use the '
'contract list on this page.', 'warning')
return redirect(url_for('customers.manage', customer_id=customer_id))
project_id = request.form.get('project_id', type=int)
facility_id = request.form.get('facility_id', type=int) or None
@@ -467,18 +584,233 @@ def remove_assignment(assignment_id):
return redirect(url_for('customers.manage', customer_id=customer_id))
# ── Contract assignments for a Customer Inspector ────────────────────────────
@bp.route('/<int:customer_id>/contracts', methods=['POST'])
@login_required
@supervisor_required
def assign_contracts(customer_id):
"""Replace a Customer Inspector's whole InspectorAssignment set.
Same replace-the-entire-set semantics as auth.assign_inspector_contracts
(rule 59) — the form posts the complete checked list, rows not in the POST
body are deleted. Callers must always send the full desired set, never a
diff.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
if not customer.is_inspector:
flash('Contract assignment is for Customer Inspector accounts. '
'Customer Directors are assigned per contract or facility below.',
'warning')
return redirect(url_for('customers.manage', customer_id=customer_id))
from app.utils.time_utils import now_eastern
selected_ids = set(request.form.getlist('project_ids', type=int))
existing = InspectorAssignment.query.filter_by(user_id=customer_id).all()
existing_pids = {a.project_id for a in existing}
for a in existing:
if a.project_id not in selected_ids:
db.session.delete(a)
for pid in selected_ids:
if pid not in existing_pids:
db.session.add(InspectorAssignment(
user_id = customer_id,
project_id = pid,
created_at = now_eastern(),
))
db.session.commit()
logger.info('CUSTOMERS | assign_contracts | admin=%s customer=%s projects=%s',
current_user.username, customer.username, sorted(selected_ids))
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'inspector_assignments={sorted(selected_ids)}')
if not selected_ids:
# Rule 57 is strict, and silently is exactly how it bites.
flash(f'{customer.display_name} now has no contracts assigned and will '
f'see nothing until at least one is granted.', 'warning')
else:
flash(f'Contract assignments updated for {customer.display_name}.', 'success')
return redirect(url_for('customers.manage', customer_id=customer_id))
# ── Per-account notification matrix ──────────────────────────────────────────
@bp.route('/<int:customer_id>/notifications', methods=['POST'])
@login_required
@supervisor_required
def save_notifications(customer_id):
"""Save this account's per-event notification overrides.
Each event posts one of 'inherit' / 'on' / 'off'. 'inherit' DELETES the row
rather than storing the global column's current value — so an account that
never expressed an opinion keeps following the global matrix when it
changes later.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
from app.models.notification_matrix import MATRIX_EVENTS
from app.models.user_notification_matrix import set_overrides
tri = {'inherit': None, 'on': True, 'off': False}
values = {}
for event_key in MATRIX_EVENTS:
# Only events this form actually posted; an unknown or missing value
# is treated as inherit rather than guessed at.
choice = request.form.get(f'event_{event_key}')
if choice is not None:
values[event_key] = tri.get(choice)
changed = set_overrides(customer.id, values)
db.session.commit()
logger.info('CUSTOMERS | notif_matrix | admin=%s customer=%s changed=%s',
current_user.username, customer.username, changed)
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'notification overrides updated ({changed} change(s))')
flash(f'Notification settings saved for {customer.display_name}.'
if changed else 'No notification changes to save.',
'success' if changed else 'info')
return redirect(url_for('customers.manage', customer_id=customer.id))
# ── Switch between the two customer roles ────────────────────────────────────
@bp.route('/<int:customer_id>/switch-role', methods=['POST'])
@login_required
@admin_required
def switch_role(customer_id):
"""Flip an account between Customer Director and Customer Inspector.
The two roles read DIFFERENT scoping tables, so flipping the column alone
would leave the account correctly labelled and seeing nothing (rule 57 is
strict for inspectors, and a director with no CustomerAssignment rows is
equally blind). The contracts are therefore mirrored across: every contract
the account could reach before, it can reach after.
Facility-level narrowing does NOT survive a switch to inspector — there is
no per-facility row for inspectors, so a director scoped to one building in
a contract becomes an inspector on that whole contract. The confirm dialog
says so; the flash repeats it. Switching BACK is lossless though: the
original facility-level rows were never deleted, and the reverse mirror
skips contracts the account can already reach, so it does not pile a
contract-wide grant on top of them.
API access changes in both directions ('external_inspector' has mobile API
access, 'customer' is 403 everywhere), so the account's refresh tokens and
device registrations are revoked — an issued JWT would otherwise keep
working until it expired, and a signed-in iPad would keep syncing.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
from app.utils.time_utils import now_eastern
from app.models.api_token import RefreshToken, DeviceToken
old_role = customer.role
new_role = 'external_inspector' if old_role == 'customer' else 'customer'
widened = False
if new_role == 'external_inspector':
# Director → Inspector: CustomerAssignment (contract or facility) →
# InspectorAssignment (contract only).
existing_pids = {
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all()
}
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all():
if a.facility_id:
widened = True
if a.project_id not in existing_pids:
db.session.add(InspectorAssignment(
user_id = customer.id,
project_id = a.project_id,
created_at = now_eastern(),
))
existing_pids.add(a.project_id)
else:
# Inspector → Director: contract-level CustomerAssignment rows
# (facility_id NULL = all facilities in the contract).
#
# `existing_pids` counts ANY row for the contract, facility-level ones
# included — NOT just the contract-wide ones. That is what makes a
# round trip lossless: an account narrowed to one facility, switched to
# inspector (which can only hold whole contracts) and switched back
# would otherwise gain a contract-wide row on top of its original
# facility row and come back with the whole contract. Skipping
# contracts the account can already reach as a director leaves the
# original narrowing intact, while contracts granted during the
# inspector spell still carry over.
existing_pids = {
a.project_id
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all()
}
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all():
if a.project_id not in existing_pids:
db.session.add(CustomerAssignment(
user_id = customer.id,
project_id = a.project_id,
facility_id = None,
))
existing_pids.add(a.project_id)
# The stale rows for the role being left are kept on purpose: switching
# back restores the account's original scope, including any facility-level
# narrowing that the inspector side cannot express. They are inert while
# the other role is active — each scope helper reads only its own table.
customer.role = new_role
revoked = (
RefreshToken.query
.filter_by(user_id=customer.id, revoked=False)
.update({'revoked': True}, synchronize_session=False)
)
devices = (
DeviceToken.query
.filter_by(user_id=customer.id)
.delete(synchronize_session=False)
)
db.session.commit()
logger.info('CUSTOMERS | switch_role | admin=%s customer=%s %s -> %s '
'tokens_revoked=%s devices_cleared=%s',
current_user.username, customer.username, old_role, new_role,
revoked, devices)
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'role switched {old_role} -> {new_role}; '
f'refresh_tokens_revoked={revoked}; devices_cleared={devices}')
msg = (f'{customer.display_name} is now a {customer.role_label}. '
f'Their contracts were carried across; any signed-in device must log in again.')
if widened:
msg += (' Note: facility-level limits do not exist for inspectors, so '
'this account now covers every facility in those contracts — '
'review the contract list below.')
flash(msg, 'warning' if widened else 'success')
return redirect(url_for('customers.manage', customer_id=customer.id))
# ── Toggle active ─────────────────────────────────────────────────────────────
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
@login_required
@supervisor_required
def toggle_active(customer_id):
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
customer.active = not customer.active
db.session.commit()
+189 -8
View File
@@ -43,7 +43,9 @@ from app.models.inspection_schedule import (InspectionSchedule,
from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area
from app.models.user import User
from functools import wraps
from app.utils.decorators import project_manager_required
from app.utils.scope import get_customer_scope
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.notifications import notify
@@ -326,6 +328,142 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
return inspection
# ── Who may plan an inspection ───────────────────────────────────────────────
#: Our own staff who plan inspections. Customer Directors are added on top by
#: schedule_manager_required — they plan work for their OWN facilities only.
_STAFF_SCHEDULERS = ('admin', 'director', 'project_manager', 'auditor')
def _is_customer_director(user):
"""True only for the portal customer role.
Equality on purpose (rule 89): a Customer Inspector PERFORMS scheduled
inspections, they do not plan them, and they are scoped by
InspectorAssignment rather than CustomerAssignment. Widening this to
User.CUSTOMER_ROLES would hand them a planning screen scoped by the wrong
table i.e. no facilities at all.
"""
return getattr(user, 'role', None) == 'customer'
def schedule_manager_required(f):
"""Who may create / edit / delete a scheduled inspection.
Our staff (_STAFF_SCHEDULERS) plus **Customer Directors**, who schedule
inspections for the facilities they are assigned. Every choice list and
every POST is narrowed to their own contracts see _form_choices(),
_scope_errors() and _schedule_in_scope().
"""
@wraps(f)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(403)
if current_user.role in _STAFF_SCHEDULERS or _is_customer_director(current_user):
return f(*args, **kwargs)
flash('You do not have permission to manage scheduled inspections.', 'danger')
return redirect(url_for('dashboard.index'))
return wrapper
def _customer_facility_ids():
"""Facility ids the current Customer Director may schedule against."""
return set(get_customer_scope(current_user) or [])
def _customer_project_ids():
"""Contract ids behind those facilities.
Derived from the facilities rather than straight off CustomerAssignment, so
a facility-level assignment resolves to its owning contract and the
contract selector still lines up with the facilities on offer.
"""
fids = _customer_facility_ids()
if not fids:
return set()
return {
f.project_id
for f in Facility.query.filter(Facility.id.in_(fids)).all()
if f.project_id
}
def _schedule_in_scope(sched):
"""May the current user act on this schedule?
Staff: any. Customer Director: only schedules at a facility they are
assigned checked on edit and delete so a hand-typed id cannot reach
another customer's schedule.
"""
if not _is_customer_director(current_user):
return True
return sched.facility_id in _customer_facility_ids()
def _scope_errors(template_id, facility_id, inspector_id):
"""Validate a submitted schedule against the actor's scope and phase55.
This route builds its form by hand (no WTForms SelectField), so narrowing
the choice lists is NOT the validation a crafted POST would sail past it.
Every id is therefore re-checked here:
* Customer Director facility and inspector must belong to their own
contracts, otherwise they could schedule work at, or assign it to,
another customer.
* Everyone the chosen form must be available on the chosen facility's
contract (phase55). Without this a manager could schedule one
customer's bespoke form against another customer's facility, and the
mismatch would only surface when the inspector opened it.
"""
errors = []
facility = db.session.get(Facility, facility_id) if facility_id else None
if _is_customer_director(current_user):
fids = _customer_facility_ids()
if not facility_id or facility_id not in fids:
logger.warning(
'SCHED INSP | out-of-scope facility blocked | user=%s | facility_id=%s',
current_user.username, facility_id)
errors.append('That facility is not one of yours. '
'Choose a facility from your contracts.')
if inspector_id and inspector_id not in {u.id for u in _schedulable_inspectors()}:
logger.warning(
'SCHED INSP | out-of-scope inspector blocked | user=%s | user_id=%s',
current_user.username, inspector_id)
errors.append('That inspector does not work on your contracts.')
template = db.session.get(InspectionTemplate, template_id) if template_id else None
if template is not None and facility is not None:
if not template.available_for_project(facility.project_id):
contract = facility.project.name if facility.project else "this facility's contract"
errors.append(f'"{template.name}" is not available on {contract}. '
f'Choose a form attached to that contract, or a shared form.')
return errors
def _schedulable_inspectors():
"""Inspectors the current user may assign a schedule to.
A Customer Director sees only inspectors holding an InspectorAssignment on
their own contracts their own people and ours, never another client's
Customer Inspector (rule 93). Staff see the whole active pool.
"""
q = User.query.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
if _is_customer_director(current_user):
from app.models.inspector_assignment import InspectorAssignment
pids = _customer_project_ids()
q = (q.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(InspectorAssignment.project_id.in_(pids))
if pids else q.filter(False))
seen, uniq = set(), []
for u in q.order_by(User.username).all():
# The join can repeat a user across assignment rows.
if u.id not in seen:
seen.add(u.id)
uniq.append(u)
return uniq
# ── CRUD ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@@ -338,8 +476,9 @@ def index():
customers are barred; managers see everything, exactly as before. All
mutating routes below keep @project_manager_required.
"""
if current_user.role == 'customer':
abort(403)
# Customer Directors now plan inspections for their own facilities, so they
# reach this list too — narrowed below. Customer Inspectors already saw it
# (they are inspectors) and keep their own-assignments-only view.
# Two tabs (phase51): Pending = schedules still producing occurrences
# (active); Completed = closed ones — fulfilled one-times, recurring
@@ -359,6 +498,13 @@ def index():
# Inspectors see only their own assignments; managers see everything.
if current_user.is_inspector:
base = base.filter(InspectionSchedule.inspector_id == current_user.id)
elif _is_customer_director(current_user):
# Only schedules at facilities they are assigned. An empty scope must
# match nothing rather than everything — filter(False), not a skipped
# filter (rule 57's failure mode).
fids = _customer_facility_ids()
base = (base.filter(InspectionSchedule.facility_id.in_(fids))
if fids else base.filter(False))
# Counts are computed on the same scoped query, so the badges match what the
# viewer can actually open.
@@ -385,15 +531,42 @@ def index():
def _form_choices():
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
inspectors = _active_inspectors()
"""Lists offered on the schedule form, narrowed to the actor's scope.
For a Customer Director every list is limited to their own contracts
including the FORM list, so another customer's bespoke form names never
appear (phase55 / rule 96). The lists are a UI convenience only; the POST
is re-validated by _scope_errors().
"""
customer_scoped = _is_customer_director(current_user)
fac_q = Facility.query.filter_by(active=True)
if customer_scoped:
fids = _customer_facility_ids()
fac_q = fac_q.filter(Facility.id.in_(fids)) if fids else fac_q.filter(False)
facilities = fac_q.order_by(Facility.name).all()
if customer_scoped:
# Shared forms plus those attached to their contracts (phase55) — the
# same union the mobile API builds.
seen, templates = set(), []
for pid in list(_customer_project_ids()) + [None]:
for t in InspectionTemplate.available_query(pid).all():
if t.id not in seen:
seen.add(t.id)
templates.append(t)
templates.sort(key=lambda t: (t.name or '').lower())
else:
templates = (InspectionTemplate.query.filter_by(active=True)
.order_by(InspectionTemplate.name).all())
inspectors = _schedulable_inspectors() if customer_scoped else _active_inspectors()
return templates, facilities, inspectors
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@project_manager_required
@schedule_manager_required
def create():
templates, facilities, inspectors = _form_choices()
@@ -421,6 +594,7 @@ def create():
if mode not in _MODES:
errors.append('Invalid mode.')
errors.extend(_recurrence_errors(request.form, frequency))
errors.extend(_scope_errors(template_id, facility_id, inspector_id))
if errors:
for e in errors:
@@ -486,11 +660,13 @@ def create():
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
@login_required
@project_manager_required
@schedule_manager_required
def edit(schedule_id):
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
if not _schedule_in_scope(schedule):
abort(403)
templates, facilities, inspectors = _form_choices()
if request.method == 'POST':
@@ -504,6 +680,9 @@ def edit(schedule_id):
if frequency not in _FREQUENCIES:
frequency = schedule.frequency
errors = _recurrence_errors(request.form, frequency)
errors.extend(_scope_errors(template_id or schedule.template_id,
facility_id or schedule.facility_id,
inspector_id))
if errors:
db.session.rollback()
for e in errors:
@@ -593,11 +772,13 @@ def edit(schedule_id):
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
@login_required
@project_manager_required
@schedule_manager_required
def delete(schedule_id):
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
if not _schedule_in_scope(schedule):
abort(403)
name = schedule.name
sid = schedule.id
db.session.delete(schedule)
+427 -35
View File
@@ -15,7 +15,7 @@ from app.models.project import Project
from app.models.issue import Issue
from app.models.user import User
from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required
from app.utils.decorators import supervisor_required, return_url
from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.models.notification import (
@@ -350,7 +350,6 @@ def index():
def start():
form = StartInspectionForm()
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# Scope projects to inspector's assigned contracts
@@ -362,7 +361,6 @@ def start():
}
projects = [p for p in projects if p.id in assigned_pids]
form.template_id.choices = [(t.id, t.name) for t in templates]
form.project_id.choices = [(p.id, p.name) for p in projects]
# Seed facility choices: use submitted project_id, session value, or first project
@@ -376,6 +374,13 @@ def start():
else:
selected_project_id = projects[0].id if projects else None
# phase52 — forms are offered per CONTRACT: shared forms plus any attached
# to the selected contract. This is also the POST validation (SelectField
# validates against its choices), so a crafted template_id for another
# customer's form is rejected here, not merely hidden in the UI.
templates = InspectionTemplate.available_query(selected_project_id).all()
form.template_id.choices = [(t.id, t.name) for t in templates]
if selected_project_id:
facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all()
else:
@@ -408,6 +413,19 @@ def start():
if template is None:
abort(404)
# Belt-and-braces: the choices above already reject a form that is not
# available on this contract, but that guard lives in how the list was
# built. Re-assert it against the FACILITY actually chosen, so a future
# change to the choice-building cannot quietly open a cross-customer
# hole here.
_fac = db.session.get(Facility, form.facility_id.data)
if not template.available_for_project(_fac.project_id if _fac else None):
logger_msg = ('INSPECTION START BLOCKED | template=%s not available for '
'facility=%s | user=%s')
current_app.logger.warning(logger_msg, template.id,
form.facility_id.data, current_user.username)
abort(403)
# Inspector facility scope check — prevent crafted POST from selecting
# a facility outside their assigned contracts.
if current_user.is_inspector:
@@ -465,6 +483,45 @@ def facilities_for_project(project_id):
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
# ── AJAX: forms available on a given contract (phase52) ──────────────────────
@bp.route('/templates_for_project/<int:project_id>')
@login_required
def templates_for_project(project_id):
"""Forms usable on this contract — shared ones plus any attached to it.
Powers the Contract -> Form cascade on the start-inspection page, the same
way facilities_for_project powers Contract -> Facility.
**Scoped to the caller's own contracts.** The POST validation in start() is
what stops a form being *used* across contracts, but this endpoint would
otherwise happily list one customer's bespoke form NAMES to another
customer's inspector who simply asked for a contract id — the same leak
that rule 96 covers on the mobile API. Empty list rather than 403, so it
does not confirm whether the contract exists either.
"""
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
allowed = {
f.project_id
for f in Facility.query.filter(Facility.id.in_(fids)).all()
} if fids else set()
if project_id not in allowed:
logger_msg = ('TEMPLATES_FOR_PROJECT | out-of-scope request | '
'user=%s | project_id=%s')
current_app.logger.warning(logger_msg, current_user.username, project_id)
return jsonify([])
elif current_user.role == 'customer':
# Customers never start inspections; nothing here is theirs to see.
return jsonify([])
templates = InspectionTemplate.available_query(project_id).all()
return jsonify([
{'id': t.id, 'name': t.name, 'shared': t.is_shared}
for t in templates
])
# ── Execute ───────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
@@ -479,7 +536,7 @@ def execute(inspection_id):
return redirect(url_for('inspections.index'))
if inspection.status == 'completed':
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
template = inspection.template
form_fields = template.get_form_schema()
@@ -601,7 +658,7 @@ def execute(inspection_id):
f'status=completed; score={score}')
flash('Inspection submitted successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
else:
_save_draft(inspection, responses)
@@ -609,11 +666,10 @@ def execute(inspection_id):
flash('Draft saved. You can continue filling in the form later.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
staff_for_flag_issue = User.query.filter(
User.role.in_(['director', 'inspector', 'external_inspector',
'project_manager', 'auditor']),
User.active == True,
).order_by(User.full_name, User.username).all()
# Scoped to this inspection's contract — see _assignable_staff_for().
# Must match flag_issue()'s choices exactly or the offcanvas silently
# fails to save (rule 60).
staff_for_flag_issue = _assignable_staff_for(inspection, current_user)
return render_template('inspections/execute.html',
inspection=inspection,
@@ -924,6 +980,95 @@ def view(inspection_id):
# ── Flag issue during inspection ──────────────────────────────────────────────
#: Internal roles that are NOT contract-scoped — they work across the whole
#: organisation, so they are offered regardless of which contract the
#: inspection belongs to. Only ever shown to our own people.
_ORG_WIDE_ASSIGNEE_ROLES = ('director', 'project_manager', 'auditor')
def _assignable_staff_for(inspection, actor):
"""Users `actor` may assign an issue to, for THIS inspection.
The candidate list is scoped by the inspection's CONTRACT, not taken
org-wide. Two distinct problems this fixes:
1. **Cross-customer leak.** A Customer Inspector could assign an issue to
anyone in the system including another client's Customer Inspector.
The assignee is notified by email and in-app with the facility name and
issue description, so this handed one customer's data to another. It is
a leak whoever flags the issue, so the contract scope is applied to the
two inspector roles for EVERY actor, not just customer ones.
2. An external account should not see our internal org chart at all. For a
customer-side actor the list is their co-workers on shared contracts
inspectors assigned to this inspection's contract — and nothing else.
Rules applied:
* inspector / external_inspector -> only those holding an
InspectorAssignment on this inspection's contract (the same rows
get_inspector_scope() reads, so the list can never disagree with what
the assignee can actually open).
* director / project_manager / auditor -> org-wide, but offered ONLY to
our own staff. These roles carry no InspectorAssignment rows, so
contract-scoping them would remove them entirely and break the normal
"escalate to the contract manager" flow.
* inactive accounts are never offered.
A facility with no contract yields no contract-scoped candidates; that is
fail-closed and correct an external actor then gets an empty list and can
only leave the issue unassigned.
Used by BOTH the offcanvas dropdown in execute() and the choices that
validate the POST in flag_issue(). They MUST stay identical: a value the UI
offers but the choices reject fails `validate_on_submit()`, and the
offcanvas JS treats the resulting 200 as success the issue is silently
never saved (rule 60's failure mode, which is exactly what the two
hand-maintained lists were already doing to project_manager and auditor).
"""
from app.models.inspector_assignment import InspectorAssignment
# `is_customer_account` is used here to WITHHOLD internal staff from an
# external account — the narrowing direction, which rule 89 permits. It
# must never be used to grant a customer-side account anything.
actor_is_external = bool(actor) and actor.is_customer_account
project_id = inspection.facility.project_id if inspection.facility else None
candidates = []
if project_id:
candidates = (
User.query
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(
InspectorAssignment.project_id == project_id,
User.role.in_(User.INSPECTOR_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
if not actor_is_external:
candidates += (
User.query
.filter(
User.role.in_(_ORG_WIDE_ASSIGNEE_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
# The join can repeat a user across assignment rows; dedupe by id, keeping
# a stable display order.
seen, out = set(), []
for u in candidates:
if u.id not in seen:
seen.add(u.id)
out.append(u)
out.sort(key=lambda u: (u.display_name or '').lower())
return out
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
@login_required
def flag_issue(inspection_id):
@@ -936,15 +1081,16 @@ def flag_issue(inspection_id):
return redirect(url_for('inspections.index'))
form = IssueForm()
staff = User.query.filter(
User.role.in_(['director', 'inspector', 'external_inspector'])
).order_by(User.username).all()
# SAME list the offcanvas rendered — this is what actually validates the
# POST, so it is also the security boundary: a crafted assigned_to for
# someone outside this contract fails validation rather than being stored.
staff = _assignable_staff_for(inspection, current_user)
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
# MT-15: suffix external (customer / third-party) inspectors so whoever is
# triaging can see the work is going outside the company. Display only.
# Suffix customer-employed inspectors so whoever is triaging can see the
# work is going outside the company. Display only.
form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, u.username + (' (External)' if u.is_external_inspector else ''))
(u.id, u.display_name + (' (Customer)' if u.is_external_inspector else ''))
for u in staff
]
@@ -1011,6 +1157,21 @@ def flag_issue(inspection_id):
flash('Issue logged successfully.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
# A failed POST must NOT come back 200. The flag-issue offcanvas treats
# `res.ok` as success and reloads the page, so a 200 here means the issue
# is silently discarded with the user believing it was logged — the exact
# failure rule 60 describes. Returning 400 routes it to the JS error branch
# so the reason is shown and the form stays open with its input intact.
if request.method == 'POST':
if form.assigned_to.errors:
# Most likely an assignee outside this inspection's contract:
# either a stale page rendered before the assignment changed, or a
# crafted id. Say something actionable rather than "invalid choice".
flash('That person cannot be assigned to an issue on this contract. '
'Reopen the panel to refresh the list.', 'danger')
return render_template('inspections/flag_issue.html',
form=form, inspection=inspection), 400
return render_template('inspections/flag_issue.html',
form=form, inspection=inspection)
@@ -1223,6 +1384,250 @@ def export_pdf(inspection_id):
# ── Flag / clear follow-up required ──────────────────────────────────────────
def _view_url(inspection_id):
"""inspections.view URL that carries the list `next` through.
Actions posted from the detail page redirect back to that same page;
re-attaching `next` is what keeps its Back button (and the next action)
pointed at the filtered list the user arrived from.
"""
nxt = request.form.get('next') or request.args.get('next')
if nxt:
return url_for('inspections.view', inspection_id=inspection_id, next=nxt)
return url_for('inspections.view', inspection_id=inspection_id)
def _collect_inspection_photos(inspection):
"""Relative storage keys owned by an inspection, for cleanup after delete.
Two sources: image field values inside the submitted form data (stored as
`uploads/...` strings in the notes JSON), and the primary photo of each
issue flagged during the inspection. Shared by the single and bulk delete
paths so they cannot drift a miss here leaves orphaned files in storage
forever, and it is invisible.
"""
paths = []
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
paths.append(issue.photo_path)
return paths
# ── Bulk actions from the inspections list ───────────────────────────────────
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
"""Apply one action to every ticked inspection on the list page.
Partial-failure policy: act on every eligible row, skip the rest, and
report exact counts. Permission is checked per ACTION (all are
admin/director level except the PDF export, which anyone who can see the
list may run); `skipped` therefore means "this row was not in a state the
action applies to".
"""
back = return_url(url_for('inspections.index'))
action = request.form.get('action', '')
ids = request.form.getlist('inspection_ids', type=int)
if not ids:
flash('No inspections selected.', 'warning')
return redirect(back)
supervisor = current_user.role in ('admin', 'director')
allowed = {
'export': True, # read-only, already scoped below
'delete': supervisor,
'flag_followup': supervisor,
'clear_followup': supervisor,
}
if action not in allowed:
flash('Unknown bulk action.', 'danger')
return redirect(back)
if not allowed[action]:
flash('You do not have permission for that bulk action.', 'danger')
return redirect(back)
q = Inspection.query.options(
joinedload(Inspection.facility),
joinedload(Inspection.template),
joinedload(Inspection.inspector),
).filter(Inspection.id.in_(ids))
# Re-apply the viewer's facility scope to the SELECTED ids. The list page
# only ever shows in-scope rows, but the id list arrives in the POST body
# and must not be trusted — a crafted request could otherwise name any
# inspection in the system.
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
elif current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
inspections = q.order_by(Inspection.inspection_date.desc()).all()
out_of_scope = len(ids) - len(inspections)
changed = 0
skipped = out_of_scope
# ── Export selected to PDF ───────────────────────────────────────────
if action == 'export':
if not inspections:
flash('None of the selected inspections are available to you.', 'warning')
return redirect(back)
from flask import Response
pdf = generate_inspections_list_pdf(
inspections,
f'Selected inspections ({len(inspections)})',
)
log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export',
f'ids={[i.id for i in inspections]}')
return Response(
pdf,
mimetype='application/pdf',
headers={'Content-Disposition':
'attachment; filename="selected_inspections.pdf"'},
)
# ── Delete ───────────────────────────────────────────────────────────
if action == 'delete':
from app.utils import storage
photo_paths = []
# Snapshot (id, label) BEFORE deleting: the objects are expired after
# the commit, and the audit pass must run after it. log_action()
# commits internally (rule 41), so auditing inside this loop would
# commit the deletes one at a time — and a mid-loop failure would
# leave rows gone with the photo cleanup below never reached.
deleted = []
for insp in inspections:
photo_paths.extend(_collect_inspection_photos(insp))
deleted.append((
insp.id,
f'{insp.template.name if insp.template else ""} @ '
f'{insp.facility.name if insp.facility else ""}',
))
db.session.delete(insp)
changed += 1
db.session.commit()
for insp_id, label in deleted:
log_action(ACTION_DELETE, 'Inspection', insp_id, label,
f'bulk deleted by {current_user.username}')
# Files only after the rows are gone — an orphaned file is recoverable,
# a deleted file belonging to a surviving row is not.
for rel_path in photo_paths:
storage.delete(rel_path)
_flash_bulk(changed, skipped, 'permanently deleted')
# ── Request follow-up ────────────────────────────────────────────────
elif action == 'flag_followup':
note = request.form.get('follow_up_note', '').strip() or None
# Only the rows this run actually flagged. Re-deriving it afterwards
# from `follow_up_requested_by == current_user.id` would also match
# inspections this same user flagged on an EARLIER run and that were
# skipped here as already-flagged — re-notifying their inspectors.
flagged = []
for insp in inspections:
# Same two guards as the single-inspection route: nothing to follow
# up on before submission, and a repeat request must not overwrite
# the pending one's note or attribution.
if insp.status != 'completed' or insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = True
insp.follow_up_note = note
insp.follow_up_requested_by = current_user.id
insp.follow_up_requested_at = now_eastern()
flagged.append(insp)
changed += 1
db.session.commit()
for insp in flagged:
body = (f'{current_user.display_name} has requested a follow-up '
f're-inspection of "{insp.template.name if insp.template else ""}" '
f'at {insp.facility.name if insp.facility else ""}.'
+ (f' Note: {note}' if note else ''))
inspector = db.session.get(User, insp.inspector_id)
if inspector and inspector.id != current_user.id:
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
event_type = EVENT_INSPECTION_DONE,
send_email = True,
)
# Through the matrix, not straight to managers — rule 73, so
# per-contract recipients fire here exactly as they do for a
# single request.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
facility_id = insp.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
db.session.commit() # notify() does not commit — rule 70
# Audited after the commit (rule 41) — log_action commits internally.
for insp in flagged:
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up_required=True by {current_user.username}')
_flash_bulk(changed, skipped, 'flagged for follow-up',
skip_reason='not submitted, or already flagged')
# ── Clear follow-up ──────────────────────────────────────────────────
elif action == 'clear_followup':
cleared = []
for insp in inspections:
if not insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = False
insp.follow_up_note = None
insp.follow_up_requested_by = None
insp.follow_up_requested_at = None
cleared.append(insp)
changed += 1
db.session.commit()
for insp in cleared: # after the commit — rule 41
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up cleared by {current_user.username}')
_flash_bulk(changed, skipped, 'cleared of the follow-up flag',
skip_reason='not flagged')
current_app.logger.info(
'INSPECTIONS | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
action, current_user.username, len(ids), changed, skipped,
)
return redirect(back)
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
"""One consistent result message for every bulk action."""
if not changed and not skipped:
flash('Nothing to do.', 'info')
return
parts = [f'{changed} inspection{"s" if changed != 1 else ""} {verb}']
if skipped:
parts.append(f'{skipped} skipped ({skip_reason})')
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
@login_required
def flag_followup(inspection_id):
@@ -1249,12 +1654,12 @@ def flag_followup(inspection_id):
# Nothing to follow up on until the inspection has been submitted.
if inspection.status != 'completed':
flash('You can only request a follow-up on a completed inspection.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
# Don't let a repeat request overwrite the note/attribution of a pending
# one — the flag is already raised and staff are already on it.
if inspection.follow_up_required:
flash('A follow-up has already been requested for this inspection.', 'info')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
elif current_user.role not in ('admin', 'director'):
abort(403)
@@ -1316,7 +1721,7 @@ def flag_followup(inspection_id):
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
else:
flash('Follow-up inspection required flag set.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
@@ -1339,7 +1744,7 @@ def clear_followup(inspection_id):
f'{inspection.template.name} @ {inspection.facility.name}',
'follow_up_required=False (cleared)')
flash('Follow-up flag cleared.', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
# ── Start a re-inspection (linked to parent) ──────────────────────────────────
@@ -1385,20 +1790,7 @@ def delete(inspection_id):
template_name = inspection.template.name
inspector_name = inspection.inspector.username
photo_paths = []
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
photo_paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
photo_paths.append(issue.photo_path)
photo_paths = _collect_inspection_photos(inspection)
db.session.delete(inspection)
db.session.commit()
@@ -1425,4 +1817,4 @@ def delete(inspection_id):
f'has been permanently deleted.',
'success'
)
return redirect(url_for('inspections.index'))
return redirect(return_url(url_for('inspections.index')))
+234 -15
View File
@@ -16,7 +16,7 @@ from app.models.notification import (
)
from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import (supervisor_required, project_manager_required,
issue_manager_required)
issue_manager_required, return_url)
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.tenancy.gates import quota_soft_check
@@ -79,7 +79,7 @@ def _assignee_label(user):
at a glance that the work is going outside the company. Display only; the
stored value is still the user id.
"""
return (f'{user.display_name} (External)'
return (f'{user.display_name} (Customer)'
if user.is_external_inspector else user.display_name)
@@ -418,7 +418,7 @@ def view(issue_id):
comment_body = request.form.get('update_notes', '').strip()
if not comment_body:
flash('Comment cannot be empty.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
comment = IssueComment(
issue_id=issue.id,
user_id=current_user.id,
@@ -432,7 +432,7 @@ def view(issue_id):
f'#{issue.id}',
'customer comment added')
flash('Comment posted.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
@@ -666,10 +666,16 @@ def view(issue_id):
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'status={issue.status}; assigned_to={issue.assigned_to}')
flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
is_following = issue.is_followed_by(current_user)
if current_user.role == 'customer':
# TEMPORARY (Aug 2026) — COMMENTS_VISIBLE_TO_ALL lifts the phase22
# restriction so customers see every comment on the issue, not only the
# ones ticked "Share with customer". is_customer_visible is still recorded
# on every comment, so setting the flag back to false restores the old
# filtering with nothing to repair. See config.py.
comments_open = current_app.config.get('COMMENTS_VISIBLE_TO_ALL', False)
if current_user.role == 'customer' and not comments_open:
comments = (issue.comments
.filter_by(is_customer_visible=True)
.order_by(IssueComment.created_at.asc()).all())
@@ -679,6 +685,7 @@ def view(issue_id):
issue=issue,
form=form,
comments=comments,
comments_open=comments_open,
is_following=is_following)
@@ -701,7 +708,7 @@ def follow(issue_id):
flash('You are now following this issue and will receive notifications for any updates.', 'success')
else:
flash('You are already following this issue.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
# ── Unfollow ──────────────────────────────────────────────────────────────────
@@ -855,7 +862,7 @@ def create():
)
db.session.commit()
flash('Issue created.', 'success')
return redirect(url_for('issues.index'))
return redirect(return_url(url_for('issues.index')))
return render_template('issues/form.html', form=form, title='Log New Issue',
projects=projects, selected_project_id=selected_project_id,
@@ -875,7 +882,7 @@ def verify(issue_id):
if issue.status not in ('resolved', 'pending_verification'):
flash('Only resolved or pending-verification issues can be verified.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
note = request.form.get('verification_note', '').strip() or None
@@ -895,7 +902,21 @@ def verify(issue_id):
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'verified_by={current_user.username}')
flash(f'Issue #{issue_id} verified and closed.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
def _view_url(issue_id):
"""issues.view URL that carries the list `next` through.
An update posted from the detail page redirects back to that same detail
page; without re-attaching `next`, the Back button would lose the filters
the user arrived with and the next action from this page would too. Only
added when there is something to carry, so ordinary links stay clean.
"""
nxt = request.form.get('next') or request.args.get('next')
if nxt:
return url_for('issues.view', issue_id=issue_id, next=nxt)
return url_for('issues.view', issue_id=issue_id)
@bp.route('/bulk-verify', methods=['POST'])
@@ -930,7 +951,205 @@ def bulk_verify():
f'bulk_verified_by={current_user.username}')
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
return redirect(url_for('issues.verification_queue'))
# Reachable from BOTH the verification queue and the issues list, so honour
# the caller's `next` and fall back to the queue as before.
return redirect(return_url(url_for('issues.verification_queue')))
# ── Bulk actions from the issues list ────────────────────────────────────────
#: Statuses a bulk status change may set, and what an issue must already be in
#: for the change to mean anything. Moving an issue to the state it is already
#: in is a no-op, so it counts as skipped rather than changed.
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
"""Apply one action to every ticked issue on the list page.
Partial-failure policy (matches bulk_verify): act on every eligible row,
skip the rest, and report exact counts never silently drop rows, and
never let one ineligible row block the batch.
Permission is checked per ACTION here rather than per row: all four actions
are manager-level, and the roles that hold them have org-wide issue access,
so there is no per-row scope question to answer. `skipped` therefore only
ever means "this row was not in a state the action applies to".
"""
back = return_url(url_for('issues.index'))
action = request.form.get('action', '')
ids = request.form.getlist('issue_ids', type=int)
if not ids:
flash('No issues selected.', 'warning')
return redirect(back)
manager = current_user.role in ('admin', 'director', 'auditor')
deleter = current_user.role in ('admin', 'director')
allowed = {
'assign': manager,
'status': manager,
'verify': manager,
'delete': deleter,
}
if action not in allowed:
flash('Unknown bulk action.', 'danger')
return redirect(back)
if not allowed[action]:
flash('You do not have permission for that bulk action.', 'danger')
return redirect(back)
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
missing = len(ids) - len(issues)
changed = 0
skipped = missing
# ── Assign ───────────────────────────────────────────────────────────
if action == 'assign':
raw = request.form.get('assigned_to', '')
user = None
if raw and raw != '0':
user = db.session.get(User, int(raw)) if raw.isdigit() else None
if user is None:
flash('That user no longer exists.', 'danger')
return redirect(back)
# Track what actually moved. Re-deriving this after the commit by
# testing `issue.assigned_to == user.id` would also match the issues
# that were ALREADY assigned to that person — they were counted as
# skipped, but would still be emailed "assigned to you" every time
# anyone ran a bulk assign over them.
newly_assigned = []
for issue in issues:
if issue.assigned_to == (user.id if user else None):
skipped += 1
continue
issue.assigned_to = user.id if user else None
newly_assigned.append(issue)
changed += 1
db.session.commit()
if user:
for issue in newly_assigned:
notify(
recipient = user,
title = f'Issue #{issue.id} assigned to you',
body = (f'{issue.severity.title()}-severity issue at '
f'{issue.resolved_facility.name if issue.resolved_facility else ""}: '
f'{issue.description[:120]}'),
link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True,
)
db.session.commit() # notify() does not commit — rule 70
label = user.display_name if user else 'Unassigned'
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
f'ids={[i.id for i in issues]}; changed={changed}')
_flash_bulk(changed, skipped, f'assigned to {label}')
# ── Status ───────────────────────────────────────────────────────────
elif action == 'status':
new_status = request.form.get('status', '')
if new_status not in _BULK_STATUSES:
flash('Please choose a status to set.', 'warning')
return redirect(back)
# (issue, old_status) for the audit pass, which must run AFTER the
# commit — log_action() commits internally (rule 41), so calling it
# inside this loop would commit each row separately and lose the
# batch's atomicity.
moved = []
for issue in issues:
if issue.status == new_status:
skipped += 1
continue
old = issue.status
moved.append((issue, old))
issue.status = new_status
# Keep resolved_at consistent with the status, the same way the
# single-issue update does — a resolved issue with no resolved_at
# breaks the SLA compliance report and the aging buckets.
if new_status == 'resolved' and not issue.resolved_at:
issue.resolved_at = now_eastern()
elif new_status in ('open', 'in_progress'):
issue.resolved_at = None
changed += 1
db.session.commit()
for issue, old in moved:
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'bulk status {old}{new_status} by {current_user.username}')
_flash_bulk(changed, skipped,
f'set to {new_status.replace("_", " ").title()}')
# ── Verify & close ───────────────────────────────────────────────────
elif action == 'verify':
verified = []
for issue in issues:
if issue.status not in ('resolved', 'pending_verification'):
skipped += 1
continue
issue.status = 'resolved'
issue.verified_by = current_user.id
issue.verified_at = now_eastern()
if not issue.resolved_at:
issue.resolved_at = now_eastern()
verified.append(issue)
changed += 1
db.session.commit()
for issue in verified: # after the commit — rule 41
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'bulk_verified_by={current_user.username}')
_flash_bulk(changed, skipped, 'verified and closed',
skip_reason='not awaiting verification')
# ── Delete ───────────────────────────────────────────────────────────
elif action == 'delete':
from app.utils import storage
photo_paths = []
# Snapshot the ids BEFORE deleting — the objects are expired after the
# commit, and the audit pass has to run after it (rule 41: log_action
# commits internally, so auditing inside this loop would commit the
# deletes one at a time and, on a mid-loop failure, leave rows gone
# with the photo cleanup below never reached).
deleted_ids = []
for issue in issues:
if issue.photo_path:
photo_paths.append(issue.photo_path)
for lst in (issue.mobile_photo_paths, issue.result_photos):
if lst:
photo_paths.extend(lst)
deleted_ids.append(issue.id)
db.session.delete(issue)
changed += 1
db.session.commit()
for issue_id in deleted_ids:
log_action(ACTION_DELETE, 'Issue', issue_id, f'#{issue_id}',
f'bulk deleted by {current_user.username}')
# Files go only after the rows are safely gone — a failure here leaves
# an orphaned file, which is recoverable; the reverse is not.
for rel_path in photo_paths:
storage.delete(rel_path)
_flash_bulk(changed, skipped, 'permanently deleted')
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
action, current_user.username, len(ids), changed, skipped)
return redirect(back)
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
"""One consistent result message for every bulk action."""
if not changed and not skipped:
flash('Nothing to do.', 'info')
return
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
if skipped:
parts.append(f'{skipped} skipped ({skip_reason})')
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@@ -952,11 +1171,11 @@ def request_verification(issue_id):
)
if not can_act:
flash('Access denied.', 'danger')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
if issue.status not in ('in_progress',):
flash('Issue must be in progress to request verification.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
issue.status = 'pending_verification'
db.session.commit()
@@ -984,7 +1203,7 @@ def request_verification(issue_id):
)
db.session.commit()
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
# ── Verification queue ────────────────────────────────────────────────────────
@@ -1077,7 +1296,7 @@ def delete(issue_id):
f'facility={facility_name}; description={issue_desc}')
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
return redirect(url_for('issues.index'))
return redirect(return_url(url_for('issues.index')))
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
+4 -1
View File
@@ -940,7 +940,10 @@ def export_inspector_performance():
for row_idx, s in enumerate(inspector_stats, start=3):
stripe = sub_fill if row_idx % 2 == 0 else None
row_data = [
s['display_name'],
# Customer-employed inspectors share this table with our own crew;
# suffixed rather than given a column so the index-based styling
# below (score = col 5, vs_avg = col 6, …) stays correct.
s['display_name'] + (' (Customer)' if s.get('external') else ''),
s['total'],
s['completed'],
s['completion_rate'],
+202 -26
View File
@@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply,
from app.models.user import User
from app.models.facility import Facility
from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope
from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.notifications import notify
@@ -69,6 +69,12 @@ Help customers with:
Rules:
- Keep answers concise (3-5 sentences max) and friendly.
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
one is present that section is curated by the provider's team and is authoritative. \
If it answers the question, use it.
- When the knowledge above contains a link (URL), email address or exact wording, quote \
it EXACTLY as written. Repeating something given to you here is not inventing do it \
freely. Never alter a URL, shorten it, or replace it with a description.
- Never invent specific staff names, contract prices, schedules, or contact numbers.
- If the customer has an access problem, billing question, or a concern you genuinely \
cannot resolve through guidance, say so clearly and suggest they click \
@@ -85,23 +91,161 @@ FAQS = [
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
]
#: Extra chips shown to a Customer Inspector, whose questions are about doing
#: the work rather than reading the results. Appended to FAQS, not replacing
#: them — they still care about scores and issues.
INSPECTOR_FAQS = [
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
]
#: Groq model used when GROQ_MODEL is unset. Verified available Aug 2026.
#: Groq RETIRES models without notice, and when the configured one disappears
#: every question fails with the generic "problem reaching the AI assistant"
#: reply — invisible until a customer complains. That is exactly how
#: llama-3.3-70b-versatile took the chat down. See the error handler in
#: chat_message(): it names the model and says to set GROQ_MODEL, which fixes
#: it with an env change and a restart — no deploy.
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
def _is_customer_side(user):
"""True for both customer-side roles — Director and Customer Inspector.
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
a Customer Inspector is part of it: they work at the customer's facilities
and have the same questions about scores, issues and the app. This is one
of the few places where User.CUSTOMER_ROLES is the right test; every
capability/scoping decision below still branches per role (see
_support_facilities and _system_prompt_for) the two roles get the same
DOOR, not the same answers.
"""
return getattr(user, 'is_customer_account', False)
def _support_facilities(user):
"""The facilities this user may pick on a support ticket.
Directors are scoped by CustomerAssignment, Customer Inspectors by
InspectorAssignment reusing the customer helper for both would silently
return nothing for an inspector (it returns None for any non-'customer'
role) and the facility dropdown would come up empty.
"""
if getattr(user, 'is_inspector', False):
fids = get_inspector_scope(user) or []
else:
fids = get_customer_scope(user) or []
if not fids:
return []
return (Facility.query
.filter(Facility.id.in_(fids), Facility.active == True)
.order_by(Facility.name).all())
#: Appended to the system prompt for a Customer Inspector. The base prompt is
#: written for the read-mostly portal customer and explicitly tells the model
#: NOT to describe staff actions; without this the assistant would deny a
#: Customer Inspector the very things they are employed to do.
_INSPECTOR_ADDENDUM = """
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
This user works FOR the customer but holds an inspecting role in JQC, limited to
the contracts they have been assigned. This section OVERRIDES the "only describe
what a customer can do" restriction above, for this user only.
Everything above about the portal still applies to their assigned facilities. IN
ADDITION, they can:
- Conduct inspections themselves start one on the web (Inspections -> New
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and
submit it.
- Use the iPad app OFFLINE: inspections and photos are stored on the device and
sync automatically when back online.
- Flag an issue during an inspection, and log new issues at their facilities.
- Assign an issue to an inspector working on the SAME contract (their own
colleagues, or the provider's inspectors) — never to anyone outside it.
- Update an issue's status, add comments, and set "Handled By"
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
- Work from Scheduled Inspections assigned to them.
They CANNOT: verify or close out issues (the provider's admin/director does that),
manage users, create or edit inspection forms, change the notification matrix, or
see anything outside their assigned contracts. If they ask for one of those, say
who to ask instead their own Customer Director, or the provider's team via
"Submit to Support".
Note on forms: the inspection forms they can choose from are the shared standard
forms plus any built specifically for their contract. A form built for a different
customer will never appear.
"""
#: The curated knowledge is spliced in immediately BEFORE this heading, not
#: appended after it. The rules under it say "ground answers in everything
#: above", so knowledge appended after them was, by the prompt's own
#: instruction, out of scope — which is exactly why admin KB entries appeared
#: to be ignored. Keep this marker in sync with the heading in _SYSTEM_PROMPT.
_STYLE_MARKER = 'Rules:'
def _system_prompt_for(user):
"""Base prompt + curated knowledge, plus the addendum for this user's role.
Kept separate from _system_prompt_with_kb() so the curated knowledge base
still lands at the same marker regardless of role.
"""
prompt = _system_prompt_with_kb()
if getattr(user, 'is_external_inspector', False):
prompt += _INSPECTOR_ADDENDUM
return prompt
def _system_prompt_with_kb():
"""Return the Groq system prompt, appending active knowledge base entries."""
"""Return the Groq system prompt with active knowledge entries spliced in.
Best-effort a knowledge-base failure never breaks the chat.
"""
try:
entries = (SupportKnowledge.query.filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(),
SupportKnowledge.id.asc()).all())
except Exception:
if not entries:
logger.info('SUPPORT | KB | no active entries — base prompt only')
return _SYSTEM_PROMPT
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the provider team; authoritative '
'— prefer it over general guesses, and quote any link in it exactly) ===']
total = 0
used = 0
for e in entries:
block = f'\n\nTopic: {e.title}\n{(e.body or "").strip()}'
if total + len(block) > _KB_MAX_CHARS:
logger.warning('SUPPORT | KB | %d of %d entries dropped — %d char cap '
'reached', len(entries) - used, len(entries), _KB_MAX_CHARS)
break
parts.append(block)
total += len(block)
used += 1
kb_block = ''.join(parts)
idx = _SYSTEM_PROMPT.find(_STYLE_MARKER)
if idx == -1: # marker renamed — fall back to append
logger.warning('SUPPORT | KB | style marker not found; appending at end')
prompt = f'{_SYSTEM_PROMPT}\n\n{kb_block}'
else:
prompt = f'{_SYSTEM_PROMPT[:idx]}{kb_block}\n\n{_SYSTEM_PROMPT[idx:]}'
logger.info('SUPPORT | KB | %d/%d entries injected (%d chars), prompt=%d chars',
used, len(entries), total, len(prompt))
return prompt
except Exception as exc:
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
return _SYSTEM_PROMPT
if not entries:
return _SYSTEM_PROMPT
kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries)
if len(kb_text) > _KB_MAX_CHARS:
kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)'
return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text
# ── Customer chat page ────────────────────────────────────────────────────────
@@ -109,13 +253,10 @@ def _system_prompt_with_kb():
@bp.route('/chat')
@login_required
def chat():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
return redirect(url_for('support.admin_tickets'))
cids = get_customer_scope(current_user) or []
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
facilities = _support_facilities(current_user)
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
session_id = request.args.get('session_id', type=int)
@@ -130,8 +271,9 @@ def chat():
if chat_session:
db_history = list(chat_session.messages)
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
return render_template('support/chat.html',
faqs=FAQS,
faqs=faqs,
facilities=facilities,
groq_ready=groq_ready,
chat_session=chat_session,
@@ -143,7 +285,7 @@ def chat():
@bp.route('/chat/message', methods=['POST'])
@login_required
def chat_message():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
return jsonify({'error': 'Forbidden'}), 403
api_key = os.environ.get('GROQ_API_KEY')
@@ -192,14 +334,14 @@ def chat_message():
from groq import Groq
client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
# 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': _redact_pii(m.content)})
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', _DEFAULT_GROQ_MODEL)
completion = client.chat.completions.create(
model=model,
messages=messages,
@@ -220,7 +362,18 @@ def chat_message():
return jsonify({'reply': reply, 'session_id': chat_session.id})
except Exception as exc:
logger.error('SUPPORT | Groq error: %s', exc)
# Always name the model — a bare "Groq error" gives whoever reads the
# log nothing to act on, and a retired model is the most likely cause
# of a total outage here.
_model = locals().get('model') or os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
if 'model_not_found' in str(exc) or 'does not exist' in str(exc):
logger.error(
'SUPPORT | Groq model %r is not available on this account — '
'the assistant is DOWN for every user. Set GROQ_MODEL to a '
'current model (see https://console.groq.com/docs/models). '
'Underlying error: %s', _model, exc)
else:
logger.error('SUPPORT | Groq error (model=%r): %s', _model, exc)
db.session.rollback()
return jsonify({'reply': (
"I ran into a problem reaching the AI assistant. "
@@ -233,7 +386,7 @@ def chat_message():
@bp.route('/my-conversations')
@login_required
def my_conversations():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
sessions = (SupportChatSession.query
.filter_by(customer_id=current_user.id)
@@ -245,7 +398,7 @@ def my_conversations():
@bp.route('/my-conversations/<int:session_id>')
@login_required
def my_conversation_detail(session_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
chat_session = db.session.get(SupportChatSession, session_id)
if chat_session is None or chat_session.customer_id != current_user.id:
@@ -261,7 +414,7 @@ def my_conversation_detail(session_id):
@bp.route('/tickets', methods=['POST'])
@login_required
def submit_ticket():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
subject = request.form.get('subject', '').strip()
@@ -272,8 +425,9 @@ def submit_ticket():
flash('Please fill in both subject and description.', 'warning')
return redirect(url_for('support.chat'))
# Validate facility belongs to this customer
cids = get_customer_scope(current_user) or []
# Validate the facility belongs to this user — by whichever assignment
# table their role is scoped through.
cids = [f.id for f in _support_facilities(current_user)]
if facility_id and facility_id not in cids:
facility_id = None
@@ -303,7 +457,7 @@ def submit_ticket():
@bp.route('/my-tickets')
@login_required
def my_tickets():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
tickets = (SupportTicket.query
@@ -318,7 +472,7 @@ def my_tickets():
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required
def my_ticket_detail(ticket_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
ticket = db.session.get(SupportTicket, ticket_id)
@@ -526,6 +680,28 @@ def admin_knowledge():
return render_template('support/admin_knowledge.html', entries=entries)
@bp.route('/admin/knowledge/preview')
@login_required
@supervisor_required
def admin_knowledge_preview():
"""Show the exact system prompt the chatbot receives, knowledge included.
Added after admin entries appeared to be ignored: without this there is no
way to tell "my entry never reached the prompt" from "the model saw it and
chose not to use it". Read-only, builds nothing of its own — it calls the
same _system_prompt_with_kb() the chat endpoint calls.
"""
prompt = _system_prompt_with_kb()
active_count = SupportKnowledge.query.filter_by(active=True).count()
total_count = SupportKnowledge.query.count()
return render_template('support/admin_knowledge_preview.html',
prompt=prompt,
active_count=active_count,
total_count=total_count,
kb_included='=== ADDITIONAL KNOWLEDGE' in prompt,
kb_cap=_KB_MAX_CHARS)
def _parse_sort_order(raw, fallback=0):
"""Coerce a submitted sort_order to a sane int.
+64 -11
View File
@@ -2,7 +2,8 @@ import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
from flask_login import login_required, current_user
from app import db
from app.models.inspection import InspectionTemplate, ChecklistItem
from app.models.inspection import InspectionTemplate, ChecklistItem, TemplateContract
from app.models.project import Project
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
from app.utils.decorators import supervisor_required
import json
@@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates')
logger = logging.getLogger(__name__)
def _populate_contract_choices(form):
"""Contract options for the "Available on contracts" multi-select.
Selecting none leaves the form SHARED (usable on every contract) that is
the default and what every template did before phase52. See
TemplateContract.
"""
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
form.contract_ids.choices = [(p.id, p.name) for p in contracts]
# ---------------------------------------------------------------------------
# Template CRUD
# ---------------------------------------------------------------------------
@@ -21,7 +33,12 @@ logger = logging.getLogger(__name__)
@login_required
def index():
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
return render_template('templates/list.html', templates=templates)
# Contract options for the Edit Template modal's "Available on contracts"
# picker (phase52) — this modal is the edit UI reached from the list, so it
# needs the same control the full editor has.
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
return render_template('templates/list.html',
templates=templates, contracts=contracts)
@bp.route('/new', methods=['GET', 'POST'])
@@ -29,6 +46,7 @@ def index():
@supervisor_required
def create_template():
form = InspectionTemplateForm()
_populate_contract_choices(form)
if form.validate_on_submit():
template = InspectionTemplate(
@@ -38,11 +56,15 @@ def create_template():
created_by=current_user.id
)
db.session.add(template)
db.session.flush() # need template.id before linking contracts
template.set_contracts(form.contract_ids.data)
db.session.commit()
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
current_user.username, template.id, template.name)
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name,
template.contract_ids or 'shared')
log_action(ACTION_CREATE, 'Template', template.id, template.name,
f'frequency={template.frequency}')
f'frequency={template.frequency}; '
f'contracts={template.contract_ids or "shared"}')
flash(f'Template "{template.name}" created successfully.', 'success')
return redirect(url_for('templates.form_editor', template_id=template.id))
@@ -72,16 +94,23 @@ def edit_template(template_id):
if template is None:
abort(404)
form = InspectionTemplateForm(obj=template)
_populate_contract_choices(form)
if request.method == 'GET':
# obj= cannot read the association rows; seed the multi-select from them.
form.contract_ids.data = template.contract_ids
if form.validate_on_submit():
template.name = form.name.data
template.description = form.description.data
template.frequency = form.frequency.data
template.set_contracts(form.contract_ids.data)
db.session.commit()
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
current_user.username, template.id, template.name)
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name,
template.contract_ids or 'shared')
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
f'frequency={template.frequency}')
f'frequency={template.frequency}; '
f'contracts={template.contract_ids or "shared"}')
flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.view_template', template_id=template.id))
@@ -120,11 +149,29 @@ def rename_template(template_id):
template.description = request.form.get('description', '').strip() or None
template.frequency = new_frequency
# phase52 — contract restrictions are edited from this modal too, since it
# is the Edit Template dialog people actually reach from the list. The
# hidden marker distinguishes "the form posted an empty selection" (make
# the template shared) from "the form has no contracts field at all", which
# must leave the existing restrictions untouched rather than silently
# sharing the template with every customer.
if request.form.get('contracts_present') == '1':
valid_pids = {
p.id for p in Project.query.filter_by(active=True).all()
}
posted = {
pid for pid in request.form.getlist('contract_ids', type=int)
if pid in valid_pids
}
template.set_contracts(posted)
db.session.commit()
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r',
current_user.username, template.id, template.name)
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name,
template.contract_ids or 'shared')
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
f'frequency={template.frequency}; via=rename')
f'frequency={template.frequency}; '
f'contracts={template.contract_ids or "shared"}; via=rename')
flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.index'))
@@ -188,6 +235,12 @@ def duplicate_template(template_id):
db.session.add(new_tpl)
db.session.flush() # get new_tpl.id before committing
# phase52 — carry the contract restrictions across. Duplicating a
# customer's bespoke form must not produce a copy that is silently shared
# with every other customer; copying a shared form still yields a shared
# one (no links to copy).
new_tpl.set_contracts(src.contract_ids)
# Duplicate all checklist items
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
new_item = ChecklistItem(