""" 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" } } """ 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//.' is unchanged across backends. 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'), })