Aug 7 - Update: add external inspector

This commit is contained in:
2026-08-07 16:08:10 -04:00
parent 97c1dec54d
commit 6ca30c0dea
28 changed files with 727 additions and 96 deletions
+3 -2
View File
@@ -29,7 +29,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_comments', __name__) bp = Blueprint('api_comments', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _comment_payload(comment: IssueComment) -> dict: def _comment_payload(comment: IssueComment) -> dict:
@@ -47,7 +48,7 @@ def _comment_payload(comment: IssueComment) -> dict:
def _check_issue_access(issue: Issue, user) -> bool: def _check_issue_access(issue: Issue, user) -> bool:
"""Return True if user may read/write this issue. False = 403.""" """Return True if user may read/write this issue. False = 403."""
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
+5 -4
View File
@@ -35,7 +35,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_inspections', __name__) bp = Blueprint('api_inspections', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _merge_form_data(existing: dict, incoming: dict) -> dict: def _merge_form_data(existing: dict, incoming: dict) -> dict:
@@ -135,7 +136,7 @@ def _resolve_schedule(schedule_id, user):
logger.warning('API INSPECTIONS | unknown schedule id=%s from user=%s ' logger.warning('API INSPECTIONS | unknown schedule id=%s from user=%s '
'— submitting unlinked', schedule_id, user.username) '— submitting unlinked', schedule_id, user.username)
return None return None
if user.role == 'inspector' and sched.inspector_id != user.id: if user.is_inspector and sched.inspector_id != user.id:
logger.warning('API INSPECTIONS | schedule id=%s not assigned to user=%s ' logger.warning('API INSPECTIONS | schedule id=%s not assigned to user=%s '
'— submitting unlinked', schedule_id, user.username) '— submitting unlinked', schedule_id, user.username)
return None return None
@@ -282,7 +283,7 @@ def list_inspections():
query = Inspection.query query = Inspection.query
# Inspectors only see their own inspections # Inspectors only see their own inspections
if user.role == 'inspector': if user.is_inspector:
query = query.filter(Inspection.inspector_id == user.id) query = query.filter(Inspection.inspector_id == user.id)
# Optional filters # Optional filters
@@ -613,7 +614,7 @@ def update_inspection(inspection_id):
if inspection is None: if inspection is None:
return api_error('Inspection not found', 404) return api_error('Inspection not found', 404)
if user.role == 'inspector' and inspection.inspector_id != user.id: if user.is_inspector and inspection.inspector_id != user.id:
return api_error('Access denied', 403) return api_error('Access denied', 403)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
+9 -8
View File
@@ -42,7 +42,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_issues', __name__) bp = Blueprint('api_issues', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
_VALID_HANDLERS = {'internal', 'facility', 'vendor'} _VALID_HANDLERS = {'internal', 'facility', 'vendor'}
@@ -156,7 +157,7 @@ def list_issues():
query = Issue.query query = Issue.query
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
if not fids: if not fids:
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset}) return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
@@ -250,7 +251,7 @@ def create_issue():
if facility is None: if facility is None:
return api_error('Facility not found', 404) return api_error('Facility not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
if not fids or facility_id not in fids: if not fids or facility_id not in fids:
return api_error('Access denied — facility is not in your assigned contracts', 403) return api_error('Access denied — facility is not in your assigned contracts', 403)
@@ -339,7 +340,7 @@ def get_issue(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -372,7 +373,7 @@ def update_issue_status(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -437,7 +438,7 @@ def update_issue_photos(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -498,7 +499,7 @@ def update_issue_result_photos(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -575,7 +576,7 @@ def update_issue_handler(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
+2 -1
View File
@@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_photos', __name__) bp = Blueprint('api_photos', __name__)
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} _ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _allowed_file(filename: str) -> bool: def _allowed_file(filename: str) -> bool:
+6 -4
View File
@@ -40,7 +40,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_scheduled', __name__) bp = Blueprint('api_scheduled', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _scheduled_payload(s): def _scheduled_payload(s):
@@ -127,7 +128,7 @@ def list_scheduled():
InspectionSchedule.mode == 'plan', InspectionSchedule.mode == 'plan',
) )
if user.role == 'inspector': if user.is_inspector:
# Inspectors only see schedules assigned directly to them. # Inspectors only see schedules assigned directly to them.
query = query.filter(InspectionSchedule.inspector_id == user.id) query = query.filter(InspectionSchedule.inspector_id == user.id)
@@ -185,7 +186,8 @@ def create_follow_up():
user = g.api_user user = g.api_user
# Auditor is read-only everywhere else; keep it that way here. # Auditor is read-only everywhere else; keep it that way here.
if user.role not in {'admin', 'director', 'inspector', 'project_manager'}: if user.role not in {'admin', 'director', 'inspector', 'external_inspector',
'project_manager'}:
return api_error('Access denied', 403) return api_error('Access denied', 403)
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
@@ -201,7 +203,7 @@ def create_follow_up():
# An inspector may only schedule a follow-up of their own work, and only # An inspector may only schedule a follow-up of their own work, and only
# within their assigned contracts — the same two gates the rest of the # within their assigned contracts — the same two gates the rest of the
# mobile API applies. Managers are unrestricted, matching the web. # mobile API applies. Managers are unrestricted, matching the web.
if user.role == 'inspector': if user.is_inspector:
if parent.inspector_id != user.id: if parent.inspector_id != user.id:
return api_error('Access denied', 403) return api_error('Access denied', 403)
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
+3 -2
View File
@@ -40,7 +40,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_stats', __name__) bp = Blueprint('api_stats', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
@bp.route('/stats/dashboard', methods=['GET']) @bp.route('/stats/dashboard', methods=['GET'])
@@ -74,7 +75,7 @@ def dashboard_stats():
today_end = today_start + timedelta(days=1) today_end = today_start + timedelta(days=1)
thirty_days_ago = now - timedelta(days=30) thirty_days_ago = now - timedelta(days=30)
is_inspector = user.role == 'inspector' is_inspector = user.is_inspector
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
# ── Today's inspections ─────────────────────────────────────────────── # ── Today's inspections ───────────────────────────────────────────────
+2 -1
View File
@@ -27,7 +27,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_templates', __name__) bp = Blueprint('api_templates', __name__)
# Customer role cannot access template data — inspectors and above only # Customer role cannot access template data — inspectors and above only
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _template_summary_payload(template: InspectionTemplate) -> dict: def _template_summary_payload(template: InspectionTemplate) -> dict:
+21
View File
@@ -10,6 +10,14 @@ role_key values
admin — all users with role='admin' admin — all users with role='admin'
director — all users with role='director' director — all users with role='director'
inspector — all users with role='inspector' inspector — all users with role='inspector'
EXCEPTION: for event 'inspection_completed', the inspector
column notifies ONLY the inspection's own inspector
(the submitter), not the whole inspector pool. Scoping is
applied in notify_by_matrix() via the inspection_id.
external_inspector — all users with role='external_inspector' (customer /
third-party inspectors). Separate column so third parties
can be routed differently from the tenant's own crew; the
'inspection_completed' scoping above applies here too.
project_manager — all users with role='project_manager' project_manager — all users with role='project_manager'
customer — all customer-portal users assigned to the relevant facility customer — all customer-portal users assigned to the relevant facility
assignee — the specific user the issue/inspection is assigned to assignee — the specific user the issue/inspection is assigned to
@@ -40,6 +48,7 @@ MATRIX_ROLES = [
('admin', 'Admin'), ('admin', 'Admin'),
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('external_inspector', 'External Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'), ('auditor', 'Auditor'),
('customer', 'Customer'), ('customer', 'Customer'),
@@ -171,6 +180,18 @@ MATRIX_DEFAULTS = {
('score_alert', 'custom'): False, ('score_alert', 'custom'): False,
} }
# MT-15 — the External Inspector column defaults to whatever the internal
# Inspector column defaults to, for every event. Mirroring rather than listing
# 14 more literals means a future event added for 'inspector' automatically
# gets a matching external default instead of silently falling back to the
# is_enabled() fallback. Admins can diverge the two columns in the UI at any
# time; this only seeds rows that do not exist yet.
MATRIX_DEFAULTS.update({
(_event, 'external_inspector'): _enabled
for (_event, _role), _enabled in list(MATRIX_DEFAULTS.items())
if _role == 'inspector'
})
class NotificationMatrix(db.Model): class NotificationMatrix(db.Model):
"""Admin-controlled per-event notification routing.""" """Admin-controlled per-event notification routing."""
+51 -1
View File
@@ -3,6 +3,20 @@ from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
# Display labels for the role ENUM. 'external_inspector' would otherwise title
# case to "External Inspector" anyway, but the map keeps every label in one
# place for templates that show a role name.
ROLE_LABELS = {
'admin': 'Admin',
'director': 'Director',
'project_manager': 'Project Manager',
'auditor': 'Auditor',
'inspector': 'Inspector',
'external_inspector': 'External Inspector',
'customer': 'Customer',
}
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
from app import db from app import db
@@ -11,6 +25,19 @@ def load_user(user_id):
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
__tablename__ = 'users' __tablename__ = 'users'
# ── Inspector roles (MT-15) ───────────────────────────────────────────────
# 'external_inspector' is an inspector employed by the customer or a third
# party rather than by the tenant. It has exactly the same capabilities as
# the internal 'inspector' role and is scoped the same way — through
# InspectorAssignment rows, via get_inspector_scope().
#
# Every place that used to test `role == 'inspector'` must test membership
# of this tuple instead, or external inspectors silently fall into the
# privileged (org-wide) branch and see every contract. Use the
# `is_inspector` property below — it is an ordinary attribute, so it reads
# the same way in Python and in Jinja (`current_user.is_inspector`).
INSPECTOR_ROLES = ('inspector', 'external_inspector')
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False, index=True) username = db.Column(db.String(100), unique=True, nullable=False, index=True)
full_name = db.Column(db.String(150), nullable=True) full_name = db.Column(db.String(150), nullable=True)
@@ -19,7 +46,10 @@ class User(UserMixin, db.Model):
role = db.Column( role = db.Column(
# Phase 11 migration complete — 'supervisor' removed from both the DB # Phase 11 migration complete — 'supervisor' removed from both the DB
# ENUM and this Python-side declaration. Director is the canonical role. # ENUM and this Python-side declaration. Director is the canonical role.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'), # MT-15 — 'external_inspector' added: a customer / third-party
# inspector with identical capabilities to 'inspector'.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer',
'auditor', 'external_inspector'),
nullable=False nullable=False
) )
created_at = db.Column(db.DateTime, default=now_eastern) created_at = db.Column(db.DateTime, default=now_eastern)
@@ -64,6 +94,26 @@ class User(UserMixin, db.Model):
def check_password(self, password): def check_password(self, password):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
@property
def is_inspector(self):
"""True for both the internal and the external inspector role.
Prefer this over `role == 'inspector'` for capability and scoping
checks. Use an explicit `role == 'external_inspector'` test only where
the two genuinely differ (currently: display labelling only).
"""
return self.role in self.INSPECTOR_ROLES
@property
def is_external_inspector(self):
"""True only for third-party / customer-employed inspectors."""
return self.role == 'external_inspector'
@property
def role_label(self):
"""Human-readable role name, used in staff-facing lists."""
return ROLE_LABELS.get(self.role, (self.role or '').replace('_', ' ').title())
@property @property
def display_name(self): def display_name(self):
"""Return full name if set, otherwise fall back to username.""" """Return full name if set, otherwise fall back to username."""
+88 -7
View File
@@ -431,20 +431,64 @@ def create_user():
if form.validate_on_submit(): if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data 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')
# 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')
return render_template('auth/user_form.html', form=form, user=None,
title='Create User',
director_editing=director_editing)
user = User( user = User(
username=form.username.data, username=form.username.data,
full_name=form.full_name.data.strip() or None, full_name=form.full_name.data.strip() or None,
email=form.email.data.strip().lower(), email=form.email.data.strip().lower(),
role=role role=role,
password_set=not invite,
) )
user.set_password(form.password.data) 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)
db.session.add(user) 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() db.session.commit()
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) logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s invite=%s',
current_user.id, current_user.username, user.username,
user.role, invite)
log_action(ACTION_CREATE, 'User', user.id, user.username, log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}') f'role={user.role}; email={user.email}; invite_sent={invite}')
flash(f'User {user.username} created successfully.', 'success')
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')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User', return render_template('auth/user_form.html', form=form, title='Create User',
@@ -488,12 +532,49 @@ def edit_user(user_id):
title='Edit User', director_editing=director_editing) title='Edit User', director_editing=director_editing)
@bp.route('/users/<int:user_id>/resend-invite', methods=['POST'])
@login_required
@admin_required
def resend_invite(user_id):
"""Re-send the set-password invitation for an account still awaiting setup.
Without this an invitation that bounces, is deleted or expires leaves the
account permanently unusable password_set=False blocks login and only a
valid token can clear it. Mirrors customers.resend_invite for staff-side
accounts (currently only external inspectors are ever invited this way).
"""
user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.password_set:
flash(f'{user.display_name} has already completed their account setup.',
'info')
return redirect(url_for('auth.list_users'))
# A fresh token invalidates the previous link.
token = user.generate_set_password_token(expires_hours=72)
db.session.commit()
logger.info('AUTH | resend_invite | admin=%s user=%s',
current_user.username, user.username)
log_action(ACTION_UPDATE, 'User', user.id, user.username,
'invitation email resent')
from app.routes.customers import _send_invite_email
_send_invite_email(user, token, base_url=request.host_url)
flash(f'Invitation resent to {user.email}.', 'success')
return redirect(url_for('auth.list_users'))
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST']) @bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @admin_required
def assign_inspector_contracts(user_id): def assign_inspector_contracts(user_id):
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
if user is None or user.role != 'inspector': # MT-15: external inspectors are scoped by the same InspectorAssignment
# rows, so this page must accept them too.
if user is None or not user.is_inspector:
abort(404) abort(404)
from app.models.project import Project from app.models.project import Project
+4 -2
View File
@@ -22,10 +22,12 @@ logger = logging.getLogger(__name__)
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast') bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
# All roles that can hold an active iOS session # All roles that can hold an active iOS session
BROADCAST_ROLES = ['inspector', 'project_manager', 'director', 'admin'] BROADCAST_ROLES = ['inspector', 'external_inspector', 'project_manager',
'director', 'admin']
ROLE_LABELS = { ROLE_LABELS = {
'inspector': 'Inspectors', 'inspector': 'Inspectors',
'external_inspector': 'External Inspectors',
'project_manager': 'Project Managers', 'project_manager': 'Project Managers',
'director': 'Directors', 'director': 'Directors',
'admin': 'Admins', 'admin': 'Admins',
+2 -2
View File
@@ -25,7 +25,7 @@ def index():
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1) today_end = today_start + timedelta(days=1)
is_inspector = current_user.role == 'inspector' is_inspector = current_user.is_inspector
is_privileged = current_user.role in ['admin', 'director'] is_privileged = current_user.role in ['admin', 'director']
is_customer = current_user.role == 'customer' is_customer = current_user.role == 'customer'
is_project_manager = current_user.role == 'project_manager' is_project_manager = current_user.role == 'project_manager'
@@ -305,7 +305,7 @@ def index():
if is_privileged or is_project_manager or is_auditor: if is_privileged or is_project_manager or is_auditor:
active_inspectors = ( active_inspectors = (
User.query User.query
.filter_by(role='inspector', active=True) .filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
.order_by(User.full_name, User.username) .order_by(User.full_name, User.username)
.all() .all()
) )
+6 -6
View File
@@ -23,7 +23,7 @@ def list_facilities():
facilities = Facility.query.filter( facilities = Facility.query.filter(
Facility.id.in_(cids), Facility.active == True Facility.id.in_(cids), Facility.active == True
).order_by(Facility.name).all() ).order_by(Facility.name).all()
elif current_user.role == 'inspector': elif current_user.is_inspector:
fids = get_inspector_scope(current_user) or [] fids = get_inspector_scope(current_user) or []
facilities = Facility.query.filter( facilities = Facility.query.filter(
Facility.id.in_(fids), Facility.active == True Facility.id.in_(fids), Facility.active == True
@@ -262,7 +262,7 @@ def _facility_for_qr_or_403(facility_id):
if facility is None: if facility is None:
abort(404) abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf). # QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
@@ -329,7 +329,7 @@ def qr_sheet():
"""Bulk print sheet — one labeled QR card per active facility.""" """Bulk print sheet — one labeled QR card per active facility."""
from app.utils.qr import qr_svg from app.utils.qr import qr_svg
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
if current_user.role == 'customer': if current_user.role == 'customer':
@@ -395,7 +395,7 @@ def _area_for_qr_or_403(area_id):
if area is None: if area is None:
abort(404) abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf). # QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
@@ -483,7 +483,7 @@ def qr_print_all():
Inspectors have no QR management (403); customers are scoped to their Inspectors have no QR management (403); customers are scoped to their
assigned facilities; managers see all active facilities. assigned facilities; managers see all active facilities.
""" """
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
contract_id = request.args.get('contract_id', type=int) contract_id = request.args.get('contract_id', type=int)
@@ -552,7 +552,7 @@ def qr_export_pdf():
Scope is enforced per-id via the same helpers as the QR pages, so a Scope is enforced per-id via the same helpers as the QR pages, so a
customer can never export a code outside their assigned facilities. customer can never export a code outside their assigned facilities.
""" """
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
facility_ids = request.form.getlist('facility_ids', type=int) facility_ids = request.form.getlist('facility_ids', type=int)
+1 -1
View File
@@ -125,7 +125,7 @@ def _can_view_full(facility):
return False return False
if current_user.role in ('admin', 'director', 'project_manager'): if current_user.role in ('admin', 'director', 'project_manager'):
return True return True
if current_user.role == 'inspector': if current_user.is_inspector:
return facility.id in (get_inspector_scope(current_user) or []) return facility.id in (get_inspector_scope(current_user) or [])
if current_user.role == 'customer': if current_user.role == 'customer':
return facility.id in (get_customer_scope(current_user) or []) return facility.id in (get_customer_scope(current_user) or [])
+4 -3
View File
@@ -189,7 +189,8 @@ def _active_inspectors():
"""Users who can be assigned inspections (inspector-capable roles).""" """Users who can be assigned inspections (inspector-capable roles)."""
return User.query.filter( return User.query.filter(
User.active.is_(True), User.active.is_(True),
User.role.in_(['inspector', 'project_manager', 'director', 'admin']), User.role.in_(['inspector', 'external_inspector', 'project_manager',
'director', 'admin']),
).order_by(User.full_name, User.username).all() ).order_by(User.full_name, User.username).all()
@@ -356,7 +357,7 @@ def index():
base = InspectionSchedule.query base = InspectionSchedule.query
# Inspectors see only their own assignments; managers see everything. # Inspectors see only their own assignments; managers see everything.
if current_user.role == 'inspector': if current_user.is_inspector:
base = base.filter(InspectionSchedule.inspector_id == current_user.id) base = base.filter(InspectionSchedule.inspector_id == current_user.id)
# Counts are computed on the same scoped query, so the badges match what the # Counts are computed on the same scoped query, so the badges match what the
@@ -645,7 +646,7 @@ def start(schedule_id):
abort(404) abort(404)
if current_user.role == 'customer': if current_user.role == 'customer':
abort(403) abort(403)
if current_user.role == 'inspector' and schedule.inspector_id != current_user.id: if current_user.is_inspector and schedule.inspector_id != current_user.id:
abort(403) abort(403)
if not schedule.active: if not schedule.active:
+27 -19
View File
@@ -216,7 +216,7 @@ def index():
joinedload(Inspection.area), joinedload(Inspection.area),
).order_by(Inspection.inspection_date.desc()) ).order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -285,12 +285,12 @@ def index():
q = q.filter(Inspection.overall_score <= float(score_max_filter)) q = q.filter(Inspection.overall_score <= float(score_max_filter))
except ValueError: except ValueError:
score_max_filter = '' score_max_filter = ''
if inspector_filter.isdigit() and current_user.role != 'inspector': if inspector_filter.isdigit() and not current_user.is_inspector:
q = q.filter(Inspection.inspector_id == int(inspector_filter)) q = q.filter(Inspection.inspector_id == int(inspector_filter))
inspections = q.paginate(page=page, per_page=20, error_out=False) inspections = q.paginate(page=page, per_page=20, error_out=False)
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) or [] fids = get_inspector_scope(current_user) or []
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) _fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
elif current_user.role == 'customer': elif current_user.role == 'customer':
@@ -317,9 +317,9 @@ def index():
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# Inspector dropdown — shown to all roles except inspector (they only see their own) # Inspector dropdown — shown to all roles except inspector (they only see their own)
if current_user.role != 'inspector': if not current_user.is_inspector:
inspectors = (User.query inspectors = (User.query
.filter(User.role == 'inspector', User.active == True) .filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
.order_by(User.full_name, User.username).all()) .order_by(User.full_name, User.username).all())
else: else:
inspectors = [] inspectors = []
@@ -354,7 +354,7 @@ def start():
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# Scope projects to inspector's assigned contracts # Scope projects to inspector's assigned contracts
if current_user.role == 'inspector': if current_user.is_inspector:
from app.models.inspector_assignment import InspectorAssignment from app.models.inspector_assignment import InspectorAssignment
assigned_pids = { assigned_pids = {
a.project_id for a in a.project_id for a in
@@ -410,7 +410,7 @@ def start():
# Inspector facility scope check — prevent crafted POST from selecting # Inspector facility scope check — prevent crafted POST from selecting
# a facility outside their assigned contracts. # a facility outside their assigned contracts.
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids or form.facility_id.data not in fids: if not fids or form.facility_id.data not in fids:
abort(403) abort(403)
@@ -474,7 +474,7 @@ def execute(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
@@ -610,7 +610,8 @@ def execute(inspection_id):
return redirect(url_for('inspections.execute', inspection_id=inspection_id)) return redirect(url_for('inspections.execute', inspection_id=inspection_id))
staff_for_flag_issue = User.query.filter( staff_for_flag_issue = User.query.filter(
User.role.in_(['director', 'inspector', 'project_manager', 'auditor']), User.role.in_(['director', 'inspector', 'external_inspector',
'project_manager', 'auditor']),
User.active == True, User.active == True,
).order_by(User.full_name, User.username).all() ).order_by(User.full_name, User.username).all()
@@ -650,7 +651,7 @@ def save_draft_ajax(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403 return jsonify({'ok': False, 'error': 'Access denied'}), 403
if inspection.status == 'completed': if inspection.status == 'completed':
@@ -689,7 +690,7 @@ def upload_photo_ajax(inspection_id):
if inspection is None: if inspection is None:
return jsonify({'ok': False, 'error': 'Not found'}), 404 return jsonify({'ok': False, 'error': 'Not found'}), 404
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403 return jsonify({'ok': False, 'error': 'Access denied'}), 403
if inspection.status == 'completed': if inspection.status == 'completed':
@@ -716,7 +717,7 @@ def view(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
if current_user.role == 'customer': if current_user.role == 'customer':
@@ -930,15 +931,22 @@ def flag_issue(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
form = IssueForm() form = IssueForm()
staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all() staff = User.query.filter(
User.role.in_(['director', 'inspector', 'external_inspector'])
).order_by(User.username).all()
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)] form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] # MT-15: suffix external (customer / third-party) 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 ''))
for u in staff
]
if form.validate_on_submit(): if form.validate_on_submit():
photo_path = _save_photo(form.photo.data, subfolder='issue_photos') photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
@@ -1020,7 +1028,7 @@ def export_list_pdf():
joinedload(Inspection.area), joinedload(Inspection.area),
).order_by(Inspection.inspection_date.desc()) ).order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -1083,7 +1091,7 @@ def export_list_pdf():
q = q.filter(Inspection.overall_score <= float(score_max_filter)) q = q.filter(Inspection.overall_score <= float(score_max_filter))
except ValueError: except ValueError:
pass pass
if inspector_filter.isdigit() and current_user.role != 'inspector': if inspector_filter.isdigit() and not current_user.is_inspector:
q = q.filter(Inspection.inspector_id == int(inspector_filter)) q = q.filter(Inspection.inspector_id == int(inspector_filter))
inspections = q.all() inspections = q.all()
@@ -1113,7 +1121,7 @@ def export_list_pdf():
filter_parts.append(f'Min score: {score_min_filter}%') filter_parts.append(f'Min score: {score_min_filter}%')
if score_max_filter: if score_max_filter:
filter_parts.append(f'Max score: {score_max_filter}%') filter_parts.append(f'Max score: {score_max_filter}%')
if inspector_filter.isdigit() and current_user.role != 'inspector': if inspector_filter.isdigit() and not current_user.is_inspector:
u = db.session.get(User, int(inspector_filter)) u = db.session.get(User, int(inspector_filter))
if u: if u:
filter_parts.append(f'Inspector: {u.display_name}') filter_parts.append(f'Inspector: {u.display_name}')
@@ -1143,7 +1151,7 @@ def export_pdf(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
if current_user.role == 'customer': if current_user.role == 'customer':
+27 -10
View File
@@ -71,6 +71,18 @@ class _SLAFilteredPage:
return iter([1]) return iter([1])
def _assignee_label(user):
"""Dropdown label for an assignee.
MT-15 external (customer / third-party) inspectors are assignable just
like the tenant's own crew, but are suffixed so whoever is triaging can see
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)'
if user.is_external_inspector else user.display_name)
@bp.route('/export-list-pdf') @bp.route('/export-list-pdf')
@login_required @login_required
def export_list_pdf(): def export_list_pdf():
@@ -86,7 +98,7 @@ def export_list_pdf():
.order_by(Issue.reported_at.desc()) .order_by(Issue.reported_at.desc())
) )
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -211,7 +223,7 @@ def index():
.order_by(Issue.reported_at.desc()) .order_by(Issue.reported_at.desc())
) )
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -319,7 +331,7 @@ def index():
# Facilities for the filter dropdown — scoped for inspectors/customers, # Facilities for the filter dropdown — scoped for inspectors/customers,
# then narrowed to the selected contract when contract_filter is active. # then narrowed to the selected contract when contract_filter is active.
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) or [] fids = get_inspector_scope(current_user) or []
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) _fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
elif current_user.role == 'customer': elif current_user.role == 'customer':
@@ -347,7 +359,8 @@ def index():
# Staff for quick-assign dropdown — same roles as the full issue form # Staff for quick-assign dropdown — same roles as the full issue form
staff = User.query.filter( staff = User.query.filter(
User.role.in_(['director', 'inspector', 'auditor']), User.active == True User.role.in_(['director', 'inspector', 'external_inspector', 'auditor']),
User.active == True
).order_by(User.username).all() ).order_by(User.username).all()
# Reporters dropdown — users who have actually filed at least one issue # Reporters dropdown — users who have actually filed at least one issue
@@ -385,7 +398,7 @@ def view(issue_id):
if issue is None: if issue is None:
abort(404) abort(404)
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -422,7 +435,7 @@ def view(issue_id):
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
form = IssueUpdateForm(obj=issue) form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
# Preserve any pre-existing assignee who is no longer in the assignable set # Preserve any pre-existing assignee who is no longer in the assignable set
# (e.g. an admin assigned before admins were removed from the dropdown) so # (e.g. an admin assigned before admins were removed from the dropdown) so
# saving the form doesn't silently unassign them. # saving the form doesn't silently unassign them.
@@ -430,7 +443,9 @@ def view(issue_id):
current_assignee = db.session.get(User, issue.assigned_to) current_assignee = db.session.get(User, issue.assigned_to)
if current_assignee: if current_assignee:
staff.append(current_assignee) staff.append(current_assignee)
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, _assignee_label(u)) for u in staff
]
form.status.data = form.status.data or issue.status form.status.data = form.status.data or issue.status
if form.validate_on_submit(): if form.validate_on_submit():
@@ -743,10 +758,12 @@ def create():
else: else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
form.facility_id.choices = [(f.id, f.name) for f in facilities] form.facility_id.choices = [(f.id, f.name) for f in facilities]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, _assignee_label(u)) for u in staff
]
# On POST validation error: identify which contract the submitted facility # On POST validation error: identify which contract the submitted facility
# belongs to so the contract selector can be restored on re-render. # belongs to so the contract selector can be restored on re-render.
@@ -1129,7 +1146,7 @@ def export_pdf(issue_id):
if issue is None: if issue is None:
abort(404) abort(404)
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
+20 -11
View File
@@ -57,7 +57,8 @@ def index():
# Inspectors get a scoped view of their own inspections and related issues. # Inspectors get a scoped view of their own inspections and related issues.
# Customers get a facility-scoped view. # Customers get a facility-scoped view.
# Internal management roles (director+) get the full unscoped view. # Internal management roles (director+) get the full unscoped view.
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']: if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
'external_inspector', 'customer']:
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -66,7 +67,7 @@ def index():
# Resolve scoping for customers (facility list) and inspectors (inspector_id) # Resolve scoping for customers (facility list) and inspectors (inspector_id)
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted customer_facility_ids = get_customer_scope(current_user) # None = unrestricted
is_inspector = current_user.role == 'inspector' is_inspector = current_user.is_inspector
# Inspector filter — admin / director / project_manager only # Inspector filter — admin / director / project_manager only
inspector_filter = None inspector_filter = None
@@ -262,7 +263,8 @@ def index():
inspectors = [] inspectors = []
if current_user.role in ('admin', 'director', 'project_manager'): if current_user.role in ('admin', 'director', 'project_manager'):
inspectors = User.query.filter_by(role='inspector', active=True)\ inspectors = User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
User.active == True)\
.order_by(User.full_name, User.username).all() .order_by(User.full_name, User.username).all()
facility_scores_list = [{ facility_scores_list = [{
@@ -307,7 +309,8 @@ def index():
@bp.route('/facility/<int:facility_id>') @bp.route('/facility/<int:facility_id>')
@login_required @login_required
def facility_report(facility_id): def facility_report(facility_id):
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']: if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
'external_inspector', 'customer']:
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -320,7 +323,7 @@ def facility_report(facility_id):
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('reports.index')) return redirect(url_for('reports.index'))
if current_user.role == 'inspector': if current_user.is_inspector:
# Inspectors may only view the facility report for facilities where # Inspectors may only view the facility report for facilities where
# they have personally conducted at least one inspection. # they have personally conducted at least one inspection.
has_access = Inspection.query.filter_by( has_access = Inspection.query.filter_by(
@@ -369,7 +372,8 @@ def facility_report(facility_id):
def facility_scorecard(facility_id): def facility_scorecard(facility_id):
"""Comprehensive per-facility scorecard: score trend, SLA compliance, """Comprehensive per-facility scorecard: score trend, SLA compliance,
issue breakdown by severity, inspection frequency.""" issue breakdown by severity, inspection frequency."""
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']: if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
'external_inspector', 'customer']:
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -385,7 +389,7 @@ def facility_scorecard(facility_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('reports.index')) return redirect(url_for('reports.index'))
if current_user.role == 'inspector': if current_user.is_inspector:
has_access = Inspection.query.filter_by( has_access = Inspection.query.filter_by(
facility_id=facility_id, facility_id=facility_id,
inspector_id=current_user.id, inspector_id=current_user.id,
@@ -701,7 +705,8 @@ def _build_inspector_stats(start, end):
all_ids = set(total_map.keys()) all_ids = set(total_map.keys())
active_inspectors = ( active_inspectors = (
User.query User.query
.filter(User.id.in_(all_ids), User.active == True, User.role == 'inspector') .filter(User.id.in_(all_ids), User.active == True,
User.role.in_(User.INSPECTOR_ROLES))
.order_by(User.full_name, User.username) .order_by(User.full_name, User.username)
.all() .all()
) if all_ids else [] ) if all_ids else []
@@ -717,6 +722,10 @@ def _build_inspector_stats(start, end):
inspector_stats.append({ inspector_stats.append({
'id': u.id, 'id': u.id,
'display_name': u.display_name, 'display_name': u.display_name,
# MT-15 — customer / third-party inspectors appear in the same
# table as the tenant's own crew, badged so the numbers can be read
# in context. Consumed by the HTML table and the export.
'external': u.is_external_inspector,
'total': tot, 'total': tot,
'completed': comp, 'completed': comp,
'completion_rate': round(comp / tot * 100) if tot else 0, 'completion_rate': round(comp / tot * 100) if tot else 0,
@@ -759,7 +768,7 @@ def inspector_performance():
if selected_id: if selected_id:
selected_inspector = db.session.get(User, selected_id) selected_inspector = db.session.get(User, selected_id)
if selected_inspector and selected_inspector.role == 'inspector': if selected_inspector and selected_inspector.is_inspector:
selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None) selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None)
trend_rows = db.session.query( trend_rows = db.session.query(
@@ -857,7 +866,7 @@ def export_inspector_performance():
.filter( .filter(
Inspection.inspection_date >= start, Inspection.inspection_date >= start,
Inspection.inspection_date <= end, Inspection.inspection_date <= end,
User.role == 'inspector', User.role.in_(User.INSPECTOR_ROLES),
) )
if selected_id: if selected_id:
detail_q = detail_q.filter(Inspection.inspector_id == selected_id) detail_q = detail_q.filter(Inspection.inspector_id == selected_id)
@@ -1628,7 +1637,7 @@ def facility_summary_pdf(facility_id):
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
if facility_id not in cids: if facility_id not in cids:
abort(403) abort(403)
elif current_user.role == 'inspector': elif current_user.is_inspector:
has = Inspection.query.filter_by(facility_id=facility_id, has = Inspection.query.filter_by(facility_id=facility_id,
inspector_id=current_user.id).first() inspector_id=current_user.id).first()
if not has: if not has:
+37 -1
View File
@@ -80,7 +80,20 @@
</div> </div>
</div> </div>
<div class="row"> {# MT-15 — an External Inspector is invited by email and chooses
their own password, so the admin never sets one. The JS at the
foot of this page swaps these two blocks when the role changes;
the server decides independently of the JS. #}
<div id="inviteNotice" class="alert alert-info d-none">
<i class="bi bi-envelope me-1"></i>
<strong>This account will be invited by email.</strong>
External inspectors work outside the business, so we do not set
a password for them. On save, an invitation is sent to the email
address above with a link to choose their own password. The link
is valid for 72 hours.
</div>
<div class="row" id="passwordFields">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }} {{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }} {{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
@@ -128,4 +141,27 @@
</div> </div>
</div> </div>
</div> </div>
<script>
(function () {
'use strict';
var roleSel = document.getElementById('role');
var pwBlock = document.getElementById('passwordFields');
var notice = document.getElementById('inviteNotice');
if (!roleSel || !pwBlock || !notice) return; // director view has no role select
function sync() {
var invited = roleSel.value === 'external_inspector';
pwBlock.classList.toggle('d-none', invited);
notice.classList.toggle('d-none', !invited);
// Clear anything already typed so an invited account can never be created
// with an admin-chosen password sitting in the POST body.
if (invited) {
pwBlock.querySelectorAll('input').forEach(function (i) { i.value = ''; });
}
}
roleSel.addEventListener('change', sync);
sync();
})();
</script>
{% endblock %} {% endblock %}
+15 -4
View File
@@ -37,12 +37,12 @@
<td>{{ user.full_name or '—' }}</td> <td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</td> <td>{{ user.email }}</td>
<td> <td>
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% else %}info{% endif %}"> <span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% elif user.role == 'external_inspector' %}dark{% else %}info{% endif %}">
{{ user.role.replace('_',' ')|title }} {{ user.role_label }}
</span> </span>
</td> </td>
<td> <td>
{% if user.role == 'inspector' %} {% if user.is_inspector %}
{% set cnt = inspector_contract_counts.get(user.id, 0) %} {% set cnt = inspector_contract_counts.get(user.id, 0) %}
{% if cnt > 0 %} {% if cnt > 0 %}
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span> <span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
@@ -65,12 +65,23 @@
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit"> <a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</a> </a>
{% if user.role == 'inspector' %} {% if user.is_inspector %}
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}" <a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
class="btn btn-sm btn-outline-secondary" title="Assign contracts"> class="btn btn-sm btn-outline-secondary" title="Assign contracts">
<i class="bi bi-briefcase"></i> <i class="bi bi-briefcase"></i>
</a> </a>
{% endif %} {% endif %}
{% if not user.password_set %}
<form method="POST" action="{{ url_for('auth.resend_invite', user_id=user.id) }}"
class="d-inline"
onsubmit="return confirm('Resend the invitation email to {{ user.email }}? The previous link will stop working.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning"
title="Resend invitation email">
<i class="bi bi-envelope-arrow-up"></i>
</button>
</form>
{% endif %}
{% if user.id != current_user.id %} {% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline"> <form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+1 -1
View File
@@ -564,7 +564,7 @@
<option value="0">— Unassigned —</option> <option value="0">— Unassigned —</option>
{% set staff = staff_for_flag_issue %} {% set staff = staff_for_flag_issue %}
{% if staff %}{% for u in staff %} {% if staff %}{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}</option> <option value="{{ u.id }}">{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
{% endfor %}{% endif %} {% endfor %}{% endif %}
</select> </select>
</div> </div>
+1 -1
View File
@@ -181,7 +181,7 @@
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;"> <select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option> <option value="">— Unassigned —</option>
{% for u in staff %} {% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option> <option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
{% endfor %} {% endfor %}
</select> </select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span> <span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
@@ -115,6 +115,9 @@
data-inspector-id="{{ s.id }}"> data-inspector-id="{{ s.id }}">
<td class="fw-semibold"> <td class="fw-semibold">
{{ s.display_name }} {{ s.display_name }}
{% if s.external %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">External</span>
{% endif %}
</td> </td>
<td class="text-center">{{ s.total }}</td> <td class="text-center">{{ s.total }}</td>
<td class="text-center">{{ s.completed }}</td> <td class="text-center">{{ s.completed }}</td>
@@ -189,6 +192,9 @@
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }} <i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
{% if selected_inspector.is_external_inspector %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">External</span>
{% endif %}
</h6> </h6>
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}" <a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-light text-primary"> class="btn btn-sm btn-light text-primary">
+4
View File
@@ -91,6 +91,10 @@ class UserForm(FlaskForm):
('admin', 'Administrator'), ('admin', 'Administrator'),
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('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'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'), ('auditor', 'Auditor'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers # 'customer' is intentionally excluded — customer accounts are managed via /customers
+7 -4
View File
@@ -570,7 +570,8 @@ def notify_by_matrix(
role_to_db = { role_to_db = {
'admin': 'admin', 'admin': 'admin',
'director': 'director', 'director': 'director',
'inspector': 'inspector', 'inspector': 'inspector',
'external_inspector': 'external_inspector',
'project_manager': 'project_manager', 'project_manager': 'project_manager',
'auditor': 'auditor', 'auditor': 'auditor',
'customer': 'customer', 'customer': 'customer',
@@ -602,15 +603,17 @@ def notify_by_matrix(
# a tenant with a dozen inspectors is a mail storm and trains people to # a tenant with a dozen inspectors is a mail storm and trains people to
# ignore notifications. Falls back to notifying nobody when the # ignore notifications. Falls back to notifying nobody when the
# inspection cannot be resolved, rather than notifying everybody. # inspection cannot be resolved, rather than notifying everybody.
if role_key == 'inspector' and event_type == 'inspection_completed': if (role_key in ('inspector', 'external_inspector')
and event_type == 'inspection_completed'):
target_id = None target_id = None
if inspection_id: if inspection_id:
from app.models.inspection import Inspection from app.models.inspection import Inspection
insp = db.session.get(Inspection, inspection_id) insp = db.session.get(Inspection, inspection_id)
target_id = insp.inspector_id if insp else None target_id = insp.inspector_id if insp else None
users = [u for u in users if u.id == target_id] if target_id else [] users = [u for u in users if u.id == target_id] if target_id else []
logger.info('MATRIX NOTIFY | event=%s | role=inspector scoped to ' logger.info('MATRIX NOTIFY | event=%s | role=%s scoped to '
'submitting inspector_id=%s', event_type, target_id) 'submitting inspector_id=%s',
event_type, role_key, target_id)
# Scope customer role to facility if provided # Scope customer role to facility if provided
if role_key == 'customer' and facility_id: if role_key == 'customer' and facility_id:
+8 -1
View File
@@ -8,6 +8,8 @@ Facility-scoping utilities for the Janitorial QC portal.
get_inspector_scope(user) -> list[int] | None get_inspector_scope(user) -> list[int] | None
Facility IDs an inspector may access via InspectorAssignment rows. Facility IDs an inspector may access via InspectorAssignment rows.
Applies to BOTH 'inspector' (internal) and 'external_inspector'
(customer / third-party) see User.INSPECTOR_ROLES.
Returns [] (empty list) when the inspector has no contract assignments, Returns [] (empty list) when the inspector has no contract assignments,
meaning they see nothing (strict mode). meaning they see nothing (strict mode).
@@ -90,7 +92,12 @@ def get_inspector_scope(user) -> list[int] | None:
None None
Returned for non-inspector roles, indicating unrestricted access. Returned for non-inspector roles, indicating unrestricted access.
""" """
if user.role != 'inspector': # MT-15: covers BOTH 'inspector' and 'external_inspector'. An external
# (customer / third-party) inspector is scoped by exactly the same
# InspectorAssignment rows — the contracts an admin grants them.
from app.models.user import User
if user.role not in User.INSPECTOR_ROLES:
return None return None
from app.models.inspector_assignment import InspectorAssignment from app.models.inspector_assignment import InspectorAssignment
@@ -0,0 +1,64 @@
"""phase51 — add 'external_inspector' role to users.role ENUM
Introduces the External Inspector role: an inspector employed by the customer
or a third party rather than by the tenant. It has exactly the same
capabilities as the internal 'inspector' role and is scoped the same way
through InspectorAssignment rows, resolved by get_inspector_scope().
Every capability/scoping check that used to test `role == 'inspector'` now
tests membership of User.INSPECTOR_ROLES (exposed as the `is_inspector`
property), so the new role picks up inspector behaviour everywhere without a
per-route allowlist.
This is a pure ENUM expansion (adds a value, removes and migrates nothing), so
the 3-step ENUM protocol does not apply and re-running the same MODIFY is a
no-op safe to re-run.
MULTI-TENANT NOTE
-----------------
This runs once per tenant database via control/tenant_migrate.py, like every
other script in migrations/versions. It is a metadata-only ALTER on a small
table, but MySQL still takes an exclusive metadata lock for the duration, so
run it during the normal migration window rather than under load.
Notification matrix rows for the new 'external_inspector' column are NOT seeded
here: MATRIX_DEFAULTS mirrors the Inspector column at runtime and is_enabled()
falls back to that default when a row is absent, so an unseeded tenant behaves
exactly like the Inspector column until an admin saves the matrix page.
"""
revision = 'phase51_external_inspector'
down_revision = 'phase50_sched_acknowledged'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_ENUM_WITH_EXTERNAL = (
"ENUM('admin','director','inspector','project_manager','customer',"
"'auditor','external_inspector')"
)
_ENUM_WITHOUT_EXTERNAL = (
"ENUM('admin','director','inspector','project_manager','customer','auditor')"
)
def upgrade():
# Idempotent: MODIFY to the expanded set is harmless if already applied.
op.execute(sa.text(
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITH_EXTERNAL} NOT NULL"
))
def downgrade():
# Reassign any external_inspector rows before contracting the ENUM so no
# account is orphaned. They become internal inspectors, which keeps their
# InspectorAssignment scoping intact — the same contracts still apply.
op.execute(sa.text(
"UPDATE users SET role = 'inspector' WHERE role = 'external_inspector'"
))
op.execute(sa.text(
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITHOUT_EXTERNAL} NOT NULL"
))
+303
View File
@@ -0,0 +1,303 @@
"""
tests/test_external_inspector.py
--------------------------------
Behaviour tests for MT-15 the 'external_inspector' role.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* the role predicates: is_inspector covers both roles, is_external_inspector
does not, and role_label renders the display name
* get_inspector_scope() applies the SAME InspectorAssignment scoping to an
external inspector as to an internal one the regression this phase exists
to prevent is an external inspector falling into the privileged branch
(scope None = unrestricted) and seeing every contract
* an unassigned external inspector sees nothing (strict mode)
* facility / inspection / issue list routes stay scoped for the new role
* the notification matrix exposes an External Inspector column whose defaults
mirror the Inspector column
* creating an external inspector sends an invitation instead of setting a
password: password_set is False and a set-password token is minted
* creating any other role still requires a password
* the assign-contracts page accepts an external inspector (it 404'd before)
The regression guard that matters most is
test_external_inspector_scope_is_not_unrestricted: if a future edit reverts a
membership test back to `role == 'inspector'`, an external inspector silently
gains org-wide visibility, which is a cross-customer data leak rather than a
cosmetic bug.
"""
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
with app.app_context():
from app import db
# get_inspector_scope() imports this model lazily, so the mapper is not
# registered at create_all() time and the table is missing when a
# logged-in inspector hits the dashboard. Import it up front.
from app.models import inspector_assignment # noqa: F401
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _user(username, role, **kw):
from app import db
from app.models.user import User
u = User(username=username, full_name=username.title(), role=role,
email=f'{username}@example.com', active=True, **kw)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _seed():
"""Two contracts, one facility each, and the three inspector-ish accounts."""
from app import db
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
from app.models.project import Project
proj_a = Project(name='Contract A', active=True)
proj_b = Project(name='Contract B', active=True)
tmpl = InspectionTemplate(name='Restroom Check', active=True,
form_schema=[{'id': 'f1', 'type': 'rating_5',
'label': 'Clean', 'row': 0, 'col': 0,
'rowSpan': 1, 'colSpan': 1}])
db.session.add_all([proj_a, proj_b, tmpl])
db.session.commit()
fac_a = Facility(name='Client A Site', active=True, project_id=proj_a.id)
fac_b = Facility(name='Client B Site', active=True, project_id=proj_b.id)
db.session.add_all([fac_a, fac_b])
db.session.commit()
internal = _user('ivy', 'inspector')
external = _user('xan', 'external_inspector')
admin = _user('ada', 'admin')
return dict(tmpl=tmpl, proj_a=proj_a, proj_b=proj_b,
fac_a=fac_a, fac_b=fac_b,
internal=internal, external=external, admin=admin)
def _assign(user, project):
from app import db
from app.models.inspector_assignment import InspectorAssignment
db.session.add(InspectorAssignment(user_id=user.id, project_id=project.id))
db.session.commit()
def _login(client, user):
return client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
# ── Role predicates ──────────────────────────────────────────────────────────
def test_is_inspector_covers_both_inspector_roles(client):
env = _seed()
assert env['internal'].is_inspector is True
assert env['external'].is_inspector is True
assert env['admin'].is_inspector is False
def test_is_external_inspector_distinguishes_the_two(client):
env = _seed()
assert env['external'].is_external_inspector is True
assert env['internal'].is_external_inspector is False
def test_role_label_renders_display_name(client):
env = _seed()
assert env['external'].role_label == 'External Inspector'
assert env['internal'].role_label == 'Inspector'
def test_inspector_roles_tuple_contains_both(client):
from app.models.user import User
assert set(User.INSPECTOR_ROLES) == {'inspector', 'external_inspector'}
# ── Scoping — the security-critical behaviour ────────────────────────────────
def test_external_inspector_scope_is_not_unrestricted(client):
"""An external inspector must NEVER get scope None (= see everything).
This is the regression this phase exists to prevent. If a membership test
is ever reverted to `role == 'inspector'`, get_inspector_scope() returns
None for the external role and every downstream query drops its facility
filter a cross-customer leak, not a cosmetic bug.
"""
from app.utils.scope import get_inspector_scope
env = _seed()
_assign(env['external'], env['proj_a'])
scope = get_inspector_scope(env['external'])
assert scope is not None, 'external inspector fell into the unrestricted branch'
assert scope == [env['fac_a'].id]
assert env['fac_b'].id not in scope
def test_external_and_internal_inspectors_scope_identically(client):
from app.utils.scope import get_inspector_scope
env = _seed()
_assign(env['internal'], env['proj_a'])
_assign(env['external'], env['proj_a'])
assert (get_inspector_scope(env['external'])
== get_inspector_scope(env['internal'])
== [env['fac_a'].id])
def test_unassigned_external_inspector_sees_nothing(client):
"""Strict mode: no assignments means an empty list, not unrestricted."""
from app.utils.scope import get_inspector_scope
env = _seed()
assert get_inspector_scope(env['external']) == []
def test_non_inspector_roles_still_unrestricted(client):
from app.utils.scope import get_inspector_scope
env = _seed()
assert get_inspector_scope(env['admin']) is None
# ── Route-level scoping ──────────────────────────────────────────────────────
def test_facility_list_is_scoped_for_external_inspector(client):
env = _seed()
_assign(env['external'], env['proj_a'])
_login(client, env['external'])
resp = client.get('/facilities/')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert 'Client A Site' in body
assert 'Client B Site' not in body
def test_inspection_and_issue_lists_load_for_external_inspector(client):
"""The new role must not 403 or 500 on the core scoped list routes."""
env = _seed()
_assign(env['external'], env['proj_a'])
_login(client, env['external'])
for path in ('/inspections/', '/issues/'):
resp = client.get(path)
assert resp.status_code == 200, f'{path} returned {resp.status_code}'
# ── Notification matrix ──────────────────────────────────────────────────────
def test_matrix_exposes_external_inspector_column(client):
from app.models.notification_matrix import MATRIX_ROLES
keys = [k for k, _ in MATRIX_ROLES]
assert 'external_inspector' in keys
def test_matrix_defaults_mirror_the_inspector_column(client):
from app.models.notification_matrix import MATRIX_DEFAULTS, MATRIX_EVENTS
mirrored = 0
for event in MATRIX_EVENTS:
if (event, 'inspector') in MATRIX_DEFAULTS:
assert (event, 'external_inspector') in MATRIX_DEFAULTS, event
assert (MATRIX_DEFAULTS[(event, 'external_inspector')]
== MATRIX_DEFAULTS[(event, 'inspector')]), event
mirrored += 1
assert mirrored > 0, 'no inspector defaults found to mirror'
# ── Invitation flow ──────────────────────────────────────────────────────────
def test_creating_external_inspector_invites_instead_of_setting_password(client):
from app.models.user import User
env = _seed()
_login(client, env['admin'])
resp = client.post('/auth/users/new', data={
'username': 'newxan',
'full_name': 'New Xan',
'email': 'newxan@example.com',
'role': 'external_inspector',
'password': '',
'confirm_password': '',
}, follow_redirects=True)
assert resp.status_code == 200
created = User.query.filter_by(username='newxan').first()
assert created is not None, 'external inspector was not created'
assert created.role == 'external_inspector'
# Invited, not password-set: login is blocked until they use the link.
assert created.password_set is False
assert created.set_password_token is not None
assert created.set_password_token_expires is not None
def test_invited_external_inspector_cannot_log_in_until_setup(client):
from app.models.user import User
env = _seed()
_login(client, env['admin'])
client.post('/auth/users/new', data={
'username': 'newxan',
'full_name': 'New Xan',
'email': 'newxan@example.com',
'role': 'external_inspector',
'password': '',
'confirm_password': '',
}, follow_redirects=True)
client.get('/auth/logout', follow_redirects=True)
created = User.query.filter_by(username='newxan').first()
# The placeholder hash is random, so no password can work; assert the
# account is in the blocked state rather than guessing a credential.
assert created.password_set is False
def test_creating_a_normal_role_still_requires_a_password(client):
from app.models.user import User
env = _seed()
_login(client, env['admin'])
resp = client.post('/auth/users/new', data={
'username': 'nopw',
'full_name': 'No Password',
'email': 'nopw@example.com',
'role': 'inspector',
'password': '',
'confirm_password': '',
}, follow_redirects=True)
assert resp.status_code == 200
assert User.query.filter_by(username='nopw').first() is None
# ── Assign-contracts page ────────────────────────────────────────────────────
def test_assign_contracts_page_accepts_external_inspector(client):
env = _seed()
_login(client, env['admin'])
resp = client.get(f"/auth/users/{env['external'].id}/assign-contracts")
assert resp.status_code == 200
def test_assign_contracts_page_still_rejects_non_inspectors(client):
env = _seed()
_login(client, env['admin'])
resp = client.get(f"/auth/users/{env['admin'].id}/assign-contracts")
assert resp.status_code == 404