""" app/api/issues.py ----------------- Mobile API endpoint for submitting issues from the iPad app. POST /api/v1/issues Creates a new issue record. Accepts a mobile_local_id for idempotency. Triggers notify_by_matrix() and log_action() identically to the web route. GET /api/v1/issues/ Returns current status, severity, description, and assigned_to for an issue. Inspectors may only fetch issues assigned to them. PATCH /api/v1/issues//status Updates the status of an issue. Inspectors may only update issues assigned to them. Admins/directors may update any issue. """ import logging from flask import Blueprint, request, g, current_app from app import db from app.models.issue import Issue from app.models.facility import Facility, Area from app.models.inspection import Inspection 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, ACTION_UPDATE from app.utils.notifications import notify_by_matrix from app.utils.time_utils import now_eastern logger = logging.getLogger(__name__) 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'} # ── Create Issue ────────────────────────────────────────────────────────────── @bp.route('/issues', methods=['POST']) @jwt_required def create_issue(): """ Create a new issue submitted from the iPad app. Idempotency: if mobile_local_id is provided and an issue with that ID already exists, the existing record is returned without duplication. Request JSON ------------ { "inspection_id": 42, // optional "facility_id": 5, // required "severity": "high", // "low"|"medium"|"high"|"critical" "description": "...", // required "photo_path": "uploads/...",// optional — already uploaded via /photos/upload "mobile_local_id": "uuid-string" // idempotency key } Response 200 ------------ { "ok": true, "data": { "issue_id": 99, "duplicate": false } } """ user = g.api_user if user.role not in _ALLOWED_ROLES: return api_error('Access denied', 403) data = request.get_json(silent=True) or {} # ── Idempotency check ───────────────────────────────────────────────── mobile_local_id = data.get('mobile_local_id') if mobile_local_id: existing = Issue.query.filter_by(mobile_local_id=mobile_local_id).first() if existing: logger.info('API ISSUES | duplicate | local_id=%s | issue_id=%d | user=%s', mobile_local_id, existing.id, user.username) return api_ok({'issue_id': existing.id, 'duplicate': True}) # ── Validate ────────────────────────────────────────────────────────── facility_id = data.get('facility_id') severity = data.get('severity', '').lower() description = (data.get('description') or '').strip() if not facility_id: return api_error('facility_id is required', 400) if severity not in _VALID_SEVERITY: return api_error(f'severity must be one of: {", ".join(sorted(_VALID_SEVERITY))}', 400) if not description: return api_error('description is required', 400) facility = db.session.get(Facility, facility_id) if facility is None: return api_error('Facility not found', 404) inspection_id = data.get('inspection_id') if inspection_id: inspection = db.session.get(Inspection, inspection_id) if inspection is None: return api_error('Inspection not found', 404) else: inspection = None # ── Create ──────────────────────────────────────────────────────────── issue = Issue( inspection_id = inspection_id, facility_id = facility_id, severity = severity, description = description, photo_path = data.get('photo_path') or None, status = 'open', reported_at = now_eastern(), mobile_local_id = mobile_local_id, ) db.session.add(issue) db.session.flush() # get issue.id # ── Notifications ───────────────────────────────────────────────────── 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}' inspection_ref = f'inspection #{inspection_id}' if inspection_id else 'a standalone report' notify_by_matrix( event_type = 'issue_flagged', title = f'New Issue #{issue.id} (Mobile)', body = ( f'A new {severity.title()}-severity issue was logged at ' f'{facility.name} during {inspection_ref}. ' f'Description: {description[:120]}' f'{"…" if len(description) > 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, 'Issue', issue.id, f'{severity} issue at {facility.name}', f'source=mobile; inspection_id={inspection_id}; ' f'local_id={mobile_local_id}') logger.info('API ISSUES | created | issue_id=%d | facility=%s | severity=%s | user=%s', issue.id, facility.name, severity, user.username) return api_ok({'issue_id': issue.id, 'duplicate': False}) # ── Get Issue Detail ────────────────────────────────────────────────────────── @bp.route('/issues/', methods=['GET']) @jwt_required 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 """ 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' 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, }) # ── Update Issue Status ─────────────────────────────────────────────────────── @bp.route('/issues//status', methods=['PATCH']) @jwt_required def update_issue_status(issue_id): """ Update the status of an issue. Request JSON ------------ { "status": "in_progress" } // one of: open | in_progress | resolved | pending_verification Access: - admin / director : any issue - inspector : only issues where assigned_to == current user """ 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' and issue.assigned_to != user.id: return api_error('Access denied — you can only update issues assigned to you', 403) data = request.get_json(silent=True) or {} new_status = (data.get('status') or '').strip().lower() if new_status not in _VALID_STATUSES: return api_error( f'status must be one of: {", ".join(sorted(_VALID_STATUSES))}', 400 ) old_status = issue.status issue.status = new_status if new_status == 'resolved' and not issue.resolved_at: issue.resolved_at = now_eastern() issue.sla_notified = None elif new_status != 'resolved': issue.resolved_at = None if old_status == 'resolved': issue.sla_notified = None db.session.commit() log_action(ACTION_UPDATE, 'Issue', issue.id, f'status {old_status} → {new_status}', f'source=mobile; updated_by={user.username}') 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})