158 lines
6.2 KiB
Python
158 lines
6.2 KiB
Python
"""
|
|
app/api/photos.py
|
|
-----------------
|
|
Mobile API endpoint for uploading photos from the iPad app.
|
|
|
|
POST /api/v1/photos/upload
|
|
Accepts a multipart/form-data upload.
|
|
Saves the file to the server's upload folder.
|
|
Returns the relative server path used in subsequent inspection/issue submissions.
|
|
|
|
The iPad app uploads photos BEFORE submitting the inspection or issue,
|
|
then includes the returned server_path in the inspection/issue payload.
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
import logging
|
|
|
|
from flask import Blueprint, request, g, current_app
|
|
from app.api.errors import api_ok, api_error
|
|
from app.api.decorators import jwt_required
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
bp = Blueprint('api_photos', __name__)
|
|
|
|
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
|
'project_manager', 'auditor'}
|
|
|
|
|
|
def _allowed_file(filename: str) -> bool:
|
|
return (
|
|
'.' in filename
|
|
and filename.rsplit('.', 1)[-1].lower() in _ALLOWED_EXTENSIONS
|
|
)
|
|
|
|
|
|
# ── Upload Photo ──────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/photos/upload', methods=['POST'])
|
|
@jwt_required
|
|
def upload_photo():
|
|
"""
|
|
Upload a photo from the iPad app.
|
|
|
|
Multipart form fields
|
|
---------------------
|
|
file — binary image data (jpg / png / gif)
|
|
entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
|
|
captured_at — OPTIONAL ISO-8601 capture time (e.g. 2026-07-20T09:14:22-04:00)
|
|
latitude — OPTIONAL decimal degrees at capture
|
|
longitude — OPTIONAL decimal degrees at capture
|
|
|
|
A capture-time + geo overlay is burned into the image before it is stored
|
|
(see app/utils/photo_stamp.py). Metadata is taken from the client fields
|
|
above, falling back to the image's EXIF, then to server receipt time.
|
|
Sending captured_at/latitude/longitude is strongly preferred for an
|
|
offline-first client: a photo taken at 09:14 but synced at 16:00 would
|
|
otherwise be stamped with the sync time.
|
|
|
|
Response 200
|
|
------------
|
|
{
|
|
"ok": true,
|
|
"data": {
|
|
"server_path": "uploads/inspection_photos/abc123.jpg",
|
|
"stamped": true,
|
|
"captured_at": "2026-07-20T09:14:22",
|
|
"capture_source": "client"
|
|
}
|
|
}
|
|
|
|
The three stamp keys are additive — an iPad build that predates them decodes
|
|
explicit CodingKeys and ignores what it doesn't know.
|
|
"""
|
|
user = g.api_user
|
|
|
|
if user.role not in _ALLOWED_ROLES:
|
|
return api_error('Access denied', 403)
|
|
|
|
# Every rejection below is logged at WARNING with the user. Only SUCCESSES
|
|
# were logged before, so when an inspector's photos failed repeatedly there
|
|
# was nothing server-side to explain why — and a photo that exhausts its
|
|
# upload attempts costs the inspection its evidence (see the iPad's
|
|
# PendingPhoto.lastUploadError for the device half of this).
|
|
if 'file' not in request.files:
|
|
logger.warning('API PHOTOS | rejected | reason=no_file_part | user=%s',
|
|
user.username)
|
|
return api_error('No file provided', 400)
|
|
|
|
file_obj = request.files['file']
|
|
entity_type = request.form.get('entity_type', 'inspection')
|
|
|
|
if not file_obj or not file_obj.filename:
|
|
logger.warning('API PHOTOS | rejected | reason=empty_file | user=%s',
|
|
user.username)
|
|
return api_error('Empty file', 400)
|
|
|
|
if not _allowed_file(file_obj.filename):
|
|
logger.warning('API PHOTOS | rejected | reason=bad_extension | file=%r | user=%s',
|
|
file_obj.filename, user.username)
|
|
return api_error(
|
|
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
|
|
400
|
|
)
|
|
|
|
# Determine destination subfolder
|
|
if entity_type == 'issue':
|
|
subfolder = 'issue_photos'
|
|
elif entity_type == 'issue_result':
|
|
subfolder = 'issue_result_photos'
|
|
else:
|
|
subfolder = 'inspection_photos'
|
|
|
|
# Burn the capture-time + geo overlay before the bytes are ever stored, so
|
|
# exactly one (already-stamped) object is written and nothing has to be
|
|
# read back out of R2. Any stamping failure returns the original bytes.
|
|
meta = {'stamped': False, 'captured_at': None, 'source': None}
|
|
if current_app.config.get('PHOTO_STAMP_ENABLED', True):
|
|
from app.utils.photo_stamp import stamp_file_storage
|
|
file_obj, meta = stamp_file_storage(
|
|
file_obj,
|
|
captured_at = request.form.get('captured_at'),
|
|
latitude = request.form.get('latitude'),
|
|
longitude = request.form.get('longitude'),
|
|
)
|
|
|
|
# Write via the active storage backend (local disk or R2). Key format
|
|
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends. The
|
|
# stamped FileStorage keeps the original filename, so the derived key — and
|
|
# the tenant prefix applied inside S3Backend — are unaffected.
|
|
from app.utils import storage
|
|
try:
|
|
server_path = storage.save(file_obj, subfolder)
|
|
except Exception as exc:
|
|
# A storage failure is the most likely cause of a REPEATED upload
|
|
# failure (disk full, R2 credentials/quota). Name it explicitly —
|
|
# otherwise it surfaces only as a generic 500 with no link to the
|
|
# inspector who is losing evidence photos.
|
|
logger.error('API PHOTOS | STORAGE WRITE FAILED | user=%s | entity_type=%s | '
|
|
'subfolder=%s | error=%s', user.username, entity_type, subfolder, exc)
|
|
return api_error('Could not store the photo. Please retry.', 500)
|
|
|
|
logger.info(
|
|
'API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s | '
|
|
'stamped=%s | capture_source=%s',
|
|
entity_type, server_path, user.username,
|
|
meta.get('stamped'), meta.get('source'),
|
|
)
|
|
|
|
captured_at = meta.get('captured_at')
|
|
return api_ok({
|
|
'server_path': server_path,
|
|
'stamped': bool(meta.get('stamped')),
|
|
'captured_at': captured_at.isoformat() if captured_at else None,
|
|
'capture_source': meta.get('source'),
|
|
}) |