Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/LT_Janitorial_Quality_Control
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Audience:** AI assistants and developers working on this codebase.
|
||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||
> **Last reviewed:** May 2026 (Phase 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal, inspector contract scoping, contract visibility in issues/inspections UI, Contract→Facility cascade filters)
|
||||
> **Last reviewed:** May 2026 (Phase 19 complete + mobile API gap-fill Phases A–E: issue comments, dashboard stats with severity breakdown, area_name/assigned_to_name in issue payload, notification inbox, area_id on issues, CSRF-exempt pattern for all new blueprints)
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
6. [Role & Permission Matrix](#6-role--permission-matrix)
|
||||
7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory)
|
||||
8. [Utility Modules](#8-utility-modules)
|
||||
9. [Mobile API (Phase 7 / Phase A / Phase B / Phase C)](#9-mobile-api-phase-7--phase-a--phase-b--phase-c)
|
||||
9. [Mobile API (Phase 7 / Phase A–E)](#9-mobile-api-phase-7--phase-ae)
|
||||
10. [iPad Native App](#10-ipad-native-app)
|
||||
11. [Notification System](#11-notification-system)
|
||||
12. [SLA Engine](#12-sla-engine)
|
||||
@@ -88,8 +88,10 @@ lt_janitorial_quality_control/
|
||||
│ │ ├── facilities.py # /api/v1/facilities/* (Phase A)
|
||||
│ │ ├── templates.py # /api/v1/templates/* (Phase A)
|
||||
│ │ ├── inspections.py # /api/v1/inspections/* (Phase B)
|
||||
│ │ ├── issues.py # /api/v1/issues/* (Phase B + Phase 19)
|
||||
│ │ ├── issues.py # /api/v1/issues/* (Phase B + Phase 19 + Phase E)
|
||||
│ │ ├── photos.py # /api/v1/photos/upload (Phase B)
|
||||
│ │ ├── stats.py # /api/v1/stats/dashboard (Phase B stats)
|
||||
│ │ ├── comments.py # /api/v1/issues/<id>/comments (Phase D)
|
||||
│ │ ├── decorators.py # @jwt_required
|
||||
│ │ ├── errors.py # JSON error helpers + error handler registration
|
||||
│ │ └── jwt_utils.py # generate_access_token()
|
||||
@@ -310,6 +312,8 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
|
||||
| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos` ← Phase 19 |
|
||||
| `api_photos` | `/api/v1` | `POST /photos/upload` |
|
||||
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
|
||||
| `api_stats` | `/api/v1` | `GET /stats/dashboard` — inspector-scoped KPIs with severity breakdown (Phase B) |
|
||||
| `api_comments` | `/api/v1` | `GET /issues/<id>/comments`, `POST /issues/<id>/comments` (Phase D) |
|
||||
|
||||
---
|
||||
|
||||
@@ -339,7 +343,7 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
|
||||
|
||||
---
|
||||
|
||||
## 9. Mobile API (Phase 7 / Phase A / Phase B / Phase C)
|
||||
## 9. Mobile API (Phase 7 / Phase A–E)
|
||||
|
||||
### CSRF Exemption Pattern — Critical
|
||||
|
||||
@@ -384,7 +388,26 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
|
||||
|
||||
| Endpoint | Auth | Description |
|
||||
|---|---|---|
|
||||
| `PATCH /api/v1/issues/<id>/photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. Access: inspector must be `assigned_to` or `reported_by`. |
|
||||
| `PATCH /api/v1/issues/<id>/photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. |
|
||||
|
||||
### Phase B (Stats) Endpoint
|
||||
|
||||
| Endpoint | Auth | Description |
|
||||
|---|---|---|
|
||||
| `GET /api/v1/stats/dashboard` | jwt_required | Inspector-scoped KPIs: `today_inspections`, `completed_today`, `open_issues`, `avg_score_30d`, `pending_followups`, `sla_breached`, `sla_at_risk`, `severity_breakdown` (dict: critical/high/medium/low). Inspectors scoped to contracted facilities. Admins/directors/PMs get org-wide numbers. Customers get 403. |
|
||||
|
||||
### Phase D (Comments) Endpoints
|
||||
|
||||
| Endpoint | Auth | Description |
|
||||
|---|---|---|
|
||||
| `GET /api/v1/issues/<id>/comments` | jwt_required | All comments oldest-first. Returns: `id`, `issue_id`, `author_name`, `author_role`, `status_at_time`, `body`, `created_at`. Inspectors limited to contracted facilities. |
|
||||
| `POST /api/v1/issues/<id>/comments` | jwt_required | Add a comment. Body: `{ "body": "..." }`. Fires `notify_by_matrix('issue_comment')`. Calls `log_action()` after commit. |
|
||||
|
||||
### Phase E Additions to Existing Endpoints
|
||||
|
||||
`_issue_payload()` in `issues.py` now returns `area_name` and `assigned_to_name` (both nullable). These populate `LocalIssue.areaNameCache` and `LocalIssue.assignedToName` on the iPad after every `pullAssignedIssues()`. `refreshStatusFromServer()` also refreshes them on demand.
|
||||
|
||||
`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
|
||||
|
||||
@@ -394,8 +417,16 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
|
||||
'facility_id', 'facility_name', 'reported_at', 'resolved_at',
|
||||
'mobile_local_id',
|
||||
'photo_path', # primary evidence photo (first iPad photo or web upload)
|
||||
'mobile_photo_paths', # extra evidence photos from iPad (list) ← Phase 19
|
||||
'mobile_photo_paths', # extra evidence photos from iPad (list)
|
||||
'result_photos', # resolution photos added via web form (list)
|
||||
# Phase A additions:
|
||||
'result_notes', # resolution notes entered by web staff
|
||||
'verified_at', # ISO 8601 datetime when fix was verified (nullable)
|
||||
'verification_note', # note from the verifier (nullable)
|
||||
'reported_by_name', # display_name of User who filed the issue (nullable)
|
||||
# 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)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -681,7 +712,7 @@ timeout = 30
|
||||
| 32 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes |
|
||||
| 33–38 | *(field ID casting, photo sentinel, notify event_type, follow-up, OperationalError)* | See prior rule entries |
|
||||
| 39 | **Inspector issue scope: assigned OR reported — web and API must match** | `issues.index()`, `issues.view()`, and all API issue endpoints (`GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos`) enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role |
|
||||
| 40 | **`_issue_payload()` must return `photo_path`, `mobile_photo_paths`, and `result_photos`** | iPad reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`; omitting `mobile_photo_paths` means extra evidence photos are invisible on the iPad after sync |
|
||||
| 40 | **`_issue_payload()` must return all documented fields** | iPad reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. Phase A–E added `result_notes`, `verified_at`, `verification_note`, `reported_by_name`, `area_name`, `assigned_to_name`. Omitting any field silently breaks the corresponding iPad display. |
|
||||
| 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row |
|
||||
| 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; use `~.any()` which emits `NOT EXISTS` |
|
||||
| 43 | **`issues.index()` outerjoin must precede all filters** | Both customer-scope and facility_filter blocks reference `Area.facility_id` |
|
||||
|
||||
@@ -161,6 +161,8 @@ def create_app(config_name='default'):
|
||||
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
|
||||
from app.api.stats import bp as _api_stats_bp
|
||||
from app.api.comments import bp as _api_comments_bp
|
||||
csrf.exempt(_api_auth_bp)
|
||||
csrf.exempt(_api_facilities_bp)
|
||||
csrf.exempt(_api_templates_bp)
|
||||
@@ -168,6 +170,8 @@ def create_app(config_name='default'):
|
||||
csrf.exempt(_api_issues_bp)
|
||||
csrf.exempt(_api_photos_bp)
|
||||
csrf.exempt(_api_notifications_bp)
|
||||
csrf.exempt(_api_stats_bp)
|
||||
csrf.exempt(_api_comments_bp)
|
||||
register_api(app)
|
||||
|
||||
# ── Security response headers ─────────────────────────────────────────
|
||||
|
||||
@@ -42,4 +42,12 @@ def register_api(app):
|
||||
from app.api.notifications import bp as notifications_bp
|
||||
api_bp.register_blueprint(notifications_bp)
|
||||
|
||||
# Phase B (stats): Dashboard KPI endpoint
|
||||
from app.api.stats import bp as stats_bp
|
||||
api_bp.register_blueprint(stats_bp)
|
||||
|
||||
# Phase D: Issue comments
|
||||
from app.api.comments import bp as comments_bp
|
||||
api_bp.register_blueprint(comments_bp)
|
||||
|
||||
app.register_blueprint(api_bp)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
app/api/comments.py
|
||||
-------------------
|
||||
Mobile API endpoints for issue comments.
|
||||
|
||||
GET /api/v1/issues/<id>/comments
|
||||
Returns all comments for an issue, ordered oldest-first.
|
||||
Inspectors may only access issues within their contracted facilities.
|
||||
|
||||
POST /api/v1/issues/<id>/comments
|
||||
Adds a comment to an issue.
|
||||
Inspectors may only comment on issues within their contracted facilities.
|
||||
Fires notify_by_matrix('issue_comment') so the relevant staff are notified.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.issue import Issue, IssueComment
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE
|
||||
from app.utils.notifications import notify_by_matrix
|
||||
from app.utils.scope import get_inspector_scope
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_comments', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
def _comment_payload(comment: IssueComment) -> dict:
|
||||
"""Serialise an IssueComment to the dict returned in API responses."""
|
||||
return {
|
||||
'id': comment.id,
|
||||
'issue_id': comment.issue_id,
|
||||
'author_name': comment.author.display_name if comment.author else 'Unknown',
|
||||
'author_role': comment.author.role if comment.author else '',
|
||||
'status_at_time': comment.status_at_time or '',
|
||||
'body': comment.body,
|
||||
'created_at': comment.created_at.isoformat() if comment.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _check_issue_access(issue: Issue, user) -> bool:
|
||||
"""Return True if user may read/write this issue. False = 403."""
|
||||
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 False
|
||||
return True
|
||||
|
||||
|
||||
# ── GET comments ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/comments', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_comments(issue_id):
|
||||
"""
|
||||
Return all comments for the given issue, oldest-first.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"issue_id": 42,
|
||||
"comments": [
|
||||
{
|
||||
"id": 1,
|
||||
"issue_id": 42,
|
||||
"author_name": "Jane Smith",
|
||||
"author_role": "director",
|
||||
"status_at_time": "in_progress",
|
||||
"body": "Cleaning crew has been notified.",
|
||||
"created_at": "2026-05-10T09:15:00"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
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 not _check_issue_access(issue, user):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
comments = (
|
||||
issue.comments
|
||||
.order_by(IssueComment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
payload = [_comment_payload(c) for c in comments]
|
||||
|
||||
logger.info('API COMMENTS | list | issue_id=%d | count=%d | user=%s',
|
||||
issue_id, len(payload), user.username)
|
||||
|
||||
return api_ok({'issue_id': issue_id, 'comments': payload, 'count': len(payload)})
|
||||
|
||||
|
||||
# ── POST comment ──────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/comments', methods=['POST'])
|
||||
@jwt_required
|
||||
def add_comment(issue_id):
|
||||
"""
|
||||
Add a comment to an issue.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "body": "The spill has been cleaned up." }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "comment_id": 7 } }
|
||||
"""
|
||||
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 not _check_issue_access(issue, user):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
body = (data.get('body') or '').strip()
|
||||
if not body:
|
||||
return api_error('body is required', 400)
|
||||
|
||||
comment = IssueComment(
|
||||
issue_id = issue_id,
|
||||
user_id = user.id,
|
||||
status_at_time = issue.status,
|
||||
body = body,
|
||||
)
|
||||
db.session.add(comment)
|
||||
db.session.flush() # get comment.id
|
||||
|
||||
# Notify via matrix — same event type as web-originated comments
|
||||
facility = issue.resolved_facility
|
||||
area_name = issue.area.name if issue.area else (facility.name if facility else '—')
|
||||
facility_id = facility.id if facility else None
|
||||
|
||||
try:
|
||||
from flask import url_for
|
||||
issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
|
||||
except RuntimeError:
|
||||
issue_link = f'/issues/{issue.id}'
|
||||
|
||||
if facility_id:
|
||||
notify_by_matrix(
|
||||
event_type = 'issue_comment',
|
||||
title = f'New Comment on Issue #{issue.id}',
|
||||
body = (
|
||||
f'{user.display_name} commented on Issue #{issue.id} '
|
||||
f'at {area_name}: '
|
||||
f'"{body[:120]}{"…" if len(body) > 120 else ""}"'
|
||||
),
|
||||
link = issue_link,
|
||||
issue_id = issue.id,
|
||||
facility_id = facility_id,
|
||||
exclude_user_ids = {user.id},
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'IssueComment', comment.id,
|
||||
f'comment on Issue #{issue_id}',
|
||||
f'source=mobile; author={user.username}; issue_status={issue.status}')
|
||||
|
||||
logger.info('API COMMENTS | created | comment_id=%d | issue_id=%d | user=%s',
|
||||
comment.id, issue_id, user.username)
|
||||
|
||||
return api_ok({'comment_id': comment.id})
|
||||
@@ -70,6 +70,17 @@ def _issue_payload(issue):
|
||||
'photo_path': issue.photo_path or None,
|
||||
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
||||
'result_photos': issue.result_photos or [],
|
||||
# Resolution details — set by web staff after fixing the issue.
|
||||
'result_notes': issue.result_notes or None,
|
||||
# Verification fields — set after a director/admin confirms fix.
|
||||
'verified_at': issue.verified_at.isoformat() if issue.verified_at else None,
|
||||
'verification_note': issue.verification_note or None,
|
||||
# Reporter display name — shows who filed the issue.
|
||||
'reported_by_name': issue.reporter.display_name if issue.reporter else None,
|
||||
# Area name — set when the issue was flagged during an area-specific inspection.
|
||||
'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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
app/api/stats.py
|
||||
----------------
|
||||
Mobile API endpoint for dashboard statistics.
|
||||
|
||||
GET /api/v1/stats/dashboard
|
||||
Returns inspector-scoped counts used by the iPad dashboard card:
|
||||
- today_inspections : inspections started or completed today
|
||||
- completed_today : completed inspections today
|
||||
- open_issues : open + in_progress issues in contracted facilities
|
||||
- avg_score_30d : average overall_score (last 30 days, own inspections)
|
||||
- pending_followups : completed inspections with follow_up_required and no
|
||||
child re-inspection yet
|
||||
- sla_breached : open/in-progress issues past their SLA deadline
|
||||
- sla_at_risk : open/in-progress issues past 75% of SLA window
|
||||
|
||||
Admins and directors receive org-wide numbers (no facility scoping).
|
||||
Project managers receive unscoped numbers same as admin.
|
||||
Inspectors receive numbers scoped to their contracted facilities / own work.
|
||||
Customers are denied (403) — stats are for operational staff only.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Blueprint, g
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.issue import Issue
|
||||
from app.models.facility import Area
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.scope import get_inspector_scope
|
||||
from app.utils.sla import sla_status
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_stats', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
@bp.route('/stats/dashboard', methods=['GET'])
|
||||
@jwt_required
|
||||
def dashboard_stats():
|
||||
"""
|
||||
Return dashboard KPI counts for the authenticated user.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"today_inspections": 3,
|
||||
"completed_today": 2,
|
||||
"open_issues": 7,
|
||||
"avg_score_30d": 84.5,
|
||||
"pending_followups": 1,
|
||||
"sla_breached": 2,
|
||||
"sla_at_risk": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
is_inspector = user.role == 'inspector'
|
||||
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
|
||||
|
||||
# ── Today's inspections ───────────────────────────────────────────────
|
||||
today_q = Inspection.query.filter(
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
)
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
today_q = today_q.filter(False)
|
||||
else:
|
||||
today_q = today_q.filter(
|
||||
Inspection.facility_id.in_(fids),
|
||||
Inspection.inspector_id == user.id,
|
||||
)
|
||||
|
||||
today_inspections = today_q.count()
|
||||
|
||||
completed_today = today_q.filter(
|
||||
Inspection.status == 'completed'
|
||||
).count()
|
||||
|
||||
# ── Open issues ───────────────────────────────────────────────────────
|
||||
open_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
open_q = open_q.filter(False)
|
||||
else:
|
||||
open_q = open_q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(fids),
|
||||
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
|
||||
)
|
||||
)
|
||||
|
||||
open_issues_all = open_q.all()
|
||||
open_issues = len(open_issues_all)
|
||||
|
||||
# ── Severity breakdown (derived from the same open_issues_all list) ───
|
||||
severity_breakdown = {
|
||||
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
||||
'high': sum(1 for i in open_issues_all if i.severity == 'high'),
|
||||
'medium': sum(1 for i in open_issues_all if i.severity == 'medium'),
|
||||
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
|
||||
}
|
||||
|
||||
# ── SLA counts (derived from the same open_issues_all list) ──────────
|
||||
sla_breached = sum(1 for i in open_issues_all if sla_status(i) == 'breached')
|
||||
sla_at_risk = sum(1 for i in open_issues_all if sla_status(i) == 'at_risk')
|
||||
|
||||
# ── Average score last 30 days ────────────────────────────────────────
|
||||
score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= thirty_days_ago,
|
||||
)
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
score_q = score_q.filter(False)
|
||||
else:
|
||||
score_q = score_q.filter(
|
||||
Inspection.facility_id.in_(fids),
|
||||
Inspection.inspector_id == user.id,
|
||||
)
|
||||
raw_avg = score_q.scalar()
|
||||
avg_score = round(float(raw_avg), 1) if raw_avg is not None else None
|
||||
|
||||
# ── Pending follow-ups ────────────────────────────────────────────────
|
||||
# Completed inspections that still need a re-inspection and have none yet.
|
||||
followup_q = Inspection.query.filter_by(
|
||||
follow_up_required=True, status='completed'
|
||||
).filter(~Inspection.follow_ups.any())
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
followup_q = followup_q.filter(False)
|
||||
else:
|
||||
followup_q = followup_q.filter(
|
||||
Inspection.facility_id.in_(fids),
|
||||
Inspection.inspector_id == user.id,
|
||||
)
|
||||
pending_followups = followup_q.count()
|
||||
|
||||
logger.info(
|
||||
'API STATS | dashboard | user=%s | role=%s | '
|
||||
'today=%d | open_issues=%d | avg=%.1f | followups=%d | sla_b=%d | sla_r=%d',
|
||||
user.username, user.role,
|
||||
today_inspections, open_issues,
|
||||
avg_score or 0.0,
|
||||
pending_followups, sla_breached, sla_at_risk,
|
||||
)
|
||||
|
||||
return api_ok({
|
||||
'today_inspections': today_inspections,
|
||||
'completed_today': completed_today,
|
||||
'open_issues': open_issues,
|
||||
'avg_score_30d': avg_score,
|
||||
'pending_followups': pending_followups,
|
||||
'sla_breached': sla_breached,
|
||||
'sla_at_risk': sla_at_risk,
|
||||
'severity_breakdown': severity_breakdown,
|
||||
})
|
||||
Reference in New Issue
Block a user