147 lines
5.7 KiB
Python
147 lines
5.7 KiB
Python
"""
|
|
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.
|
|
"""
|
|
|
|
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 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
|
|
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'}
|
|
|
|
|
|
# ── 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
|
|
"area_id": 12, // 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 ──────────────────────────────────────────────────────────
|
|
area_id = data.get('area_id')
|
|
severity = data.get('severity', '').lower()
|
|
description = (data.get('description') or '').strip()
|
|
|
|
if not area_id:
|
|
return api_error('area_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)
|
|
|
|
area = db.session.get(Area, area_id)
|
|
if area is None:
|
|
return api_error('Area 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,
|
|
area_id = area_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 ─────────────────────────────────────────────────────
|
|
facility_id = area.facility_id
|
|
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 in '
|
|
f'{area.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 in {area.name}',
|
|
f'source=mobile; inspection_id={inspection_id}; '
|
|
f'local_id={mobile_local_id}')
|
|
|
|
logger.info('API ISSUES | created | issue_id=%d | area=%s | severity=%s | user=%s',
|
|
issue.id, area.name, severity, user.username)
|
|
|
|
return api_ok({'issue_id': issue.id, 'duplicate': False}) |