""" app/utils/scope.py ------------------ Facility-scoping utilities for the Janitorial QC portal. get_customer_scope(user) -> list[int] | None Facility IDs a customer may access via CustomerAssignment rows. get_inspector_scope(user) -> list[int] | None Facility IDs an inspector may access via InspectorAssignment rows. Applies to BOTH 'inspector' (internal) and 'external_inspector' (customer / third-party) — see User.INSPECTOR_ROLES. Returns [] (empty list) when the inspector has no contract assignments, meaning they see nothing (strict mode). For non-customer / non-inspector roles both functions return None, signalling that no facility-level scoping is required (full access applies). """ import logging from app import db from app.models.project import CustomerAssignment from app.models.facility import Facility logger = logging.getLogger(__name__) def get_customer_scope(user) -> list[int] | None: """Return the list of facility IDs accessible to a customer user. Parameters ---------- user : User The currently authenticated user. Returns ------- list[int] Facility IDs the customer may access. May be empty if no assignments exist yet — callers should treat an empty list as "no access". None Returned for non-customer roles, indicating unrestricted access. """ if user.role != 'customer': return None # no scoping needed for internal staff # Select only the two columns needed. The previous .all() built full # CustomerAssignment ORM objects (and their identity-map entries) purely to # read two integers off each one; this function runs on nearly every # request for a customer, sometimes more than once. assignments = db.session.query( CustomerAssignment.project_id, CustomerAssignment.facility_id, ).filter(CustomerAssignment.user_id == user.id).all() if not assignments: return [] # Separate direct facility assignments from project-level assignments direct_facility_ids = {fac_id for _, fac_id in assignments if fac_id} project_ids = {proj_id for proj_id, fac_id in assignments if not fac_id} facility_ids = set(direct_facility_ids) # Single bulk query for all project-scoped facilities — replaces the # previous per-assignment Facility.query loop (N+1 pattern). Only the id # column is read; nothing here needs a hydrated Facility. if project_ids: facility_ids.update( fid for (fid,) in db.session.query(Facility.id).filter( Facility.project_id.in_(project_ids), Facility.active == True, ).all() ) logger.debug( 'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s', 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. """ # MT-15: covers BOTH 'inspector' and 'external_inspector'. An external # (customer / third-party) inspector is scoped by exactly the same # InspectorAssignment rows — the contracts an admin grants them. from app.models.user import User if user.role not in User.INSPECTOR_ROLES: return None from app.models.inspector_assignment import InspectorAssignment # Column-only selects — see the note in get_customer_scope(). This runs on # every scoped request for both inspector roles. project_ids = [ pid for (pid,) in db.session.query(InspectorAssignment.project_id) .filter(InspectorAssignment.user_id == user.id).all() ] if not project_ids: return [] # strict: no assignments = no access facility_ids = [ fid for (fid,) in db.session.query(Facility.id).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)