05/02/2026 Updates for iPad app: Phase 1
This commit is contained in:
+14
-7
@@ -15,8 +15,13 @@ Blueprint layout
|
|||||||
/api/v1/auth/me → api_auth.me
|
/api/v1/auth/me → api_auth.me
|
||||||
/api/v1/devices/register → api_auth.register_device
|
/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:
|
Future phases will add:
|
||||||
/api/v1/facilities → api_facilities.*
|
|
||||||
/api/v1/inspections → api_inspections.*
|
/api/v1/inspections → api_inspections.*
|
||||||
/api/v1/issues → api_issues.*
|
/api/v1/issues → api_issues.*
|
||||||
/api/v1/notifications → api_notifications.*
|
/api/v1/notifications → api_notifications.*
|
||||||
@@ -41,20 +46,22 @@ def register_api(app):
|
|||||||
|
|
||||||
Called once from create_app() in app/__init__.py.
|
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
|
from app.api.auth import bp as auth_bp
|
||||||
api_bp.register_blueprint(auth_bp)
|
api_bp.register_blueprint(auth_bp)
|
||||||
|
|
||||||
# ── Phase 2+: Additional blueprints registered here as phases complete
|
# ── Phase A (iPad): Reference data ──────────────────────────────────
|
||||||
# from app.api.facilities import bp as facilities_bp
|
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.inspections import bp as inspections_bp
|
||||||
# from app.api.issues import bp as issues_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
|
# 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(inspections_bp)
|
||||||
# api_bp.register_blueprint(issues_bp)
|
# api_bp.register_blueprint(issues_bp)
|
||||||
# api_bp.register_blueprint(notifications_bp)
|
|
||||||
# api_bp.register_blueprint(photos_bp)
|
# api_bp.register_blueprint(photos_bp)
|
||||||
|
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
@@ -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)})
|
||||||
@@ -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)})
|
||||||
Reference in New Issue
Block a user