From 729f1a6df845276053d76de8224dc928dc8dd3c7 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 13 Jul 2026 12:36:28 -0400 Subject: [PATCH 1/2] Jul 13 - Update web API to support iOS app on scheduled inspection and issue's handler --- app/__init__.py | 2 + app/api/__init__.py | 4 ++ app/api/issues.py | 93 ++++++++++++++++++++++++++++++++++++++ app/api/scheduled.py | 104 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 app/api/scheduled.py diff --git a/app/__init__.py b/app/__init__.py index 9d20bd3..d936422 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -213,6 +213,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) @@ -222,6 +223,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 178697c..28ff6ab 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -50,4 +50,8 @@ def register_api(app): from app.api.comments import bp as comments_bp api_bp.register_blueprint(comments_bp) + # Scheduled/recurring inspection assignments (phase36) — mobile list + from app.api.scheduled import bp as scheduled_bp + api_bp.register_blueprint(scheduled_bp) + app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/issues.py b/app/api/issues.py index 6604370..92a4310 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -44,6 +44,7 @@ bp = Blueprint('api_issues', __name__) _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _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, @@ -81,6 +82,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", phase35) ─────────────────────────────── + # 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, } @@ -373,6 +386,86 @@ def update_issue_status(issue_id): return api_ok({'issue_id': issue.id, 'status': issue.status}) + +# ── 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), + the iPad allows the assigned inspector to set the handler from the field, + scoped to issues at their assigned facilities. + + 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 : any issue + - inspector : only issues at their assigned facilities + """ + 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}) + + # ── Update Issue Photos (mobile) ────────────────────────────────────────────── @bp.route('/issues//photos', methods=['PATCH']) diff --git a/app/api/scheduled.py b/app/api/scheduled.py new file mode 100644 index 0000000..158a41b --- /dev/null +++ b/app/api/scheduled.py @@ -0,0 +1,104 @@ +""" +app/api/scheduled.py +-------------------- +Mobile API endpoint for planned/recurring inspection assignments (phase36). + +GET /api/v1/scheduled-inspections + Returns ACTIVE scheduled inspections the caller is responsible for. + - inspector : only schedules where inspector_id == the caller + - admin / director / project_manager : all active 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. + +A ScheduledInspection is a PLAN, not an inspection — see +app/models/scheduled_inspection.py for the full lifecycle. +""" + +import logging + +from flask import Blueprint, request, g +from app.models.scheduled_inspection import ScheduledInspection +from app.api.errors import api_ok, api_error +from app.api.decorators import jwt_required +from app.utils.scope import get_inspector_scope + +logger = logging.getLogger(__name__) + +bp = Blueprint('api_scheduled', __name__) + +_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} + + +def _scheduled_payload(s): + """Serialise a ScheduledInspection to the dict returned in list responses.""" + return { + 'id': s.id, + 'facility_id': s.facility_id, + 'facility_name': s.facility.name if s.facility 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, + 'next_due_date': s.next_due_date.isoformat() if s.next_due_date 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 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) + + limit = min(int(request.args.get('limit', 100)), 200) + offset = max(int(request.args.get('offset', 0)), 0) + + query = ScheduledInspection.query.filter(ScheduledInspection.active.is_(True)) + + if user.role == 'inspector': + # Inspectors only see schedules assigned directly to them. + query = query.filter(ScheduledInspection.inspector_id == user.id) + + total = query.count() + rows = ( + query + .order_by(ScheduledInspection.next_due_date.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}) From 9299f084b021b5b69d60f49745802aa5bce7964c Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 13 Jul 2026 15:25:32 -0400 Subject: [PATCH 2/2] Jul 13 - Update document CLAUDE.md --- CLAUDE.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index edb616c..ce88164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -603,7 +603,20 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa `stats.py` now returns `severity_breakdown` dict alongside the existing KPIs. Derived from the already-loaded `open_issues_all` list — zero extra DB queries. -### Issue API — `_issue_payload()` fields +### Scheduled Inspections + Issue Handler Endpoints (July 2026) + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/scheduled-inspections` | jwt_required | Active scheduled/recurring assignments (`app/api/scheduled.py`, new blueprint). **Inspector:** only rows where `inspector_id == self`. **admin/director/PM:** all active. Sorted by `next_due_date`. Returns per row: `id`, `facility_id`, `facility_name`, `template_id`, `template_name`, `inspector_id`, `frequency`, `frequency_label`, `next_due_date` (ISO date), `is_overdue`, `notes`, plus `total`/`limit`/`offset`. Powers the iPad "Scheduled" section on Dashboard + My Inspections. Read-only — the schedule lifecycle (fulfil/roll-forward) stays web-driven; the iPad "Start" just seeds the new-inspection flow. | +| `PATCH /api/v1/issues//handler` | jwt_required | Set "Handled By" from the iPad (`update_issue_handler`). Body: `{ "handler_type": "internal"\|"facility"\|"vendor", ...optional detail keys }`. Detail keys (`facility_handler_name/contact/notes`, `vendor_name/contact/notes`) are updated only when present; empty string clears a field. **`log_action()` after commit.** | + +**Handler permission divergence — deliberate (see rule 78).** The web issue form limits handler edits to admin/director/PM. This API endpoint additionally allows the assigned **inspector**, scoped by `get_inspector_scope()` (403 if the issue's facility isn't in their contracted set). The iPad is a field tool; inspectors set the handler from Issue Detail. Do not "align" the API back to the web restriction without explicit direction. + +The new `scheduled` blueprint is registered in `app/api/__init__.py` and CSRF-exempted in `app/__init__.py` (`csrf.exempt(_api_scheduled_bp)` — parent-exempt does not cascade to child blueprints, per the CSRF pattern above). + +No migration was needed for either feature: the `scheduled_inspections` table (phase36) and the issue handler columns (phase35) already existed; both additions are pure serialization + one new route. + + ```python { @@ -621,6 +634,11 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa # Phase E additions: 'area_name', # name of the Area the issue was flagged in (nullable) 'assigned_to_name', # display_name of currently assigned User (nullable) + # Handler ("Handled By", July 2026) additions: + 'handler_type', # 'internal' | 'facility' | 'vendor' (defaults 'internal') + 'handler_label', # human-readable label (Issue.handler_label property) + 'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable + 'vendor_name', 'vendor_contact', 'vendor_notes', # nullable } ``` @@ -1279,6 +1297,8 @@ timeout = 30 | 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, and honeypot-guarded; public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. | | 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. | | 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. | +| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). | +| 78 | **`PATCH /api/v1/issues//handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all 8 handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. | ---