05/15 Update: implement iPad notification function 2
This commit is contained in:
+86
-13
@@ -3,6 +3,10 @@ app/api/issues.py
|
|||||||
-----------------
|
-----------------
|
||||||
Mobile API endpoint for submitting issues from the iPad app.
|
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
|
POST /api/v1/issues
|
||||||
Creates a new issue record.
|
Creates a new issue record.
|
||||||
Accepts a mobile_local_id for idempotency.
|
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'}
|
_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'])
|
@bp.route('/issues', methods=['POST'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
@@ -180,18 +264,7 @@ def get_issue(issue_id):
|
|||||||
if user.role == 'inspector' and issue.assigned_to != user.id:
|
if user.role == 'inspector' and issue.assigned_to != user.id:
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
facility = issue.resolved_facility
|
return api_ok(_issue_payload(issue))
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ── Update Issue Status ───────────────────────────────────────────────────────
|
# ── Update Issue Status ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class Notification(db.Model):
|
|||||||
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True)
|
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)
|
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 tracking: set to True when created, cleared after digest email sent
|
||||||
digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||||
|
|
||||||
|
|||||||
@@ -873,6 +873,24 @@ def flag_followup(inspection_id):
|
|||||||
inspection.follow_up_note = note
|
inspection.follow_up_note = note
|
||||||
db.session.commit()
|
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(
|
current_app.logger.info(
|
||||||
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
|
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
|
||||||
inspection_id, current_user.username, note,
|
inspection_id, current_user.username, note,
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ def notify(
|
|||||||
link = link,
|
link = link,
|
||||||
issue_id = issue_id,
|
issue_id = issue_id,
|
||||||
inspection_id = inspection_id,
|
inspection_id = inspection_id,
|
||||||
|
event_type = event_type,
|
||||||
is_read = False,
|
is_read = False,
|
||||||
digest_pending = hold_for_digest,
|
digest_pending = hold_for_digest,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
))
|
||||||
Reference in New Issue
Block a user