diff --git a/app/api/__init__.py b/app/api/__init__.py index 653b2aa..c1f0831 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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//areas → api_facilities.list_areas +/api/v1/templates → api_templates.list_templates +/api/v1/templates/ → 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) \ No newline at end of file diff --git a/app/api/facilities.py b/app/api/facilities.py new file mode 100644 index 0000000..8acb074 --- /dev/null +++ b/app/api/facilities.py @@ -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//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//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)}) \ No newline at end of file diff --git a/app/api/templates.py b/app/api/templates.py new file mode 100644 index 0000000..4d204eb --- /dev/null +++ b/app/api/templates.py @@ -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/ + 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/', 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)}) \ No newline at end of file