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