First commit
This commit is contained in:
@@ -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
@@ -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})
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)})
|
||||
@@ -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
|
||||
@@ -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)})
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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)})
|
||||
Reference in New Issue
Block a user