05/21 Fix issue with multiple photos 2

This commit is contained in:
Nguyen Ngo
2026-05-21 13:14:56 -04:00
parent 70f7978ce2
commit 1459b5316b
5 changed files with 114 additions and 45 deletions
+33 -36
View File
@@ -58,10 +58,12 @@ def _issue_payload(issue):
'reported_at': issue.reported_at.isoformat() if issue.reported_at 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, 'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
'mobile_local_id': issue.mobile_local_id, 'mobile_local_id': issue.mobile_local_id,
# photo_path is the primary issue photo; result_photos are resolution photos. # photo_path: primary evidence photo (first iPad photo).
# Both are relative paths from the server static root. # mobile_photo_paths: extra evidence photos from iPad (shown under Photo Evidence).
'photo_path': issue.photo_path or None, # result_photos: resolution photos added via the web update form.
'result_photos': issue.result_photos or [], 'photo_path': issue.photo_path or None,
'mobile_photo_paths': issue.mobile_photo_paths or [],
'result_photos': issue.result_photos or [],
} }
@@ -204,26 +206,24 @@ def create_issue():
inspection = None inspection = None
# ── Create ──────────────────────────────────────────────────────────── # ── Create ────────────────────────────────────────────────────────────
# result_photos may be supplied by the mobile app as a list of paths that # result_photos in the POST body = extra evidence photos from the iPad.
# were already uploaded via /api/v1/photos/upload (one call per photo). # Store in mobile_photo_paths (not result_photos) so they appear under
# Validate that it is a list of non-empty strings; silently drop bad entries. # "Photo Evidence" on the web, not "Resolution Details".
raw_result_photos = data.get('result_photos') raw_mobile = data.get('result_photos')
if isinstance(raw_result_photos, list): mobile_photo_paths = [p for p in raw_mobile if isinstance(p, str) and p.strip()] \
result_photos = [p for p in raw_result_photos if isinstance(p, str) and p.strip()] if isinstance(raw_mobile, list) else []
else:
result_photos = []
issue = Issue( issue = Issue(
inspection_id = inspection_id, inspection_id = inspection_id,
facility_id = facility_id, facility_id = facility_id,
severity = severity, severity = severity,
description = description, description = description,
photo_path = data.get('photo_path') or None, photo_path = data.get('photo_path') or None,
result_photos = result_photos or None, mobile_photo_paths = mobile_photo_paths or None,
status = 'open', status = 'open',
reported_at = now_eastern(), reported_at = now_eastern(),
reported_by = user.id, reported_by = user.id,
mobile_local_id = mobile_local_id, mobile_local_id = mobile_local_id,
) )
db.session.add(issue) db.session.add(issue)
db.session.flush() # get issue.id db.session.flush() # get issue.id
@@ -352,17 +352,16 @@ def update_issue_status(issue_id):
@jwt_required @jwt_required
def update_issue_photos(issue_id): def update_issue_photos(issue_id):
""" """
Attach additional photos to an existing issue created from the mobile app. Attach additional evidence photos to an issue created from the mobile app.
Called by the iOS app immediately after create_issue when the inspector Called by the iOS app after create_issue when the inspector attached more
attached more than one photo. The photos are already uploaded to the server than one photo. Photos are already uploaded via /api/v1/photos/upload.
via /api/v1/photos/upload; this endpoint stores their paths in result_photos. Stored in mobile_photo_paths so they display under "Photo Evidence" on the
web, not "Resolution Details".
Request JSON Request JSON
------------ ------------
{ { "result_photos": ["uploads/issue_photos/a.jpg", "uploads/issue_photos/b.jpg"] }
"result_photos": ["uploads/issue_photos/a.jpg", "uploads/issue_photos/b.jpg"]
}
Response 200 Response 200
------------ ------------
@@ -376,12 +375,11 @@ def update_issue_photos(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
# Inspectors may only update issues they reported or are assigned to
if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id: if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
return api_error('Access denied', 403) return api_error('Access denied', 403)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
raw = data.get('result_photos') raw = data.get('result_photos')
if not isinstance(raw, list): if not isinstance(raw, list):
return api_error('result_photos must be a list of path strings', 400) return api_error('result_photos must be a list of path strings', 400)
@@ -390,16 +388,15 @@ def update_issue_photos(issue_id):
if not new_photos: if not new_photos:
return api_error('result_photos must contain at least one valid path', 400) return api_error('result_photos must contain at least one valid path', 400)
# Merge with any existing result_photos rather than overwriting, # Merge idempotently with any existing mobile_photo_paths
# so multiple PATCH calls (e.g. retry) are idempotent. existing = issue.mobile_photo_paths or []
existing = issue.result_photos or []
merged = existing + [p for p in new_photos if p not in existing] merged = existing + [p for p in new_photos if p not in existing]
issue.result_photos = merged issue.mobile_photo_paths = merged
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id, log_action(ACTION_UPDATE, 'Issue', issue.id,
f'result_photos updated (+{len(new_photos)} photos)', f'mobile_photo_paths updated (+{len(new_photos)} photos)',
f'source=mobile; updated_by={user.username}') f'source=mobile; updated_by={user.username}')
logger.info('API ISSUES | photos_updated | issue_id=%d | added=%d | user=%s', logger.info('API ISSUES | photos_updated | issue_id=%d | added=%d | user=%s',
+4
View File
@@ -63,6 +63,10 @@ class Issue(db.Model):
resolved_at = db.Column(db.DateTime) resolved_at = db.Column(db.DateTime)
result_notes = db.Column(db.Text) result_notes = db.Column(db.Text)
result_photos = db.Column(db.JSON) # list of relative paths e.g. ["uploads/issue_photos/abc.jpg"] result_photos = db.Column(db.JSON) # list of relative paths e.g. ["uploads/issue_photos/abc.jpg"]
# Extra evidence photos submitted from the iPad at issue-creation time.
# Stored separately from result_photos (resolution photos added via web)
# so they display under "Photo Evidence" rather than "Resolution Details".
mobile_photo_paths = db.Column(db.JSON, nullable=True)
# ── Resolution verification ────────────────────────────────────────── # ── Resolution verification ──────────────────────────────────────────
verified_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) verified_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
+15 -4
View File
@@ -54,12 +54,23 @@
<h6>Description</h6> <h6>Description</h6>
<p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p> <p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p>
{% if issue.photo_path %} {% if issue.photo_path or issue.mobile_photo_paths %}
<hr> <hr>
<h6>Photo Evidence</h6> <h6>Photo Evidence</h6>
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank"> <div class="d-flex flex-wrap gap-2">
<img src="{{ url_for('static', filename=issue.photo_path) }}" class="img-fluid rounded" style="max-height:300px;"> {% if issue.photo_path %}
</a> <a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
<img src="{{ url_for('static', filename=issue.photo_path) }}"
class="img-fluid rounded" style="max-height:200px; max-width:100%;">
</a>
{% endif %}
{% for photo in (issue.mobile_photo_paths or []) %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}"
class="rounded border" style="max-height:200px; max-width:100%; object-fit:cover;">
</a>
{% endfor %}
</div>
{% endif %} {% endif %}
{% if issue.result_notes or issue.result_photos %} {% if issue.result_notes or issue.result_photos %}
+15 -4
View File
@@ -43,12 +43,23 @@
<h6>Description</h6> <h6>Description</h6>
<p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p> <p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p>
{% if issue.photo_path %} {% if issue.photo_path or issue.mobile_photo_paths %}
<hr> <hr>
<h6>Photo Evidence</h6> <h6>Photo Evidence</h6>
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank"> <div class="d-flex flex-wrap gap-2">
<img src="{{ url_for('static', filename=issue.photo_path) }}" class="img-fluid rounded" style="max-height:300px;"> {% if issue.photo_path %}
</a> <a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
<img src="{{ url_for('static', filename=issue.photo_path) }}"
class="img-fluid rounded" style="max-height:300px; max-width:100%;">
</a>
{% endif %}
{% for photo in (issue.mobile_photo_paths or []) %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}"
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
</a>
{% endfor %}
</div>
{% endif %} {% endif %}
{% if issue.result_notes or issue.result_photos %} {% if issue.result_notes or issue.result_photos %}
@@ -0,0 +1,46 @@
"""phase19 — add mobile_photo_paths column to issues table
Background
----------
Issues created on the iPad can have multiple evidence photos. The first photo
is stored in `photo_path` (existing single-string column). Additional photos
were previously stored in `result_photos` (intended for resolution photos),
causing them to appear under "Resolution Details" on the web instead of
"Photo Evidence".
This migration adds `mobile_photo_paths JSON NULL` to store the extra
evidence photos from the iPad separately from resolution photos.
Safe to re-run — uses INFORMATION_SCHEMA existence check.
"""
import sqlalchemy as sa
from alembic import op
revision = 'phase19_issue_mobile_photos'
down_revision = 'phase18_issue_reported_by'
branch_labels = None
depends_on = None
def _column_exists(bind, table, column):
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'issues', 'mobile_photo_paths'):
op.add_column('issues', sa.Column(
'mobile_photo_paths', sa.JSON(), nullable=True
))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'issues', 'mobile_photo_paths'):
op.execute(sa.text("ALTER TABLE issues DROP COLUMN mobile_photo_paths"))