Aug 6 - Add external inspector role

This commit is contained in:
2026-08-06 10:40:33 -04:00
parent 0830585ff5
commit c988f8cabb
32 changed files with 329 additions and 99 deletions
+2 -1
View File
@@ -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(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(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 \"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 *)"
] ]
} }
} }
+31 -2
View File
@@ -193,7 +193,13 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
password_set, set_password_token (indexed), set_password_token_expires 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/<id>/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. **`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') @project_manager_required # role in ('admin', 'director', 'project_manager', 'auditor')
@issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete) @issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete)
@customer_required # role == 'customer' only @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 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` ### 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. `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 → phase44_sched_end_date
→ phase45_sched_parent_insp → phase45_sched_parent_insp
→ phase46_followup_req_by → 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 `('<event>', 'inspector')` default into `('<event>', '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 #### 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. | | 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. | | 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. | | 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/<id>/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. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/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. |
--- ---
+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:
@@ -121,7 +122,7 @@ def _resolve_schedule(schedule_id, user):
logger.warning('API INSPECTIONS | unknown scheduled_inspection_id=%s from user=%s ' logger.warning('API INSPECTIONS | unknown scheduled_inspection_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 | scheduled_inspection_id=%s not assigned to user=%s ' logger.warning('API INSPECTIONS | scheduled_inspection_id=%s not assigned to user=%s '
'— submitting unlinked', schedule_id, user.username) '— submitting unlinked', schedule_id, user.username)
return None return None
@@ -267,7 +268,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
@@ -598,7 +599,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
@@ -41,7 +41,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'}
@@ -153,7 +154,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})
@@ -245,7 +246,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)
@@ -334,7 +335,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:
@@ -367,7 +368,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:
@@ -445,7 +446,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:
@@ -515,7 +516,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:
@@ -576,7 +577,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:
+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
@@ -32,7 +32,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):
@@ -107,7 +108,7 @@ def list_scheduled():
query = ScheduledInspection.query.filter(ScheduledInspection.active.is_(True)) query = ScheduledInspection.query.filter(ScheduledInspection.active.is_(True))
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(ScheduledInspection.inspector_id == user.id) query = query.filter(ScheduledInspection.inspector_id == user.id)
@@ -161,7 +162,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 {}
@@ -177,7 +179,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:
+17
View File
@@ -14,6 +14,10 @@ inspector — all users with role='inspector'
column notifies ONLY the inspection's own inspector column notifies ONLY the inspection's own inspector
(the submitter), not the whole inspector pool. Scoping is (the submitter), not the whole inspector pool. Scoping is
applied in notify_by_matrix() via the inspection_id. 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' 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
@@ -44,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'),
@@ -175,6 +180,18 @@ MATRIX_DEFAULTS = {
('score_alert', 'custom'): False, ('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): class NotificationMatrix(db.Model):
"""Admin-controlled per-event notification routing.""" """Admin-controlled per-event notification routing."""
+49 -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 (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) 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,8 @@ 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'), 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)
@@ -63,6 +91,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."""
+1 -1
View File
@@ -344,7 +344,7 @@ def edit_user(user_id):
@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': 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',
+7 -3
View File
@@ -37,7 +37,7 @@ def index():
# Start of the current week (Monday 00:00) for the "Submitted This Week" card. # Start of the current week (Monday 00:00) for the "Submitted This Week" card.
week_start = today_start - timedelta(days=today_start.weekday()) 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_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'
@@ -336,7 +336,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()
) )
@@ -354,7 +354,11 @@ def index():
.all() .all()
) )
inspector_activity = sorted( 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], for u in active_inspectors],
key=lambda x: (-x['count'], x['name']), key=lambda x: (-x['count'], x['name']),
) )
+3 -3
View File
@@ -21,7 +21,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
@@ -210,7 +210,7 @@ def facility_qr_print_all():
assigned facilities; managers see all active facilities. assigned facilities; managers see all active facilities.
""" """
# QR management is not an inspector task. # QR management is not an inspector task.
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)
@@ -279,7 +279,7 @@ def facility_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)
+25 -19
View File
@@ -215,7 +215,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)
@@ -284,12 +284,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':
@@ -316,9 +316,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 = []
@@ -352,7 +352,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
@@ -408,7 +408,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)
@@ -472,7 +472,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'))
@@ -609,7 +609,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()
@@ -649,7 +650,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':
@@ -688,7 +689,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':
@@ -715,7 +716,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':
@@ -929,15 +930,20 @@ 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] 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')
@@ -1019,7 +1025,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)
@@ -1082,7 +1088,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()
@@ -1112,7 +1118,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}')
@@ -1142,7 +1148,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':
+28 -10
View File
@@ -83,6 +83,19 @@ class _SLAFilteredPage:
return iter([1]) 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') @bp.route('/export-list-pdf')
@login_required @login_required
def export_list_pdf(): def export_list_pdf():
@@ -98,7 +111,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)
@@ -229,7 +242,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)
@@ -330,7 +343,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':
@@ -358,7 +371,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
@@ -396,7 +410,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:
@@ -433,7 +447,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.
@@ -441,7 +455,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():
@@ -760,10 +776,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.
@@ -1142,7 +1160,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:
+24 -12
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,
# 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, '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)
@@ -931,7 +940,10 @@ def export_inspector_performance():
for row_idx, s in enumerate(inspector_stats, start=3): for row_idx, s in enumerate(inspector_stats, start=3):
stripe = sub_fill if row_idx % 2 == 0 else None stripe = sub_fill if row_idx % 2 == 0 else None
row_data = [ 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['total'],
s['completed'], s['completed'],
s['completion_rate'], s['completion_rate'],
@@ -1616,7 +1628,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:
+3 -2
View File
@@ -82,7 +82,8 @@ def _populate_choices(form):
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
templates = (InspectionTemplate.query templates = (InspectionTemplate.query
.filter_by(active=True).order_by(InspectionTemplate.name).all()) .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()) .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.template_id.choices = [(t.id, t.name) for t in templates] form.template_id.choices = [(t.id, t.name) for t in templates]
@@ -236,7 +237,7 @@ def index():
base = ScheduledInspection.query base = ScheduledInspection.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(ScheduledInspection.inspector_id == current_user.id) base = base.filter(ScheduledInspection.inspector_id == current_user.id)
pending_count = base.filter(ScheduledInspection.active.is_(True)).count() pending_count = base.filter(ScheduledInspection.active.is_(True)).count()
+4 -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,7 +65,7 @@
<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>
+7 -3
View File
@@ -253,7 +253,7 @@
<th>Date</th> <th>Date</th>
<th>Facility</th> <th>Facility</th>
<th>Area</th> <th>Area</th>
{% if current_user.role != 'inspector' %}<th>Inspector</th>{% endif %} {% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
<th>Score</th> <th>Score</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
@@ -264,7 +264,7 @@
<td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td> <td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ insp.facility.name }}</td> <td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td> <td>{{ insp.area.name if insp.area else '—' }}</td>
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.display_name }}</td>{% endif %} {% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
<td> <td>
{% if insp.overall_score %} {% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}"> <span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
@@ -314,7 +314,11 @@
<tbody> <tbody>
{% for row in inspector_activity %} {% for row in inspector_activity %}
<tr class="{{ 'table-success' if row.count > 0 else '' }}"> <tr class="{{ 'table-success' if row.count > 0 else '' }}">
<td>{{ row.name }}</td> <td>{{ row.name }}
{% if row.external %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">External</span>
{% endif %}
</td>
<td class="text-center"> <td class="text-center">
{% if row.count > 0 %} {% if row.count > 0 %}
<span class="badge bg-success">{{ row.count }}</span> <span class="badge bg-success">{{ row.count }}</span>
+1 -1
View File
@@ -8,7 +8,7 @@
<h2><i class="bi bi-building"></i> Facilities</h2> <h2><i class="bi bi-building"></i> Facilities</h2>
</div> </div>
<div class="col-md-6 text-end"> <div class="col-md-6 text-end">
{% if current_user.role != 'inspector' %} {% if not current_user.is_inspector %}
<a href="{{ url_for('facilities.facility_qr_print_all') }}" <a href="{{ url_for('facilities.facility_qr_print_all') }}"
class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes"> class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
<i class="bi bi-qr-code"></i> Print All QR Codes <i class="bi bi-qr-code"></i> Print All QR Codes
+1 -1
View File
@@ -579,7 +579,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
@@ -177,7 +177,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>
+7 -3
View File
@@ -235,7 +235,7 @@
<th>Date</th> <th>Date</th>
<th>Facility Name</th> <th>Facility Name</th>
<th>Area</th> <th>Area</th>
{% if current_user.role != 'inspector' %}<th>Inspector</th>{% endif %} {% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
<th>Score</th> <th>Score</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
@@ -253,7 +253,7 @@
</td> </td>
<td>{{ insp.facility.name }}</td> <td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td> <td>{{ insp.area.name if insp.area else '—' }}</td>
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.display_name }}</td>{% endif %} {% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
<td> <td>
{% if insp.overall_score %} {% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}"> <span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
@@ -302,7 +302,11 @@
<tbody> <tbody>
{% for row in inspector_activity %} {% for row in inspector_activity %}
<tr> <tr>
<td>{{ row.name }}</td> <td>{{ row.name }}
{% if row.external %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">External</span>
{% endif %}
</td>
<td class="text-center"> <td class="text-center">
{% if row.count > 0 %} {% if row.count > 0 %}
<span class="badge bg-success">{{ row.count }}</span> <span class="badge bg-success">{{ row.count }}</span>
+2 -2
View File
@@ -16,7 +16,7 @@
</div> </div>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
{% if current_user.role != 'inspector' %} {% if not current_user.is_inspector %}
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('facilities.facility_qr_print_all') }}"> <a class="jqc-hub-card" href="{{ url_for('facilities.facility_qr_print_all') }}">
<div class="d-flex gap-4 align-items-start"> <div class="d-flex gap-4 align-items-start">
@@ -79,7 +79,7 @@
<i class="bi bi-building"></i> All Facilities <i class="bi bi-building"></i> All Facilities
</h2> </h2>
<div> <div>
{% if current_user.role != 'inspector' %} {% if not current_user.is_inspector %}
<a href="{{ url_for('facilities.facility_qr_print_all') }}" <a href="{{ url_for('facilities.facility_qr_print_all') }}"
class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes"> class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
<i class="bi bi-qr-code"></i> Print All QR Codes <i class="bi bi-qr-code"></i> Print All QR Codes
+1 -1
View File
@@ -206,7 +206,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
@@ -54,6 +54,10 @@ class UserForm(FlaskForm):
('admin', 'Administrator'), ('admin', 'Administrator'),
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('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'), ('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
@@ -565,7 +565,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',
@@ -594,15 +595,17 @@ def notify_by_matrix(
# OWN inspector (the person who did the work), not the whole inspector # OWN inspector (the person who did the work), not the whole inspector
# pool. Without this, enabling the Inspector column for this event would # pool. Without this, enabling the Inspector column for this event would
# notify every inspector on every submission. # 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 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': # 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 return None
from app.models.inspector_assignment import InspectorAssignment from app.models.inspector_assignment import InspectorAssignment
@@ -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"
))