First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
"""
app/api/__init__.py
-------------------
Registers the /api/v1 blueprint group.
Phase A: /api/v1/facilities/*, /api/v1/templates/*
Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/*
Phase C: /api/v1/notifications/*
"""
from flask import Blueprint
from app.api.errors import register_error_handlers
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
register_error_handlers(api_bp)
def register_api(app):
"""
Register all API sub-blueprints.
CSRF exemption for each child blueprint is handled in app/__init__.py.
"""
# Phase 7: Auth
from app.api.auth import bp as auth_bp
api_bp.register_blueprint(auth_bp)
# Phase A: Reference data
from app.api.facilities import bp as facilities_bp
from app.api.templates import bp as templates_bp
api_bp.register_blueprint(facilities_bp)
api_bp.register_blueprint(templates_bp)
# Phase B: Offline inspection submission
from app.api.inspections import bp as inspections_bp
from app.api.issues import bp as issues_bp
from app.api.photos import bp as photos_bp
api_bp.register_blueprint(inspections_bp)
api_bp.register_blueprint(issues_bp)
api_bp.register_blueprint(photos_bp)
# Phase C: Notification polling
from app.api.notifications import bp as notifications_bp
api_bp.register_blueprint(notifications_bp)
# Phase B (stats): Dashboard KPI endpoint
from app.api.stats import bp as stats_bp
api_bp.register_blueprint(stats_bp)
# Phase D: Issue comments
from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp)
app.register_blueprint(api_bp)
+333
View File
@@ -0,0 +1,333 @@
"""
app/api/auth.py
---------------
Authentication endpoints for the JQC mobile app.
POST /api/v1/auth/login
Accepts username + password.
Returns a short-lived access token (JWT) and a long-lived refresh token
(opaque, stored in DB). The app stores both in the iOS Keychain.
POST /api/v1/auth/refresh
Accepts a refresh token.
Returns a new access token. The refresh token is rotated — the old one
is revoked and a new one is issued, preventing replay attacks.
POST /api/v1/auth/logout
Accepts a refresh token.
Revokes it so it can no longer be used to issue new access tokens.
The app should discard both tokens from the Keychain after this call.
POST /api/v1/devices/register
Registers or updates the APNs device token for push notifications.
Called on every app launch after the user has already authenticated.
Requires a valid access token (JWT).
GET /api/v1/auth/me
Returns the current user's profile from the access token.
Useful for the app to verify the token is still valid on launch.
"""
import logging
from flask import Blueprint, request, g
from app import db, limiter
from app.models.user import User
from app.models.api_token import RefreshToken, DeviceToken
from app.api.errors import api_ok, api_error
from app.api.jwt_utils import generate_access_token
from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_LOGIN, ACTION_LOGOUT
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__)
bp = Blueprint('api_auth', __name__)
def _user_payload(user: User) -> dict:
"""Serialize a User to the dict returned in auth responses."""
return {
'id': user.id,
'username': user.username,
'full_name': user.full_name or '',
'email': user.email,
'role': user.role,
'created_at': user.created_at.isoformat() if user.created_at else None,
}
# ── Login ─────────────────────────────────────────────────────────────────────
@bp.route('/auth/login', methods=['POST'])
@limiter.limit('10 per minute; 3 per second')
def login():
"""
Authenticate with username + password.
Request JSON
------------
{
"username": "john",
"password": "secret",
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
"device_name": "John's iPhone" // (optional)
}
Response 200
------------
{
"ok": true,
"data": {
"access_token": "<jwt>",
"refresh_token": "<opaque_hex>",
"token_type": "Bearer",
"expires_in": 3600,
"user": { id, username, email, role, created_at }
}
}
"""
data = request.get_json(silent=True) or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
return api_error('username and password are required', 400)
user = User.query.filter_by(username=username).first()
# Generic message — never reveal whether the username exists
if user is None or not user.check_password(password):
logger.warning('API login failed | username=%s | ip=%s',
username, request.remote_addr)
return api_error('Invalid credentials', 401)
if not user.active:
return api_error('Account is disabled. Please contact an administrator.', 401)
device_id = (data.get('device_id') or '')[:64] or None
device_name = (data.get('device_name') or '')[:100] or None
# Issue tokens
access_token = generate_access_token(user)
raw_refresh, rt_row = RefreshToken.create_for(
user,
device_id=device_id,
device_name=device_name,
)
db.session.commit()
# Passive cleanup — delete expired/revoked tokens for this user only
# so the table never accumulates dead rows without a cron dependency.
try:
from app.utils.time_utils import now_eastern
now = now_eastern()
RefreshToken.query.filter(
RefreshToken.user_id == user.id,
db.or_(
RefreshToken.expires_at < now,
RefreshToken.revoked == True, # noqa: E712
),
).delete(synchronize_session=False)
db.session.commit()
except Exception as _cleanup_exc:
logger.warning('API LOGIN passive token cleanup failed: %s', _cleanup_exc)
db.session.rollback()
log_action(ACTION_LOGIN, 'User', user.id, user.username,
f'source=mobile_api; device_id={device_id}')
logger.info('API LOGIN | user=%s | role=%s | device_id=%s',
user.username, user.role, device_id)
return api_ok({
'access_token': access_token,
'refresh_token': raw_refresh,
'token_type': 'Bearer',
'expires_in': 3600, # seconds — matches ACCESS_TOKEN_LIFETIME_MINUTES * 60
'user': _user_payload(user),
})
# ── Refresh ───────────────────────────────────────────────────────────────────
@bp.route('/auth/refresh', methods=['POST'])
@limiter.limit('30 per minute; 5 per second')
def refresh():
"""
Exchange a valid refresh token for a new access token.
The refresh token is rotated on every call — the submitted token is
revoked and a brand new one is issued. This limits the damage window
if a token is ever stolen.
Request JSON
------------
{ "refresh_token": "<opaque_hex>" }
Response 200
------------
{
"ok": true,
"data": {
"access_token": "<new_jwt>",
"refresh_token": "<new_opaque_hex>",
"token_type": "Bearer",
"expires_in": 3600
}
}
"""
data = request.get_json(silent=True) or {}
raw_token = (data.get('refresh_token') or '').strip()
if not raw_token:
return api_error('refresh_token is required', 400)
rt_row = RefreshToken.verify(raw_token)
if rt_row is None:
logger.warning('API refresh rejected | invalid/expired token | ip=%s',
request.remote_addr)
return api_error('Refresh token is invalid or expired', 401)
user = db.session.get(User, rt_row.user_id)
if user is None or not user.active:
rt_row.revoke()
db.session.commit()
return api_error('Account not available', 401)
# Rotate: revoke old token, issue new pair
device_id = rt_row.device_id
device_name = rt_row.device_name
rt_row.revoke()
new_access = generate_access_token(user)
new_raw_refresh, new_rt = RefreshToken.create_for(
user,
device_id=device_id,
device_name=device_name,
)
db.session.commit()
logger.info('API TOKEN REFRESH | user=%s | device_id=%s',
user.username, device_id)
return api_ok({
'access_token': new_access,
'refresh_token': new_raw_refresh,
'token_type': 'Bearer',
'expires_in': 3600,
})
# ── Logout ────────────────────────────────────────────────────────────────────
@bp.route('/auth/logout', methods=['POST'])
@jwt_required
def logout():
"""
Revoke the current session's refresh token.
The app should call this when the user taps "Log out" and then discard
both the access token and refresh token from the Keychain.
Request JSON
------------
{ "refresh_token": "<opaque_hex>" }
Response 200
------------
{ "ok": true, "data": { "message": "Logged out" } }
"""
data = request.get_json(silent=True) or {}
raw_token = (data.get('refresh_token') or '').strip()
if raw_token:
rt_row = RefreshToken.verify(raw_token)
if rt_row and rt_row.user_id == g.api_user.id:
rt_row.revoke()
db.session.commit()
log_action(ACTION_LOGOUT, 'User', g.api_user.id, g.api_user.username,
'source=mobile_api')
logger.info('API LOGOUT | user=%s', g.api_user.username)
return api_ok({'message': 'Logged out successfully'})
# ── Current user ──────────────────────────────────────────────────────────────
@bp.route('/auth/me', methods=['GET'])
@jwt_required
def me():
"""
Return the authenticated user's profile.
Called by the app on launch to verify the stored access token is still
valid and to refresh the local user record.
Response 200
------------
{ "ok": true, "data": { "user": { id, username, email, role, ... } } }
"""
return api_ok({'user': _user_payload(g.api_user)})
# ── Device token registration ─────────────────────────────────────────────────
@bp.route('/devices/register', methods=['POST'])
@jwt_required
def register_device():
"""
Register or update the APNs device token for the authenticated user.
Called on every app launch after authentication so the server always
has the current token (APNs rotates tokens periodically).
Request JSON
------------
{
"device_id": "<UIDevice.identifierForVendor>",
"apns_token": "<hex_string_from_didRegisterForRemoteNotifications>",
"device_name": "John's iPhone", // optional
"app_version": "1.0.3" // optional
}
Response 200
------------
{ "ok": true, "data": { "registered": true } }
"""
data = request.get_json(silent=True) or {}
device_id = (data.get('device_id') or '').strip()[:64]
apns_token = (data.get('apns_token') or '').strip()[:200]
device_name = (data.get('device_name') or '').strip()[:100] or None
app_version = (data.get('app_version') or '').strip()[:20] or None
if not device_id or not apns_token:
return api_error('device_id and apns_token are required', 400)
# Upsert: update existing row or insert new one
existing = DeviceToken.query.filter_by(
user_id=g.api_user.id,
device_id=device_id,
).first()
if existing:
existing.apns_token = apns_token
existing.device_name = device_name
existing.app_version = app_version
existing.registered_at = now_eastern()
else:
db.session.add(DeviceToken(
user_id = g.api_user.id,
device_id = device_id,
apns_token = apns_token,
device_name = device_name,
app_version = app_version,
))
db.session.commit()
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s',
g.api_user.username, device_id, apns_token[-6:])
return api_ok({'registered': True})
+187
View File
@@ -0,0 +1,187 @@
"""
app/api/comments.py
-------------------
Mobile API endpoints for issue comments.
GET /api/v1/issues/<id>/comments
Returns all comments for an issue, ordered oldest-first.
Inspectors may only access issues within their contracted facilities.
POST /api/v1/issues/<id>/comments
Adds a comment to an issue.
Inspectors may only comment on issues within their contracted facilities.
Fires notify_by_matrix('issue_comment') so the relevant staff are notified.
"""
import logging
from flask import Blueprint, request, g
from app import db
from app.models.issue import Issue, IssueComment
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_CREATE
from app.utils.notifications import notify_by_matrix
from app.utils.scope import get_inspector_scope
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__)
bp = Blueprint('api_comments', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
def _comment_payload(comment: IssueComment) -> dict:
"""Serialise an IssueComment to the dict returned in API responses."""
return {
'id': comment.id,
'issue_id': comment.issue_id,
'author_name': comment.author.display_name if comment.author else 'Unknown',
'author_role': comment.author.role if comment.author else '',
'status_at_time': comment.status_at_time or '',
'body': comment.body,
'created_at': comment.created_at.isoformat() if comment.created_at else None,
}
def _check_issue_access(issue: Issue, user) -> bool:
"""Return True if user may read/write this issue. False = 403."""
if user.role == 'inspector':
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return False
return True
# ── GET comments ─────────────────────────────────────────────────────────────
@bp.route('/issues/<int:issue_id>/comments', methods=['GET'])
@jwt_required
def list_comments(issue_id):
"""
Return all comments for the given issue, oldest-first.
Response 200
------------
{
"ok": true,
"data": {
"issue_id": 42,
"comments": [
{
"id": 1,
"issue_id": 42,
"author_name": "Jane Smith",
"author_role": "director",
"status_at_time": "in_progress",
"body": "Cleaning crew has been notified.",
"created_at": "2026-05-10T09:15:00"
}
],
"count": 1
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if not _check_issue_access(issue, user):
return api_error('Access denied', 403)
comments = (
issue.comments
.order_by(IssueComment.created_at.asc())
.all()
)
payload = [_comment_payload(c) for c in comments]
logger.info('API COMMENTS | list | issue_id=%d | count=%d | user=%s',
issue_id, len(payload), user.username)
return api_ok({'issue_id': issue_id, 'comments': payload, 'count': len(payload)})
# ── POST comment ──────────────────────────────────────────────────────────────
@bp.route('/issues/<int:issue_id>/comments', methods=['POST'])
@jwt_required
def add_comment(issue_id):
"""
Add a comment to an issue.
Request JSON
------------
{ "body": "The spill has been cleaned up." }
Response 200
------------
{ "ok": true, "data": { "comment_id": 7 } }
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if not _check_issue_access(issue, user):
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
body = (data.get('body') or '').strip()
if not body:
return api_error('body is required', 400)
comment = IssueComment(
issue_id = issue_id,
user_id = user.id,
status_at_time = issue.status,
body = body,
)
db.session.add(comment)
db.session.flush() # get comment.id
# Notify via matrix — same event type as web-originated comments
facility = issue.resolved_facility
area_name = issue.area.name if issue.area else (facility.name if facility else '')
facility_id = facility.id if facility else None
try:
from flask import url_for
issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
except RuntimeError:
issue_link = f'/issues/{issue.id}'
if facility_id:
notify_by_matrix(
event_type = 'issue_comment',
title = f'New Comment on Issue #{issue.id}',
body = (
f'{user.display_name} commented on Issue #{issue.id} '
f'at {area_name}: '
f'"{body[:120]}{"" if len(body) > 120 else ""}"'
),
link = issue_link,
issue_id = issue.id,
facility_id = facility_id,
exclude_user_ids = {user.id},
)
db.session.commit()
log_action(ACTION_CREATE, 'IssueComment', comment.id,
f'comment on Issue #{issue_id}',
f'source=mobile; author={user.username}; issue_status={issue.status}')
logger.info('API COMMENTS | created | comment_id=%d | issue_id=%d | user=%s',
comment.id, issue_id, user.username)
return api_ok({'comment_id': comment.id})
+109
View File
@@ -0,0 +1,109 @@
"""
app/api/decorators.py
---------------------
Request-level guards for all /api/v1/ endpoints.
@jwt_required
Validates the Bearer token in the Authorization header.
On success, sets flask.g.api_user to the authenticated User instance
so any route can access it without a second DB query.
@api_role_required(*roles)
Must be applied AFTER @jwt_required.
Rejects callers whose role is not in the allowed set.
Usage
-----
@bp.route('/inspections')
@jwt_required
def list_inspections():
user = g.api_user
...
@bp.route('/admin/users')
@jwt_required
@api_role_required('admin')
def admin_only():
...
"""
import logging
from functools import wraps
from flask import request, g, abort
from app.api.jwt_utils import decode_access_token
from app.api.errors import api_error
from app import db
from app.models.user import User
logger = logging.getLogger(__name__)
def jwt_required(f):
"""
Validate the JWT Bearer token and load the user into flask.g.api_user.
Returns 401 if:
- Authorization header is missing or malformed
- Token is expired or invalid
- User referenced by the token no longer exists
- User account has been disabled (active=False)
"""
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return api_error('Missing or malformed Authorization header', 401)
raw_token = auth_header[len('Bearer '):]
payload = decode_access_token(raw_token)
if payload is None:
return api_error('Access token is invalid or expired', 401)
user_id = int(payload.get('sub', 0))
user = db.session.get(User, user_id)
if user is None:
return api_error('User not found', 401)
if not user.active:
return api_error('Account is disabled', 401)
# Make the user available to the route without re-querying
g.api_user = user
return f(*args, **kwargs)
return decorated
def api_role_required(*roles):
"""
Restrict an endpoint to users whose role is in the provided list.
Must be stacked BELOW @jwt_required so that g.api_user is already set.
Example
-------
@jwt_required
@api_role_required('admin', 'supervisor')
def supervisor_only_route():
...
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
user = getattr(g, 'api_user', None)
if user is None:
# Defensive: jwt_required should always run first
return api_error('Authentication required', 401)
if user.role not in roles:
logger.warning(
'API role denied | user=%s role=%s required=%s endpoint=%s',
user.username, user.role, roles, request.endpoint,
)
return api_error('Insufficient permissions', 403)
return f(*args, **kwargs)
return decorated
return decorator
+87
View File
@@ -0,0 +1,87 @@
"""
app/api/errors.py
-----------------
Consistent JSON error responses for every API endpoint.
Every response — success or failure — uses the same envelope:
{
"ok": true | false,
"data": { ... } | null,
"error": null | "Human-readable message"
}
Usage
-----
from app.api.errors import api_error, api_ok
return api_ok({'inspection': {...}})
return api_error('Inspection not found', 404)
"""
from flask import jsonify
def api_ok(data=None, status=200):
"""Return a successful JSON response."""
return jsonify({
'ok': True,
'data': data,
'error': None,
}), status
def api_error(message: str, status: int = 400):
"""Return an error JSON response."""
return jsonify({
'ok': False,
'data': None,
'error': message,
}), status
# ── Registered error handlers (attached to the api blueprint) ─────────────────
def register_error_handlers(bp):
"""
Attach JSON error handlers to the given blueprint so that Flask
exceptions (404, 405, 500, etc.) return JSON instead of HTML within
the /api/v1/ prefix.
"""
from werkzeug.exceptions import HTTPException
@bp.errorhandler(400)
def bad_request(e):
return api_error(str(e.description) if hasattr(e, 'description') else 'Bad request', 400)
@bp.errorhandler(401)
def unauthorized(e):
return api_error('Authentication required', 401)
@bp.errorhandler(403)
def forbidden(e):
return api_error('Access denied', 403)
@bp.errorhandler(404)
def not_found(e):
return api_error('Resource not found', 404)
@bp.errorhandler(405)
def method_not_allowed(e):
return api_error('Method not allowed', 405)
@bp.errorhandler(413)
def payload_too_large(e):
return api_error('Uploaded file is too large', 413)
@bp.errorhandler(422)
def unprocessable(e):
return api_error('Unprocessable request', 422)
@bp.errorhandler(500)
def internal_error(e):
return api_error('Internal server error', 500)
@bp.errorhandler(HTTPException)
def generic_http(e):
return api_error(e.description or e.name, e.code)
+194
View File
@@ -0,0 +1,194 @@
"""
app/api/facilities.py
---------------------
Mobile API endpoints for facilities and areas.
GET /api/v1/facilities
Returns all active facilities accessible to the current user.
Respects customer scoping via get_customer_scope().
Staff roles (admin, director, inspector, project_manager) receive all
active facilities.
GET /api/v1/facilities/<facility_id>/areas
Returns all areas for a specific facility.
Used by the iPad app to populate the area picker when starting an
inspection.
"""
import logging
from flask import Blueprint, g
from app import db
from app.models.facility import Facility, Area
from app.models.project import Project
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.scope import get_customer_scope, get_inspector_scope
logger = logging.getLogger(__name__)
bp = Blueprint('api_facilities', __name__)
def _facility_payload(facility: Facility) -> dict:
"""Serialize a Facility to the dict returned in API responses."""
project_name = facility.project.name if facility.project else None
return {
'id': facility.id,
'name': facility.name,
'address': facility.address or '',
'contact_person': facility.contact_person or '',
'contact_phone': facility.contact_phone or '',
'project_id': facility.project_id,
'project_name': project_name,
'is_active': facility.active,
}
def _area_payload(area: Area) -> dict:
"""Serialize an Area to the dict returned in API responses."""
return {
'id': area.id,
'facility_id': area.facility_id,
'name': area.name,
'area_type': area.area_type or '',
}
# ── Facilities List ───────────────────────────────────────────────────────────
@bp.route('/facilities', methods=['GET'])
@jwt_required
def list_facilities():
"""
Return all active facilities the current user has access to.
Staff roles (admin, director, inspector, project_manager) receive all
active facilities across all contracts.
Customer role receives only their scoped facilities (via
CustomerAssignment records).
Response 200
------------
{
"ok": true,
"data": {
"facilities": [
{
"id": 7,
"name": "Main Office Building",
"address": "123 Corporate Dr",
"contact_person": "Jane Smith",
"contact_phone": "555-1234",
"project_id": 2,
"project_name": "Corporate Cleaning Contract",
"is_active": true
}
],
"count": 1
}
}
"""
user = g.api_user
customer_fids = get_customer_scope(user)
inspector_fids = get_inspector_scope(user)
if customer_fids is not None:
# Customer — scope to assigned facilities only
if not customer_fids:
logger.info('API FACILITIES | user=%s | role=customer | no_assignments',
user.username)
return api_ok({'facilities': [], 'count': 0})
facilities = (
Facility.query
.filter(Facility.id.in_(customer_fids), Facility.active == True)
.order_by(Facility.name)
.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)
.all()
)
else:
# All other staff — all active facilities
facilities = (
Facility.query
.filter(Facility.active == True) # noqa: E712
.order_by(Facility.name)
.all()
)
payload = [_facility_payload(f) for f in facilities]
logger.info('API FACILITIES | list | user=%s | role=%s | count=%d',
user.username, user.role, len(payload))
return api_ok({'facilities': payload, 'count': len(payload)})
# ── Areas for a Facility ──────────────────────────────────────────────────────
@bp.route('/facilities/<int:facility_id>/areas', methods=['GET'])
@jwt_required
def list_areas(facility_id):
"""
Return all areas for the given facility.
Used by the iPad app to populate the area picker when starting an
inspection. Customer users are validated against their scope before
the areas are returned.
Response 200
------------
{
"ok": true,
"data": {
"facility_id": 7,
"areas": [
{ "id": 12, "facility_id": 7, "name": "Main Lobby", "area_type": "lobby" }
],
"count": 1
}
}
"""
user = g.api_user
facility = db.session.get(Facility, facility_id)
if facility is None or not facility.active:
return api_error('Facility not found', 404)
# Scope validation — customers and inspectors may only access their facilities
customer_fids = get_customer_scope(user)
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',
user.username, facility_id)
return api_error('Access denied', 403)
areas = (
Area.query
.filter_by(facility_id=facility_id)
.order_by(Area.name)
.all()
)
payload = [_area_payload(a) for a in areas]
logger.info('API FACILITIES/AREAS | user=%s | facility_id=%d | count=%d',
user.username, facility_id, len(payload))
return api_ok({'facility_id': facility_id, 'areas': payload, 'count': len(payload)})
+601
View File
@@ -0,0 +1,601 @@
"""
app/api/inspections.py
----------------------
Mobile API endpoints for submitting and retrieving inspections.
POST /api/v1/inspections
Creates a new inspection (offline sync submission).
Idempotent via mobile_local_id.
PATCH /api/v1/inspections/<inspection_id>
Updates an existing inspection (draft → completed).
GET /api/v1/inspections
Returns the authenticated inspector's own inspection history.
Supports ?limit=N&offset=N&facility_id=N&status=completed
"""
import logging
import json
import re
from datetime import datetime
from flask import Blueprint, request, g
from app import db
from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
from app.utils.notifications import notify_by_matrix
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__)
bp = Blueprint('api_inspections', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
def _merge_form_data(existing: dict, incoming: dict) -> dict:
"""Merge incoming form_data into existing, preserving file paths.
New non-empty values always win. The one exception: an empty string
coming from the client will NOT overwrite an existing server-side upload
path (any value that starts with 'uploads/'). This protects photo paths
stored during an earlier POST from being silently blanked when the iOS
sends a final PATCH whose form_data was rebuilt without re-including the
already-uploaded paths.
"""
merged = dict(existing)
for k, v in incoming.items():
existing_v = merged.get(k)
if (not v
and isinstance(existing_v, str)
and existing_v.startswith('uploads/')):
continue # keep the saved photo path
merged[k] = v
return merged
_UUID_RE = re.compile(
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
re.IGNORECASE,
)
def _parse_datetime(value):
"""Parse an ISO 8601 datetime string; return a naive Eastern datetime.
Handles the formats produced by both the web forms and iOS
ISO8601DateFormatter():
2026-05-28T09:41:00 (web form, already Eastern-naive)
2026-05-28T09:41:00.000 (web form with ms)
2026-05-28T09:41:00Z (iOS ISO8601DateFormatter, UTC)
2026-05-28T09:41:00.000000Z (iOS with fractional seconds, UTC)
Values ending with 'Z' are treated as UTC and converted to Eastern.
Values without a timezone suffix are assumed to already be Eastern-local.
"""
if not value:
return None
from datetime import datetime, timezone as _tz
from app.utils.time_utils import EASTERN
is_utc = isinstance(value, str) and value.endswith('Z')
normalised = value.rstrip('Z') if isinstance(value, str) else value
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
try:
dt = datetime.strptime(normalised, fmt)
if is_utc:
dt = (dt.replace(tzinfo=_tz.utc)
.astimezone(EASTERN)
.replace(tzinfo=None))
return dt
except (ValueError, TypeError):
pass
return None
def _inspection_payload(inspection):
"""Serialize an Inspection to the dict returned in API responses."""
# Extract form responses from the notes JSON blob.
# Mobile submissions store form data as {"_form_data": {...}, "_inspector_notes": "..."}.
# Web submissions store form data in the form_data column directly.
form_data = {}
inspector_notes = ''
if inspection.notes:
try:
notes_obj = json.loads(inspection.notes)
if isinstance(notes_obj, dict):
form_data = notes_obj.get('_form_data', {}) or {}
inspector_notes = notes_obj.get('_inspector_notes', '') or ''
except (json.JSONDecodeError, TypeError):
pass
# Fallback: web-created inspections store responses in form_data column
if not form_data and inspection.form_data:
form_data = inspection.form_data if isinstance(inspection.form_data, dict) else {}
# Include the template's form_schema so the iPad can render history
# without needing a locally cached copy of the template.
form_schema = []
if inspection.template:
form_schema = inspection.template.get_form_schema()
return {
'id': inspection.id,
'template_id': inspection.template_id,
'template_name': inspection.template.name if inspection.template else '',
'facility_id': inspection.facility_id,
'facility_name': inspection.facility.name if inspection.facility else '',
'area_id': inspection.area_id,
'area_name': inspection.area.name if inspection.area else None,
'status': inspection.status,
'overall_score': float(inspection.overall_score) if inspection.overall_score is not None else None,
'inspection_date': inspection.inspection_date.isoformat()
if inspection.inspection_date else None,
'completed_at': inspection.completed_at.isoformat()
if inspection.completed_at else None,
'mobile_local_id': inspection.mobile_local_id,
'form_data': form_data,
'form_schema': form_schema,
'inspector_notes': inspector_notes,
# ── Follow-up / re-inspection fields ──────────────────────────────
'follow_up_required': inspection.follow_up_required,
'follow_up_note': inspection.follow_up_note,
'parent_inspection_id': inspection.parent_inspection_id,
}
# ── Inspection History ────────────────────────────────────────────────────────
@bp.route('/inspections', methods=['GET'])
@jwt_required
def list_inspections():
"""
Return the authenticated user's inspection history.
Inspectors see only their own inspections.
Admins/directors/project_managers see all inspections.
Query parameters
----------------
limit int default 50, max 200
offset int default 0
facility_id int filter by facility
status str filter by status (completed, in_progress, flagged)
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
Response 200
------------
{
"ok": true,
"data": {
"inspections": [...],
"total": 42,
"limit": 50,
"offset": 0
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 50)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
query = Inspection.query
# Inspectors only see their own inspections
if user.role == 'inspector':
query = query.filter(Inspection.inspector_id == user.id)
# Optional filters
facility_id = request.args.get('facility_id', type=int)
if facility_id:
query = query.filter(Inspection.facility_id == facility_id)
status = request.args.get('status')
if status:
query = query.filter(Inspection.status == status)
from_date_str = request.args.get('from_date')
if from_date_str:
try:
from_dt = datetime.strptime(from_date_str, '%Y-%m-%d').date()
query = query.filter(Inspection.inspection_date >= from_dt)
except ValueError:
pass # malformed date — ignore silently
to_date_str = request.args.get('to_date')
if to_date_str:
try:
to_dt = datetime.strptime(to_date_str, '%Y-%m-%d').date()
query = query.filter(Inspection.inspection_date <= to_dt)
except ValueError:
pass # malformed date — ignore silently
total = query.count()
inspections = (
query
.order_by(Inspection.inspection_date.desc())
.offset(offset)
.limit(limit)
.all()
)
payload = [_inspection_payload(i) for i in inspections]
logger.info('API INSPECTIONS | list | user=%s | count=%d | total=%d',
user.username, len(payload), total)
return api_ok({
'inspections': payload,
'total': total,
'limit': limit,
'offset': offset,
})
# ── Create Inspection ─────────────────────────────────────────────────────────
@bp.route('/inspections', methods=['POST'])
@jwt_required
def create_inspection():
"""
Create a new inspection submitted from the iPad app.
Idempotency: if mobile_local_id is provided and an inspection with that
ID already exists, the existing record is returned without duplication.
Request JSON
------------
{
"template_id": 3,
"facility_id": 7,
"area_id": 12,
"status": "completed",
"form_data": { ... },
"notes": "...",
"overall_score": 87.5,
"inspection_date": "2026-05-01T14:30:00",
"completed_at": "2026-05-01T15:00:00",
"mobile_local_id": "uuid-string"
}
Response 200
------------
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
# ── Idempotency check ─────────────────────────────────────────────────
mobile_local_id = data.get('mobile_local_id')
if mobile_local_id:
if not _UUID_RE.match(str(mobile_local_id)):
return api_error('mobile_local_id must be a valid UUID', 400)
existing = Inspection.query.filter_by(mobile_local_id=mobile_local_id).first()
if existing:
logger.info('API INSPECTIONS | duplicate | local_id=%s | inspection_id=%d | user=%s',
mobile_local_id, existing.id, user.username)
return api_ok({'inspection_id': existing.id, 'duplicate': True})
# ── Validate required fields ──────────────────────────────────────────
template_id = data.get('template_id')
facility_id = data.get('facility_id')
if not template_id or not facility_id:
return api_error('template_id and facility_id are required', 400)
template = db.session.get(InspectionTemplate, template_id)
if template is None:
return api_error('Template not found', 404)
facility = db.session.get(Facility, facility_id)
if facility is None or not facility.active:
return api_error('Facility not found', 404)
area_id = data.get('area_id')
if area_id:
area = db.session.get(Area, area_id)
if area is None or area.facility_id != facility_id:
return api_error('Area not found or does not belong to the facility', 400)
status = data.get('status', 'completed')
if status not in ('in_progress', 'completed'):
return api_error('status must be "in_progress" or "completed"', 400)
# ── Optional parent link (re-inspection) ──────────────────────────────
parent_inspection_id = data.get('parent_inspection_id')
if parent_inspection_id:
parent = db.session.get(Inspection, parent_inspection_id)
if parent is None:
return api_error('Parent inspection not found', 404)
# ── Score calculation ─────────────────────────────────────────────────
overall_score = data.get('overall_score')
if overall_score is None and status == 'completed':
form_data = data.get('form_data') or {}
form_fields = template.get_form_schema()
overall_score = _compute_score(form_fields, form_data)
# ── Build inspection record ───────────────────────────────────────────
inspection_date = _parse_datetime(data.get('inspection_date')) or now_eastern()
completed_at = _parse_datetime(data.get('completed_at'))
form_data = data.get('form_data') or {}
notes_payload = {}
if data.get('notes'):
notes_payload['_inspector_notes'] = data['notes']
notes_payload['_form_data'] = form_data
# If the client sent completed_at but it failed to parse (e.g. unrecognised
# format), fall back to now rather than storing NULL. This mirrors the
# PATCH handler's behaviour.
if completed_at is None and status == 'completed':
completed_at = now_eastern()
# ── GPS (mobile submission) ───────────────────────────────────────────
# The iPad sends submit_latitude / submit_longitude when CoreLocation
# granted permission and a fix was obtained before the inspector confirmed
# submission. Both fields are nullable — absence is silently ignored.
_lat = data.get('submit_latitude')
_lng = data.get('submit_longitude')
try:
submit_latitude = float(_lat) if _lat is not None else None
submit_longitude = float(_lng) if _lng is not None else None
except (ValueError, TypeError):
submit_latitude = None
submit_longitude = None
inspection = Inspection(
template_id = template_id,
facility_id = facility_id,
area_id = area_id,
inspector_id = user.id,
inspection_date = inspection_date,
overall_score = overall_score,
status = status,
notes = json.dumps(notes_payload),
completed_at = completed_at if status == 'completed' else None,
mobile_local_id = mobile_local_id,
parent_inspection_id = parent_inspection_id,
submit_latitude = submit_latitude,
submit_longitude = submit_longitude,
)
db.session.add(inspection)
db.session.flush()
# ── Auto-clear follow-up flag on parent ───────────────────────────────
# When a completed re-inspection arrives that links to a parent, clear
# follow_up_required on the parent automatically. This mirrors the web
# list view's implicit logic (which hides the badge when follow_ups.any())
# and ensures the History API response reflects the resolved state.
# Capture parent label strings before commit while ORM objects are loaded.
# log_action() for the parent update must fire AFTER db.session.commit() to
# avoid audit.py's internal commit() persisting the parent flag change before
# the new inspection row is committed — a partial state that would be incorrect
# if the main commit subsequently failed.
_parent_log_args = None
if parent_inspection_id and status == 'completed':
parent_insp = db.session.get(Inspection, parent_inspection_id)
if parent_insp and parent_insp.follow_up_required:
parent_insp.follow_up_required = False
logger.info(
'API INSPECTIONS | follow_up cleared | parent_id=%d | '
'by_inspection_id=%d | user=%s',
parent_inspection_id, inspection.id, user.username,
)
# Snapshot label strings now — ORM objects may be expired after commit
_parent_log_args = (
parent_inspection_id,
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
f'follow_up_required=False (cleared by re-inspection '
f'#{inspection.id} via mobile API)',
)
# ── Notifications ─────────────────────────────────────────────────────
if status == 'completed':
score_display = f'{overall_score:.1f}%' if overall_score is not None else 'N/A'
try:
from flask import url_for
inspection_link = url_for('inspections.view',
inspection_id=inspection.id, _external=False)
except RuntimeError:
inspection_link = f'/inspections/{inspection.id}'
notify_by_matrix(
event_type = 'inspection_completed',
title = f'Inspection #{inspection.id} Completed (Mobile)',
body = (
f'{user.display_name} completed an inspection at '
f'{facility.name} using the "{template.name}" template. '
f'Overall score: {score_display}.'
),
link = inspection_link,
inspection_id = inspection.id,
facility_id = facility_id,
)
db.session.commit()
# ── Post-commit audit logging ──────────────────────────────────────────
# All log_action() calls must come AFTER db.session.commit() because
# audit.py calls db.session.commit() internally. Calling it before the
# main commit would persist the audit row (and any dirty ORM state) before
# the primary transaction completes.
if _parent_log_args:
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
log_action(ACTION_CREATE, 'Inspection', inspection.id,
f'{template.name} @ {facility.name}',
f'source=mobile; status={status}; score={overall_score}; '
f'local_id={mobile_local_id}; parent_id={parent_inspection_id}')
logger.info('API INSPECTIONS | created | inspection_id=%d | facility=%s | '
'template=%s | status=%s | score=%s | user=%s',
inspection.id, facility.name, template.name,
status, overall_score, user.username)
return api_ok({'inspection_id': inspection.id, 'duplicate': False})
# ── Update Inspection ─────────────────────────────────────────────────────────
@bp.route('/inspections/<int:inspection_id>', methods=['PATCH'])
@jwt_required
def update_inspection(inspection_id):
"""
Update an existing inspection (e.g. draft → completed).
Request JSON (all fields optional)
------------
{
"status": "completed",
"form_data": { ... },
"notes": "...",
"overall_score": 91.0,
"completed_at": "2026-05-01T15:30:00"
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
return api_error('Inspection not found', 404)
if user.role == 'inspector' and inspection.inspector_id != user.id:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
if 'form_data' in data or 'notes' in data:
existing_notes = {}
if inspection.notes:
try:
existing_notes = json.loads(inspection.notes)
except (json.JSONDecodeError, TypeError):
existing_notes = {}
if 'form_data' in data:
existing_notes['_form_data'] = _merge_form_data(
existing_notes.get('_form_data') or {},
data['form_data'] or {},
)
if 'notes' in data:
existing_notes['_inspector_notes'] = data['notes']
inspection.notes = json.dumps(existing_notes)
prev_status = inspection.status
if 'status' in data:
inspection.status = data['status']
if 'overall_score' in data:
inspection.overall_score = data['overall_score']
elif data.get('status') == 'completed' and inspection.overall_score is None:
form_data = data.get('form_data') or {}
form_fields = inspection.template.get_form_schema()
inspection.overall_score = _compute_score(form_fields, form_data)
if 'completed_at' in data:
inspection.completed_at = _parse_datetime(data['completed_at']) or now_eastern()
elif data.get('status') == 'completed' and not inspection.completed_at:
inspection.completed_at = now_eastern()
db.session.commit()
# Notify when a draft transitions to completed — mirrors the POST handler.
transitioning_to_complete = (
data.get('status') == 'completed' and prev_status != 'completed'
)
if transitioning_to_complete:
score_val = inspection.overall_score
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
template_name = inspection.template.name if inspection.template else 'Unknown'
facility_name = inspection.facility.name if inspection.facility else 'Unknown'
try:
from flask import url_for
inspection_link = url_for('inspections.view',
inspection_id=inspection.id, _external=False)
except RuntimeError:
inspection_link = f'/inspections/{inspection.id}'
notify_by_matrix(
event_type = 'inspection_completed',
title = f'Inspection #{inspection.id} Completed (Mobile)',
body = (
f'{user.display_name} completed an inspection at '
f'{facility_name} using the "{template_name}" template. '
f'Overall score: {score_display}.'
),
link = inspection_link,
inspection_id = inspection.id,
facility_id = inspection.facility_id,
)
db.session.commit() # persist notification rows added by notify()
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'source=mobile; fields_updated={list(data.keys())}')
logger.info('API INSPECTIONS | updated | inspection_id=%d | user=%s | fields=%s',
inspection_id, user.username, list(data.keys()))
return api_ok({'inspection_id': inspection_id})
# ── Score helper ──────────────────────────────────────────────────────────────
def _compute_score(form_fields, responses):
"""
Mirror of routes/inspections.py::_compute_score_from_form().
Rating value 0 = unanswered — excluded from calculation.
"""
scoreable = [f for f in form_fields
if f.get('type') in ('rating', 'checkbox', 'radio', 'pass_fail')]
if not scoreable:
return None
total, earned = 0, 0
for field in scoreable:
fid = field.get('id')
val = responses.get(str(fid), responses.get(fid, ''))
ftype = field.get('type')
if ftype == 'rating':
try:
v = int(val)
if v == 0:
continue
earned += v
total += 5
except (ValueError, TypeError):
pass
elif ftype == 'checkbox':
total += 1
if val == 'true':
earned += 1
elif ftype == 'radio':
total += 1
if str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
earned += 1
elif ftype == 'pass_fail':
if not val:
continue
total += 1
if str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
earned += 1
return round((earned / total) * 100, 2) if total else None
+435
View File
@@ -0,0 +1,435 @@
"""
app/api/issues.py
-----------------
Mobile API endpoint for submitting issues from the iPad app.
GET /api/v1/issues
Returns issues assigned to the authenticated inspector (or all for admin/director).
Used by the iPad to display assigned issues that were created via the web portal.
POST /api/v1/issues
Creates a new issue record.
Accepts a mobile_local_id for idempotency.
Triggers notify_by_matrix() and log_action() identically to the web route.
GET /api/v1/issues/<id>
Returns current status, severity, description, and assigned_to for an issue.
Inspectors may only fetch issues assigned to them.
PATCH /api/v1/issues/<id>/status
Updates the status of an issue.
Inspectors may only update issues assigned to them.
Admins/directors may update any issue.
"""
import logging
import re
from flask import Blueprint, request, g, current_app
from app import db
from app.models.issue import Issue
from app.models.facility import Facility, Area
from app.models.inspection import Inspection
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
from app.utils.notifications import notify_by_matrix
from app.utils.time_utils import now_eastern
from app.utils.scope import get_inspector_scope
logger = logging.getLogger(__name__)
bp = Blueprint('api_issues', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
_UUID_RE = re.compile(
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
re.IGNORECASE,
)
def _issue_payload(issue):
"""Serialise an Issue to the dict returned in list/detail responses."""
facility = issue.resolved_facility
return {
'id': issue.id,
'status': issue.status,
'severity': issue.severity,
'description': issue.description,
'assigned_to': issue.assigned_to,
'facility_id': facility.id if facility else None,
'facility_name': facility.name if facility else None,
'reported_at': issue.reported_at.isoformat() if issue.reported_at else None,
'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
'mobile_local_id': issue.mobile_local_id,
# photo_path: primary evidence photo (first iPad photo).
# mobile_photo_paths: extra evidence photos from iPad (shown under Photo Evidence).
# result_photos: resolution photos added via the web update form.
'photo_path': issue.photo_path or None,
'mobile_photo_paths': issue.mobile_photo_paths or [],
'result_photos': issue.result_photos or [],
# Resolution details — set by web staff after fixing the issue.
'result_notes': issue.result_notes or None,
# Verification fields — set after a director/admin confirms fix.
'verified_at': issue.verified_at.isoformat() if issue.verified_at else None,
'verification_note': issue.verification_note or None,
# Reporter display name — shows who filed the issue.
'reported_by_name': issue.reporter.display_name if issue.reporter else None,
# Area name — set when the issue was flagged during an area-specific inspection.
'area_name': issue.area.name if issue.area else None,
# Assigned-to display name — set when a director assigns the issue to a user.
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
}
# ── List Assigned Issues ──────────────────────────────────────────────────────
@bp.route('/issues', methods=['GET'])
@jwt_required
def list_issues():
"""
Return active issues for the authenticated user.
All roles see all non-resolved issues (inspectors included) so the iPad
shows the full picture of open work at their facilities.
A ?status= filter can be used to override the default exclusion.
Query parameters
----------------
status str Filter by status. Omit to get all non-resolved issues.
limit int Default 100, max 200.
offset int Default 0.
Response 200
------------
{
"ok": true,
"data": {
"issues": [...],
"total": 12,
"limit": 100,
"offset": 0
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
query = Issue.query
if user.role == 'inspector':
fids = get_inspector_scope(user)
if not fids:
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
query = query.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(fids),
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
)
)
else:
# Broader roles: exclude resolved by default so the list stays manageable
status_filter = request.args.get('status')
if status_filter:
query = query.filter(Issue.status == status_filter)
else:
query = query.filter(Issue.status != 'resolved')
total = query.count()
issues = (
query
.order_by(Issue.reported_at.desc())
.offset(offset)
.limit(limit)
.all()
)
payload = [_issue_payload(i) for i in issues]
logger.info('API ISSUES | list | user=%s | count=%d | total=%d',
user.username, len(payload), total)
return api_ok({'issues': payload, 'total': total, 'limit': limit, 'offset': offset})
@bp.route('/issues', methods=['POST'])
@jwt_required
def create_issue():
"""
Create a new issue submitted from the iPad app.
Idempotency: if mobile_local_id is provided and an issue with that ID
already exists, the existing record is returned without duplication.
Request JSON
------------
{
"inspection_id": 42, // optional
"facility_id": 5, // required
"severity": "high", // "low"|"medium"|"high"|"critical"
"description": "...", // required
"photo_path": "uploads/...",// optional already uploaded via /photos/upload
"mobile_local_id": "uuid-string" // idempotency key
}
Response 200
------------
{ "ok": true, "data": { "issue_id": 99, "duplicate": false } }
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
# ── Idempotency check ─────────────────────────────────────────────────
mobile_local_id = data.get('mobile_local_id')
if mobile_local_id:
if not _UUID_RE.match(str(mobile_local_id)):
return api_error('mobile_local_id must be a valid UUID', 400)
existing = Issue.query.filter_by(mobile_local_id=mobile_local_id).first()
if existing:
logger.info('API ISSUES | duplicate | local_id=%s | issue_id=%d | user=%s',
mobile_local_id, existing.id, user.username)
return api_ok({'issue_id': existing.id, 'duplicate': True})
# ── Validate ──────────────────────────────────────────────────────────
facility_id = data.get('facility_id')
severity = data.get('severity', '').lower()
description = (data.get('description') or '').strip()
if not facility_id:
return api_error('facility_id is required', 400)
if severity not in _VALID_SEVERITY:
return api_error(f'severity must be one of: {", ".join(sorted(_VALID_SEVERITY))}', 400)
if not description:
return api_error('description is required', 400)
facility = db.session.get(Facility, facility_id)
if facility is None:
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')
if inspection_id:
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
return api_error('Inspection not found', 404)
else:
inspection = None
# ── Create ────────────────────────────────────────────────────────────
# result_photos in the POST body = extra evidence photos from the iPad.
# Store in mobile_photo_paths (not result_photos) so they appear under
# "Photo Evidence" on the web, not "Resolution Details".
raw_mobile = data.get('result_photos')
mobile_photo_paths = [p for p in raw_mobile if isinstance(p, str) and p.strip()] \
if isinstance(raw_mobile, list) else []
issue = Issue(
inspection_id = inspection_id,
facility_id = facility_id,
severity = severity,
description = description,
photo_path = data.get('photo_path') or None,
mobile_photo_paths = mobile_photo_paths or None,
status = 'open',
reported_at = now_eastern(),
reported_by = user.id,
mobile_local_id = mobile_local_id,
)
db.session.add(issue)
db.session.commit() # commit issue first so FK references in notifications are valid
# ── Notifications ─────────────────────────────────────────────────────
try:
from flask import url_for
issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
except RuntimeError:
issue_link = f'/issues/{issue.id}'
inspection_ref = f'inspection #{inspection_id}' if inspection_id else 'a standalone report'
notify_by_matrix(
event_type = 'issue_created',
title = f'New Issue #{issue.id} at {facility.name} (Mobile)',
body = (
f'A new {severity.title()}-severity issue was logged at '
f'{facility.name} during {inspection_ref}. '
f'Description: {description[:120]}'
f'{"" if len(description) > 120 else ""}'
),
link = issue_link,
issue_id = issue.id,
facility_id = facility_id,
)
db.session.commit()
log_action(ACTION_CREATE, 'Issue', issue.id,
f'{severity} issue at {facility.name}',
f'source=mobile; inspection_id={inspection_id}; '
f'local_id={mobile_local_id}')
logger.info('API ISSUES | created | issue_id=%d | facility=%s | severity=%s | user=%s',
issue.id, facility.name, severity, user.username)
return api_ok({'issue_id': issue.id, 'duplicate': False})
# ── Get Issue Detail ──────────────────────────────────────────────────────────
@bp.route('/issues/<int:issue_id>', methods=['GET'])
@jwt_required
def get_issue(issue_id):
"""
Return current status, severity, description, assigned_to, and facility
for a single issue.
Access: all allowed roles may fetch any issue.
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
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))
# ── Update Issue Status ───────────────────────────────────────────────────────
@bp.route('/issues/<int:issue_id>/status', methods=['PATCH'])
@jwt_required
def update_issue_status(issue_id):
"""
Update the status of an issue.
Request JSON
------------
{ "status": "in_progress" } // one of: open | in_progress | resolved | pending_verification
Access:
- admin / director : any issue
- inspector : only issues where assigned_to == current user
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
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 {}
new_status = (data.get('status') or '').strip().lower()
if new_status not in _VALID_STATUSES:
return api_error(
f'status must be one of: {", ".join(sorted(_VALID_STATUSES))}', 400
)
old_status = issue.status
issue.status = new_status
if new_status == 'resolved' and not issue.resolved_at:
issue.resolved_at = now_eastern()
issue.sla_notified = None
elif new_status != 'resolved':
issue.resolved_at = None
if old_status == 'resolved':
issue.sla_notified = None
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'status {old_status}{new_status}',
f'source=mobile; updated_by={user.username}')
logger.info('API ISSUES | status_updated | issue_id=%d | %s%s | user=%s',
issue.id, old_status, new_status, user.username)
return api_ok({'issue_id': issue.id, 'status': issue.status})
# ── Update Issue Photos (mobile) ──────────────────────────────────────────────
@bp.route('/issues/<int:issue_id>/photos', methods=['PATCH'])
@jwt_required
def update_issue_photos(issue_id):
"""
Attach additional evidence photos to an issue created from the mobile app.
Called by the iOS app after create_issue when the inspector attached more
than one photo. Photos are already uploaded via /api/v1/photos/upload.
Stored in mobile_photo_paths so they display under "Photo Evidence" on the
web, not "Resolution Details".
Request JSON
------------
{ "result_photos": ["uploads/issue_photos/a.jpg", "uploads/issue_photos/b.jpg"] }
Response 200
------------
{ "ok": true, "data": { "issue_id": 99, "result_photos_count": 2 } }
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
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 {}
raw = data.get('result_photos')
if not isinstance(raw, list):
return api_error('result_photos must be a list of path strings', 400)
new_photos = [p for p in raw if isinstance(p, str) and p.strip()]
if not new_photos:
return api_error('result_photos must contain at least one valid path', 400)
# Merge idempotently with any existing mobile_photo_paths
existing = issue.mobile_photo_paths or []
merged = existing + [p for p in new_photos if p not in existing]
issue.mobile_photo_paths = merged
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'mobile_photo_paths updated (+{len(new_photos)} photos)',
f'source=mobile; updated_by={user.username}')
logger.info('API ISSUES | photos_updated | issue_id=%d | added=%d | user=%s',
issue.id, len(new_photos), user.username)
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
+74
View File
@@ -0,0 +1,74 @@
"""
app/api/jwt_utils.py
--------------------
Thin wrappers around PyJWT for signing and verifying access tokens.
Access tokens are short-lived JWTs (default 60 minutes) signed with
HMAC-SHA256 using the app's SECRET_KEY. They carry only the minimum
claims needed to identify the caller:
{
"sub": "42", # user.id as string
"role": "inspector", # user.role
"iat": 1710000000, # issued-at (UTC epoch)
"exp": 1710003600, # expiry (UTC epoch, 60 min later)
}
Refresh tokens are opaque random strings stored in the DB
(see app/models/api_token.py). This module only handles JWTs.
"""
import logging
from datetime import datetime, timezone, timedelta
import jwt
from flask import current_app
logger = logging.getLogger(__name__)
ACCESS_TOKEN_LIFETIME_MINUTES = 60
def _secret():
return current_app.config['SECRET_KEY']
def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str:
"""
Create and sign a new access token for the given user.
Parameters
----------
user : User ORM instance
lifetime_minutes : Token validity window (default 60 min)
Returns
-------
str
Signed JWT string ready to include in Authorization header.
"""
now = datetime.now(timezone.utc)
payload = {
'sub': str(user.id),
'role': user.role,
'iat': now,
'exp': now + timedelta(minutes=lifetime_minutes),
}
return jwt.encode(payload, _secret(), algorithm='HS256')
def decode_access_token(token: str) -> dict | None:
"""
Decode and verify a JWT access token.
Returns the payload dict on success, or None if the token is invalid,
expired, or tampered with. Logs the failure reason at DEBUG level.
"""
try:
return jwt.decode(token, _secret(), algorithms=['HS256'])
except jwt.ExpiredSignatureError:
logger.debug('JWT decode failed: token expired')
return None
except jwt.InvalidTokenError as exc:
logger.debug('JWT decode failed: %s', exc)
return None
+165
View File
@@ -0,0 +1,165 @@
"""
app/api/notifications.py
------------------------
Mobile API endpoint for polling in-app notifications.
GET /api/v1/notifications
Returns unread notifications for the authenticated user.
Accepts ?since=<ISO 8601> to fetch only notifications created after
that datetime used by the iPad poller to avoid re-delivering already-
seen alerts. Returns a maximum of 50 notifications per call.
PATCH /api/v1/notifications/mark-read
Marks a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
"""
import logging
from datetime import datetime
import sqlalchemy.exc
from flask import Blueprint, request, g
from app import db
from app.models.notification import Notification
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
logger = logging.getLogger(__name__)
bp = Blueprint('api_notifications', __name__)
def _parse_since(value):
"""Parse ?since= ISO 8601 string; return None on failure."""
if not value:
return None
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
try:
return datetime.strptime(value, fmt)
except (ValueError, TypeError):
pass
return None
def _build_payload(notifications, has_event_type):
"""Serialise notification rows to dicts. Works before and after phase17 migration."""
rows = []
for n in notifications:
rows.append({
'id': n.id,
'title': n.title,
'body': n.body,
'event_type': (n.event_type if has_event_type else None),
'issue_id': n.issue_id,
'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'),
})
return rows
@bp.route('/notifications', methods=['GET'])
@jwt_required
def list_notifications():
"""
Return unread notifications for the authenticated user.
Query parameters
----------------
since ISO 8601 datetime Only return notifications created after this time.
limit int (default 50, max 50)
Response 200
------------
{ "ok": true, "data": { "notifications": [...], "count": N } }
"""
user = g.api_user
since = _parse_since(request.args.get('since'))
limit = min(int(request.args.get('limit', 50)), 50)
def _run_orm():
q = Notification.query.filter_by(user_id=user.id, is_read=False)
if since:
q = q.filter(Notification.created_at > since)
return q.order_by(Notification.created_at.asc()).limit(limit).all()
# Attempt the ORM query (works after phase17 migration runs).
# If the event_type column does not yet exist in the DB, MySQL raises
# OperationalError: Unknown column 'notifications.event_type' in SELECT.
# getattr() does NOT protect against this — the failure is at the SQL layer.
# The fallback raw-SQL query selects only the pre-phase17 columns so the
# endpoint stays functional before the migration runs.
try:
notifications = _run_orm()
has_event_type = True
except sqlalchemy.exc.OperationalError:
db.session.rollback()
sql_parts = (
"SELECT id, title, body, issue_id, is_read, created_at "
"FROM notifications "
"WHERE user_id = :uid AND is_read = 0 "
)
params = {'uid': user.id, 'lim': limit}
if since:
sql_parts += "AND created_at > :since "
params['since'] = since
sql_parts += "ORDER BY created_at ASC LIMIT :lim"
rows = db.session.execute(db.text(sql_parts), params).fetchall()
class _Row:
"""Minimal shim so _build_payload works with raw SQL rows."""
__slots__ = ('id', 'title', 'body', 'issue_id', 'created_at')
def __init__(self, r):
self.id = r[0]
self.title = r[1]
self.body = r[2]
self.issue_id = r[3]
self.created_at = r[5]
notifications = [_Row(r) for r in rows]
has_event_type = False
payload = _build_payload(notifications, has_event_type)
logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d | has_event_type=%s',
user.username, since, len(payload), has_event_type)
return api_ok({'notifications': payload, 'count': len(payload)})
@bp.route('/notifications/mark-read', methods=['PATCH'])
@jwt_required
def mark_read():
"""
Mark a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
Response 200
------------
{ "ok": true, "data": { "marked": 3 } }
"""
user = g.api_user
data = request.get_json(silent=True) or {}
ids = data.get('ids') or []
if not isinstance(ids, list):
return api_error('ids must be a list', 400)
if ids:
updated = (
Notification.query
.filter(
Notification.id.in_(ids),
Notification.user_id == user.id,
)
.all()
)
for n in updated:
n.is_read = True
db.session.commit()
count = len(updated)
else:
count = 0
logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count)
return api_ok({'marked': count})
+100
View File
@@ -0,0 +1,100 @@
"""
app/api/photos.py
-----------------
Mobile API endpoint for uploading photos from the iPad app.
POST /api/v1/photos/upload
Accepts a multipart/form-data upload.
Saves the file to the server's upload folder.
Returns the relative server path used in subsequent inspection/issue submissions.
The iPad app uploads photos BEFORE submitting the inspection or issue,
then includes the returned server_path in the inspection/issue payload.
"""
import os
import uuid
import logging
from flask import Blueprint, request, g, current_app
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
logger = logging.getLogger(__name__)
bp = Blueprint('api_photos', __name__)
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
def _allowed_file(filename: str) -> bool:
return (
'.' in filename
and filename.rsplit('.', 1)[-1].lower() in _ALLOWED_EXTENSIONS
)
# ── Upload Photo ──────────────────────────────────────────────────────────────
@bp.route('/photos/upload', methods=['POST'])
@jwt_required
def upload_photo():
"""
Upload a photo from the iPad app.
Multipart form fields
---------------------
file binary image data (jpg / png / gif)
entity_type "inspection" | "issue" (controls subfolder)
Response 200
------------
{
"ok": true,
"data": {
"server_path": "uploads/inspection_photos/abc123.jpg"
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
if 'file' not in request.files:
return api_error('No file provided', 400)
file_obj = request.files['file']
entity_type = request.form.get('entity_type', 'inspection')
if not file_obj or not file_obj.filename:
return api_error('Empty file', 400)
if not _allowed_file(file_obj.filename):
return api_error(
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
400
)
# Determine destination subfolder
if entity_type == 'issue':
subfolder = 'issue_photos'
else:
subfolder = 'inspection_photos'
ext = file_obj.filename.rsplit('.', 1)[-1].lower()
filename = f'{uuid.uuid4().hex}.{ext}'
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
os.makedirs(dest_dir, exist_ok=True)
dest_path = os.path.join(dest_dir, filename)
file_obj.save(dest_path)
server_path = f'uploads/{subfolder}/{filename}'
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
entity_type, server_path, user.username)
return api_ok({'server_path': server_path})
+178
View File
@@ -0,0 +1,178 @@
"""
app/api/stats.py
----------------
Mobile API endpoint for dashboard statistics.
GET /api/v1/stats/dashboard
Returns inspector-scoped counts used by the iPad dashboard card:
- today_inspections : inspections started or completed today
- completed_today : completed inspections today
- open_issues : open + in_progress issues in contracted facilities
- avg_score_30d : average overall_score (last 30 days, own inspections)
- pending_followups : completed inspections with follow_up_required and no
child re-inspection yet
- sla_breached : open/in-progress issues past their SLA deadline
- sla_at_risk : open/in-progress issues past 75% of SLA window
Admins and directors receive org-wide numbers (no facility scoping).
Project managers receive unscoped numbers same as admin.
Inspectors receive numbers scoped to their contracted facilities / own work.
Customers are denied (403) stats are for operational staff only.
"""
import logging
from datetime import timedelta
from flask import Blueprint, g
from sqlalchemy import func
from app import db
from app.models.inspection import Inspection
from app.models.issue import Issue
from app.models.facility import Area
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.scope import get_inspector_scope
from app.utils.sla import sla_status
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__)
bp = Blueprint('api_stats', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
@bp.route('/stats/dashboard', methods=['GET'])
@jwt_required
def dashboard_stats():
"""
Return dashboard KPI counts for the authenticated user.
Response 200
------------
{
"ok": true,
"data": {
"today_inspections": 3,
"completed_today": 2,
"open_issues": 7,
"avg_score_30d": 84.5,
"pending_followups": 1,
"sla_breached": 2,
"sla_at_risk": 1
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
now = now_eastern()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
thirty_days_ago = now - timedelta(days=30)
is_inspector = user.role == 'inspector'
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
# ── Today's inspections ───────────────────────────────────────────────
today_q = Inspection.query.filter(
Inspection.inspection_date >= today_start,
Inspection.inspection_date < today_end,
)
if is_inspector:
if not fids:
today_q = today_q.filter(False)
else:
today_q = today_q.filter(
Inspection.facility_id.in_(fids),
Inspection.inspector_id == user.id,
)
today_inspections = today_q.count()
completed_today = today_q.filter(
Inspection.status == 'completed'
).count()
# ── Open issues ───────────────────────────────────────────────────────
open_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_inspector:
if not fids:
open_q = open_q.filter(False)
else:
open_q = open_q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(fids),
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
)
)
open_issues_all = open_q.all()
open_issues = len(open_issues_all)
# ── Severity breakdown (derived from the same open_issues_all list) ───
severity_breakdown = {
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
'high': sum(1 for i in open_issues_all if i.severity == 'high'),
'medium': sum(1 for i in open_issues_all if i.severity == 'medium'),
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
}
# ── SLA counts (derived from the same open_issues_all list) ──────────
sla_breached = sum(1 for i in open_issues_all if sla_status(i) == 'breached')
sla_at_risk = sum(1 for i in open_issues_all if sla_status(i) == 'at_risk')
# ── Average score last 30 days ────────────────────────────────────────
score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
Inspection.inspection_date >= thirty_days_ago,
)
if is_inspector:
if not fids:
score_q = score_q.filter(False)
else:
score_q = score_q.filter(
Inspection.facility_id.in_(fids),
Inspection.inspector_id == user.id,
)
raw_avg = score_q.scalar()
avg_score = round(float(raw_avg), 1) if raw_avg is not None else None
# ── Pending follow-ups ────────────────────────────────────────────────
# Completed inspections that still need a re-inspection and have none yet.
followup_q = Inspection.query.filter_by(
follow_up_required=True, status='completed'
).filter(~Inspection.follow_ups.any())
if is_inspector:
if not fids:
followup_q = followup_q.filter(False)
else:
followup_q = followup_q.filter(
Inspection.facility_id.in_(fids),
Inspection.inspector_id == user.id,
)
pending_followups = followup_q.count()
logger.info(
'API STATS | dashboard | user=%s | role=%s | '
'today=%d | open_issues=%d | avg=%.1f | followups=%d | sla_b=%d | sla_r=%d',
user.username, user.role,
today_inspections, open_issues,
avg_score or 0.0,
pending_followups, sla_breached, sla_at_risk,
)
return api_ok({
'today_inspections': today_inspections,
'completed_today': completed_today,
'open_issues': open_issues,
'avg_score_30d': avg_score,
'pending_followups': pending_followups,
'sla_breached': sla_breached,
'sla_at_risk': sla_at_risk,
'severity_breakdown': severity_breakdown,
})
+149
View File
@@ -0,0 +1,149 @@
"""
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
from app import db
from app.models.inspection import InspectionTemplate
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
logger = logging.getLogger(__name__)
bp = Blueprint('api_templates', __name__)
# Customer role cannot access template data — inspectors and above only
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
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)
templates = (
InspectionTemplate.query
.filter_by(active=True)
.order_by(InspectionTemplate.name)
.all()
)
payload = [_template_summary_payload(t) for t in templates]
logger.info('API TEMPLATES | list | user=%s | count=%d',
user.username, 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)
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)})