1121 lines
46 KiB
Python
1121 lines
46 KiB
Python
"""
|
|
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-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
|
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
|
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
|
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
|
from app.utils.scope import get_customer_scope
|
|
|
|
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 — both customer roles."""
|
|
customers = (
|
|
User.query
|
|
.filter(User.role.in_(User.CUSTOMER_ROLES))
|
|
.order_by(User.username)
|
|
.all()
|
|
)
|
|
|
|
customer_ids = [c.id for c in customers]
|
|
|
|
# ── Single bulk query for all assignments ─────────────────────────────
|
|
# Replaces per-customer CustomerAssignment.query.filter_by(user_id=...) loop
|
|
all_assignments = (
|
|
CustomerAssignment.query
|
|
.filter(CustomerAssignment.user_id.in_(customer_ids))
|
|
.all()
|
|
) if customer_ids else []
|
|
|
|
assignment_map = {c.id: [] for c in customers}
|
|
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}
|
|
| {a.project_id for a in all_inspector_assignments}
|
|
)
|
|
|
|
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
|
if assigned_project_ids:
|
|
proj_facs = (
|
|
Facility.query
|
|
.filter(
|
|
Facility.project_id.in_(assigned_project_ids),
|
|
Facility.active == True,
|
|
)
|
|
.all()
|
|
)
|
|
for f in proj_facs:
|
|
project_facilities_map[f.project_id].append(f.id)
|
|
|
|
scope_map = {} # user_id → sorted list[int] facility IDs
|
|
for customer in customers:
|
|
ids = set()
|
|
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
|
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
|
|
# ── Expired pending-setup invitations ─────────────────────────────────
|
|
# Surface customer accounts whose invitation token has expired but
|
|
# password_set is still False — they need a fresh invite to log in.
|
|
from app.utils.time_utils import now_eastern
|
|
expired_invitations = [
|
|
c for c in customers
|
|
if not c.password_set
|
|
and c.set_password_token_expires is not None
|
|
and c.set_password_token_expires < now_eastern()
|
|
]
|
|
|
|
return render_template(
|
|
'customers/index.html',
|
|
customers = customers,
|
|
assignment_map = assignment_map,
|
|
inspector_assignment_map = inspector_assignment_map,
|
|
scope_map = scope_map,
|
|
projects = projects,
|
|
expired_invitations = expired_invitations,
|
|
)
|
|
|
|
|
|
# ── Create customer (invitation flow) ────────────────────────────────────────
|
|
|
|
@bp.route('/new', methods=['GET', 'POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def create():
|
|
"""Create a customer-side account via email invitation.
|
|
|
|
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()
|
|
|
|
if form.validate_on_submit():
|
|
import re, secrets
|
|
|
|
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
|
|
# they complete the set-password flow via the emailed link.
|
|
base_uname = re.sub(r'[^a-z0-9._-]', '', email.split('@')[0])[:40] or 'customer'
|
|
username = base_uname
|
|
suffix = 1
|
|
while User.query.filter_by(username=username).first():
|
|
username = f'{base_uname}{suffix}'
|
|
suffix += 1
|
|
|
|
# Create user with a random placeholder password (password_set=False
|
|
# blocks login until the customer completes the set-password flow).
|
|
user = User(
|
|
username = username,
|
|
full_name = full_name,
|
|
email = email,
|
|
role = role,
|
|
active = True,
|
|
password_set = False,
|
|
)
|
|
user.set_password(secrets.token_hex(32))
|
|
db.session.add(user)
|
|
db.session.flush()
|
|
|
|
token = user.generate_set_password_token(expires_hours=72)
|
|
db.session.commit()
|
|
|
|
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={user.role}; email={user.email}; invite_sent=True')
|
|
|
|
_send_invite_email(user, token, base_url=request.host_url)
|
|
|
|
flash(
|
|
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'
|
|
)
|
|
return redirect(url_for('customers.manage', customer_id=user.id))
|
|
|
|
return render_template('customers/invite.html', form=form)
|
|
|
|
|
|
def _send_invite_email(user, token, base_url=None):
|
|
"""Send the account setup email to a newly created customer."""
|
|
from flask import current_app, render_template_string
|
|
from flask_mail import Message
|
|
from app import mail
|
|
import threading
|
|
|
|
if not current_app.config.get('MAIL_SERVER'):
|
|
logger.warning('INVITE EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
|
|
return
|
|
|
|
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
|
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
|
|
|
|
# Per-domain branded From as a (display_name, address) tuple. The display
|
|
# NAME always tracks the host (e.g. "Gov Services QC"); the ADDRESS is
|
|
# branded only for DNS-authorized domains and otherwise stays the
|
|
# authenticated identity so the mail always delivers. See
|
|
# app/utils/mail_utils.py and CLAUDE.md rule 64.
|
|
from app.utils.mail_utils import branded_sender
|
|
sender = branded_sender(effective_base)
|
|
|
|
html_body = render_template_string("""<!DOCTYPE html>
|
|
<html>
|
|
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
|
<h2 style="color:#0d6efd;">Welcome to the Janitorial QC System</h2>
|
|
<p>Hi {{ name }},</p>
|
|
<p>An account has been created for you on the Janitorial QC (JQC) portal.
|
|
To get started, please set your password using the button below.</p>
|
|
<p>
|
|
<a href="{{ link }}"
|
|
style="background:#0d6efd;color:#fff;padding:12px 24px;
|
|
text-decoration:none;border-radius:4px;display:inline-block;font-weight:bold;">
|
|
Set My Password
|
|
</a>
|
|
</p>
|
|
<p style="font-size:13px;color:#666;">
|
|
This link expires in <strong>72 hours</strong>. If you did not expect this email,
|
|
you can safely ignore it.
|
|
</p>
|
|
<p style="font-size:13px;color:#888;">
|
|
Or copy this URL:<br>
|
|
<a href="{{ link }}" style="color:#0d6efd;">{{ link }}</a>
|
|
</p>
|
|
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
|
<p style="font-size:12px;color:#888;">Janitorial QC System — do not reply.</p>
|
|
</body>
|
|
</html>""", name=user.display_name, link=setup_link)
|
|
|
|
text_body = (
|
|
f'Hi {user.display_name},\n\n'
|
|
f'An account has been created for you on the Janitorial QC portal.\n'
|
|
f'Set your password here:\n\n{setup_link}\n\n'
|
|
f'This link expires in 72 hours.\n\nJanitorial QC System'
|
|
)
|
|
|
|
msg = Message(
|
|
subject = '[JQC] Your account is ready — please set your password',
|
|
sender = sender,
|
|
recipients = [user.email],
|
|
body = text_body,
|
|
html = html_body,
|
|
)
|
|
|
|
app = current_app._get_current_object()
|
|
|
|
def _send():
|
|
with app.app_context():
|
|
try:
|
|
mail.send(msg)
|
|
logger.info('INVITE EMAIL SENT | to=%s | user=%s', user.email, user.username)
|
|
except Exception as exc:
|
|
logger.error('INVITE EMAIL FAILED | to=%s | error=%s', user.email, exc)
|
|
|
|
threading.Thread(target=_send, daemon=True).start()
|
|
|
|
|
|
# ── Resend invitation email ───────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def resend_invite(customer_id):
|
|
"""Generate a fresh token and resend the set-password invitation email."""
|
|
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
|
|
db.session.commit()
|
|
|
|
logger.info('CUSTOMERS | resend_invite | admin=%s customer=%s',
|
|
current_user.username, customer.username)
|
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
|
f'invite resent by {current_user.username}')
|
|
|
|
_send_invite_email(customer, token, base_url=request.host_url)
|
|
flash(f'Invitation email resent to {customer.email}.', 'success')
|
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
|
|
|
|
|
# ── Public: set password via token ────────────────────────────────────────────
|
|
|
|
@bp.route('/set-password/<token>', methods=['GET', 'POST'])
|
|
def set_password(token):
|
|
"""Public page — customer sets their password via the emailed link."""
|
|
from app.utils.forms import SetPasswordForm
|
|
user = User.verify_set_password_token(token)
|
|
if user is None:
|
|
flash(
|
|
'This password setup link is invalid or has expired. '
|
|
'Please contact your administrator to resend the invitation.',
|
|
'danger'
|
|
)
|
|
return redirect(url_for('auth.login'))
|
|
|
|
form = SetPasswordForm()
|
|
if form.validate_on_submit():
|
|
user.username = form.username.data.strip()
|
|
user.password_set = True
|
|
user.set_password(form.password.data)
|
|
user.clear_set_password_token()
|
|
db.session.commit()
|
|
|
|
logger.info('CUSTOMERS | password_set | user=%s', user.username)
|
|
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
|
'customer chose username and password via invite link')
|
|
|
|
flash('Your account is ready. You can now log in.', 'success')
|
|
return redirect(url_for('auth.login'))
|
|
|
|
return render_template('customers/set_password.html', form=form, user=user)
|
|
|
|
|
|
# ── Edit customer ─────────────────────────────────────────────────────────────
|
|
|
|
# ── Edit customer ─────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def edit(customer_id):
|
|
customer, moved = _get_customer_or_redirect(customer_id)
|
|
if moved:
|
|
return moved
|
|
|
|
form = CustomerUserForm(user=customer, obj=customer)
|
|
|
|
if form.validate_on_submit():
|
|
customer.username = form.username.data
|
|
customer.full_name = form.full_name.data.strip() or None
|
|
customer.email = form.email.data.strip().lower()
|
|
if form.password.data:
|
|
customer.set_password(form.password.data)
|
|
db.session.commit()
|
|
logger.info('CUSTOMERS | edit | admin=%s customer_id=%s username=%s',
|
|
current_user.username, customer.id, customer.username)
|
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
|
f'email={customer.email}; updated_via=customer_mgmt')
|
|
flash(f'Customer "{customer.username}" updated successfully.', 'success')
|
|
return redirect(url_for('customers.manage', customer_id=customer.id))
|
|
|
|
return render_template('customers/form.html', form=form, customer=customer,
|
|
title='Edit Customer Account')
|
|
|
|
|
|
# ── Customer detail / assignment management ───────────────────────────────────
|
|
|
|
@bp.route('/<int:customer_id>')
|
|
@login_required
|
|
@supervisor_required
|
|
def manage(customer_id):
|
|
"""Single-account detail page: profile + assignments + notification matrix.
|
|
|
|
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 []
|
|
|
|
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,
|
|
assigned_pids = assigned_pids,
|
|
facilities = facilities,
|
|
aform = aform,
|
|
projects = projects,
|
|
matrix_rows = matrix_rows,
|
|
)
|
|
|
|
|
|
# ── Add assignment (from customer detail page) ────────────────────────────────
|
|
|
|
@bp.route('/<int:customer_id>/assignments/add', methods=['POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def add_assignment(customer_id):
|
|
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
|
|
|
|
if not project_id:
|
|
flash('Please select a contract.', 'warning')
|
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
|
|
|
project = db.session.get(Project, project_id)
|
|
if project is None:
|
|
abort(404)
|
|
|
|
# Guard: duplicate assignment
|
|
existing = CustomerAssignment.query.filter_by(
|
|
user_id = customer_id,
|
|
project_id = project_id,
|
|
facility_id = facility_id,
|
|
).first()
|
|
|
|
if existing:
|
|
flash('That assignment already exists.', 'warning')
|
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
|
|
|
assignment = CustomerAssignment(
|
|
user_id = customer_id,
|
|
project_id = project_id,
|
|
facility_id = facility_id,
|
|
)
|
|
db.session.add(assignment)
|
|
db.session.commit()
|
|
|
|
scope_label = f'facility_id={facility_id}' if facility_id else 'all facilities'
|
|
logger.info('CUSTOMERS | assignment_add | admin=%s customer=%s project_id=%s scope=%s',
|
|
current_user.username, customer.username, project_id, scope_label)
|
|
log_action(ACTION_CREATE, 'CustomerAssignment', assignment.id,
|
|
f'{customer.username} → {project.name}',
|
|
f'scope={scope_label}')
|
|
flash(f'Assignment added: "{customer.username}" → "{project.name}".', 'success')
|
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
|
|
|
|
|
# ── Remove assignment ─────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/assignments/<int:assignment_id>/remove', methods=['POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def remove_assignment(assignment_id):
|
|
assignment = db.session.get(CustomerAssignment, assignment_id)
|
|
if assignment is None:
|
|
abort(404)
|
|
customer_id = assignment.user_id
|
|
customer = db.session.get(User, customer_id)
|
|
project = db.session.get(Project, assignment.project_id)
|
|
|
|
username = customer.username if customer else f'user_id={customer_id}'
|
|
project_name = project.name if project else f'project_id={assignment.project_id}'
|
|
snap_id = assignment.id
|
|
|
|
db.session.delete(assignment)
|
|
db.session.commit()
|
|
logger.info('CUSTOMERS | assignment_remove | admin=%s customer=%s project=%s',
|
|
current_user.username, username, project_name)
|
|
log_action(ACTION_DELETE, 'CustomerAssignment', snap_id,
|
|
f'{username} → {project_name}')
|
|
flash(f'Assignment removed for "{username}".', 'success')
|
|
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, moved = _get_customer_or_redirect(customer_id)
|
|
if moved:
|
|
return moved
|
|
|
|
customer.active = not customer.active
|
|
db.session.commit()
|
|
|
|
label = 'enabled' if customer.active else 'disabled'
|
|
logger.info('CUSTOMERS | toggle_active | admin=%s customer=%s action=%s',
|
|
current_user.username, customer.username, label)
|
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
|
f'account {label} via customer_mgmt by {current_user.username}')
|
|
flash(f'Customer "{customer.username}" has been {label}.', 'success')
|
|
return redirect(safe_redirect_url(request.referrer, fallback=url_for('customers.index')))
|
|
|
|
|
|
# ── AJAX: facilities for a project (used by add-assignment form) ──────────────
|
|
|
|
|
|
# ── CSV template download ─────────────────────────────────────────────────────
|
|
|
|
@bp.route('/import/template')
|
|
@login_required
|
|
@supervisor_required
|
|
def import_template():
|
|
"""Download a blank CSV template showing the expected import format."""
|
|
import csv, io
|
|
from flask import Response
|
|
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
writer.writerow([
|
|
'username', 'email', 'password',
|
|
'project_name', 'facility_name',
|
|
])
|
|
writer.writerow([
|
|
'jane.smith', 'jane@acme.com', 'SecurePass1!',
|
|
'Acme Contract', 'Downtown Office',
|
|
])
|
|
writer.writerow([
|
|
'bob.jones', 'bob@acme.com', 'SecurePass2!',
|
|
'Acme Contract', '',
|
|
])
|
|
buf.seek(0)
|
|
return Response(
|
|
buf.getvalue(),
|
|
mimetype='text/csv',
|
|
headers={'Content-Disposition': 'attachment; filename="customer_import_template.csv"'},
|
|
)
|
|
|
|
|
|
# ── Bulk import (upload → preview → confirm) ──────────────────────────────────
|
|
|
|
@bp.route('/import', methods=['GET', 'POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def bulk_import():
|
|
"""Two-phase CSV import for customer accounts.
|
|
|
|
Phase 1 (GET / POST with file):
|
|
Parse and validate the CSV, return a preview of what will be created.
|
|
No database writes occur here.
|
|
|
|
Phase 2 (POST with confirmed=1):
|
|
Write all validated rows to the database.
|
|
|
|
CSV columns
|
|
-----------
|
|
username : required — must be unique across users
|
|
email : required — must be unique across users
|
|
password : required — min 8 characters
|
|
project_name : optional — must match an existing active Project name exactly
|
|
facility_name : optional — if given, must match an active Facility within the project
|
|
|
|
One row = one user. A user may have at most one assignment per import row;
|
|
import the same username on multiple rows to assign them to multiple projects.
|
|
Duplicate username rows after the first are treated as additional assignments.
|
|
"""
|
|
import csv, io
|
|
from flask import session as _session
|
|
|
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
proj_by_name = {p.name.strip().lower(): p for p in projects}
|
|
|
|
# ── Phase 2: commit confirmed rows ────────────────────────────────────
|
|
if request.method == 'POST' and request.form.get('confirmed') == '1':
|
|
import json
|
|
rows_json = request.form.get('rows_json', '[]')
|
|
try:
|
|
rows = json.loads(rows_json)
|
|
except Exception:
|
|
flash('Import session expired. Please re-upload the file.', 'danger')
|
|
return redirect(url_for('customers.bulk_import'))
|
|
|
|
created_users = 0
|
|
created_assign = 0
|
|
skipped = 0
|
|
|
|
# Track users created in this batch (username → User) so duplicate
|
|
# rows for the same username add assignments rather than re-creating.
|
|
batch_users = {}
|
|
|
|
for row in rows:
|
|
uname = row['username']
|
|
email = row['email']
|
|
pw = row['password']
|
|
proj_id = row.get('project_id')
|
|
fac_id = row.get('facility_id')
|
|
|
|
# Get or create user
|
|
user = (
|
|
batch_users.get(uname)
|
|
or User.query.filter_by(username=uname).first()
|
|
)
|
|
|
|
if user is None:
|
|
user = User(
|
|
username = uname,
|
|
email = email,
|
|
role = 'customer',
|
|
active = True,
|
|
)
|
|
user.set_password(pw)
|
|
db.session.add(user)
|
|
db.session.flush() # populate user.id before assignment
|
|
batch_users[uname] = user
|
|
created_users += 1
|
|
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
|
f'role=customer; email={email}; source=bulk_import')
|
|
logger.info('BULK IMPORT | user_created | username=%s email=%s by=%s',
|
|
uname, email, current_user.username)
|
|
|
|
# Create assignment if a project was specified
|
|
if proj_id:
|
|
existing = CustomerAssignment.query.filter_by(
|
|
user_id = user.id,
|
|
project_id = proj_id,
|
|
facility_id = fac_id or None,
|
|
).first()
|
|
if not existing:
|
|
assign = CustomerAssignment(
|
|
user_id = user.id,
|
|
project_id = proj_id,
|
|
facility_id = fac_id or None,
|
|
)
|
|
db.session.add(assign)
|
|
db.session.flush() # populate assign.id before audit log
|
|
created_assign += 1
|
|
log_action(ACTION_CREATE, 'CustomerAssignment', assign.id,
|
|
f'{uname} → project_id={proj_id}',
|
|
f'facility_id={fac_id}; source=bulk_import')
|
|
else:
|
|
skipped += 1
|
|
|
|
db.session.commit()
|
|
logger.info(
|
|
'BULK IMPORT COMMITTED | by=%s | users=%s | assignments=%s | skipped=%s',
|
|
current_user.username, created_users, created_assign, skipped,
|
|
)
|
|
flash(
|
|
f'Import complete: {created_users} user(s) created, '
|
|
f'{created_assign} assignment(s) added'
|
|
+ (f', {skipped} duplicate assignment(s) skipped.' if skipped else '.'),
|
|
'success',
|
|
)
|
|
return redirect(url_for('customers.index'))
|
|
|
|
# ── Phase 1: parse and validate ───────────────────────────────────────
|
|
preview_rows = []
|
|
errors = []
|
|
raw_valid_rows = [] # serialisable dicts passed to phase 2 via hidden field
|
|
|
|
if request.method == 'POST':
|
|
file = request.files.get('csv_file')
|
|
|
|
if not file or not file.filename:
|
|
flash('Please select a CSV file to upload.', 'warning')
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
if not file.filename.lower().endswith('.csv'):
|
|
flash('Only .csv files are accepted.', 'danger')
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
try:
|
|
stream = io.StringIO(file.stream.read().decode('utf-8-sig'))
|
|
reader = csv.DictReader(stream)
|
|
raw_rows = list(reader)
|
|
except Exception as exc:
|
|
flash(f'Could not parse file: {exc}', 'danger')
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
required_cols = {'username', 'email', 'password'}
|
|
if not required_cols.issubset(set(reader.fieldnames or [])):
|
|
flash(
|
|
f'CSV is missing required columns: {required_cols - set(reader.fieldnames or [])}. '
|
|
'Download the template to see the expected format.',
|
|
'danger',
|
|
)
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
# Track usernames seen in this file to catch intra-file duplicates
|
|
seen_usernames = {} # username → first row index (1-based)
|
|
seen_emails = {}
|
|
|
|
for i, raw in enumerate(raw_rows, start=2): # row 1 = header
|
|
row_errors = []
|
|
|
|
uname = (raw.get('username') or '').strip()
|
|
email = (raw.get('email') or '').strip()
|
|
pw = (raw.get('password') or '').strip()
|
|
pname = (raw.get('project_name') or '').strip()
|
|
fname = (raw.get('facility_name') or '').strip()
|
|
|
|
if not uname:
|
|
row_errors.append('username is required')
|
|
if not email:
|
|
row_errors.append('email is required')
|
|
if not pw:
|
|
row_errors.append('password is required')
|
|
elif len(pw) < 8:
|
|
row_errors.append('password must be at least 8 characters')
|
|
|
|
# Duplicate username within file (first occurrence creates the user;
|
|
# subsequent occurrences add assignments — that's intentional)
|
|
if uname:
|
|
if uname in seen_usernames:
|
|
# Allowed only if it's an additional assignment row
|
|
pass
|
|
else:
|
|
seen_usernames[uname] = i
|
|
# Check DB uniqueness only for new usernames
|
|
if User.query.filter_by(username=uname).first():
|
|
row_errors.append(f'username "{uname}" already exists in the system')
|
|
|
|
if email:
|
|
if email in seen_emails:
|
|
row_errors.append(f'email "{email}" appears more than once in this file')
|
|
else:
|
|
seen_emails[email] = i
|
|
if User.query.filter_by(email=email).first():
|
|
row_errors.append(f'email "{email}" already exists in the system')
|
|
|
|
# Resolve project
|
|
project = None
|
|
facility = None
|
|
proj_id = None
|
|
fac_id = None
|
|
|
|
if pname:
|
|
project = proj_by_name.get(pname.lower())
|
|
if project is None:
|
|
row_errors.append(f'contract "{pname}" not found or inactive')
|
|
else:
|
|
proj_id = project.id
|
|
if fname:
|
|
from app.models.facility import Facility
|
|
facility = Facility.query.filter(
|
|
Facility.project_id == project.id,
|
|
Facility.active == True,
|
|
db.func.lower(Facility.name) == fname.lower(),
|
|
).first()
|
|
if facility is None:
|
|
row_errors.append(
|
|
f'facility "{fname}" not found in contract "{pname}"'
|
|
)
|
|
else:
|
|
fac_id = facility.id
|
|
elif fname:
|
|
row_errors.append('facility_name requires project_name to also be set')
|
|
|
|
status = 'error' if row_errors else 'ok'
|
|
preview_rows.append({
|
|
'row': i,
|
|
'username': uname,
|
|
'email': email,
|
|
'project': project.name if project else '—',
|
|
'facility': facility.name if facility else ('All' if project else '—'),
|
|
'status': status,
|
|
'errors': row_errors,
|
|
})
|
|
|
|
if not row_errors:
|
|
raw_valid_rows.append({
|
|
'username': uname,
|
|
'email': email,
|
|
'password': pw,
|
|
'project_id': proj_id,
|
|
'facility_id': fac_id,
|
|
})
|
|
else:
|
|
errors.extend(row_errors)
|
|
|
|
import json
|
|
return render_template(
|
|
'customers/import.html',
|
|
projects = projects,
|
|
preview_rows = preview_rows,
|
|
has_errors = bool(errors),
|
|
valid_count = len(raw_valid_rows),
|
|
rows_json = json.dumps(raw_valid_rows),
|
|
)
|
|
|
|
@bp.route('/facilities-for-project/<int:project_id>')
|
|
@login_required
|
|
@supervisor_required
|
|
def facilities_for_project(project_id):
|
|
from flask import jsonify
|
|
project = db.session.get(Project, project_id)
|
|
if project is None:
|
|
abort(404)
|
|
facilities = project.facilities.filter_by(active=True).order_by(Facility.name).all()
|
|
return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) |