05/25 Update inspectors contract assignment

This commit is contained in:
2026-05-25 13:58:37 -04:00
parent e703675370
commit 688308fca2
13 changed files with 463 additions and 79 deletions
+12 -3
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase. > **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
> **Last reviewed:** May 2026 (Phase 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal) > **Last reviewed:** May 2026 (Phase 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal, inspector contract scoping)
--- ---
@@ -179,6 +179,10 @@ areas: id, facility_id (FK), name, area_type
projects: id, name, description, project_manager_id, active, created_at projects: id, name, description, project_manager_id, active, created_at
customer_assignments: id, user_id, project_id, facility_id (nullable) customer_assignments: id, user_id, project_id, facility_id (nullable)
UniqueConstraint(user_id, project_id, facility_id) UniqueConstraint(user_id, project_id, facility_id)
inspector_assignments: id, user_id, project_id, created_at
UniqueConstraint(user_id, project_id, name='uq_inspector_project')
ForeignKey user_id → users(id) ON DELETE CASCADE
ForeignKey project_id → projects(id) ON DELETE CASCADE
``` ```
### Inspection ### Inspection
@@ -318,7 +322,8 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session. `log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session.
### `scope.py` ### `scope.py`
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff. `get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers.
`get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities.
### `forms.py` ### `forms.py`
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role. All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role.
@@ -522,7 +527,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase15_audit_log_indexes → phase16_notifications_columns → phase15_audit_log_indexes → phase16_notifications_columns
→ phase17_notification_event_type → phase17_notification_event_type
→ phase18_issue_reported_by → phase18_issue_reported_by
→ phase19_issue_mobile_photos ← HEAD → phase19_issue_mobile_photos
→ phase20_inspector_assignments ← HEAD
``` ```
### phase19_issue_mobile_photos ### phase19_issue_mobile_photos
@@ -684,6 +690,9 @@ timeout = 30
| 54 | **Bulk issue verification via `POST /issues/bulk-verify`** | `@supervisor_required`. Accepts `issue_ids` list from form. Skips issues not in `resolved` or `pending_verification` state. Calls `log_action()` after `db.session.commit()` per rule 10. | | 54 | **Bulk issue verification via `POST /issues/bulk-verify`** | `@supervisor_required`. Accepts `issue_ids` list from form. Skips issues not in `resolved` or `pending_verification` state. Calls `log_action()` after `db.session.commit()` per rule 10. |
| 55 | **Scheduled "issues" report groups by facility with SLA status** | `_build_report_data()` now produces `issues_by_facility` (list of `(facility_name, [(issue, sla), ...])`) and `sla_breached`/`sla_at_risk` counts alongside the flat `issues` list. CSV builder uses `resolved_facility` (not `area.facility`) to avoid crash when `area_id` is None. | | 55 | **Scheduled "issues" report groups by facility with SLA status** | `_build_report_data()` now produces `issues_by_facility` (list of `(facility_name, [(issue, sla), ...])`) and `sla_breached`/`sla_at_risk` counts alongside the flat `issues` list. CSV builder uses `resolved_facility` (not `area.facility`) to avoid crash when `area_id` is None. |
| 56 | **Customer role: `POST` to `issues.view` returns 403** | The `view()` route checks `request.method == 'POST'` inside the customer scope block and calls `abort(403)`. Customers have read-only access; the template already hides the update form, but server-side enforcement is required against crafted requests. | | 56 | **Customer role: `POST` to `issues.view` returns 403** | The `view()` route checks `request.method == 'POST'` inside the customer scope block and calls `abort(403)`. Customers have read-only access; the template already hides the update form, but server-side enforcement is required against crafted requests. |
| 57 | **Inspector contract scoping: `get_inspector_scope()` — strict, no fallback** | Inspectors with NO `InspectorAssignment` rows see nothing (empty list, not `None`). Returns `None` only for non-inspector roles. All routes and API endpoints that currently filter by `inspector_id` or `assigned_to/reported_by` must instead filter by the facility list returned by `get_inspector_scope()`. |
| 58 | **Inspector scope covers all data in contracted facilities, not just own work** | Facility list, inspection list, issue list — all scoped to contracted facilities. Dashboard personal stats (today's work, avg score, trend) additionally filter by `inspector_id` so the productivity view stays personal. Issues show ALL facility issues, not just assigned ones. |
| 59 | **`assign_inspector_contracts` route replaces the entire assignment set on POST** | The form sends the full checked list; existing assignments not in the POST body are deleted, new ones are inserted. Callers must always POST the complete desired set, not a diff. |
--- ---
+27 -14
View File
@@ -23,7 +23,7 @@ from app.models.facility import Facility, Area
from app.models.project import Project from app.models.project import Project
from app.api.errors import api_ok, api_error from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required from app.api.decorators import jwt_required
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope, get_inspector_scope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,27 +92,35 @@ def list_facilities():
""" """
user = g.api_user user = g.api_user
# Customer role: honour facility-level scoping customer_fids = get_customer_scope(user)
facility_ids = get_customer_scope(user) inspector_fids = get_inspector_scope(user)
if facility_ids is not None: if customer_fids is not None:
# Customer — scope to assigned facilities only # Customer — scope to assigned facilities only
if not facility_ids: if not customer_fids:
logger.info('API FACILITIES | user=%s | role=customer | no_assignments', logger.info('API FACILITIES | user=%s | role=customer | no_assignments',
user.username) user.username)
return api_ok({'facilities': [], 'count': 0}) return api_ok({'facilities': [], 'count': 0})
facilities = ( facilities = (
Facility.query Facility.query
.filter( .filter(Facility.id.in_(customer_fids), Facility.active == True)
Facility.id.in_(facility_ids), .order_by(Facility.name)
Facility.active == True, # noqa: E712 .all()
) )
elif inspector_fids is not None:
# Inspector — scope to contracted facilities
if not inspector_fids:
logger.info('API FACILITIES | user=%s | role=inspector | no_assignments',
user.username)
return api_ok({'facilities': [], 'count': 0})
facilities = (
Facility.query
.filter(Facility.id.in_(inspector_fids), Facility.active == True)
.order_by(Facility.name) .order_by(Facility.name)
.all() .all()
) )
else: else:
# Internal staff — all active facilities # All other staff — all active facilities
facilities = ( facilities = (
Facility.query Facility.query
.filter(Facility.active == True) # noqa: E712 .filter(Facility.active == True) # noqa: E712
@@ -159,9 +167,14 @@ def list_areas(facility_id):
if facility is None or not facility.active: if facility is None or not facility.active:
return api_error('Facility not found', 404) return api_error('Facility not found', 404)
# Customer scope validation — ensure the customer is assigned to this facility # Scope validation — customers and inspectors may only access their facilities
facility_ids = get_customer_scope(user) customer_fids = get_customer_scope(user)
if facility_ids is not None and facility_id not in facility_ids: inspector_fids = get_inspector_scope(user)
if customer_fids is not None and facility_id not in customer_fids:
logger.warning('API FACILITIES/AREAS | access denied | user=%s | facility_id=%d',
user.username, facility_id)
return api_error('Access denied', 403)
if inspector_fids is not None and facility_id not in inspector_fids:
logger.warning('API FACILITIES/AREAS | access denied | user=%s | facility_id=%d', logger.warning('API FACILITIES/AREAS | access denied | user=%s | facility_id=%d',
user.username, facility_id) user.username, facility_id)
return api_error('Access denied', 403) return api_error('Access denied', 403)
+27 -13
View File
@@ -35,6 +35,7 @@ from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
from app.utils.notifications import notify_by_matrix from app.utils.notifications import notify_by_matrix
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from app.utils.scope import get_inspector_scope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -112,14 +113,13 @@ def list_issues():
query = Issue.query query = Issue.query
if user.role == 'inspector': if user.role == 'inspector':
# Inspectors see issues assigned to them OR issues they reported. fids = get_inspector_scope(user)
# The reported_by path covers issues created on the iPad that haven't if not fids:
# been assigned yet (assigned_to is NULL until a director assigns them). return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
# Pre-phase18 rows with reported_by = NULL still surface via assigned_to. query = query.outerjoin(Area, Issue.area_id == Area.id).filter(
query = query.filter(
db.or_( db.or_(
Issue.assigned_to == user.id, Issue.facility_id.in_(fids),
Issue.reported_by == user.id, db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
) )
) )
else: else:
@@ -204,6 +204,11 @@ 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':
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)
inspection_id = data.get('inspection_id') inspection_id = data.get('inspection_id')
if inspection_id: if inspection_id:
inspection = db.session.get(Inspection, inspection_id) inspection = db.session.get(Inspection, inspection_id)
@@ -290,8 +295,11 @@ 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' and issue.assigned_to != user.id and issue.reported_by != user.id: if user.role == 'inspector':
return api_error('Access denied', 403) fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return api_error('Access denied', 403)
return api_ok(_issue_payload(issue)) return api_ok(_issue_payload(issue))
@@ -320,8 +328,11 @@ 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' and issue.assigned_to != user.id and issue.reported_by != user.id: if user.role == 'inspector':
return api_error('Access denied — you can only update issues assigned to or reported by you', 403) fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
new_status = (data.get('status') or '').strip().lower() new_status = (data.get('status') or '').strip().lower()
@@ -382,8 +393,11 @@ 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' and issue.assigned_to != user.id and issue.reported_by != user.id: if user.role == 'inspector':
return api_error('Access denied', 403) fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
raw = data.get('result_photos') raw = data.get('result_photos')
+25
View File
@@ -0,0 +1,25 @@
from app import db
from app.utils.time_utils import now_eastern
class InspectorAssignment(db.Model):
__tablename__ = 'inspector_assignments'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
project_id = db.Column(db.Integer,
db.ForeignKey('projects.id', ondelete='CASCADE'),
nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
inspector = db.relationship('User', backref='inspector_assignments')
project = db.relationship('Project', backref='inspector_assignments')
__table_args__ = (
db.UniqueConstraint('user_id', 'project_id', name='uq_inspector_project'),
)
def __repr__(self):
return f'<InspectorAssignment user={self.user_id} project={self.project_id}>'
+62 -1
View File
@@ -120,9 +120,24 @@ def list_users():
.order_by(User.created_at.desc()) .order_by(User.created_at.desc())
.all() .all()
) )
# Build a map of inspector_id -> assignment count for the Contracts column
from app.models.inspector_assignment import InspectorAssignment
from sqlalchemy import func
rows = (
db.session.query(
InspectorAssignment.user_id,
func.count(InspectorAssignment.id).label('cnt'),
)
.group_by(InspectorAssignment.user_id)
.all()
)
inspector_contract_counts = {r.user_id: r.cnt for r in rows}
logger.info('AUTH | list_users | admin=%s | internal_users_count=%s', logger.info('AUTH | list_users | admin=%s | internal_users_count=%s',
current_user.username, len(users)) current_user.username, len(users))
return render_template('auth/users.html', users=users) return render_template('auth/users.html', users=users,
inspector_contract_counts=inspector_contract_counts)
@bp.route('/users/new', methods=['GET', 'POST']) @bp.route('/users/new', methods=['GET', 'POST'])
@@ -194,6 +209,52 @@ def edit_user(user_id):
title='Edit User', director_editing=director_editing) title='Edit User', director_editing=director_editing)
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
@login_required
@admin_required
def assign_inspector_contracts(user_id):
user = db.session.get(User, user_id)
if user is None or user.role != 'inspector':
abort(404)
from app.models.project import Project
from app.models.inspector_assignment import InspectorAssignment
from app.utils.time_utils import now_eastern
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
if request.method == 'POST':
selected_ids = set(request.form.getlist('project_ids', type=int))
existing = InspectorAssignment.query.filter_by(user_id=user_id).all()
existing_pids = {a.project_id for a in existing}
for a in existing:
if a.project_id not in selected_ids:
db.session.delete(a)
for pid in selected_ids:
if pid not in existing_pids:
db.session.add(InspectorAssignment(
user_id = user_id,
project_id = pid,
created_at = now_eastern(),
))
db.session.commit()
log_action(ACTION_UPDATE, 'User', user.id, user.username,
f'inspector_assignments={sorted(selected_ids)}')
flash(f'Contract assignments updated for {user.display_name}.', 'success')
return redirect(url_for('auth.list_users'))
assigned_pids = {
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=user_id).all()
}
return render_template('auth/inspector_assignments.html',
user=user,
projects=projects,
assigned_pids=assigned_pids)
@bp.route('/users/<int:user_id>/delete', methods=['POST']) @bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
+74 -19
View File
@@ -7,7 +7,7 @@ from app.models.facility import Facility
from app.models.issue import Issue from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.utils.sla import sla_status, SLA_HOURS from app.utils.sla import sla_status, SLA_HOURS
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope, get_inspector_scope
from sqlalchemy import func from sqlalchemy import func
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
@@ -31,13 +31,20 @@ def index():
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'
# Resolve facility scope for customer users # Resolve facility scope
customer_facility_ids = get_customer_scope(current_user) # None for non-customers customer_facility_ids = get_customer_scope(current_user) # None for non-customers
inspector_facility_ids = get_inspector_scope(current_user) # None for non-inspectors
# ── Today's stats ───────────────────────────────────────────────────── # ── Today's stats (inspector: own work within contracted facilities) ───
base_q = Inspection.query base_q = Inspection.query
if is_inspector: if is_inspector:
base_q = base_q.filter(Inspection.inspector_id == current_user.id) if not inspector_facility_ids:
base_q = base_q.filter(False)
else:
base_q = base_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer: elif is_customer:
if not customer_facility_ids: if not customer_facility_ids:
base_q = base_q.filter(False) # no access base_q = base_q.filter(False) # no access
@@ -55,12 +62,19 @@ def index():
Inspection.inspection_date < today_end, Inspection.inspection_date < today_end,
).count() ).count()
# ── Open issues ──────────────────────────────────────────────────────── # ── Open issues (inspector: all issues in contracted facilities) ───────
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_inspector: if is_inspector:
open_issues_q = open_issues_q.join( if not inspector_facility_ids:
Inspection, Issue.inspection_id == Inspection.id open_issues_q = open_issues_q.filter(False)
).filter(Inspection.inspector_id == current_user.id) else:
from app.models.facility import Area
open_issues_q = open_issues_q.outerjoin(
Area, Issue.area_id == Area.id
).filter(db.or_(
Issue.facility_id.in_(inspector_facility_ids),
Area.facility_id.in_(inspector_facility_ids)
))
elif is_customer: elif is_customer:
if not customer_facility_ids: if not customer_facility_ids:
open_issues_q = open_issues_q.filter(False) open_issues_q = open_issues_q.filter(False)
@@ -83,14 +97,20 @@ def index():
'low': sum(1 for i in open_issues_all if i.severity == 'low'), 'low': sum(1 for i in open_issues_all if i.severity == 'low'),
} }
# ── Average score (last 30 days) ─────────────────────────────────────── # ── Average score (last 30 days, inspector: own work in contracted facilities)
score_q = db.session.query(func.avg(Inspection.overall_score)).filter( score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.status == 'completed', Inspection.status == 'completed',
Inspection.overall_score.isnot(None), Inspection.overall_score.isnot(None),
Inspection.inspection_date >= thirty_days_ago, Inspection.inspection_date >= thirty_days_ago,
) )
if is_inspector: if is_inspector:
score_q = score_q.filter(Inspection.inspector_id == current_user.id) if not inspector_facility_ids:
score_q = score_q.filter(False)
else:
score_q = score_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer: elif is_customer:
if customer_facility_ids: if customer_facility_ids:
score_q = score_q.filter(Inspection.facility_id.in_(customer_facility_ids)) score_q = score_q.filter(Inspection.facility_id.in_(customer_facility_ids))
@@ -101,7 +121,13 @@ def index():
# ── Recent inspections ───────────────────────────────────────────────── # ── Recent inspections ─────────────────────────────────────────────────
recent_q = Inspection.query.order_by(Inspection.inspection_date.desc()) recent_q = Inspection.query.order_by(Inspection.inspection_date.desc())
if is_inspector: if is_inspector:
recent_q = recent_q.filter(Inspection.inspector_id == current_user.id) if not inspector_facility_ids:
recent_q = recent_q.filter(False)
else:
recent_q = recent_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer: elif is_customer:
if customer_facility_ids: if customer_facility_ids:
recent_q = recent_q.filter(Inspection.facility_id.in_(customer_facility_ids)) recent_q = recent_q.filter(Inspection.facility_id.in_(customer_facility_ids))
@@ -119,7 +145,13 @@ def index():
follow_up_required=True, status='completed' follow_up_required=True, status='completed'
).filter(~Inspection.follow_ups.any()) ).filter(~Inspection.follow_ups.any())
if is_inspector: if is_inspector:
followup_q = followup_q.filter(Inspection.inspector_id == current_user.id) if not inspector_facility_ids:
followup_q = followup_q.filter(False)
else:
followup_q = followup_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer: elif is_customer:
if customer_facility_ids: if customer_facility_ids:
followup_q = followup_q.filter(Inspection.facility_id.in_(customer_facility_ids)) followup_q = followup_q.filter(Inspection.facility_id.in_(customer_facility_ids))
@@ -140,9 +172,17 @@ def index():
Facility.active == True, Facility.active == True,
).order_by(Facility.name).all() ).order_by(Facility.name).all()
# ── SLA summary (open + in_progress issues only) ────────────────────── # ── SLA summary (open + in_progress issues, scoped) ───────────────────
sla_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) sla_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_customer and customer_facility_ids: if is_inspector and inspector_facility_ids:
from app.models.facility import Area
sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(inspector_facility_ids),
Area.facility_id.in_(inspector_facility_ids)
)
)
elif is_customer and customer_facility_ids:
from app.models.facility import Area from app.models.facility import Area
sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter( sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_( db.or_(
@@ -150,9 +190,14 @@ def index():
Area.facility_id.in_(customer_facility_ids) Area.facility_id.in_(customer_facility_ids)
) )
) )
all_open_issues = sla_q.all() if not is_customer or customer_facility_ids else [] if is_inspector and not inspector_facility_ids:
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached') all_open_issues = []
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk') elif is_customer and not customer_facility_ids:
all_open_issues = []
else:
all_open_issues = sla_q.all()
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
# ── Score trend (last 30 days, grouped by day) ──────────────────────── # ── Score trend (last 30 days, grouped by day) ────────────────────────
trend_q = ( trend_q = (
@@ -167,7 +212,13 @@ def index():
) )
) )
if is_inspector: if is_inspector:
trend_q = trend_q.filter(Inspection.inspector_id == current_user.id) if not inspector_facility_ids:
trend_q = trend_q.filter(False)
else:
trend_q = trend_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer: elif is_customer:
if customer_facility_ids: if customer_facility_ids:
trend_q = trend_q.filter(Inspection.facility_id.in_(customer_facility_ids)) trend_q = trend_q.filter(Inspection.facility_id.in_(customer_facility_ids))
@@ -222,6 +273,10 @@ def index():
# ── Facilities list for the trend-by-facility chart selector ──────────── # ── Facilities list for the trend-by-facility chart selector ────────────
if is_privileged or is_project_manager: if is_privileged or is_project_manager:
all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
elif is_inspector and inspector_facility_ids:
all_facilities = Facility.query.filter(
Facility.id.in_(inspector_facility_ids), Facility.active == True
).order_by(Facility.name).all()
elif is_customer and customer_facility_ids: elif is_customer and customer_facility_ids:
all_facilities = Facility.query.filter( all_facilities = Facility.query.filter(
Facility.id.in_(customer_facility_ids), Facility.active == True Facility.id.in_(customer_facility_ids), Facility.active == True
+6 -1
View File
@@ -7,7 +7,7 @@ from app.models.project import Project
from app.utils.forms import FacilityForm, AreaForm from app.utils.forms import FacilityForm, AreaForm
from app.utils.decorators import supervisor_required, admin_required from app.utils.decorators import supervisor_required, admin_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope, get_inspector_scope
bp = Blueprint('facilities', __name__, url_prefix='/facilities') bp = Blueprint('facilities', __name__, url_prefix='/facilities')
@@ -21,6 +21,11 @@ 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':
fids = get_inspector_scope(current_user) or []
facilities = Facility.query.filter(
Facility.id.in_(fids), Facility.active == True
).order_by(Facility.name).all()
else: else:
facilities = Facility.query.order_by(Facility.name).all() facilities = Facility.query.order_by(Facility.name).all()
+26 -3
View File
@@ -23,7 +23,7 @@ from app.models.notification import (
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED, EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
) )
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope, get_inspector_scope
bp = Blueprint('inspections', __name__, url_prefix='/inspections') bp = Blueprint('inspections', __name__, url_prefix='/inspections')
@@ -201,7 +201,11 @@ def index():
q = Inspection.query.order_by(Inspection.inspection_date.desc()) q = Inspection.query.order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector': if current_user.role == 'inspector':
q = q.filter(Inspection.inspector_id == current_user.id) fids = get_inspector_scope(current_user)
if not fids:
q = q.filter(False)
else:
q = q.filter(Inspection.facility_id.in_(fids))
elif current_user.role == 'customer': elif current_user.role == 'customer':
customer_facility_ids = get_customer_scope(current_user) customer_facility_ids = get_customer_scope(current_user)
if not customer_facility_ids: if not customer_facility_ids:
@@ -223,7 +227,10 @@ def index():
).filter(~Inspection.follow_ups.any()) ).filter(~Inspection.follow_ups.any())
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 == 'customer': if current_user.role == 'inspector':
fids = get_inspector_scope(current_user) or []
facilities = Facility.query.filter(Facility.id.in_(fids), Facility.active == True).order_by(Facility.name).all()
elif current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
facilities = Facility.query.filter(Facility.id.in_(cids), Facility.active == True).order_by(Facility.name).all() facilities = Facility.query.filter(Facility.id.in_(cids), Facility.active == True).order_by(Facility.name).all()
else: else:
@@ -248,6 +255,15 @@ def start():
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all() templates = InspectionTemplate.query.order_by(InspectionTemplate.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()
# Scope projects to inspector's assigned contracts
if current_user.role == 'inspector':
from app.models.inspector_assignment import InspectorAssignment
assigned_pids = {
a.project_id for a in
InspectorAssignment.query.filter_by(user_id=current_user.id).all()
}
projects = [p for p in projects if p.id in assigned_pids]
form.template_id.choices = [(t.id, t.name) for t in templates] form.template_id.choices = [(t.id, t.name) for t in templates]
form.project_id.choices = [(p.id, p.name) for p in projects] form.project_id.choices = [(p.id, p.name) for p in projects]
@@ -284,6 +300,13 @@ def start():
if template is None: if template is None:
abort(404) abort(404)
# Inspector facility scope check — prevent crafted POST from selecting
# a facility outside their assigned contracts.
if current_user.role == 'inspector':
fids = get_inspector_scope(current_user)
if not fids or form.facility_id.data not in fids:
abort(403)
if not template.get_form_schema(): if not template.get_form_schema():
flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning') flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning')
return redirect(url_for('inspections.start')) return redirect(url_for('inspections.start'))
+22 -10
View File
@@ -17,7 +17,7 @@ from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.sla import sla_status from app.utils.sla import sla_status
bp = Blueprint('issues', __name__, url_prefix='/issues') bp = Blueprint('issues', __name__, url_prefix='/issues')
@@ -80,8 +80,14 @@ def index():
) )
if current_user.role == 'inspector': if current_user.role == 'inspector':
q = q.filter(db.or_(Issue.assigned_to == current_user.id, fids = get_inspector_scope(current_user)
Issue.reported_by == current_user.id)) if not fids:
q = q.filter(False)
else:
q = q.filter(db.or_(
Issue.facility_id.in_(fids),
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
))
elif current_user.role == 'customer': elif current_user.role == 'customer':
customer_facility_ids = get_customer_scope(current_user) customer_facility_ids = get_customer_scope(current_user)
if not customer_facility_ids: if not customer_facility_ids:
@@ -138,8 +144,13 @@ def index():
for f in IssueFollower.query.filter_by(user_id=current_user.id).all() for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
} }
# Facilities for the filter dropdown — scoped for customers, full list otherwise # Facilities for the filter dropdown — scoped for inspectors/customers
if current_user.role == 'customer': if current_user.role == 'inspector':
fids = get_inspector_scope(current_user) or []
facilities = Facility.query.filter(
Facility.id.in_(fids), Facility.active == True
).order_by(Facility.name).all()
elif current_user.role == 'customer':
facility_ids = get_customer_scope(current_user) or [] facility_ids = get_customer_scope(current_user) or []
facilities = Facility.query.filter( facilities = Facility.query.filter(
Facility.id.in_(facility_ids), Facility.active == True Facility.id.in_(facility_ids), Facility.active == True
@@ -172,11 +183,12 @@ def view(issue_id):
if issue is None: if issue is None:
abort(404) abort(404)
if current_user.role == 'inspector' and \ if current_user.role == 'inspector':
issue.assigned_to != current_user.id and \ fids = get_inspector_scope(current_user)
issue.reported_by != current_user.id: facility = issue.resolved_facility
flash('Access denied.', 'danger') if not fids or not facility or facility.id not in fids:
return redirect(url_for('issues.index')) flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
facility = issue.resolved_facility facility = issue.resolved_facility
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Contract Assignments — {{ user.display_name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-briefcase text-primary me-2"></i>Contract Assignments</h2>
<p class="text-muted mb-0">
Inspector <strong>{{ user.display_name }}</strong> can only access facilities
belonging to the contracts ticked below.
</p>
</div>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold">Active Contracts</span>
<span class="badge bg-primary">{{ assigned_pids|length }} assigned</span>
</div>
{% if projects %}
<div class="list-group list-group-flush">
{% for project in projects %}
<label class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3">
<input class="form-check-input flex-shrink-0" type="checkbox"
name="project_ids" value="{{ project.id }}"
{{ 'checked' if project.id in assigned_pids }}>
<div>
<div class="fw-semibold">{{ project.name }}</div>
{% if project.description %}
<div class="text-muted small">{{ project.description }}</div>
{% endif %}
<div class="text-muted small">
{{ project.facilities.count() }} facilit{{ 'ies' if project.facilities.count() != 1 else 'y' }}
</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="card-body text-muted">
No active contracts exist. Create a contract first.
</div>
{% endif %}
</div>
{% if projects %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i>Save Assignments
</button>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{% endif %}
</form>
{% endblock %}
+20 -1
View File
@@ -24,9 +24,10 @@
<th>Full Name</th> <th>Full Name</th>
<th>Email</th> <th>Email</th>
<th>Role</th> <th>Role</th>
<th>Contracts</th>
<th>Created</th> <th>Created</th>
<th>Status</th> <th>Status</th>
<th width="180">Actions</th> <th width="220">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -40,6 +41,18 @@
{{ user.role.replace('_',' ')|title }} {{ user.role.replace('_',' ')|title }}
</span> </span>
</td> </td>
<td>
{% if user.role == 'inspector' %}
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
{% if cnt > 0 %}
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
{% else %}
<span class="badge bg-warning text-dark" title="No contracts assigned — inspector sees nothing">None</span>
{% endif %}
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td> <td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td> <td>
{% if user.active %} {% if user.active %}
@@ -52,6 +65,12 @@
<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' %}
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
<i class="bi bi-briefcase"></i>
</a>
{% endif %}
{% if user.id != current_user.id %} {% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline"> <form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+53 -14
View File
@@ -1,23 +1,18 @@
""" """
app/utils/scope.py app/utils/scope.py
------------------ ------------------
Customer-scoping utility for the Janitorial QC portal. Facility-scoping utilities for the Janitorial QC portal.
Provides a single entry-point — get_customer_scope(user) — that returns the get_customer_scope(user) -> list[int] | None
set of facility IDs a customer is authorised to view, derived from their Facility IDs a customer may access via CustomerAssignment rows.
CustomerAssignment rows.
Usage (inside any route that serves customer users): get_inspector_scope(user) -> list[int] | None
Facility IDs an inspector may access via InspectorAssignment rows.
Returns [] (empty list) when the inspector has no contract assignments,
meaning they see nothing (strict mode).
from app.utils.scope import get_customer_scope For non-customer / non-inspector roles both functions return None, signalling
that no facility-level scoping is required (full access applies).
facility_ids = get_customer_scope(current_user)
inspections = Inspection.query.filter(
Inspection.facility_id.in_(facility_ids)
).all()
For non-customer roles the function returns None, signalling that no
facility-level scoping is required (full access applies).
""" """
import logging import logging
@@ -76,4 +71,48 @@ def get_customer_scope(user) -> list[int] | None:
user.id, user.username, sorted(facility_ids), user.id, user.username, sorted(facility_ids),
) )
return sorted(facility_ids)
def get_inspector_scope(user) -> list[int] | None:
"""Return the list of facility IDs accessible to a contract-scoped inspector.
Parameters
----------
user : User
The currently authenticated user.
Returns
-------
list[int]
Facility IDs the inspector may access. An empty list means the
inspector has no contract assignments and should see nothing.
None
Returned for non-inspector roles, indicating unrestricted access.
"""
if user.role != 'inspector':
return None
from app.models.inspector_assignment import InspectorAssignment
project_ids = [
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=user.id).all()
]
if not project_ids:
return [] # strict: no assignments = no access
facility_ids = [
f.id for f in Facility.query.filter(
Facility.project_id.in_(project_ids),
Facility.active == True,
).all()
]
logger.debug(
'SCOPE | inspector_scope | user_id=%s username=%s facility_ids=%s',
user.id, user.username, sorted(facility_ids),
)
return sorted(facility_ids) return sorted(facility_ids)
@@ -0,0 +1,47 @@
"""phase20 — inspector contract assignments
Adds inspector_assignments table so each inspector can be scoped to one or
more contracts (projects). Inspectors with no assignments see nothing.
Safe to re-run — uses INFORMATION_SCHEMA existence check.
"""
import sqlalchemy as sa
from alembic import op
revision = 'phase20_inspector_assignments'
down_revision = 'phase19_issue_mobile_photos'
branch_labels = None
depends_on = None
def _table_exists(bind, table):
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, 'inspector_assignments'):
op.create_table(
'inspector_assignments',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.UniqueConstraint('user_id', 'project_id', name='uq_inspector_project'),
)
op.create_index('ix_inspector_assignments_user_id',
'inspector_assignments', ['user_id'])
def downgrade():
bind = op.get_bind()
if _table_exists(bind, 'inspector_assignments'):
op.drop_table('inspector_assignments')