import os import zipfile import tempfile import traceback import time import requests from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.http import JsonResponse, HttpResponse, HttpResponseForbidden, HttpResponseBadRequest from django.utils.translation import gettext as _ from django.contrib import messages from django.core.files.base import ContentFile from django.conf import settings from django.core import signing from common.models import UserConfig from .models import PanoramaxUpload from .utils import ( parse_gpx, extract_frames_from_video, write_gps_exif, interpolate_gpx, calculate_bearing, calculate_stable_heading, call_panoramax_api_create_set, call_panoramax_api_upload_file, call_panoramax_api_complete_set ) from PIL import Image # Shared thread pool executor with 2 workers to serialize heavy video/image processing tasks _upload_executor = ThreadPoolExecutor(max_workers=2) @login_required def index(request): """ Panoramax 360 photo viewer page. """ user_config = get_object_or_404(UserConfig, user=request.user) # Check if user has access to the panoramax view if not user_config.can_access_view('panoramax'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) # Self-healing: if some completed uploads don't have associated collections stored, # fetch them from Panoramax API and save them. unfetched_uploads = PanoramaxUpload.objects.filter(status='completed') unfetched_uploads = [up for up in unfetched_uploads if not up.associated_collections] headers = {} if settings.PANORAMAX_API_KEY: headers['Authorization'] = f'Bearer {settings.PANORAMAX_API_KEY}' if unfetched_uploads: for up in unfetched_uploads: if up.upload_set_id: try: url = f"{settings.PANORAMAX_API_URL.rstrip('/')}/upload_sets/{up.upload_set_id}" r = requests.get(url, headers=headers, timeout=2.0) if r.status_code == 200: data = r.json() collections = data.get('associated_collections', []) if collections: associated = [] for c in collections: date_val = None try: date_str = c.get('extent', {}).get('temporal', {}).get('interval', [[None]])[0][0] if date_str: date_val = date_str.split('T')[0] except Exception: pass associated.append({ 'id': c['id'], 'date': date_val }) up.associated_collections = associated up.save() except Exception as e: print(f"Error self-healing upload {up.id}: {e}") # Convert legacy list of strings to list of dicts with dates all_completed = PanoramaxUpload.objects.filter(status='completed') for up in all_completed: changed = False enriched = [] for item in up.associated_collections: if isinstance(item, str): date_val = "2026-06-15" try: url = f"{settings.PANORAMAX_API_URL.rstrip('/')}/collections/{item}" r = requests.get(url, headers=headers, timeout=2.0) if r.status_code == 200: data = r.json() date_str = data.get('extent', {}).get('temporal', {}).get('interval', [[None]])[0][0] if date_str: date_val = date_str.split('T')[0] except Exception: pass enriched.append({ 'id': item, 'date': date_val }) changed = True elif isinstance(item, dict): if not item.get('date'): date_val = "2026-06-15" try: url = f"{settings.PANORAMAX_API_URL.rstrip('/')}/collections/{item.get('id')}" r = requests.get(url, headers=headers, timeout=2.0) if r.status_code == 200: data = r.json() date_str = data.get('extent', {}).get('temporal', {}).get('interval', [[None]])[0][0] if date_str: date_val = date_str.split('T')[0] except Exception: pass item['date'] = date_val changed = True enriched.append(item) if changed: up.associated_collections = enriched up.save() # Query users who have completed uploads from django.contrib.auth.models import User from django.db.models import Count import json upload_users = User.objects.filter( panoramaxupload__status='completed' ).annotate( upload_count=Count('panoramaxupload') ).order_by('username') # Construct the JSON mapping of username -> sequence_ids and flat list of all sequences user_sequences = {} all_sequences = [] for u in upload_users: seqs = [] uploads = PanoramaxUpload.objects.filter(user=u, status='completed') for up in uploads: if up.associated_collections: for item in up.associated_collections: if isinstance(item, dict): seq_id = item.get('id') seq_date = item.get('date') or up.created_at.strftime('%Y-%m-%d') else: seq_id = item seq_date = up.created_at.strftime('%Y-%m-%d') if seq_id: seqs.append(seq_id) all_sequences.append({ 'id': seq_id, 'date': seq_date, 'user': u.username }) user_sequences[u.username] = seqs from django.core import signing image_token = signing.dumps({"user_id": request.user.id}, salt="panoramax-images") context = { 'user_config': user_config, 'panoramax_api_url': settings.PANORAMAX_API_URL if hasattr(settings, 'PANORAMAX_API_URL') else '', 'image_token': image_token, 'upload_users': upload_users, 'user_sequences_json': json.dumps(user_sequences), 'all_sequences_json': json.dumps(all_sequences), } return render(request, "panoramax/viewer.html", context) @login_required def upload(request): """ Shows list of uploads and handles photo/video uploads. """ user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('panoramax') or not user_config.panoramax_can_upload: raise PermissionDenied(_("Vous n'avez pas l'autorisation d'uploader des photos.")) if request.method == 'POST': title = request.POST.get('title') if not title: title = f"Upload du {datetime.now().strftime('%d/%m/%Y %H:%M')}" upload_type = request.POST.get('upload_type') gpx_file = request.FILES.get('gpx_file') camera_offset_val = request.POST.get('camera_offset', '0') try: camera_offset = int(camera_offset_val) except ValueError: camera_offset = 0 upload_task = PanoramaxUpload( user=request.user, title=title, upload_type=upload_type, camera_offset=camera_offset, status='pending', progress=0 ) if gpx_file: upload_task.gpx_file = gpx_file uploaded_files = request.FILES.getlist('uploaded_file') if not uploaded_files: messages.error(request, _("Veuillez sélectionner des fichiers à uploader.")) return redirect('panoramax:upload') # Handle file storage based on upload type if upload_type in ['video', 'zip']: # Single file expected upload_task.uploaded_file = uploaded_files[0] else: # Multiple files: files selection or folder selection # We compress multiple files into a single zip file on the fly to avoid loose files in storage temp_zip_file = tempfile.NamedTemporaryFile(suffix='.zip', delete=False) try: with zipfile.ZipFile(temp_zip_file.name, 'w', zipfile.ZIP_DEFLATED) as zip_ref: for idx, file in enumerate(uploaded_files): # Ensure only image files are zipped if file.name.lower().endswith(('.jpg', '.jpeg')): zip_ref.writestr(file.name, file.read()) # Reopen file and save as FileField content temp_zip_file.seek(0) upload_task.uploaded_file.save(f"photos_{int(time.time())}.zip", ContentFile(temp_zip_file.read()), save=False) finally: temp_zip_file.close() try: os.unlink(temp_zip_file.name) except OSError: pass upload_task.save() # Submit task to the single worker thread queue _upload_executor.submit(process_upload_task, upload_task.id) messages.success(request, _("Votre demande de téléversement a été enregistrée. Le traitement est lancé en arrière-plan.")) return redirect('panoramax:upload') # GET: display history and form uploads = PanoramaxUpload.objects.filter(user=request.user).order_by('-created_at') context = { 'user_config': user_config, 'uploads': uploads } return render(request, "panoramax/upload.html", context) @login_required def upload_status(request, upload_id): """ JSON API view returning the status and progress of a task. """ user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('panoramax') or not user_config.panoramax_can_upload: return JsonResponse({'error': 'Unauthorized'}, status=403) upload_task = get_object_or_404(PanoramaxUpload, id=upload_id, user=request.user) panoramax_status = None if upload_task.status == 'completed' and upload_task.upload_set_id: try: url = f"{settings.PANORAMAX_API_URL}/upload_sets/{upload_task.upload_set_id}" headers = {} if settings.PANORAMAX_API_KEY: headers['Authorization'] = f'Bearer {settings.PANORAMAX_API_KEY}' r = requests.get(url, headers=headers, timeout=1.5) if r.status_code == 200: data = r.json() is_ready = data.get('ready', False) # Extract associated collections collections = data.get('associated_collections', []) if collections and not upload_task.associated_collections: upload_task.associated_collections = [c['id'] for c in collections] upload_task.save() status = data.get('items_status', {}) prepared = status.get('prepared', 0) preparing = status.get('preparing', 0) not_processed = status.get('not_processed', 0) broken = status.get('broken', 0) rejected = status.get('rejected', 0) total = prepared + preparing + not_processed + broken + rejected pct = int((prepared / total * 100)) if total > 0 else 0 panoramax_status = { 'ready': is_ready, 'prepared': prepared, 'preparing': preparing, 'not_processed': not_processed, 'broken': broken, 'rejected': rejected, 'total': total, 'pct': pct } except Exception as e: print(f"Error checking external Panoramax status: {e}") return JsonResponse({ 'status': upload_task.status, 'status_display': upload_task.get_status_display(), 'progress': upload_task.progress, 'error_message': upload_task.error_message or '', 'upload_set_id': upload_task.upload_set_id or '', 'panoramax_status': panoramax_status }) def process_upload_task(upload_id): """ Background worker function running in a separate thread. Parses files, processes EXIF geo-tagging and uploads to Panoramax. """ task = None try: task = PanoramaxUpload.objects.get(id=upload_id) task.status = 'processing' task.progress = 5 task.save() # Step 1: Initialize temporary directory with tempfile.TemporaryDirectory() as temp_dir: image_paths = [] # Step 2: Parse GPS track points gps_points = [] if task.gpx_file: gps_points = parse_gpx(task.gpx_file.path) elif task.upload_type == 'video': # Attempt to extract GPS track from the video itself (e.g. GoPro GPMD) from .utils import extract_gpmf_gps gps_points = extract_gpmf_gps(task.uploaded_file.path) # Step 3: Process inputs based on type if task.upload_type == 'video': # Video processing if not gps_points: raise ValueError(_("Aucune donnée GPS n'a été trouvée dans la vidéo et aucun fichier GPX n'a été fourni.")) def progress_cb(pct): task.progress = int(5 + pct * 0.7) # Map 0-80% of utils progress to 5-61% of total task task.save() image_paths = extract_frames_from_video( task.uploaded_file.path, gps_points, temp_dir, camera_offset=task.camera_offset, progress_callback=progress_cb ) else: # Zip/photos extraction task.progress = 10 task.save() with zipfile.ZipFile(task.uploaded_file.path, 'r') as zip_ref: # Extract zip file zip_ref.extractall(temp_dir) # Scan directory for images raw_paths = [] for root, subdirs, files in os.walk(temp_dir): for file in files: if file.lower().endswith(('.jpg', '.jpeg')): raw_paths.append(os.path.join(root, file)) total_images = len(raw_paths) if total_images == 0: raise ValueError(_("Aucune image JPEG n'a été trouvée dans le fichier d'upload.")) # Geotag images if GPX is provided photo_metadata = [] for idx, path in enumerate(raw_paths): photo_time = None try: img = Image.open(path) exif = img.getexif() time_str = exif.get(36867) # DateTimeOriginal if time_str: try: photo_time = datetime.strptime(time_str, "%Y:%m:%d %H:%M:%S") except ValueError: pass except Exception: pass if not photo_time: mtime = os.path.getmtime(path) photo_time = datetime.fromtimestamp(mtime) pos = None if gps_points: pos = interpolate_gpx(gps_points, photo_time) photo_metadata.append({ 'path': path, 'time': photo_time, 'pos': pos }) # Sort photos chronologically by capture time to calculate heading along the sequence photo_metadata.sort(key=lambda x: x['time'] or datetime.min) total_offset = task.camera_offset num_photos = len(photo_metadata) for idx, item in enumerate(photo_metadata): path = item['path'] pos = item['pos'] if pos: if num_photos > 1: if idx < num_photos - 1: next_pos = photo_metadata[idx + 1]['pos'] if next_pos: bearing = calculate_bearing(pos['lat'], pos['lon'], next_pos['lat'], next_pos['lon']) else: bearing = 0.0 else: prev_pos = photo_metadata[idx - 1]['pos'] if prev_pos: bearing = calculate_bearing(prev_pos['lat'], prev_pos['lon'], pos['lat'], pos['lon']) else: bearing = 0.0 heading = (bearing + total_offset) % 360 else: heading = (0.0 + total_offset) % 360 write_gps_exif(path, pos['lat'], pos['lon'], pos['ele'], pos['time'], heading=heading) image_paths.append(path) # Update progress during photo preprocessing (reaches up to 50%) task.progress = int(10 + (idx / num_photos) * 40) task.save() time.sleep(0.01) # Yield CPU control slightly # Step 4: Upload to Panoramax task.progress = 60 task.save() if not image_paths: raise ValueError(_("Aucune image valide n'a été générée pour l'upload.")) # Create UploadSet in Panoramax API upload_set_id = call_panoramax_api_create_set(task.title, len(image_paths), creator=task.user.username) task.upload_set_id = upload_set_id task.progress = 70 task.save() # Upload files for idx, path in enumerate(image_paths): call_panoramax_api_upload_file(upload_set_id, path) # Update progress during upload phase (70% -> 95%) task.progress = int(70 + (idx / len(image_paths)) * 25) task.save() # Voluntary yield to avoid network card & server saturation time.sleep(0.02) # Mark upload set completed call_panoramax_api_complete_set(upload_set_id) task.progress = 100 task.status = 'completed' task.save() except Exception as e: traceback.print_exc() if task: task.status = 'failed' task.error_message = str(e) task.save() finally: # Nettoyage des fichiers média volumineux une fois l'importation terminée if task: try: task.refresh_from_db() if task.uploaded_file: task.uploaded_file.delete(save=False) if task.gpx_file: task.gpx_file.delete(save=False) task.save() except Exception as cleanup_err: print(f"Erreur lors du nettoyage des fichiers panoramax: {cleanup_err}") traceback.print_exc() from django.core.signing import BadSignature, SignatureExpired from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden, Http404 import boto3 @login_required def serve_panoramax_picture(request, picture_id, path): """ Validates a 30-minute signed token and redirects the browser directly to a temporary presigned S3 URL. - Method 1: If PANORAMAX_AWS_S3_BUCKET is configured in settings, constructs the S3 key directly (fastest, zero Panoramax query overhead). - Method 2: Otherwise, queries the Panoramax API to inspect if it redirects to S3, and parses the S3 URL dynamically. - Method 3: Falls back to streaming the image bytes directly if no S3 redirection or credentials are configured. """ token = request.GET.get('token') if not token: return HttpResponseForbidden("Missing authorization token") try: # Validate the token (max age = 1800 seconds / 30 minutes) signing.loads(token, salt="panoramax-images", max_age=1800) except (BadSignature, SignatureExpired): return HttpResponseForbidden("Expired or invalid authorization token") aws_key = getattr(settings, 'PANORAMAX_AWS_ACCESS_KEY_ID', None) aws_secret = getattr(settings, 'PANORAMAX_AWS_SECRET_ACCESS_KEY', None) bucket = getattr(settings, 'PANORAMAX_AWS_S3_BUCKET', None) prefix = getattr(settings, 'PANORAMAX_AWS_S3_PREFIX', 'photos').strip('/') # Method 1: Direct construction if bucket name is set in settings (Option B direct) if aws_key and aws_secret and bucket: uuid_str = str(picture_id).replace("-", "").lower() prefix1 = uuid_str[0:2] prefix2 = uuid_str[2:4] prefix3 = uuid_str[4:6] prefix4 = uuid_str[6:8] rest_uuid = str(picture_id)[9:] if path == 'hd.jpg': s3_key = f"{prefix}/permanent/{prefix1}/{prefix2}/{prefix3}/{prefix4}/{rest_uuid}.jpg" elif path in ['sd.jpg', 'thumb.jpg']: s3_key = f"{prefix}/derivates/{prefix1}/{prefix2}/{prefix3}/{prefix4}/{rest_uuid}/{path}" elif path.startswith('tiled/'): tiles_path = path.replace('tiled/', 'tiles/') s3_key = f"{prefix}/derivates/{prefix1}/{prefix2}/{prefix3}/{prefix4}/{rest_uuid}/{tiles_path}" else: s3_key = f"{prefix}/derivates/{prefix1}/{prefix2}/{prefix3}/{prefix4}/{rest_uuid}/{path}" endpoint_url = getattr(settings, 'PANORAMAX_AWS_S3_ENDPOINT_URL', 'https://s3.rbx.io.cloud.ovh.net') try: s3_client = boto3.client( 's3', aws_access_key_id=aws_key, aws_secret_access_key=aws_secret, endpoint_url=endpoint_url ) presigned_url = s3_client.generate_presigned_url( 'get_object', Params={'Bucket': bucket, 'Key': s3_key}, ExpiresIn=1800 ) return HttpResponseRedirect(presigned_url) except Exception as e: import traceback traceback.print_exc() # If direct construction signing fails, fall through to query method # Method 2: Query Panoramax API to inspect redirect behavior url = f"{settings.PANORAMAX_API_URL.rstrip('/')}/pictures/{picture_id}/{path}" headers = {} if settings.PANORAMAX_API_KEY: headers['Authorization'] = f'Bearer {settings.PANORAMAX_API_KEY}' try: # Perform a request on the Panoramax API without following redirects. # This is extremely fast because it stops immediately upon receiving the redirect headers. r = requests.get(url, headers=headers, allow_redirects=False, timeout=5) # If Panoramax redirects to an external S3 storage and we have AWS credentials to sign it: if r.status_code in [301, 302, 303, 307, 308] and 'Location' in r.headers and aws_key and aws_secret: s3_redirect_url = r.headers['Location'] # Parse the direct S3 URL to extract bucket, endpoint, and key from urllib.parse import urlparse parsed_url = urlparse(s3_redirect_url) # virtual-hosted style (e.g. bucket.s3.region.domain) netloc_parts = parsed_url.netloc.split('.') parsed_bucket = netloc_parts[0] endpoint_host = ".".join(netloc_parts[1:]) endpoint_url = f"{parsed_url.scheme}://{endpoint_host}" # Use environment override if defined if getattr(settings, 'PANORAMAX_AWS_S3_ENDPOINT_URL', None): endpoint_url = settings.PANORAMAX_AWS_S3_ENDPOINT_URL s3_key = parsed_url.path.lstrip('/') # Generate the S3 Presigned URL (valid for 30 minutes) s3_client = boto3.client( 's3', aws_access_key_id=aws_key, aws_secret_access_key=aws_secret, endpoint_url=endpoint_url ) presigned_url = s3_client.generate_presigned_url( 'get_object', Params={'Bucket': parsed_bucket, 'Key': s3_key}, ExpiresIn=1800 ) return HttpResponseRedirect(presigned_url) else: # Method 3: Fallback to streaming the image bytes directly r_get = requests.get(url, headers=headers, stream=True, timeout=15) if r_get.status_code == 200: from django.http import StreamingHttpResponse response = StreamingHttpResponse( r_get.iter_content(chunk_size=8192), content_type=r_get.headers.get('Content-Type', 'image/jpeg') ) if 'Cache-Control' in r_get.headers: response['Cache-Control'] = r_get.headers['Cache-Control'] return response else: raise Http404("Image not found in Panoramax") except Exception as e: import traceback traceback.print_exc() raise Http404(f"Error fetching picture: {e}") @login_required def delete_collection(request, collection_id): """ Deletes an entire collection (sequence) from Panoramax if the user owns it or is admin. """ if request.method != 'POST': return HttpResponseBadRequest("Only POST method is allowed") user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('panoramax'): return HttpResponseForbidden("Access denied to Panoramax") collection_id_str = str(collection_id) # Check authorization: user must own the sequence OR be staff/superuser is_authorized = request.user.is_staff or request.user.is_superuser if not is_authorized: user_uploads = PanoramaxUpload.objects.filter(user=request.user, status='completed') for up in user_uploads: if up.associated_collections: for item in up.associated_collections: item_id = item.get('id') if isinstance(item, dict) else item if item_id == collection_id_str: is_authorized = True break if is_authorized: break if not is_authorized: return HttpResponseForbidden("You are not authorized to delete this collection") # Send DELETE request to Panoramax API url = f"{settings.PANORAMAX_API_URL.rstrip('/')}/collections/{collection_id_str}" headers = {} if settings.PANORAMAX_API_KEY: headers['Authorization'] = f'Bearer {settings.PANORAMAX_API_KEY}' try: response = requests.delete(url, headers=headers, timeout=15) if response.status_code not in [200, 204, 404]: return HttpResponse( f"API error: {response.text}", status=response.status_code ) # Clean up database: remove from associated_collections in uploader tasks for up in PanoramaxUpload.objects.all(): if up.associated_collections: new_cols = [] changed = False for item in up.associated_collections: item_id = item.get('id') if isinstance(item, dict) else item if item_id == collection_id_str: changed = True else: new_cols.append(item) if changed: up.associated_collections = new_cols up.save() return HttpResponse("Collection deleted successfully") except Exception as e: traceback.print_exc() return HttpResponse(f"Error calling Panoramax API: {e}", status=500) @login_required def delete_picture(request, collection_id, picture_id): """ Deletes a single picture from a collection in Panoramax if the user owns the collection or is admin. """ if request.method != 'POST': return HttpResponseBadRequest("Only POST method is allowed") user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('panoramax'): return HttpResponseForbidden("Access denied to Panoramax") collection_id_str = str(collection_id) picture_id_str = str(picture_id) # Check authorization: user must own the sequence OR be staff/superuser is_authorized = request.user.is_staff or request.user.is_superuser if not is_authorized: user_uploads = PanoramaxUpload.objects.filter(user=request.user, status='completed') for up in user_uploads: if up.associated_collections: for item in up.associated_collections: item_id = item.get('id') if isinstance(item, dict) else item if item_id == collection_id_str: is_authorized = True break if is_authorized: break if not is_authorized: return HttpResponseForbidden("You are not authorized to delete pictures from this collection") # Send DELETE request to Panoramax API url = f"{settings.PANORAMAX_API_URL.rstrip('/')}/collections/{collection_id_str}/items/{picture_id_str}" headers = {} if settings.PANORAMAX_API_KEY: headers['Authorization'] = f'Bearer {settings.PANORAMAX_API_KEY}' try: response = requests.delete(url, headers=headers, timeout=15) if response.status_code not in [200, 204, 404]: return HttpResponse( f"API error: {response.text}", status=response.status_code ) return HttpResponse("Picture deleted successfully") except Exception as e: traceback.print_exc() return HttpResponse(f"Error calling Panoramax API: {e}", status=500)