diff --git a/.claude/settings.json b/.claude/settings.json index 19e2d57..0252436 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,7 +15,8 @@ "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/utils/notifications.py',encoding='utf-8'\\).read\\(\\)\\); print\\('notif OK'\\)\")", "Bash(SECRET_KEY=x DATABASE_URL=sqlite:///:memory: DIGEST_SECRET=x MAIL_SERVER=localhost MAIL_USERNAME=x MAIL_PASSWORD=x MAIL_PORT=587 APP_BASE_URL=http://localhost MAIL_DEFAULT_SENDER=x@x.com python -c ' *)", "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/dashboard.py',encoding='utf-8'\\).read\\(\\)\\); print\\('dashboard route OK'\\)\")", - "Bash(python -c ' *)" + "Bash(python -c ' *)", + "Bash(git diff *)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 615602f..fa47a59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,13 @@ users: id, username (unique, indexed), full_name, email (unique, indexed), password_set, set_password_token (indexed), set_password_token_expires ``` -**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor` +**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor`, `external_inspector` + +**`external_inspector` (Phase 49):** An inspector employed by **the customer or a third party** rather than by us. It has **exactly the same capabilities as `inspector`** and is scoped the **same way** — through `InspectorAssignment` rows resolved by `get_inspector_scope()`, i.e. an admin grants it the customer's contracts on the existing **Assign Contracts** page (`/auth/users//assign-contracts`, now gated on `user.is_inspector`). Strict scoping applies unchanged: no assignments = sees nothing. + +The two roles are distinguished by **display only**. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` (backed by the `ROLE_LABELS` map) drive the "External" badges: users list, dashboard **Inspector Activity**, **Inspector Performance** report (HTML + the Excel export, where the name cell is suffixed `(External)` rather than gaining a column so the index-based cell styling stays correct), and every assignee dropdown (`(External)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue). + +Assignable (rule 80 set becomes `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access** — `external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`), whose defaults mirror the Inspector column (see §11). **`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM. @@ -502,6 +508,10 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi @project_manager_required # role in ('admin', 'director', 'project_manager', 'auditor') @issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete) @customer_required # role == 'customer' only + +# Not a decorator, but the same idea for the two inspector roles: +# user.is_inspector → role in ('inspector', 'external_inspector') +# Never write `role == 'inspector'` for a capability or scoping check. ``` --- @@ -760,6 +770,10 @@ EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36 EVENT_FOLLOWUP_REQUESTED = 'followup_requested' ← Phase 46 ``` +### External Inspector column (Phase 49) + +`MATRIX_ROLES` gains `('external_inspector', 'External Inspector')`, and `notify_by_matrix()`'s `role_to_db` map routes it to the `external_inspector` DB role. `MATRIX_DEFAULTS` **mirrors** the Inspector column for every event (a comprehension, not 14 more literals) so a future event added for `inspector` automatically gets a matching external default. The `inspection_completed` scoping below applies to **both** inspector columns — without that, enabling the External column would notify every third-party inspector on every submission. + ### Inspector role scoping for `inspection_completed` `notify_by_matrix()` special-cases the **inspector** role for the `inspection_completed` event: instead of notifying every active inspector, it notifies **only the inspection's own inspector** (`Inspection.inspector_id`, resolved from the passed `inspection_id`). So enabling the "Inspector" column for "Inspection completed" in the matrix alerts just the inspector who submitted that inspection — not the whole inspector pool. All three dispatch sites (web `routes/inspections.py`, both mobile-API `api/inspections.py`) pass `inspection_id`, so the scoping applies uniformly; if `inspection_id` is ever omitted for this event, the inspector role notifies no one (fail-closed). Other roles/events are unaffected. @@ -888,7 +902,21 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase44_sched_end_date → phase45_sched_parent_insp → phase46_followup_req_by - → phase47_sched_acknowledged ← HEAD + → phase47_sched_acknowledged + → phase48_user_ui_theme + → phase49_external_inspector ← HEAD + +#### phase49 — External Inspector role + +Revision id `phase49_external_inspector` (file `phase49_external_inspector_role.py`, down_revision `phase48_user_ui_theme` — note phase48, the design A/B test, is the real head, NOT phase47). Adds `external_inspector` to the `users.role` ENUM. **Pure ENUM expansion** (adds a value, migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `external_inspector` rows to `inspector` first, which preserves their `InspectorAssignment` scoping exactly. + +**No matrix rows are seeded.** `MATRIX_DEFAULTS` mirrors every `('', 'inspector')` default into `('', 'external_inspector')` at import time, and `is_enabled()` falls back to that default when a row is absent — so an un-seeded install behaves identically to the Inspector column until an admin saves the matrix page. + +**Deploy order:** +```bash +flask db upgrade # expands users.role ENUM with 'external_inspector' +sudo systemctl restart gunicorn +``` ``` #### phase47 — scheduled inspection receipt acknowledgement @@ -1457,6 +1485,7 @@ timeout = 30 | 82 | **A schedule's recurrence columns must be CLEARED when they don't apply to the chosen frequency** | `_apply_recurrence()` in `routes/scheduled_inspections.py` is the single write path for `frequency` + `weekdays`/`month_mode`/`day_of_month`/`nth_week`/`nth_weekday`, and it NULLs the blocks that don't apply. Setting `sched.frequency` directly (as create/edit used to) leaves stale settings behind — a weekly→monthly switch would keep `weekdays` and `recurrence_label` would lie. The hidden form blocks still POST their values, so client-side hiding is not enough. | | 84 | **"Instructions" is a LABEL over `notes` — never rename the field, attribute, column or API key** | `ScheduledInspectionForm.notes` renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays `notes` (`api/scheduled.py::_scheduled_payload`), which is what `APIScheduledInspection.notes` decodes into `LocalScheduledInspection.notes`; the iPad exposes it through a computed `instructions` accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is `try?`-decoded, so it would fail to nil rather than throwing. | | 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. | +| 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues//photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. | --- diff --git a/app/api/comments.py b/app/api/comments.py index cd5ddcc..a57d64d 100644 --- a/app/api/comments.py +++ b/app/api/comments.py @@ -29,7 +29,8 @@ logger = logging.getLogger(__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: @@ -47,7 +48,7 @@ def _comment_payload(comment: IssueComment) -> dict: def _check_issue_access(issue: Issue, user) -> bool: """Return True if user may read/write this issue. False = 403.""" - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: diff --git a/app/api/inspections.py b/app/api/inspections.py index 46d0f0f..1e79968 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -35,7 +35,8 @@ logger = logging.getLogger(__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: @@ -121,7 +122,7 @@ def _resolve_schedule(schedule_id, user): logger.warning('API INSPECTIONS | unknown scheduled_inspection_id=%s from user=%s ' '— submitting unlinked', schedule_id, user.username) 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 | scheduled_inspection_id=%s not assigned to user=%s ' '— submitting unlinked', schedule_id, user.username) return None @@ -267,7 +268,7 @@ def list_inspections(): query = Inspection.query # Inspectors only see their own inspections - if user.role == 'inspector': + if user.is_inspector: query = query.filter(Inspection.inspector_id == user.id) # Optional filters @@ -598,7 +599,7 @@ def update_inspection(inspection_id): if inspection is None: 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) data = request.get_json(silent=True) or {} diff --git a/app/api/issues.py b/app/api/issues.py index ec23e3b..f8fbdb3 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -41,7 +41,8 @@ logger = logging.getLogger(__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_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} _VALID_HANDLERS = {'internal', 'facility', 'vendor'} @@ -153,7 +154,7 @@ def list_issues(): query = Issue.query - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) if not fids: return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset}) @@ -245,7 +246,7 @@ def create_issue(): if facility is None: return api_error('Facility not found', 404) - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) if not fids or facility_id not in fids: return api_error('Access denied — facility is not in your assigned contracts', 403) @@ -334,7 +335,7 @@ def get_issue(issue_id): if issue is None: return api_error('Issue not found', 404) - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: @@ -367,7 +368,7 @@ def update_issue_status(issue_id): if issue is None: return api_error('Issue not found', 404) - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: @@ -445,7 +446,7 @@ def update_issue_handler(issue_id): if issue is None: return api_error('Issue not found', 404) - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: @@ -515,7 +516,7 @@ def update_issue_photos(issue_id): if issue is None: return api_error('Issue not found', 404) - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: @@ -576,7 +577,7 @@ def update_issue_result_photos(issue_id): if issue is None: return api_error('Issue not found', 404) - if user.role == 'inspector': + if user.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: diff --git a/app/api/photos.py b/app/api/photos.py index a212677..0afbe8d 100644 --- a/app/api/photos.py +++ b/app/api/photos.py @@ -25,7 +25,8 @@ logger = logging.getLogger(__name__) bp = Blueprint('api_photos', __name__) _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: diff --git a/app/api/scheduled.py b/app/api/scheduled.py index a47e41d..459f158 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -32,7 +32,8 @@ logger = logging.getLogger(__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): @@ -107,7 +108,7 @@ def list_scheduled(): query = ScheduledInspection.query.filter(ScheduledInspection.active.is_(True)) - if user.role == 'inspector': + if user.is_inspector: # Inspectors only see schedules assigned directly to them. query = query.filter(ScheduledInspection.inspector_id == user.id) @@ -161,7 +162,8 @@ def create_follow_up(): user = g.api_user # 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) body = request.get_json(silent=True) or {} @@ -177,7 +179,7 @@ def create_follow_up(): # 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 # mobile API applies. Managers are unrestricted, matching the web. - if user.role == 'inspector': + if user.is_inspector: if parent.inspector_id != user.id: return api_error('Access denied', 403) fids = get_inspector_scope(user) diff --git a/app/api/stats.py b/app/api/stats.py index 41abe55..fc877a3 100644 --- a/app/api/stats.py +++ b/app/api/stats.py @@ -40,7 +40,8 @@ logger = logging.getLogger(__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']) @@ -74,7 +75,7 @@ def dashboard_stats(): today_end = today_start + timedelta(days=1) 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 # ── Today's inspections ─────────────────────────────────────────────── diff --git a/app/api/templates.py b/app/api/templates.py index b1f3f1c..195de63 100644 --- a/app/api/templates.py +++ b/app/api/templates.py @@ -27,7 +27,8 @@ logger = logging.getLogger(__name__) bp = Blueprint('api_templates', __name__) # 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: diff --git a/app/models/notification_matrix.py b/app/models/notification_matrix.py index dce56f7..3c28af2 100644 --- a/app/models/notification_matrix.py +++ b/app/models/notification_matrix.py @@ -14,6 +14,10 @@ inspector — all users with role='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 our own crew; the + 'inspection_completed' scoping above applies here too. project_manager — all users with role='project_manager' customer — all customer-portal users assigned to the relevant facility assignee — the specific user the issue/inspection is assigned to @@ -44,6 +48,7 @@ MATRIX_ROLES = [ ('admin', 'Admin'), ('director', 'Director'), ('inspector', 'Inspector'), + ('external_inspector', 'External Inspector'), ('project_manager', 'Project Manager'), ('auditor', 'Auditor'), ('customer', 'Customer'), @@ -175,6 +180,18 @@ MATRIX_DEFAULTS = { ('score_alert', 'custom'): False, } +# phase49 — 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): """Admin-controlled per-event notification routing.""" diff --git a/app/models/user.py b/app/models/user.py index 107e0aa..4048cbe 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -3,6 +3,20 @@ from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash 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 def load_user(user_id): from app import db @@ -11,6 +25,19 @@ def load_user(user_id): class User(UserMixin, db.Model): __tablename__ = 'users' + # ── Inspector roles (phase49) ───────────────────────────────────────────── + # 'external_inspector' is an inspector employed by the customer or a third + # party rather than by us. 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) username = db.Column(db.String(100), unique=True, nullable=False, index=True) full_name = db.Column(db.String(150), nullable=True) @@ -19,7 +46,8 @@ class User(UserMixin, db.Model): role = db.Column( # Phase 11 migration complete — 'supervisor' removed from both the DB # ENUM and this Python-side declaration. Director is the canonical role. - db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'), + db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', + 'auditor', 'external_inspector'), nullable=False ) created_at = db.Column(db.DateTime, default=now_eastern) @@ -63,6 +91,26 @@ class User(UserMixin, db.Model): def check_password(self, 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 def display_name(self): """Return full name if set, otherwise fall back to username.""" diff --git a/app/routes/auth.py b/app/routes/auth.py index 50c4491..15444f8 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -344,7 +344,7 @@ def edit_user(user_id): @admin_required def assign_inspector_contracts(user_id): user = db.session.get(User, user_id) - if user is None or user.role != 'inspector': + if user is None or not user.is_inspector: abort(404) from app.models.project import Project diff --git a/app/routes/broadcast.py b/app/routes/broadcast.py index 3d5e608..32e7f0c 100644 --- a/app/routes/broadcast.py +++ b/app/routes/broadcast.py @@ -22,10 +22,12 @@ logger = logging.getLogger(__name__) bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast') # 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 = { - 'inspector': 'Inspectors', + 'inspector': 'Inspectors', + 'external_inspector': 'External Inspectors', 'project_manager': 'Project Managers', 'director': 'Directors', 'admin': 'Admins', diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 1e7a278..fa0a2ce 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -37,7 +37,7 @@ def index(): # Start of the current week (Monday 00:00) for the "Submitted This Week" card. week_start = today_start - timedelta(days=today_start.weekday()) - is_inspector = current_user.role == 'inspector' + is_inspector = current_user.is_inspector is_privileged = current_user.role in ['admin', 'director'] is_customer = current_user.role == 'customer' is_project_manager = current_user.role == 'project_manager' @@ -336,7 +336,7 @@ def index(): if is_privileged or is_project_manager or is_auditor: active_inspectors = ( 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) .all() ) @@ -354,7 +354,11 @@ def index(): .all() ) inspector_activity = sorted( - [{'name': u.display_name, 'count': today_counts.get(u.id, 0)} + [{'name': u.display_name, + 'count': today_counts.get(u.id, 0), + # phase49 — flags customer / third-party inspectors so the table + # can badge them; internal and external are listed together. + 'external': u.is_external_inspector} for u in active_inspectors], key=lambda x: (-x['count'], x['name']), ) diff --git a/app/routes/facilities.py b/app/routes/facilities.py index 63a3b63..e064732 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -21,7 +21,7 @@ def list_facilities(): facilities = Facility.query.filter( Facility.id.in_(cids), Facility.active == True ).order_by(Facility.name).all() - elif current_user.role == 'inspector': + elif current_user.is_inspector: fids = get_inspector_scope(current_user) or [] facilities = Facility.query.filter( Facility.id.in_(fids), Facility.active == True @@ -210,7 +210,7 @@ def facility_qr_print_all(): assigned facilities; managers see all active facilities. """ # QR management is not an inspector task. - if current_user.role == 'inspector': + if current_user.is_inspector: abort(403) contract_id = request.args.get('contract_id', type=int) @@ -279,7 +279,7 @@ def facility_qr_export_pdf(): 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. """ - if current_user.role == 'inspector': + if current_user.is_inspector: abort(403) facility_ids = request.form.getlist('facility_ids', type=int) diff --git a/app/routes/inspections.py b/app/routes/inspections.py index fda90e0..99c3893 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -215,7 +215,7 @@ def index(): joinedload(Inspection.area), ).order_by(Inspection.inspection_date.desc()) - if current_user.role == 'inspector': + if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids: q = q.filter(False) @@ -284,12 +284,12 @@ def index(): q = q.filter(Inspection.overall_score <= float(score_max_filter)) except ValueError: 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)) 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 [] _fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) elif current_user.role == 'customer': @@ -316,9 +316,9 @@ def index(): 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) - if current_user.role != 'inspector': + if not current_user.is_inspector: 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()) else: inspectors = [] @@ -352,7 +352,7 @@ def start(): projects = Project.query.filter_by(active=True).order_by(Project.name).all() # Scope projects to inspector's assigned contracts - if current_user.role == 'inspector': + if current_user.is_inspector: from app.models.inspector_assignment import InspectorAssignment assigned_pids = { a.project_id for a in @@ -408,7 +408,7 @@ def start(): # Inspector facility scope check — prevent crafted POST from selecting # a facility outside their assigned contracts. - if current_user.role == 'inspector': + if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids or form.facility_id.data not in fids: abort(403) @@ -472,7 +472,7 @@ def execute(inspection_id): if inspection is None: 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') return redirect(url_for('inspections.index')) @@ -609,7 +609,8 @@ def execute(inspection_id): return redirect(url_for('inspections.execute', inspection_id=inspection_id)) 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, ).order_by(User.full_name, User.username).all() @@ -649,7 +650,7 @@ def save_draft_ajax(inspection_id): if inspection is None: 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 if inspection.status == 'completed': @@ -688,7 +689,7 @@ def upload_photo_ajax(inspection_id): if inspection is None: 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 if inspection.status == 'completed': @@ -715,7 +716,7 @@ def view(inspection_id): if inspection is None: 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') return redirect(url_for('inspections.index')) if current_user.role == 'customer': @@ -929,15 +930,20 @@ def flag_issue(inspection_id): if inspection is None: 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') return redirect(url_for('inspections.index')) 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.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] + 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(): photo_path = _save_photo(form.photo.data, subfolder='issue_photos') @@ -1019,7 +1025,7 @@ def export_list_pdf(): joinedload(Inspection.area), ).order_by(Inspection.inspection_date.desc()) - if current_user.role == 'inspector': + if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids: q = q.filter(False) @@ -1082,7 +1088,7 @@ def export_list_pdf(): q = q.filter(Inspection.overall_score <= float(score_max_filter)) except ValueError: 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)) inspections = q.all() @@ -1112,7 +1118,7 @@ def export_list_pdf(): filter_parts.append(f'Min score: {score_min_filter}%') if 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)) if u: filter_parts.append(f'Inspector: {u.display_name}') @@ -1142,7 +1148,7 @@ def export_pdf(inspection_id): if inspection is None: 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') return redirect(url_for('inspections.index')) if current_user.role == 'customer': diff --git a/app/routes/issues.py b/app/routes/issues.py index e77eddc..c9de50c 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -83,6 +83,19 @@ class _SLAFilteredPage: return iter([1]) + +def _assignee_label(user): + """Dropdown label for an assignee. + + phase49 — external (customer / third-party) inspectors are assignable just + like our 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') @login_required def export_list_pdf(): @@ -98,7 +111,7 @@ def export_list_pdf(): .order_by(Issue.reported_at.desc()) ) - if current_user.role == 'inspector': + if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids: q = q.filter(False) @@ -229,7 +242,7 @@ def index(): .order_by(Issue.reported_at.desc()) ) - if current_user.role == 'inspector': + if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids: q = q.filter(False) @@ -330,7 +343,7 @@ def index(): # Facilities for the filter dropdown — scoped for inspectors/customers, # 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 [] _fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) elif current_user.role == 'customer': @@ -358,7 +371,8 @@ def index(): # Staff for quick-assign dropdown — same roles as the full issue form 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() # Reporters dropdown — users who have actually filed at least one issue @@ -396,7 +410,7 @@ def view(issue_id): if issue is None: abort(404) - if current_user.role == 'inspector': + if current_user.is_inspector: fids = get_inspector_scope(current_user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: @@ -433,7 +447,7 @@ def view(issue_id): return redirect(url_for('issues.view', issue_id=issue_id)) 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 # (e.g. an admin assigned before admins were removed from the dropdown) so # saving the form doesn't silently unassign them. @@ -441,7 +455,9 @@ def view(issue_id): current_assignee = db.session.get(User, issue.assigned_to) if 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 if form.validate_on_submit(): @@ -760,10 +776,12 @@ def create(): else: facilities = Facility.query.filter_by(active=True).order_by(Facility.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.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 # belongs to so the contract selector can be restored on re-render. @@ -1142,7 +1160,7 @@ def export_pdf(issue_id): if issue is None: abort(404) - if current_user.role == 'inspector': + if current_user.is_inspector: fids = get_inspector_scope(current_user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: diff --git a/app/routes/reports.py b/app/routes/reports.py index 09e2279..67c56ec 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -57,7 +57,8 @@ def index(): # Inspectors get a scoped view of their own inspections and related issues. # Customers get a facility-scoped 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 flash('Access denied.', 'danger') return redirect(url_for('dashboard.index')) @@ -66,7 +67,7 @@ def index(): # Resolve scoping for customers (facility list) and inspectors (inspector_id) 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 = None @@ -262,7 +263,8 @@ def index(): inspectors = [] 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() facility_scores_list = [{ @@ -307,7 +309,8 @@ def index(): @bp.route('/facility/') @login_required 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 flash('Access denied.', 'danger') return redirect(url_for('dashboard.index')) @@ -320,7 +323,7 @@ def facility_report(facility_id): from flask import flash, redirect, url_for flash('Access denied.', 'danger') 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 # they have personally conducted at least one inspection. has_access = Inspection.query.filter_by( @@ -369,7 +372,8 @@ def facility_report(facility_id): def facility_scorecard(facility_id): """Comprehensive per-facility scorecard: score trend, SLA compliance, 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 flash('Access denied.', 'danger') return redirect(url_for('dashboard.index')) @@ -385,7 +389,7 @@ def facility_scorecard(facility_id): flash('Access denied.', 'danger') return redirect(url_for('reports.index')) - if current_user.role == 'inspector': + if current_user.is_inspector: has_access = Inspection.query.filter_by( facility_id=facility_id, inspector_id=current_user.id, @@ -701,7 +705,8 @@ def _build_inspector_stats(start, end): all_ids = set(total_map.keys()) active_inspectors = ( 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) .all() ) if all_ids else [] @@ -717,6 +722,10 @@ def _build_inspector_stats(start, end): inspector_stats.append({ 'id': u.id, 'display_name': u.display_name, + # phase49 — customer / third-party inspectors appear in the same + # table as our own crew, badged so the numbers can be read in + # context. Consumed by the HTML table and the Excel export. + 'external': u.is_external_inspector, 'total': tot, 'completed': comp, 'completion_rate': round(comp / tot * 100) if tot else 0, @@ -759,7 +768,7 @@ def inspector_performance(): if 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) trend_rows = db.session.query( @@ -857,7 +866,7 @@ def export_inspector_performance(): .filter( Inspection.inspection_date >= start, Inspection.inspection_date <= end, - User.role == 'inspector', + User.role.in_(User.INSPECTOR_ROLES), ) if selected_id: detail_q = detail_q.filter(Inspection.inspector_id == selected_id) @@ -931,7 +940,10 @@ def export_inspector_performance(): for row_idx, s in enumerate(inspector_stats, start=3): stripe = sub_fill if row_idx % 2 == 0 else None row_data = [ - s['display_name'], + # phase49 — external inspectors share this table with our own crew; + # suffixed rather than given a column so the index-based styling + # below (score = col 5, vs_avg = col 6, …) stays correct. + s['display_name'] + (' (External)' if s.get('external') else ''), s['total'], s['completed'], s['completion_rate'], @@ -1616,7 +1628,7 @@ def facility_summary_pdf(facility_id): cids = get_customer_scope(current_user) or [] if facility_id not in cids: abort(403) - elif current_user.role == 'inspector': + elif current_user.is_inspector: has = Inspection.query.filter_by(facility_id=facility_id, inspector_id=current_user.id).first() if not has: diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index 4821b62..7582825 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -82,7 +82,8 @@ def _populate_choices(form): facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() templates = (InspectionTemplate.query .filter_by(active=True).order_by(InspectionTemplate.name).all()) - 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.username).all()) form.facility_id.choices = [(f.id, f.name) for f in facilities] form.template_id.choices = [(t.id, t.name) for t in templates] @@ -236,7 +237,7 @@ def index(): base = ScheduledInspection.query # Inspectors see only their own assignments; managers see everything. - if current_user.role == 'inspector': + if current_user.is_inspector: base = base.filter(ScheduledInspection.inspector_id == current_user.id) pending_count = base.filter(ScheduledInspection.active.is_(True)).count() diff --git a/app/templates/auth/users.html b/app/templates/auth/users.html index 21e8f97..d7b5907 100644 --- a/app/templates/auth/users.html +++ b/app/templates/auth/users.html @@ -37,12 +37,12 @@ {{ user.full_name or '—' }} {{ user.email }} - - {{ user.role.replace('_',' ')|title }} + + {{ user.role_label }} - {% if user.role == 'inspector' %} + {% if user.is_inspector %} {% set cnt = inspector_contract_counts.get(user.id, 0) %} {% if cnt > 0 %} {{ cnt }} contract{{ 's' if cnt != 1 else '' }} @@ -65,7 +65,7 @@ - {% if user.role == 'inspector' %} + {% if user.is_inspector %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 1aaa96c..f56d259 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -253,7 +253,7 @@ Date Facility Area - {% if current_user.role != 'inspector' %}Inspector{% endif %} + {% if not current_user.is_inspector %}Inspector{% endif %} Score Status @@ -264,7 +264,7 @@ {{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }} {{ insp.facility.name }} {{ insp.area.name if insp.area else '—' }} - {% if current_user.role != 'inspector' %}{{ insp.inspector.display_name }}{% endif %} + {% if not current_user.is_inspector %}{{ insp.inspector.display_name }}{% endif %} {% if insp.overall_score %} @@ -314,7 +314,11 @@ {% for row in inspector_activity %} - {{ row.name }} + {{ row.name }} + {% if row.external %} + External + {% endif %} + {% if row.count > 0 %} {{ row.count }} diff --git a/app/templates/facilities/list.html b/app/templates/facilities/list.html index e9338c9..2ee19ff 100644 --- a/app/templates/facilities/list.html +++ b/app/templates/facilities/list.html @@ -8,7 +8,7 @@

Facilities

diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html index 75d5329..2eb33f2 100644 --- a/app/templates/issues/list.html +++ b/app/templates/issues/list.html @@ -177,7 +177,7 @@ diff --git a/app/templates/modern/dashboard.html b/app/templates/modern/dashboard.html index a0541c3..2828b77 100644 --- a/app/templates/modern/dashboard.html +++ b/app/templates/modern/dashboard.html @@ -235,7 +235,7 @@ Date Facility Name Area - {% if current_user.role != 'inspector' %}Inspector{% endif %} + {% if not current_user.is_inspector %}Inspector{% endif %} Score Status @@ -253,7 +253,7 @@ {{ insp.facility.name }} {{ insp.area.name if insp.area else '—' }} - {% if current_user.role != 'inspector' %}{{ insp.inspector.display_name }}{% endif %} + {% if not current_user.is_inspector %}{{ insp.inspector.display_name }}{% endif %} {% if insp.overall_score %} @@ -302,7 +302,11 @@ {% for row in inspector_activity %} - {{ row.name }} + {{ row.name }} + {% if row.external %} + External + {% endif %} + {% if row.count > 0 %} {{ row.count }} diff --git a/app/templates/modern/facilities/list.html b/app/templates/modern/facilities/list.html index 2e72214..5d2d9a5 100644 --- a/app/templates/modern/facilities/list.html +++ b/app/templates/modern/facilities/list.html @@ -16,7 +16,7 @@
- {% if current_user.role != 'inspector' %} + {% if not current_user.is_inspector %}
@@ -79,7 +79,7 @@ All Facilities
- {% if current_user.role != 'inspector' %} + {% if not current_user.is_inspector %} Print All QR Codes diff --git a/app/templates/modern/issues/list.html b/app/templates/modern/issues/list.html index d5c52ef..8dc2ace 100644 --- a/app/templates/modern/issues/list.html +++ b/app/templates/modern/issues/list.html @@ -206,7 +206,7 @@ diff --git a/app/templates/reports/inspector_performance.html b/app/templates/reports/inspector_performance.html index a85ddf7..64648a5 100644 --- a/app/templates/reports/inspector_performance.html +++ b/app/templates/reports/inspector_performance.html @@ -115,6 +115,9 @@ data-inspector-id="{{ s.id }}"> {{ s.display_name }} + {% if s.external %} + External + {% endif %} {{ s.total }} {{ s.completed }} @@ -189,6 +192,9 @@
{{ selected_inspector.display_name }} + {% if selected_inspector.is_external_inspector %} + External + {% endif %}
diff --git a/app/utils/forms.py b/app/utils/forms.py index 1d24de6..e37e10e 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -54,6 +54,10 @@ class UserForm(FlaskForm): ('admin', 'Administrator'), ('director', 'Director'), ('inspector', 'Inspector'), + # phase49 — an inspector employed by the customer or a third party. + # Same capabilities as 'inspector'; scoped to the contracts assigned on + # the Assign Contracts page (see User.INSPECTOR_ROLES). + ('external_inspector', 'External Inspector'), ('project_manager', 'Project Manager'), ('auditor', 'Auditor'), # 'customer' is intentionally excluded — customer accounts are managed via /customers diff --git a/app/utils/notifications.py b/app/utils/notifications.py index 0a0bca4..07cf561 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -565,7 +565,8 @@ def notify_by_matrix( role_to_db = { 'admin': 'admin', 'director': 'director', - 'inspector': 'inspector', + 'inspector': 'inspector', + 'external_inspector': 'external_inspector', 'project_manager': 'project_manager', 'auditor': 'auditor', 'customer': 'customer', @@ -594,15 +595,17 @@ def notify_by_matrix( # OWN inspector (the person who did the work), not the whole inspector # pool. Without this, enabling the Inspector column for this event would # notify every inspector on every submission. - if role_key == 'inspector' and event_type == 'inspection_completed': + if (role_key in ('inspector', 'external_inspector') + and event_type == 'inspection_completed'): target_id = None if inspection_id: from app.models.inspection import Inspection insp = db.session.get(Inspection, inspection_id) target_id = insp.inspector_id if insp else None 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 ' - 'submitting inspector_id=%s', event_type, target_id) + logger.info('MATRIX NOTIFY | event=%s | role=%s scoped to ' + 'submitting inspector_id=%s', + event_type, role_key, target_id) # Scope customer role to facility if provided if role_key == 'customer' and facility_id: diff --git a/app/utils/scope.py b/app/utils/scope.py index 2748b47..8f67d77 100644 --- a/app/utils/scope.py +++ b/app/utils/scope.py @@ -8,6 +8,8 @@ Facility-scoping utilities for the Janitorial QC portal. get_inspector_scope(user) -> list[int] | None 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, meaning they see nothing (strict mode). @@ -90,7 +92,12 @@ def get_inspector_scope(user) -> list[int] | None: None Returned for non-inspector roles, indicating unrestricted access. """ - if user.role != 'inspector': + # phase49: 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 from app.models.inspector_assignment import InspectorAssignment diff --git a/migrations/versions/phase49_external_inspector_role.py b/migrations/versions/phase49_external_inspector_role.py new file mode 100644 index 0000000..397f992 --- /dev/null +++ b/migrations/versions/phase49_external_inspector_role.py @@ -0,0 +1,56 @@ +"""phase49 — 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 us. 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, 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. + +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 install behaves +exactly like the Inspector column until an admin saves the matrix page. +""" + +revision = 'phase49_external_inspector' +down_revision = 'phase48_user_ui_theme' +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" + ))