From b69a52e5f5846ec720d2993bef288af0714b03f9 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 15 May 2026 15:15:10 -0400 Subject: [PATCH] 05/15 Update: implement iPad notification function 2 --- app/api/issues.py | 99 ++++++++++++++++--- app/models/notification.py | 4 + app/routes/inspections.py | 18 ++++ app/utils/notifications.py | 1 + .../phase17_notification_event_type.py | 53 ++++++++++ 5 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 migrations/versions/phase17_notification_event_type.py diff --git a/app/api/issues.py b/app/api/issues.py index d67204a..f428465 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -3,6 +3,10 @@ app/api/issues.py ----------------- Mobile API endpoint for submitting issues from the iPad app. +GET /api/v1/issues + Returns issues assigned to the authenticated inspector (or all for admin/director). + Used by the iPad to display assigned issues that were created via the web portal. + POST /api/v1/issues Creates a new issue record. Accepts a mobile_local_id for idempotency. @@ -40,7 +44,87 @@ _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} -# ── Create Issue ────────────────────────────────────────────────────────────── +def _issue_payload(issue): + """Serialise an Issue to the dict returned in list/detail responses.""" + facility = issue.resolved_facility + return { + 'id': issue.id, + 'status': issue.status, + 'severity': issue.severity, + 'description': issue.description, + 'assigned_to': issue.assigned_to, + 'facility_id': facility.id if facility else None, + 'facility_name': facility.name if facility else None, + 'reported_at': issue.reported_at.isoformat() if issue.reported_at else None, + 'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None, + 'mobile_local_id': issue.mobile_local_id, + } + + +# ── List Assigned Issues ────────────────────────────────────────────────────── + +@bp.route('/issues', methods=['GET']) +@jwt_required +def list_issues(): + """ + Return issues assigned to the authenticated user. + + Inspectors: only issues where assigned_to == current user. + Admin / director / project_manager: all non-resolved issues (capped at 200). + + Query parameters + ---------------- + status str Filter by status (default: excludes resolved). + limit int Default 100, max 200. + offset int Default 0. + + Response 200 + ------------ + { + "ok": true, + "data": { + "issues": [...], + "total": 12, + "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 = Issue.query + + if user.role == 'inspector': + # Inspectors only see issues assigned to them + query = query.filter(Issue.assigned_to == user.id) + else: + # Broader roles: exclude resolved by default so the list stays manageable + status_filter = request.args.get('status') + if status_filter: + query = query.filter(Issue.status == status_filter) + else: + query = query.filter(Issue.status != 'resolved') + + total = query.count() + issues = ( + query + .order_by(Issue.reported_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + payload = [_issue_payload(i) for i in issues] + + logger.info('API ISSUES | list | user=%s | count=%d | total=%d', + user.username, len(payload), total) + + return api_ok({'issues': payload, 'total': total, 'limit': limit, 'offset': offset}) @bp.route('/issues', methods=['POST']) @jwt_required @@ -180,18 +264,7 @@ def get_issue(issue_id): if user.role == 'inspector' and issue.assigned_to != user.id: return api_error('Access denied', 403) - facility = issue.resolved_facility - return api_ok({ - 'id': issue.id, - 'status': issue.status, - 'severity': issue.severity, - 'description': issue.description, - 'assigned_to': issue.assigned_to, - 'facility_id': facility.id if facility else None, - 'facility_name': facility.name if facility else None, - 'reported_at': issue.reported_at.isoformat() if issue.reported_at else None, - 'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None, - }) + return api_ok(_issue_payload(issue)) # ── Update Issue Status ─────────────────────────────────────────────────────── diff --git a/app/models/notification.py b/app/models/notification.py index 7b011b7..d94abef 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -53,6 +53,10 @@ class Notification(db.Model): issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True) inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True) + # Event type — stored for mobile API polling so the iPad can categorise alerts. + # Added phase17; NULL for notifications created before the migration. + event_type = db.Column(db.String(50), nullable=True) + # Digest tracking: set to True when created, cleared after digest email sent digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True) diff --git a/app/routes/inspections.py b/app/routes/inspections.py index b8592c8..2a019b1 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -873,6 +873,24 @@ def flag_followup(inspection_id): inspection.follow_up_note = note db.session.commit() + # Notify the original inspector so they see it on the iPad + inspector = db.session.get(User, inspection.inspector_id) + if inspector and inspector.id != current_user.id: + note_suffix = f' Note: {note}' if note else '' + notify( + recipient = inspector, + title = f'Follow-Up Required: Inspection #{inspection_id}', + body = ( + f'{current_user.username} has requested a follow-up re-inspection ' + f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}' + ), + link = url_for('inspections.view', inspection_id=inspection_id), + inspection_id = inspection_id, + event_type = EVENT_INSPECTION_DONE, + send_email = True, + ) + db.session.commit() + current_app.logger.info( 'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r', inspection_id, current_user.username, note, diff --git a/app/utils/notifications.py b/app/utils/notifications.py index b23a856..f5d5e73 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -207,6 +207,7 @@ def notify( link = link, issue_id = issue_id, inspection_id = inspection_id, + event_type = event_type, is_read = False, digest_pending = hold_for_digest, ) diff --git a/migrations/versions/phase17_notification_event_type.py b/migrations/versions/phase17_notification_event_type.py new file mode 100644 index 0000000..4109bac --- /dev/null +++ b/migrations/versions/phase17_notification_event_type.py @@ -0,0 +1,53 @@ +"""phase17 — add event_type column to notifications table + +Background +---------- +The `notifications` table has no `event_type` column, but the mobile API +endpoint GET /api/v1/notifications references `n.event_type`, causing an +AttributeError (500) on every poll — silently breaking iPad notifications. + +This migration adds `event_type VARCHAR(50) NULL` so the column is stored +at creation time and returned correctly to the mobile poller. + +The `notify()` utility is updated separately to pass event_type when creating +Notification records. + +Uses INFORMATION_SCHEMA existence check — safe to re-run (CLAUDE.md rules 16, 17). + +Revision ID: phase17_notification_event_type +Revises: phase16_notifications_columns +""" + +revision = 'phase17_notification_event_type' +down_revision = 'phase16_notifications_columns' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(bind, table, column): + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {'t': table, 'c': column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'notifications', 'event_type'): + op.execute(sa.text( + "ALTER TABLE notifications " + "ADD COLUMN event_type VARCHAR(50) NULL" + )) + + +def downgrade(): + bind = op.get_bind() + if _column_exists(bind, 'notifications', 'event_type'): + op.execute(sa.text( + "ALTER TABLE notifications DROP COLUMN event_type" + ))