From bad39522fa02850bdd9e57a9d3ffea999c9439cb Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 2 May 2026 10:55:28 -0400 Subject: [PATCH] 05/02 Phase B --- app/__init__.py | 17 +- app/api/__init__.py | 58 ++----- app/api/inspections.py | 40 +++++ app/api/issues.py | 147 ++++++++++++++++++ app/api/photos.py | 100 ++++++++++++ app/models/inspection.py | 1 + app/models/issue.py | 1 + .../versions/phase_b_mobile_local_id.py | 75 +++++++++ 8 files changed, 386 insertions(+), 53 deletions(-) create mode 100644 app/api/inspections.py create mode 100644 app/api/issues.py create mode 100644 app/api/photos.py create mode 100644 migrations/versions/phase_b_mobile_local_id.py diff --git a/app/__init__.py b/app/__init__.py index 148e247..48f5ce3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -147,24 +147,25 @@ def create_app(config_name='default'): app.register_blueprint(customers.bp) app.register_blueprint(scheduled_reports.bp) - # ── Mobile API (Phase 7 / Phase A) ────────────────────────────────────── + # ── Mobile API (Phase 7 / Phase A / Phase B) ───────────────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. # # Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object - # (e.g. api_auth, api_facilities) is in _exempt_blueprints. Exempting - # the parent api_bp alone does NOT cascade to its sub-blueprints because - # request.blueprint returns the dotted child name ('api.api_auth'), and - # current_app.blueprints maps that to the child Blueprint object — which - # is never equal to the parent object in the exempt set. - # - # Fix: import every child blueprint object and exempt each one explicitly. + # is in _exempt_blueprints. Exempting the parent api_bp does NOT cascade + # to sub-blueprints. Each child blueprint must be exempted individually. from app.api import register_api, api_bp from app.api.auth import bp as _api_auth_bp from app.api.facilities import bp as _api_facilities_bp from app.api.templates import bp as _api_templates_bp + from app.api.inspections import bp as _api_inspections_bp + from app.api.issues import bp as _api_issues_bp + from app.api.photos import bp as _api_photos_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) + csrf.exempt(_api_inspections_bp) + csrf.exempt(_api_issues_bp) + csrf.exempt(_api_photos_bp) register_api(app) # ── Error handler: 413 Request Entity Too Large ─────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index 7ef01c6..03b474c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -3,70 +3,38 @@ app/api/__init__.py ------------------- Registers the /api/v1 blueprint group. -All mobile API routes live under the prefix /api/v1/. -This module is imported once from app/__init__.py — see the integration -instructions at the bottom of this file. - -Blueprint layout ----------------- -/api/v1/auth/login → api_auth.login -/api/v1/auth/refresh → api_auth.refresh -/api/v1/auth/logout → api_auth.logout -/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/inspections → api_inspections.* -/api/v1/issues → api_issues.* -/api/v1/notifications → api_notifications.* -/api/v1/photos/upload → api_photos.* +Phase A: /api/v1/facilities/*, /api/v1/templates/* +Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/* """ from flask import Blueprint from app.api.errors import register_error_handlers -# Parent blueprint — all sub-blueprints registered under this prefix api_bp = Blueprint('api', __name__, url_prefix='/api/v1') - -# Register JSON error handlers so Flask exceptions within /api/v1/ -# return JSON instead of HTML error pages. register_error_handlers(api_bp) def register_api(app): """ - Import and register all API sub-blueprints onto api_bp, then - register api_bp on the Flask app. - - Called once from create_app() in app/__init__.py. - - NOTE: CSRF exemption for each sub-blueprint is handled in app/__init__.py - before this function is called, because csrf.exempt() must receive the - child Blueprint object directly — exempting the parent api_bp does not - cascade to its sub-blueprints. + Register all API sub-blueprints. + CSRF exemption for each child blueprint is handled in app/__init__.py. """ - # ── Phase 7 (Web): Auth ────────────────────────────────────────────── + # Phase 7: Auth from app.api.auth import bp as auth_bp api_bp.register_blueprint(auth_bp) - # ── Phase A (iPad): Reference data ────────────────────────────────── + # 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+ (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.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 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) app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/inspections.py b/app/api/inspections.py new file mode 100644 index 0000000..03b474c --- /dev/null +++ b/app/api/inspections.py @@ -0,0 +1,40 @@ +""" +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/* +""" + +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) + + app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/issues.py b/app/api/issues.py new file mode 100644 index 0000000..d0dd3be --- /dev/null +++ b/app/api/issues.py @@ -0,0 +1,147 @@ +""" +app/api/issues.py +----------------- +Mobile API endpoint for submitting issues from the iPad app. + +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. +""" + +import logging + +from flask import Blueprint, request, g, current_app +from app import db +from app.models.issue import Issue +from app.models.facility import 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 +from app.utils.notifications import notify_by_matrix +from app.utils.time_utils import now_eastern + +logger = logging.getLogger(__name__) + +bp = Blueprint('api_issues', __name__) + +_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} +_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} + + +# ── Create Issue ────────────────────────────────────────────────────────────── + +@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 + "area_id": 12, // 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: + 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 ────────────────────────────────────────────────────────── + area_id = data.get('area_id') + severity = data.get('severity', '').lower() + description = (data.get('description') or '').strip() + + if not area_id: + return api_error('area_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) + + area = db.session.get(Area, area_id) + if area is None: + return api_error('Area not found', 404) + + 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 ──────────────────────────────────────────────────────────── + issue = Issue( + inspection_id = inspection_id, + area_id = area_id, + severity = severity, + description = description, + photo_path = data.get('photo_path') or None, + status = 'open', + reported_at = now_eastern(), + mobile_local_id = mobile_local_id, + ) + db.session.add(issue) + db.session.flush() # get issue.id + + # ── Notifications ───────────────────────────────────────────────────── + facility_id = area.facility_id + 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_flagged', + title = f'New Issue #{issue.id} (Mobile)', + body = ( + f'A new {severity.title()}-severity issue was logged in ' + f'{area.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, + exclude_user_ids = {user.id}, + ) + + db.session.commit() + + log_action(ACTION_CREATE, 'Issue', issue.id, + f'{severity} issue in {area.name}', + f'source=mobile; inspection_id={inspection_id}; ' + f'local_id={mobile_local_id}') + + logger.info('API ISSUES | created | issue_id=%d | area=%s | severity=%s | user=%s', + issue.id, area.name, severity, user.username) + + return api_ok({'issue_id': issue.id, 'duplicate': False}) \ No newline at end of file diff --git a/app/api/photos.py b/app/api/photos.py new file mode 100644 index 0000000..fcfbbff --- /dev/null +++ b/app/api/photos.py @@ -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}) \ No newline at end of file diff --git a/app/models/inspection.py b/app/models/inspection.py index 42752d1..eff4c36 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -63,6 +63,7 @@ class Inspection(db.Model): notes = db.Column(db.Text) # inspector free-text notes form_data = db.Column(db.JSON) # filled form field responses {field_id: value} completed_at = db.Column(db.DateTime) + mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions # ── Re-inspection / follow-up workflow ──────────────────────────────── parent_inspection_id = db.Column( diff --git a/app/models/issue.py b/app/models/issue.py index 12841e6..aaa36b4 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -66,6 +66,7 @@ class Issue(db.Model): # Tracks which SLA alert level has already been notified so cron runs # don't fire duplicate notifications. Values: None / 'at_risk' / 'breached' sla_notified = db.Column(db.String(10), nullable=True, default=None) + mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions # Relationships assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues') diff --git a/migrations/versions/phase_b_mobile_local_id.py b/migrations/versions/phase_b_mobile_local_id.py new file mode 100644 index 0000000..56c10ec --- /dev/null +++ b/migrations/versions/phase_b_mobile_local_id.py @@ -0,0 +1,75 @@ +"""Add mobile_local_id to inspections and issues for mobile sync idempotency. + +Phase B — iPad offline inspection submission. + +Each inspection or issue submitted from the iPad app carries a UUID generated +on the device (mobile_local_id). The server checks this field before creating +a new record so that network retries never produce duplicate rows. + +Revision ID: phase_b_mobile_local_id +Revises: phase9_user_full_name +""" + +from alembic import op +import sqlalchemy as sa + +revision = 'phase_b_mobile_local_id' +down_revision = 'phase9_user_full_name' +branch_labels = None +depends_on = None + + +def upgrade(): + # ── inspections.mobile_local_id ─────────────────────────────────────── + with op.batch_alter_table('inspections') as batch_op: + # Check if column already exists (safe re-run) + conn = op.get_bind() + columns = [row[1] for row in conn.execute(sa.text('PRAGMA table_info(inspections)')).fetchall()] \ + if conn.dialect.name == 'sqlite' \ + else [row['COLUMN_NAME'] for row in conn.execute( + sa.text( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = 'inspections' AND TABLE_SCHEMA = DATABASE()" + ) + ).mappings()] + + if 'mobile_local_id' not in columns: + batch_op.add_column( + sa.Column('mobile_local_id', sa.String(64), nullable=True) + ) + + # Index for fast idempotency lookups + op.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_inspections_mobile_local_id " + "ON inspections(mobile_local_id)" + )) + + # ── issues.mobile_local_id ──────────────────────────────────────────── + with op.batch_alter_table('issues') as batch_op: + conn = op.get_bind() + columns = [row[1] for row in conn.execute(sa.text('PRAGMA table_info(issues)')).fetchall()] \ + if conn.dialect.name == 'sqlite' \ + else [row['COLUMN_NAME'] for row in conn.execute( + sa.text( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = 'issues' AND TABLE_SCHEMA = DATABASE()" + ) + ).mappings()] + + if 'mobile_local_id' not in columns: + batch_op.add_column( + sa.Column('mobile_local_id', sa.String(64), nullable=True) + ) + + op.execute(sa.text( + "CREATE INDEX IF NOT EXISTS idx_issues_mobile_local_id " + "ON issues(mobile_local_id)" + )) + + +def downgrade(): + with op.batch_alter_table('inspections') as batch_op: + batch_op.drop_column('mobile_local_id') + + with op.batch_alter_table('issues') as batch_op: + batch_op.drop_column('mobile_local_id') \ No newline at end of file