|
|
|
@@ -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'
|
|
|
|
|
)
|
|
|
|
@@ -258,12 +335,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
|
|
|
|
@@ -320,12 +394,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)
|
|
|
|
|
|
|
|
|
@@ -353,36 +424,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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -392,12 +505,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
|
|
|
|
@@ -466,18 +583,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()
|
|
|
|
|