Aug 19 - Update code to catch up with ST

This commit is contained in:
2026-08-19 14:05:18 -04:00
parent c9984e7ae6
commit 12141c2f75
52 changed files with 3321 additions and 342 deletions
+23
View File
@@ -28,6 +28,29 @@ def safe_redirect_url(url: str | None, fallback: str | None = None) -> str:
return fallback
return url
def return_url(fallback: str) -> str:
"""Where to go back to after a list-page action, preserving its filters.
Reads the `next` value the page carried through the action — POST body
first (forms), then query string (links) — and validates it with
safe_redirect_url, so a crafted `next` can never redirect off-site.
The problem this solves: a delete or an edit launched from a filtered list
used to redirect to the bare index, throwing away the filters the user had
set. Every list-page action now round-trips the list URL instead.
`next` is deliberately the FULL list URL (page number and all), not a
reconstructed set of arguments — that keeps this helper working when a new
filter is added to either list page without anyone having to remember to
thread it through here.
"""
from flask import request
return safe_redirect_url(
request.form.get('next') or request.args.get('next'),
fallback=fallback,
)
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
+23 -10
View File
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField,
RadioField)
RadioField, SelectMultipleField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError)
import re as _re
@@ -91,13 +91,13 @@ class UserForm(FlaskForm):
('admin', 'Administrator'),
('director', 'Director'),
('inspector', 'Inspector'),
# MT-15 — an inspector employed by the customer or a third party.
# Same capabilities as 'inspector'; scoped to the contracts assigned on
# the Assign Contracts page (see User.INSPECTOR_ROLES).
('external_inspector', 'External Inspector'),
('project_manager', 'Project Manager'),
('auditor', 'Auditor'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers
# Both customer-side roles are intentionally excluded — 'customer'
# (Customer Director) and 'external_inspector' (Customer Inspector) are
# created, edited and switched exclusively in Customer Management
# (/customers). phase51 removed 'external_inspector' from here; see
# User.CUSTOMER_ROLES.
], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is
# hidden in user_form.html for them). Role enforcement is handled in the
@@ -164,6 +164,11 @@ class InspectionTemplateForm(FlaskForm):
('daily','Daily'), ('weekly','Weekly'),
('monthly','Monthly'), ('quarterly','Quarterly'),
], validators=[DataRequired()])
# phase52 — which contracts may use this form. Choices are populated in the
# route. Selecting NONE leaves the form shared with every contract, which
# is the default and what every pre-phase52 template does.
contract_ids = SelectMultipleField('Available on contracts', coerce=int,
validators=[Optional()])
class ChecklistItemForm(FlaskForm):
@@ -308,14 +313,22 @@ class CustomerUserForm(FlaskForm):
raise ValidationError('Password is required for new accounts.')
class CustomerInviteForm(FlaskForm):
"""Simplified form for creating a customer account via email invitation.
"""Create a customer-side account via email invitation.
Admin enters Full Name and Email only. A username is auto-generated
from the email address. The customer sets their own username and
password via the emailed link.
Admin enters Full Name, Email and which of the two customer roles the
person holds. A username is auto-generated from the email address; the
invitee sets their own username and password via the emailed link.
Both roles use the SAME invitation flow — neither is an account we set a
password for. phase51 folded the Customer Inspector (stored as
'external_inspector') in here from User Management.
"""
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
role = SelectField('Role', choices=[
('customer', 'Customer Director — portal access for their facilities'),
('external_inspector', 'Customer Inspector — performs inspections on their contracts'),
], default='customer', validators=[DataRequired()])
def validate_email(self, field):
if User.query.filter_by(email=field.data.strip().lower()).first():
+70 -1
View File
@@ -214,6 +214,28 @@ def notify(
in-app Notification row is unchanged, so a recipient reading
it in the bell menu simply follows `link` as before.
"""
# ── Per-account override (phase51) ───────────────────────────────────
# A customer-side account's own notification matrix governs EVERY path
# that reaches it, not just matrix broadcasts: follower fan-out
# (_notify_followers) and direct assignee notifications both call notify()
# straight, so without this the editor would offer rows — "Issue follow
# update", "Issue assigned" — that appeared to be off while the
# notifications kept arriving.
#
# Only an explicit `False` suppresses. No row means inherit, which is the
# default for every account and leaves behaviour exactly as before. The
# getattr fallback is deliberate: if the attribute is unavailable for any
# reason we send, never silently drop.
if event_type and getattr(recipient, 'is_customer_account', False):
from app.models.user_notification_matrix import override_for
if override_for(recipient.id, event_type) is False:
logger.info(
'NOTIFICATION SUPPRESSED | user=%s | event=%s | '
'reason=per_account_override_off',
recipient.username, event_type,
)
return
# Determine digest flag before creating the record.
# Digest mode is only respected when individual preferences are in effect.
hold_for_digest = (
@@ -342,6 +364,7 @@ def notify_customers_for_facility(
link: str = None,
issue_id: int = None,
inspection_id: int = None,
allowed_user_ids: set = None,
):
"""Dispatch in-app + email notifications to all customer users assigned
to the given facility.
@@ -362,6 +385,12 @@ def notify_customers_for_facility(
link : Relative URL for 'View Details'.
issue_id : FK to issues.id (optional).
inspection_id : FK to inspections.id (optional).
allowed_user_ids :
Optional whitelist. When notify_by_matrix() calls this it has already
applied each account's per-event override (phase51), so it passes the
surviving ids here — this function re-derives recipients from the
assignment rows and would otherwise notify accounts that opted out.
None (the default, used by direct callers) means no filtering.
"""
try:
from app.models.project import CustomerAssignment
@@ -399,8 +428,20 @@ def notify_customers_for_facility(
)
return
if allowed_user_ids is not None:
notified_user_ids &= set(allowed_user_ids)
if not notified_user_ids:
logger.debug(
'notify_customers_for_facility | facility_id=%s | all '
'assigned customers filtered out by per-account overrides',
facility_id,
)
return
for user_id in notified_user_ids:
user = db.session.get(User, user_id)
# role != 'customer' stays an EQUALITY check: a Customer Inspector
# is not a portal customer and is routed by the inspector column.
if not user or not user.active or user.role != 'customer':
continue
try:
@@ -562,11 +603,20 @@ def notify_by_matrix(
from app.models.notification_matrix import (
is_enabled, get_custom_emails_for, MATRIX_ROLES,
)
from app.models.user_notification_matrix import overrides_for_event
from app.models.user import User
exclude = set(exclude_user_ids or [])
notified = set() # deduplicate across roles
# ── Per-account overrides (phase51) ───────────────────────────────────
# {user_id: bool} for this event, one query. Applies to the two
# customer-side role columns only; staff roles use the global matrix alone.
# An account with no entry inherits the global column, which is why this
# feature is a no-op until an admin actually sets something.
overrides = overrides_for_event(event_type)
customer_keys = User.CUSTOMER_ROLES # ('customer', 'external_inspector')
role_to_db = {
'admin': 'admin',
'director': 'director',
@@ -585,7 +635,14 @@ def notify_by_matrix(
enabled = is_enabled(event_type, role_key)
logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s',
event_type, role_key, enabled)
if not enabled:
# A customer-side column must NOT be skipped just because the global
# switch is off — an account that opted IN individually still has to be
# reached. Only skip when the column is off AND nobody opted in.
# (Getting this wrong is silent: the per-account "on" would save fine,
# show as on, and never send.)
is_customer_col = role_key in customer_keys
if not enabled and not (is_customer_col and any(overrides.values())):
continue
db_role = role_to_db.get(role_key)
@@ -615,6 +672,15 @@ def notify_by_matrix(
'submitting inspector_id=%s',
event_type, role_key, target_id)
# Apply the per-account overrides to the customer-side columns. An
# account with no override falls back to `enabled`, i.e. the global
# column — so this line is what makes both directions work: opt-in
# against an off column, and opt-out of an on one.
if is_customer_col:
users = [u for u in users if overrides.get(u.id, enabled)]
logger.info('MATRIX NOTIFY | event=%s | role=%s | after overrides=%s',
event_type, role_key, [u.username for u in users])
# Scope customer role to facility if provided
if role_key == 'customer' and facility_id:
from app.utils.notifications import notify_customers_for_facility
@@ -626,6 +692,9 @@ def notify_by_matrix(
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
# Without this the facility-scoped path would re-query customers
# itself and bypass every override applied just above.
allowed_user_ids = {u.id for u in users},
)
continue # notify_customers_for_facility handles dedup internally