From 8d9730e3df8ade874aa641ec34098c25fcef2761 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 17 Jul 2026 12:30:04 -0400 Subject: [PATCH] Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT5 --- app/__init__.py | 2 + app/api/__init__.py | 4 ++ app/api/issues.py | 96 +++++++++++++++++++++++++++++++++ app/api/scheduled.py | 124 +++++++++++++++++++++++++++++++++++++++++++ app/models/issue.py | 14 +++++ 5 files changed, 240 insertions(+) create mode 100644 app/api/scheduled.py diff --git a/app/__init__.py b/app/__init__.py index f851fe6..f8946bc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -266,6 +266,7 @@ def create_app(config_name='default'): from app.api.notifications import bp as _api_notifications_bp from app.api.stats import bp as _api_stats_bp from app.api.comments import bp as _api_comments_bp + from app.api.scheduled import bp as _api_scheduled_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -275,6 +276,7 @@ def create_app(config_name='default'): csrf.exempt(_api_notifications_bp) csrf.exempt(_api_stats_bp) csrf.exempt(_api_comments_bp) + csrf.exempt(_api_scheduled_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index 53b5441..9b248ff 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -50,6 +50,10 @@ def register_api(app): from app.api.comments import bp as comments_bp api_bp.register_blueprint(comments_bp) + # MT-5: Planned inspection assignments (plan-mode schedules) + from app.api.scheduled import bp as scheduled_bp + api_bp.register_blueprint(scheduled_bp) + # NOTE: device registration lives on the auth blueprint # (POST /api/v1/devices/register in app/api/auth.py) and writes to the # canonical api_device_tokens table (model DeviceToken). A former duplicate diff --git a/app/api/issues.py b/app/api/issues.py index 054086e..9286daf 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -45,6 +45,7 @@ bp = Blueprint('api_issues', __name__) _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} +_VALID_HANDLERS = {'internal', 'facility', 'vendor'} _UUID_RE = re.compile( r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$', re.IGNORECASE, @@ -82,6 +83,18 @@ def _issue_payload(issue): 'area_name': issue.area.name if issue.area else None, # Assigned-to display name — set when a director assigns the issue to a user. 'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None, + # ── Handler ("Handled By", phase39) ─────────────────────────────── + # handler_type categorises WHO resolves the issue: + # internal = our staff (assigned_to) facility = facility's own staff + # vendor = external contractor + 'handler_type': issue.handler_type or 'internal', + 'handler_label': issue.handler_label, + 'facility_handler_name': issue.facility_handler_name or None, + 'facility_handler_contact': issue.facility_handler_contact or None, + 'facility_handler_notes': issue.facility_handler_notes or None, + 'vendor_name': issue.vendor_name or None, + 'vendor_contact': issue.vendor_contact or None, + 'vendor_notes': issue.vendor_notes or None, } @@ -497,3 +510,86 @@ def update_issue_result_photos(issue_id): issue.id, len(new_photos), user.username) return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)}) + + +# ── Update Issue Handler ("Handled By") ─────────────────────────────────────── + +@bp.route('/issues//handler', methods=['PATCH']) +@jwt_required +def update_issue_handler(issue_id): + """ + Set who handles an issue ("Handled By") from the mobile app. + + Unlike the web form (which limits handler edits to admin/director/PM/auditor), + the iPad allows the assigned inspector to set the handler from the field, + scoped to issues at their assigned facilities. This divergence is deliberate: + the inspector is the one standing in the building who knows whether the + facility's own staff or a vendor should take it. + + Request JSON + ------------ + { + "handler_type": "internal" | "facility" | "vendor", + "facility_handler_name": "...", // optional (facility handler) + "facility_handler_contact": "...", // optional + "facility_handler_notes": "...", // optional + "vendor_name": "...", // optional (vendor handler) + "vendor_contact": "...", // optional + "vendor_notes": "..." // optional + } + + Only keys present in the body are updated; empty strings clear a field. + handler_type is required. + + Access: + - admin / director / project_manager / auditor : any issue + - inspector : only issues at their assigned facilities + - customer : denied + """ + user = g.api_user + if user.role not in _ALLOWED_ROLES: + return api_error('Access denied', 403) + + issue = db.session.get(Issue, issue_id) + if issue is None: + return api_error('Issue not found', 404) + + if user.role == 'inspector': + fids = get_inspector_scope(user) + facility = issue.resolved_facility + if not fids or not facility or facility.id not in fids: + return api_error('Access denied', 403) + + data = request.get_json(silent=True) or {} + handler = (data.get('handler_type') or '').strip().lower() + + if handler not in _VALID_HANDLERS: + return api_error( + f'handler_type must be one of: {", ".join(sorted(_VALID_HANDLERS))}', 400 + ) + + old_handler = issue.handler_type or 'internal' + issue.handler_type = handler + + # Update only the detail fields that were supplied. Empty string clears + # the field (stored as NULL); a missing key leaves the field untouched. + _text_fields = ( + 'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', + 'vendor_name', 'vendor_contact', 'vendor_notes', + ) + for field in _text_fields: + if field in data: + val = (data.get(field) or '').strip() + setattr(issue, field, val or None) + + db.session.commit() + + log_action(ACTION_UPDATE, 'Issue', issue.id, + f'handler {old_handler} → {handler}', + f'source=mobile; updated_by={user.username}') + + logger.info('API ISSUES | handler_updated | issue_id=%d | %s→%s | user=%s', + issue.id, old_handler, handler, user.username) + + return api_ok({'issue_id': issue.id, 'handler_type': issue.handler_type, + 'handler_label': issue.handler_label}) diff --git a/app/api/scheduled.py b/app/api/scheduled.py new file mode 100644 index 0000000..07b8e0b --- /dev/null +++ b/app/api/scheduled.py @@ -0,0 +1,124 @@ +""" +app/api/scheduled.py +-------------------- +Mobile API endpoint for planned inspection assignments (MT-5, phase43). + +GET /api/v1/scheduled-inspections + Returns ACTIVE, PLAN-MODE schedules the caller is responsible for. + - inspector : only schedules where inspector_id == the caller + - admin / director / project_manager / auditor : all active plan schedules + Powers the "Scheduled" section on the iPad Dashboard and My Inspections + lists. The iPad taps "Start", which opens the normal new-inspection flow + with the facility + template preselected (client-side); the schedule + lifecycle (fulfil / roll-forward) continues to be driven by the web app. + +Why plan-mode only +------------------ +`mode='auto'` schedules materialise themselves into a real Inspection at +next_run_at, which the iPad already fetches via /api/v1/inspections. Returning +them here too would show the same work twice, and "Start" is meaningless for a +schedule that starts itself. This mirrors the web dashboard panel (MT-4). + +A plan-mode schedule is a PLAN, not an inspection — see +app/models/inspection_schedule.py for the full lifecycle. +""" + +import logging + +from flask import Blueprint, request, g +from app.models.inspection_schedule import InspectionSchedule +from app.api.errors import api_ok, api_error +from app.api.decorators import jwt_required + +logger = logging.getLogger(__name__) + +bp = Blueprint('api_scheduled', __name__) + +_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} + + +def _scheduled_payload(s): + """Serialise an InspectionSchedule to the dict returned in list responses. + + `next_due_date` is the date part of next_run_at — MT reuses next_run_at as + the due datetime for both modes (see phase43). + """ + return { + 'id': s.id, + 'name': s.name, + 'facility_id': s.facility_id, + 'facility_name': s.facility.name if s.facility else None, + 'area_id': s.area_id, + 'area_name': s.area.name if s.area else None, + 'template_id': s.template_id, + 'template_name': s.template.name if s.template else None, + 'inspector_id': s.inspector_id, + 'frequency': s.frequency, + 'frequency_label': s.frequency_label, + 'mode': s.mode, + 'next_due_date': s.next_run_at.date().isoformat() if s.next_run_at else None, + 'is_overdue': s.is_overdue(), + 'notes': s.notes or None, + } + + +# ── List Scheduled Inspections ──────────────────────────────────────────────── + +@bp.route('/scheduled-inspections', methods=['GET']) +@jwt_required +def list_scheduled(): + """ + Return active plan-mode scheduled inspections for the authenticated user. + + Query parameters + ---------------- + limit int Default 100, max 200. + offset int Default 0. + + Response 200 + ------------ + { + "ok": true, + "data": { + "scheduled": [...], + "total": 3, + "limit": 100, + "offset": 0 + } + } + """ + user = g.api_user + if user.role not in _ALLOWED_ROLES: + return api_error('Access denied', 403) + + try: + limit = min(int(request.args.get('limit', 100)), 200) + offset = max(int(request.args.get('offset', 0)), 0) + except (TypeError, ValueError): + return api_error('limit and offset must be integers', 400) + + query = InspectionSchedule.query.filter( + InspectionSchedule.active.is_(True), + InspectionSchedule.mode == 'plan', + ) + + if user.role == 'inspector': + # Inspectors only see schedules assigned directly to them. + query = query.filter(InspectionSchedule.inspector_id == user.id) + + total = query.count() + rows = ( + query + .order_by(InspectionSchedule.next_run_at.asc()) + .offset(offset) + .limit(limit) + .all() + ) + + payload = [_scheduled_payload(s) for s in rows] + + logger.info('API SCHEDULED | list | user=%s | count=%d | total=%d', + user.username, len(payload), total) + + return api_ok({'scheduled': payload, 'total': total, + 'limit': limit, 'offset': offset}) diff --git a/app/models/issue.py b/app/models/issue.py index fcb6eb9..10d9fce 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -116,6 +116,20 @@ class Issue(db.Model): """Return True if the given user is currently following this issue.""" return self.followers.filter_by(user_id=user.id).first() is not None + # Display labels for handler_type. The web templates hardcode these inline; + # this mapping exists so the mobile API can return a human-readable label + # without the client duplicating the strings. (phase43 / MT-5) + HANDLER_LABELS = { + 'internal': 'Janitorial Staff', + 'facility': 'Facility Staff', + 'vendor': 'External Vendor', + } + + @property + def handler_label(self): + """Human-readable label for handler_type; defaults to internal.""" + return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Janitorial Staff') + @property def resolved_facility(self): """Returns the Facility for this issue regardless of which path was used to create it.