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
+21 -24
View File
@@ -58,9 +58,11 @@ def _issue_payload(issue):
'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 is the primary issue photo; result_photos are resolution photos.
# Both are relative paths from the server static root.
# 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 [],
}
@@ -204,14 +206,12 @@ def create_issue():
inspection = None
# ── Create ────────────────────────────────────────────────────────────
# result_photos may be supplied by the mobile app as a list of paths that
# were already uploaded via /api/v1/photos/upload (one call per photo).
# Validate that it is a list of non-empty strings; silently drop bad entries.
raw_result_photos = data.get('result_photos')
if isinstance(raw_result_photos, list):
result_photos = [p for p in raw_result_photos if isinstance(p, str) and p.strip()]
else:
result_photos = []
# 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,
@@ -219,7 +219,7 @@ def create_issue():
severity = severity,
description = description,
photo_path = data.get('photo_path') or None,
result_photos = result_photos or None,
mobile_photo_paths = mobile_photo_paths or None,
status = 'open',
reported_at = now_eastern(),
reported_by = user.id,
@@ -352,17 +352,16 @@ def update_issue_status(issue_id):
@jwt_required
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
attached more than one photo. The photos are already uploaded to the server
via /api/v1/photos/upload; this endpoint stores their paths in result_photos.
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"]
}
{ "result_photos": ["uploads/issue_photos/a.jpg", "uploads/issue_photos/b.jpg"] }
Response 200
------------
@@ -376,7 +375,6 @@ def update_issue_photos(issue_id):
if issue is None:
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:
return api_error('Access denied', 403)
@@ -390,16 +388,15 @@ def update_issue_photos(issue_id):
if not new_photos:
return api_error('result_photos must contain at least one valid path', 400)
# Merge with any existing result_photos rather than overwriting,
# so multiple PATCH calls (e.g. retry) are idempotent.
existing = issue.result_photos or []
# 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.result_photos = merged
issue.mobile_photo_paths = merged
db.session.commit()
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}')
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)
result_notes = db.Column(db.Text)
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 ──────────────────────────────────────────
verified_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
+13 -2
View File
@@ -54,13 +54,24 @@
<h6>Description</h6>
<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>
<h6>Photo Evidence</h6>
<div class="d-flex flex-wrap gap-2">
{% if issue.photo_path %}
<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;">
<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 %}
{% if issue.result_notes or issue.result_photos %}
<hr>
+13 -2
View File
@@ -43,13 +43,24 @@
<h6>Description</h6>
<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>
<h6>Photo Evidence</h6>
<div class="d-flex flex-wrap gap-2">
{% if issue.photo_path %}
<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;">
<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 %}
{% if issue.result_notes or issue.result_photos %}
<hr>
@@ -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"))