05/21 Fix issue with multiple photos 2
This commit is contained in:
+21
-24
@@ -58,9 +58,11 @@ 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).
|
||||||
|
# result_photos: resolution photos added via the web update form.
|
||||||
'photo_path': issue.photo_path or None,
|
'photo_path': issue.photo_path or None,
|
||||||
|
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
||||||
'result_photos': issue.result_photos or [],
|
'result_photos': issue.result_photos or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,14 +206,12 @@ 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,
|
||||||
@@ -219,7 +219,7 @@ def create_issue():
|
|||||||
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,
|
||||||
@@ -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,7 +375,6 @@ 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)
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -54,13 +54,24 @@
|
|||||||
<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>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% if issue.photo_path %}
|
||||||
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if issue.result_notes or issue.result_photos %}
|
||||||
<hr>
|
<hr>
|
||||||
|
|||||||
@@ -43,13 +43,24 @@
|
|||||||
<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>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% if issue.photo_path %}
|
||||||
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if issue.result_notes or issue.result_photos %}
|
||||||
<hr>
|
<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"))
|
||||||
Reference in New Issue
Block a user