""" app/api/templates.py -------------------- Mobile API endpoints for inspection templates. GET /api/v1/templates Returns a lightweight list of all active inspection templates. Used by the iPad app to populate the template picker when starting an inspection. GET /api/v1/templates/ Returns the full template including its form_schema JSON. The app caches this locally in SwiftData so inspections can be executed without a network connection. """ import logging from flask import Blueprint, g, request from app import db from app.models.inspection import InspectionTemplate from app.models.facility import Facility from app.api.errors import api_ok, api_error from app.api.decorators import jwt_required from app.utils.scope import get_inspector_scope logger = logging.getLogger(__name__) bp = Blueprint('api_templates', __name__) # Customer role cannot access template data — inspectors and above only _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector', 'project_manager', 'auditor'} def _visible_project_ids(user): """Contract ids whose forms this user may see, or None for "no limit". An inspector (ours or a customer's) is limited to the contracts they are assigned; every other allowed role sees all. Derived from the facilities get_inspector_scope() returns, so the API can never disagree with the web. """ if not user.is_inspector: return None fids = get_inspector_scope(user) or [] if not fids: return [] return sorted({ f.project_id for f in Facility.query.filter(Facility.id.in_(fids)).all() if f.project_id }) def _visible_templates(user, project_id=None): """Forms this user may use, optionally narrowed to one contract. phase52 — a form attached to specific contracts must not reach an inspector working for a different customer. Shared forms (no contract links) stay visible to everyone, which is what keeps existing installs behaving exactly as before. With `project_id`: exactly the web picker's list for that contract. Without: the union across every contract the user can reach — the iPad caches templates up-front and picks the facility later, so it needs the whole set it might legitimately use. """ pids = _visible_project_ids(user) if project_id is not None: # The caller names a contract. Their OWN scope still applies — without # this, passing another customer's facility_id would list that # customer's form names back to an inspector who has no business # seeing them. Empty list, not an error: the endpoint must not confirm # whether that contract exists either. if pids is not None and project_id not in pids: logger.warning('API TEMPLATES | out-of-scope project filter | ' 'user=%s | project_id=%s', user.username, project_id) return [] return InspectionTemplate.available_query(project_id).all() if pids is None: return (InspectionTemplate.query .filter_by(active=True) .order_by(InspectionTemplate.name) .all()) seen, out = set(), [] # Always include the shared forms, even when the user has no contracts — # otherwise an unassigned inspector would see nothing at all rather than # the standard forms. for pid in list(pids) + [None]: for t in InspectionTemplate.available_query(pid).all(): if t.id not in seen: seen.add(t.id) out.append(t) out.sort(key=lambda t: (t.name or '').lower()) return out def _template_summary_payload(template: InspectionTemplate) -> dict: """Serialize a template to the lightweight summary dict (no form_schema).""" return { 'id': template.id, 'name': template.name, 'description': template.description or '', 'frequency': template.frequency or '', 'is_active': template.active, } def _template_full_payload(template: InspectionTemplate) -> dict: """Serialize a template including its full form_schema.""" return { 'id': template.id, 'name': template.name, 'description': template.description or '', 'frequency': template.frequency or '', 'form_schema': template.get_form_schema(), # always returns a list } # ── Template List ───────────────────────────────────────────────────────────── @bp.route('/templates', methods=['GET']) @jwt_required def list_templates(): """ Return a lightweight list of all inspection templates. Only internal staff roles (admin, director, inspector, project_manager) may access templates. Customer accounts are excluded. Response 200 ------------ { "ok": true, "data": { "templates": [ { "id": 3, "name": "Weekly Restroom Inspection", "description": "Standard weekly restroom checklist", "frequency": "weekly" } ], "count": 1 } } """ user = g.api_user if user.role not in _ALLOWED_ROLES: logger.warning('API TEMPLATES | access denied | user=%s | role=%s', user.username, user.role) return api_error('Access denied', 403) # Optional ?project_id= narrows to one contract (matches the web picker); # ?facility_id= is accepted as a convenience and resolved to its contract. project_id = request.args.get('project_id', type=int) if project_id is None: facility_id = request.args.get('facility_id', type=int) if facility_id is not None: facility = db.session.get(Facility, facility_id) project_id = facility.project_id if facility else None templates = _visible_templates(user, project_id) payload = [_template_summary_payload(t) for t in templates] logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d', user.username, project_id, len(payload)) return api_ok({'templates': payload, 'count': len(payload)}) # ── Full Template (with form_schema) ───────────────────────────────────────── @bp.route('/templates/', methods=['GET']) @jwt_required def get_template(template_id): """ Return a single template including its complete form_schema. The iPad app calls this endpoint once per template and caches the result in SwiftData. Subsequent inspection executions use the cached schema without any network calls. Response 200 ------------ { "ok": true, "data": { "template": { "id": 3, "name": "Weekly Restroom Inspection", "description": "...", "frequency": "weekly", "form_schema": [ { "id": "f1", "type": "section", "label": "General Cleanliness" }, { "id": "f2", "type": "rating", "label": "Floor condition", "required": true } ] } } } """ user = g.api_user if user.role not in _ALLOWED_ROLES: logger.warning('API TEMPLATES | access denied | user=%s | role=%s', user.username, user.role) return api_error('Access denied', 403) template = db.session.get(InspectionTemplate, template_id) if template is None: return api_error('Template not found', 404) # phase52 — a restricted form must not be fetchable by an inspector on a # different customer's contracts. 404 rather than 403: whether another # customer's form exists is itself not this user's business. if template.id not in {t.id for t in _visible_templates(user)}: logger.warning('API TEMPLATES | out-of-contract fetch blocked | ' 'user=%s | template_id=%s', user.username, template_id) return api_error('Template not found', 404) logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s', user.username, template_id, template.name) return api_ok({'template': _template_full_payload(template)})