Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/LT_Janitorial_Quality_Control
This commit is contained in:
+9
-7
@@ -147,25 +147,27 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(customers.bp)
|
||||
app.register_blueprint(scheduled_reports.bp)
|
||||
|
||||
# ── Mobile API (Phase 7 / Phase A / Phase B) ─────────────────────────────
|
||||
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||
#
|
||||
# Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object
|
||||
# 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
|
||||
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
|
||||
from app.api.notifications import bp as _api_notifications_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)
|
||||
csrf.exempt(_api_notifications_bp)
|
||||
register_api(app)
|
||||
|
||||
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
||||
|
||||
@@ -5,6 +5,7 @@ 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/*
|
||||
Phase C: /api/v1/notifications/*
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
@@ -37,4 +38,8 @@ def register_api(app):
|
||||
api_bp.register_blueprint(issues_bp)
|
||||
api_bp.register_blueprint(photos_bp)
|
||||
|
||||
# Phase C: Notification polling
|
||||
from app.api.notifications import bp as notifications_bp
|
||||
api_bp.register_blueprint(notifications_bp)
|
||||
|
||||
app.register_blueprint(api_bp)
|
||||
@@ -50,6 +50,7 @@ def _user_payload(user: User) -> dict:
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'full_name': user.full_name or '',
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
|
||||
+48
-5
@@ -50,6 +50,29 @@ def _parse_datetime(value):
|
||||
|
||||
def _inspection_payload(inspection):
|
||||
"""Serialize an Inspection to the dict returned in API responses."""
|
||||
# Extract form responses from the notes JSON blob.
|
||||
# Mobile submissions store form data as {"_form_data": {...}, "_inspector_notes": "..."}.
|
||||
# Web submissions store form data in the form_data column directly.
|
||||
form_data = {}
|
||||
inspector_notes = ''
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_obj = json.loads(inspection.notes)
|
||||
if isinstance(notes_obj, dict):
|
||||
form_data = notes_obj.get('_form_data', {}) or {}
|
||||
inspector_notes = notes_obj.get('_inspector_notes', '') or ''
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# Fallback: web-created inspections store responses in form_data column
|
||||
if not form_data and inspection.form_data:
|
||||
form_data = inspection.form_data if isinstance(inspection.form_data, dict) else {}
|
||||
|
||||
# Include the template's form_schema so the iPad can render history
|
||||
# without needing a locally cached copy of the template.
|
||||
form_schema = []
|
||||
if inspection.template:
|
||||
form_schema = inspection.template.get_form_schema()
|
||||
|
||||
return {
|
||||
'id': inspection.id,
|
||||
'template_id': inspection.template_id,
|
||||
@@ -59,12 +82,15 @@ def _inspection_payload(inspection):
|
||||
'area_id': inspection.area_id,
|
||||
'area_name': inspection.area.name if inspection.area else None,
|
||||
'status': inspection.status,
|
||||
'overall_score': inspection.overall_score,
|
||||
'overall_score': float(inspection.overall_score) if inspection.overall_score is not None else None,
|
||||
'inspection_date': inspection.inspection_date.isoformat()
|
||||
if inspection.inspection_date else None,
|
||||
'completed_at': inspection.completed_at.isoformat()
|
||||
if inspection.completed_at else None,
|
||||
'mobile_local_id': inspection.mobile_local_id,
|
||||
'form_data': form_data,
|
||||
'form_schema': form_schema,
|
||||
'inspector_notes': inspector_notes,
|
||||
# ── Follow-up / re-inspection fields ──────────────────────────────
|
||||
'follow_up_required': inspection.follow_up_required,
|
||||
'follow_up_note': inspection.follow_up_note,
|
||||
@@ -264,6 +290,12 @@ def create_inspection():
|
||||
# follow_up_required on the parent automatically. This mirrors the web
|
||||
# list view's implicit logic (which hides the badge when follow_ups.any())
|
||||
# and ensures the History API response reflects the resolved state.
|
||||
# Capture parent label strings before commit while ORM objects are loaded.
|
||||
# log_action() for the parent update must fire AFTER db.session.commit() to
|
||||
# avoid audit.py's internal commit() persisting the parent flag change before
|
||||
# the new inspection row is committed — a partial state that would be incorrect
|
||||
# if the main commit subsequently failed.
|
||||
_parent_log_args = None
|
||||
if parent_inspection_id and status == 'completed':
|
||||
parent_insp = db.session.get(Inspection, parent_inspection_id)
|
||||
if parent_insp and parent_insp.follow_up_required:
|
||||
@@ -273,10 +305,13 @@ def create_inspection():
|
||||
'by_inspection_id=%d | user=%s',
|
||||
parent_inspection_id, inspection.id, user.username,
|
||||
)
|
||||
log_action(ACTION_UPDATE, 'Inspection', parent_inspection_id,
|
||||
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
|
||||
f'follow_up_required=False (cleared by re-inspection '
|
||||
f'#{inspection.id} via mobile API)')
|
||||
# Snapshot label strings now — ORM objects may be expired after commit
|
||||
_parent_log_args = (
|
||||
parent_inspection_id,
|
||||
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
|
||||
f'follow_up_required=False (cleared by re-inspection '
|
||||
f'#{inspection.id} via mobile API)',
|
||||
)
|
||||
|
||||
# ── Notifications ─────────────────────────────────────────────────────
|
||||
if status == 'completed':
|
||||
@@ -304,6 +339,14 @@ def create_inspection():
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# ── Post-commit audit logging ──────────────────────────────────────────
|
||||
# All log_action() calls must come AFTER db.session.commit() because
|
||||
# audit.py calls db.session.commit() internally. Calling it before the
|
||||
# main commit would persist the audit row (and any dirty ORM state) before
|
||||
# the primary transaction completes.
|
||||
if _parent_log_args:
|
||||
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
|
||||
|
||||
log_action(ACTION_CREATE, 'Inspection', inspection.id,
|
||||
f'{template.name} @ {facility.name}',
|
||||
f'source=mobile; status={status}; score={overall_score}; '
|
||||
|
||||
+105
-20
@@ -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,100 @@ _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,
|
||||
# photo_path is the primary issue photo; result_photos are resolution photos.
|
||||
# Both are relative paths from the server static root.
|
||||
'photo_path': issue.photo_path or None,
|
||||
'result_photos': issue.result_photos or [],
|
||||
}
|
||||
|
||||
|
||||
# ── List Assigned Issues ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_issues():
|
||||
"""
|
||||
Return active issues for the authenticated user.
|
||||
|
||||
All roles see all non-resolved issues (inspectors included) so the iPad
|
||||
shows the full picture of open work at their facilities.
|
||||
A ?status= filter can be used to override the default exclusion.
|
||||
|
||||
Query parameters
|
||||
----------------
|
||||
status str Filter by status. Omit to get all non-resolved issues.
|
||||
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 see issues assigned to them OR issues they reported.
|
||||
# The reported_by path covers issues created on the iPad that haven't
|
||||
# been assigned yet (assigned_to is NULL until a director assigns them).
|
||||
# Pre-phase18 rows with reported_by = NULL still surface via assigned_to.
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Issue.assigned_to == user.id,
|
||||
Issue.reported_by == 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
|
||||
@@ -115,6 +212,7 @@ def create_issue():
|
||||
photo_path = data.get('photo_path') or None,
|
||||
status = 'open',
|
||||
reported_at = now_eastern(),
|
||||
reported_by = user.id,
|
||||
mobile_local_id = mobile_local_id,
|
||||
)
|
||||
db.session.add(issue)
|
||||
@@ -165,9 +263,7 @@ def get_issue(issue_id):
|
||||
Return current status, severity, description, assigned_to, and facility
|
||||
for a single issue.
|
||||
|
||||
Access:
|
||||
- admin / director / project_manager : any issue
|
||||
- inspector : only issues where assigned_to == current user
|
||||
Access: all allowed roles may fetch any issue.
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
@@ -177,21 +273,10 @@ def get_issue(issue_id):
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector' and issue.assigned_to != user.id:
|
||||
if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != 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 ───────────────────────────────────────────────────────
|
||||
@@ -218,8 +303,8 @@ def update_issue_status(issue_id):
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector' and issue.assigned_to != user.id:
|
||||
return api_error('Access denied — you can only update issues assigned to you', 403)
|
||||
if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
|
||||
return api_error('Access denied — you can only update issues assigned to or reported by you', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
new_status = (data.get('status') or '').strip().lower()
|
||||
@@ -249,4 +334,4 @@ def update_issue_status(issue_id):
|
||||
logger.info('API ISSUES | status_updated | issue_id=%d | %s→%s | user=%s',
|
||||
issue.id, old_status, new_status, user.username)
|
||||
|
||||
return api_ok({'issue_id': issue.id, 'status': issue.status})
|
||||
return api_ok({'issue_id': issue.id, 'status': issue.status})
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
app/api/notifications.py
|
||||
------------------------
|
||||
Mobile API endpoint for polling in-app notifications.
|
||||
|
||||
GET /api/v1/notifications
|
||||
Returns unread notifications for the authenticated user.
|
||||
Accepts ?since=<ISO 8601> to fetch only notifications created after
|
||||
that datetime — used by the iPad poller to avoid re-delivering already-
|
||||
seen alerts. Returns a maximum of 50 notifications per call.
|
||||
|
||||
PATCH /api/v1/notifications/mark-read
|
||||
Marks a list of notification IDs as read.
|
||||
Request JSON: { "ids": [1, 2, 3] }
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy.exc
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.notification import Notification
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_notifications', __name__)
|
||||
|
||||
|
||||
def _parse_since(value):
|
||||
"""Parse ?since= ISO 8601 string; return None on failure."""
|
||||
if not value:
|
||||
return None
|
||||
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _build_payload(notifications, has_event_type):
|
||||
"""Serialise notification rows to dicts. Works before and after phase17 migration."""
|
||||
rows = []
|
||||
for n in notifications:
|
||||
rows.append({
|
||||
'id': n.id,
|
||||
'title': n.title,
|
||||
'body': n.body,
|
||||
'event_type': (n.event_type if has_event_type else None),
|
||||
'issue_id': n.issue_id,
|
||||
'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
@bp.route('/notifications', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_notifications():
|
||||
"""
|
||||
Return unread notifications for the authenticated user.
|
||||
|
||||
Query parameters
|
||||
----------------
|
||||
since ISO 8601 datetime Only return notifications created after this time.
|
||||
limit int (default 50, max 50)
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "notifications": [...], "count": N } }
|
||||
"""
|
||||
user = g.api_user
|
||||
since = _parse_since(request.args.get('since'))
|
||||
limit = min(int(request.args.get('limit', 50)), 50)
|
||||
|
||||
def _run_orm():
|
||||
q = Notification.query.filter_by(user_id=user.id, is_read=False)
|
||||
if since:
|
||||
q = q.filter(Notification.created_at > since)
|
||||
return q.order_by(Notification.created_at.asc()).limit(limit).all()
|
||||
|
||||
# Attempt the ORM query (works after phase17 migration runs).
|
||||
# If the event_type column does not yet exist in the DB, MySQL raises
|
||||
# OperationalError: Unknown column 'notifications.event_type' in SELECT.
|
||||
# getattr() does NOT protect against this — the failure is at the SQL layer.
|
||||
# The fallback raw-SQL query selects only the pre-phase17 columns so the
|
||||
# endpoint stays functional before the migration runs.
|
||||
try:
|
||||
notifications = _run_orm()
|
||||
has_event_type = True
|
||||
except sqlalchemy.exc.OperationalError:
|
||||
db.session.rollback()
|
||||
sql_parts = (
|
||||
"SELECT id, title, body, issue_id, is_read, created_at "
|
||||
"FROM notifications "
|
||||
"WHERE user_id = :uid AND is_read = 0 "
|
||||
)
|
||||
params = {'uid': user.id, 'lim': limit}
|
||||
if since:
|
||||
sql_parts += "AND created_at > :since "
|
||||
params['since'] = since
|
||||
sql_parts += "ORDER BY created_at ASC LIMIT :lim"
|
||||
|
||||
rows = db.session.execute(db.text(sql_parts), params).fetchall()
|
||||
|
||||
class _Row:
|
||||
"""Minimal shim so _build_payload works with raw SQL rows."""
|
||||
__slots__ = ('id', 'title', 'body', 'issue_id', 'created_at')
|
||||
def __init__(self, r):
|
||||
self.id = r[0]
|
||||
self.title = r[1]
|
||||
self.body = r[2]
|
||||
self.issue_id = r[3]
|
||||
self.created_at = r[5]
|
||||
|
||||
notifications = [_Row(r) for r in rows]
|
||||
has_event_type = False
|
||||
|
||||
payload = _build_payload(notifications, has_event_type)
|
||||
|
||||
logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d | has_event_type=%s',
|
||||
user.username, since, len(payload), has_event_type)
|
||||
|
||||
return api_ok({'notifications': payload, 'count': len(payload)})
|
||||
|
||||
|
||||
@bp.route('/notifications/mark-read', methods=['PATCH'])
|
||||
@jwt_required
|
||||
def mark_read():
|
||||
"""
|
||||
Mark a list of notification IDs as read.
|
||||
|
||||
Request JSON: { "ids": [1, 2, 3] }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "marked": 3 } }
|
||||
"""
|
||||
user = g.api_user
|
||||
data = request.get_json(silent=True) or {}
|
||||
ids = data.get('ids') or []
|
||||
|
||||
if not isinstance(ids, list):
|
||||
return api_error('ids must be a list', 400)
|
||||
|
||||
if ids:
|
||||
updated = (
|
||||
Notification.query
|
||||
.filter(
|
||||
Notification.id.in_(ids),
|
||||
Notification.user_id == user.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for n in updated:
|
||||
n.is_read = True
|
||||
db.session.commit()
|
||||
count = len(updated)
|
||||
else:
|
||||
count = 0
|
||||
|
||||
logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count)
|
||||
return api_ok({'marked': count})
|
||||
@@ -54,6 +54,11 @@ class Issue(db.Model):
|
||||
photo_path = db.Column(db.String(255))
|
||||
status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open')
|
||||
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
# Set at creation time to the user who filed the issue (inspector or admin).
|
||||
# Nullable for backward compatibility — pre-phase18 rows will be NULL.
|
||||
# Used by the mobile API to return issues the inspector created but hasn't
|
||||
# been assigned yet (assigned_to is NULL until a director assigns them).
|
||||
reported_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
reported_at = db.Column(db.DateTime, default=now_eastern)
|
||||
resolved_at = db.Column(db.DateTime)
|
||||
result_notes = db.Column(db.Text)
|
||||
@@ -75,6 +80,7 @@ class Issue(db.Model):
|
||||
# with that backref at mapper configuration time (CLAUDE.md rule 31 revised).
|
||||
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues')
|
||||
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
|
||||
reporter = db.relationship('User', foreign_keys=[reported_by], backref='reported_issues')
|
||||
verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues')
|
||||
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
|
||||
order_by='IssueComment.created_at',
|
||||
|
||||
@@ -12,6 +12,9 @@ EVENT_ISSUE_COMMENT = 'issue_comment'
|
||||
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
|
||||
EVENT_INSPECTION_DONE = 'inspection_completed'
|
||||
EVENT_SLA_ALERT = 'sla_alert'
|
||||
# Fired when an issue is flagged during an inspection (web or mobile).
|
||||
# Listed here so users can configure email preferences for this event.
|
||||
EVENT_ISSUE_FLAGGED = 'issue_flagged'
|
||||
|
||||
# ── Customer portal events ─────────────────────────────────────────────────
|
||||
# Fired when an inspection completes or an issue is created/updated at a
|
||||
@@ -25,6 +28,7 @@ ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
EVENT_ISSUE_COMMENT: 'New comment on issue',
|
||||
EVENT_ISSUE_FOLLOW: 'Updates on followed issues',
|
||||
EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)',
|
||||
EVENT_INSPECTION_DONE: 'Inspection completed',
|
||||
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
||||
# Customer-facing — only relevant for customer role accounts
|
||||
@@ -53,6 +57,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)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ issue_assigned : admin ✗ director ✗ inspector ✗ pm ✗ cust
|
||||
issue_status : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
|
||||
issue_comment : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
|
||||
issue_follow_update : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
|
||||
issue_flagged : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
|
||||
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
|
||||
@@ -113,8 +113,8 @@ MATRIX_DEFAULTS = {
|
||||
('issue_follow_update', 'customer'): False,
|
||||
('issue_follow_update', 'custom'): False,
|
||||
# issue_flagged (from inspection)
|
||||
('issue_flagged', 'admin'): False,
|
||||
('issue_flagged', 'director'): False,
|
||||
('issue_flagged', 'admin'): True,
|
||||
('issue_flagged', 'director'): True,
|
||||
('issue_flagged', 'inspector'): False,
|
||||
('issue_flagged', 'project_manager'): False,
|
||||
('issue_flagged', 'customer'): True,
|
||||
@@ -216,4 +216,4 @@ def get_custom_emails_for(event_type: str) -> list:
|
||||
).first()
|
||||
if row is None:
|
||||
return []
|
||||
return row.get_custom_emails()
|
||||
return row.get_custom_emails()
|
||||
@@ -730,6 +730,7 @@ def flag_issue(inspection_id):
|
||||
status = 'open',
|
||||
assigned_to = form.assigned_to.data or None,
|
||||
reported_at = now_eastern(),
|
||||
reported_by = current_user.id,
|
||||
)
|
||||
db.session.add(issue)
|
||||
|
||||
@@ -873,6 +874,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,
|
||||
|
||||
+12
-7
@@ -70,12 +70,18 @@ class _SLAFilteredPage:
|
||||
@login_required
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
q = Issue.query.order_by(Issue.reported_at.desc())
|
||||
# outerjoin Area once here so both the customer-scope filter and the
|
||||
# facility_filter block can reference Area.facility_id without a cartesian
|
||||
# product. Issues with no area_id get NULL for all Area columns (outer join).
|
||||
q = (
|
||||
Issue.query
|
||||
.outerjoin(Area, Issue.area_id == Area.id)
|
||||
.order_by(Issue.reported_at.desc())
|
||||
)
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
q = q.filter(Issue.assigned_to == current_user.id)
|
||||
elif current_user.role == 'customer':
|
||||
from app.models.facility import Area
|
||||
customer_facility_ids = get_customer_scope(current_user)
|
||||
if not customer_facility_ids:
|
||||
q = q.filter(False)
|
||||
@@ -86,11 +92,10 @@ def index():
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
db.and_(
|
||||
Issue.area_id.isnot(None),
|
||||
Issue.area_id == Area.id,
|
||||
Area.facility_id.in_(customer_facility_ids)
|
||||
Area.facility_id.in_(customer_facility_ids),
|
||||
)
|
||||
)
|
||||
).outerjoin(Area, Issue.area_id == Area.id)
|
||||
)
|
||||
|
||||
severity_filter = request.args.get('severity', '')
|
||||
status_filter = request.args.get('status', '')
|
||||
@@ -106,8 +111,7 @@ def index():
|
||||
q = q.filter(
|
||||
db.or_(
|
||||
Issue.facility_id == fid,
|
||||
db.and_(Issue.area_id.isnot(None),
|
||||
Area.facility_id == fid)
|
||||
Area.facility_id == fid,
|
||||
)
|
||||
)
|
||||
# SLA filter — SLA status is computed in Python (not a DB column).
|
||||
@@ -447,6 +451,7 @@ def create():
|
||||
status = 'open',
|
||||
assigned_to = form.assigned_to.data or None,
|
||||
reported_at = now_eastern(),
|
||||
reported_by = current_user.id,
|
||||
)
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
|
||||
+6
-1
@@ -44,6 +44,11 @@ def log_action(action: str,
|
||||
"""
|
||||
Write a single AuditLog row. Safe to call from any request context.
|
||||
|
||||
⚠️ This function calls db.session.commit() internally.
|
||||
Always call it AFTER the primary db.session.commit() for the business
|
||||
transaction — never before. Calling it mid-transaction will commit any
|
||||
dirty ORM state accumulated in the session up to that point.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
action : One of the ACTION_* constants (or a custom string ≤ 50 chars).
|
||||
@@ -90,4 +95,4 @@ def log_action(action: str,
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user