""" 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 import re 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 from app.utils.scope import get_inspector_scope logger = logging.getLogger(__name__) bp = Blueprint('api_issues', __name__) _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector', 'project_manager', 'auditor'} _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} _VALID_HANDLERS = {'internal', 'facility', 'vendor'} _UUID_RE = re.compile( r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$', re.IGNORECASE, ) def _photo_urls(keys): """Map storage keys to absolute display URLs (presigned on R2, absolute-static on local). Falsy keys are skipped. Used for iPad photo rendering.""" from app.utils import storage return [storage.media_url(k, external=True) for k in keys if k] 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 [], # Absolute display URLs (presigned on R2, absolute-static on local) for # the iPad, which loads photos off-origin. Relative keys above stay as # keys. photo_urls order mirrors the iPad's evidence merge: # [photo_path] + mobile_photo_paths. 'photo_urls': _photo_urls( ([issue.photo_path] if issue.photo_path else []) + (issue.mobile_photo_paths or [])), 'result_photo_urls': _photo_urls(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, # ── Handler ("Handled By", phase35) ─────────────────────────────── # handler_type categorises WHO resolves the issue: # internal = our staff (assigned_to) facility = facility's own staff # vendor = external contractor 'handler_type': issue.handler_type or 'internal', 'handler_label': issue.handler_label, 'facility_handler_name': issue.facility_handler_name or None, 'facility_handler_contact': issue.facility_handler_contact or None, 'facility_handler_notes': issue.facility_handler_notes or None, 'vendor_name': issue.vendor_name or None, 'vendor_contact': issue.vendor_contact or None, 'vendor_notes': issue.vendor_notes or None, 'internal_handler_name': issue.internal_handler_name or None, 'internal_handler_contact': issue.internal_handler_contact or None, } # ── 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.is_inspector: fids = get_inspector_scope(user) if not fids: return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset}) query = query.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)), ) ) 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: if not _UUID_RE.match(str(mobile_local_id)): return api_error('mobile_local_id must be a valid UUID', 400) 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) if user.is_inspector: fids = get_inspector_scope(user) if not fids or facility_id not in fids: return api_error('Access denied — facility is not in your assigned contracts', 403) 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.commit() # commit issue first so FK references in notifications are valid # ── 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_created', title = f'New Issue #{issue.id} at {facility.name} (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, ) 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.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: 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.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: return api_error('Access denied', 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 Handler ("Handled By") ─────────────────────────────────────── @bp.route('/issues//handler', methods=['PATCH']) @jwt_required def update_issue_handler(issue_id): """ Set who handles an issue ("Handled By") from the mobile app. Unlike the web form (which limits handler edits to admin/director/PM), the iPad allows the assigned inspector to set the handler from the field, scoped to issues at their assigned facilities. Request JSON ------------ { "handler_type": "internal" | "facility" | "vendor", "facility_handler_name": "...", // optional (facility handler) "facility_handler_contact": "...", // optional "facility_handler_notes": "...", // optional "vendor_name": "...", // optional (vendor handler) "vendor_contact": "...", // optional "vendor_notes": "...", // optional "internal_handler_name": "...", // optional (janitorial staff name) "internal_handler_contact": "..." // optional (janitorial staff contact) } Only keys present in the body are updated; empty strings clear a field. handler_type is required. Access: - admin / director / project_manager : any issue - inspector : only issues at their assigned facilities """ 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.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: return api_error('Access denied', 403) data = request.get_json(silent=True) or {} handler = (data.get('handler_type') or '').strip().lower() if handler not in _VALID_HANDLERS: return api_error( f'handler_type must be one of: {", ".join(sorted(_VALID_HANDLERS))}', 400 ) old_handler = issue.handler_type or 'internal' issue.handler_type = handler # Update only the detail fields that were supplied. Empty string clears # the field (stored as NULL); a missing key leaves the field untouched. _text_fields = ( 'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', 'vendor_name', 'vendor_contact', 'vendor_notes', 'internal_handler_name', 'internal_handler_contact', ) for field in _text_fields: if field in data: val = (data.get(field) or '').strip() setattr(issue, field, val or None) db.session.commit() log_action(ACTION_UPDATE, 'Issue', issue.id, f'handler {old_handler} → {handler}', f'source=mobile; updated_by={user.username}') logger.info('API ISSUES | handler_updated | issue_id=%d | %s→%s | user=%s', issue.id, old_handler, handler, user.username) return api_ok({'issue_id': issue.id, 'handler_type': issue.handler_type}) # ── 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.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: 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)}) # ── Attach Resolution Photos (mobile) ───────────────────────────────────────── @bp.route('/issues//result_photos', methods=['PATCH']) @jwt_required def update_issue_result_photos(issue_id): """ Attach resolution photos to an issue from the mobile app. Called when an inspector marks an issue resolved and uploads photos showing the fix. Stored in Issue.result_photos so they appear under "Resolution Details" on the web — identical to photos uploaded via the web update form. Request JSON ------------ { "result_photos": ["uploads/issue_result_photos/a.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.is_inspector: fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: 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 result_photos existing = issue.result_photos or [] merged = existing + [p for p in new_photos if p not in existing] issue.result_photos = merged db.session.commit() log_action(ACTION_UPDATE, 'Issue', issue.id, f'result_photos updated (+{len(new_photos)} photos)', f'source=mobile; updated_by={user.username}') logger.info('API ISSUES | result_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)})