Aug 19 - Update code to catch up with ST
This commit is contained in:
+50
-41
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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) ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user