05/02/2026 Updates for iPad app: Phase 1
This commit is contained in:
@@ -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)})
|
||||
Reference in New Issue
Block a user