229 lines
8.4 KiB
Python
229 lines
8.4 KiB
Python
"""
|
|
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/<template_id>
|
|
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/<int:template_id>', 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)}) |