05/02/2026 Updates for iPad app: Phase 1

This commit is contained in:
Nguyen Ngo
2026-05-02 08:16:23 -04:00
parent fd057068e1
commit 1b4a2cc74e
3 changed files with 342 additions and 7 deletions
+14 -7
View File
@@ -15,8 +15,13 @@ Blueprint layout
/api/v1/auth/me → api_auth.me
/api/v1/devices/register → api_auth.register_device
Phase A (iPad App):
/api/v1/facilities → api_facilities.list_facilities
/api/v1/facilities/<id>/areas → api_facilities.list_areas
/api/v1/templates → api_templates.list_templates
/api/v1/templates/<id> → api_templates.get_template
Future phases will add:
/api/v1/facilities → api_facilities.*
/api/v1/inspections → api_inspections.*
/api/v1/issues → api_issues.*
/api/v1/notifications → api_notifications.*
@@ -41,20 +46,22 @@ def register_api(app):
Called once from create_app() in app/__init__.py.
"""
# ── Phase 1: Auth ────────────────────────────────────────────────────
# ── Phase 1 (Web): Auth ──────────────────────────────────────────────
from app.api.auth import bp as auth_bp
api_bp.register_blueprint(auth_bp)
# ── Phase 2+: Additional blueprints registered here as phases complete
# from app.api.facilities import bp as facilities_bp
# ── Phase A (iPad): 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+ (iPad): Inspections, issues, photos ─────────────────────
# from app.api.inspections import bp as inspections_bp
# from app.api.issues import bp as issues_bp
# from app.api.notifications import bp as notifications_bp
# from app.api.photos import bp as photos_bp
# api_bp.register_blueprint(facilities_bp)
# api_bp.register_blueprint(inspections_bp)
# api_bp.register_blueprint(issues_bp)
# api_bp.register_blueprint(notifications_bp)
# api_bp.register_blueprint(photos_bp)
app.register_blueprint(api_bp)
+181
View File
@@ -0,0 +1,181 @@
"""
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
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 role: honour facility-level scoping
facility_ids = get_customer_scope(user)
if facility_ids is not None:
# Customer — scope to assigned facilities only
if not facility_ids:
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_(facility_ids),
Facility.active == True, # noqa: E712
)
.order_by(Facility.name)
.all()
)
else:
# Internal 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)
# Customer scope validation — ensure the customer is assigned to this facility
facility_ids = get_customer_scope(user)
if facility_ids is not None and facility_id not in facility_ids:
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)})
+147
View File
@@ -0,0 +1,147 @@
"""
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 '',
}
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
.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)})