605 lines
24 KiB
Python
605 lines
24 KiB
Python
"""
|
|
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/<id>
|
|
Returns current status, severity, description, and assigned_to for an issue.
|
|
Inspectors may only fetch issues assigned to them.
|
|
|
|
PATCH /api/v1/issues/<id>/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', '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,
|
|
}
|
|
|
|
|
|
# ── 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':
|
|
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.role == '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/<int:issue_id>', 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':
|
|
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/<int:issue_id>/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':
|
|
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/<int:issue_id>/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
|
|
}
|
|
|
|
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.role == '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',
|
|
)
|
|
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/<int:issue_id>/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':
|
|
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/<int:issue_id>/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.role == '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)})
|