""" 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. 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'} 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: primary evidence photo (first iPad photo). # mobile_photo_paths: extra evidence photos from iPad (shown under Photo Evidence). # result_photos: resolution photos added via the web update form. 'photo_path': issue.photo_path or None, 'mobile_photo_paths': issue.mobile_photo_paths or [], '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 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 ──────────────────────────────────────────────────────────── # result_photos in the POST body = extra evidence photos from the iPad. # Store in mobile_photo_paths (not result_photos) so they appear under # "Photo Evidence" on the web, not "Resolution Details". raw_mobile = data.get('result_photos') mobile_photo_paths = [p for p in raw_mobile if isinstance(p, str) and p.strip()] \ if isinstance(raw_mobile, list) else [] issue = Issue( inspection_id = inspection_id, facility_id = facility_id, severity = severity, description = description, photo_path = data.get('photo_path') or None, mobile_photo_paths = mobile_photo_paths or None, status = 'open', reported_at = now_eastern(), reported_by = user.id, 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: all allowed roles may fetch any issue. """ 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 and issue.reported_by != user.id: return api_error('Access denied', 403) return api_ok(_issue_payload(issue)) # ── 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 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() 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}) # ── Update Issue Photos (mobile) ────────────────────────────────────────────── @bp.route('/issues//photos', methods=['PATCH']) @jwt_required def update_issue_photos(issue_id): """ Attach additional evidence photos to an issue created from the mobile app. Called by the iOS app after create_issue when the inspector attached more than one photo. Photos are already uploaded via /api/v1/photos/upload. Stored in mobile_photo_paths so they display under "Photo Evidence" on the web, not "Resolution Details". Request JSON ------------ { "result_photos": ["uploads/issue_photos/a.jpg", "uploads/issue_photos/b.jpg"] } Response 200 ------------ { "ok": true, "data": { "issue_id": 99, "result_photos_count": 2 } } """ 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 and issue.reported_by != user.id: return api_error('Access denied', 403) data = request.get_json(silent=True) or {} raw = data.get('result_photos') if not isinstance(raw, list): return api_error('result_photos must be a list of path strings', 400) new_photos = [p for p in raw if isinstance(p, str) and p.strip()] if not new_photos: return api_error('result_photos must contain at least one valid path', 400) # Merge idempotently with any existing mobile_photo_paths existing = issue.mobile_photo_paths or [] merged = existing + [p for p in new_photos if p not in existing] issue.mobile_photo_paths = merged db.session.commit() log_action(ACTION_UPDATE, 'Issue', issue.id, f'mobile_photo_paths updated (+{len(new_photos)} photos)', f'source=mobile; updated_by={user.username}') logger.info('API ISSUES | photos_updated | issue_id=%d | added=%d | user=%s', issue.id, len(new_photos), user.username) return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})