""" Multi Image Upload API Views 複数画像一括アップロード機能 """ import os import base64 import uuid import time import logging from datetime import datetime from django.conf import settings from django.core.files.base import ContentFile from django.core.files.storage import default_storage from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from django.db import transaction from PIL import Image import io from .models import UploadedImage, NewEvent2, Entry from .serializers import ( MultiImageUploadSerializer, MultiImageUploadResponseSerializer, UploadedImageSerializer ) logger = logging.getLogger(__name__) @api_view(['POST']) @permission_classes([AllowAny]) def multi_image_upload(request): """ 複数画像一括アップロードAPI POST /api/images/multi-upload Request: { "event_code": "岐阜ロゲイニング2025", "team_name": "チーム名", "cp_number": 1, "images": [ { "file_data": "base64_encoded_image_data", "filename": "checkpoint1_photo1.jpg", "mime_type": "image/jpeg", "file_size": 2048576, "capture_timestamp": "2025-09-15T11:30:00Z" } ], "upload_source": "sharing_intent", "device_platform": "ios" } """ start_time = time.time() try: # リクエストデータ検証 serializer = MultiImageUploadSerializer(data=request.data) if not serializer.is_valid(): return Response({ 'status': 'error', 'message': 'Invalid request data', 'errors': serializer.errors }, status=status.HTTP_400_BAD_REQUEST) validated_data = serializer.validated_data event_code = validated_data['event_code'] team_name = validated_data['team_name'] cp_number = validated_data['cp_number'] images_data = validated_data['images'] upload_source = validated_data.get('upload_source', 'direct') device_platform = validated_data.get('device_platform') # イベントの存在確認 try: event = NewEvent2.objects.get(event_name=event_code) except NewEvent2.DoesNotExist: return Response({ 'status': 'error', 'message': f'イベント "{event_code}" が見つかりません' }, status=status.HTTP_404_NOT_FOUND) # エントリーの存在確認 try: entry = Entry.objects.filter( event=event, team__team_name=team_name ).first() except Entry.DoesNotExist: entry = None uploaded_files = [] failed_files = [] total_upload_size = 0 # トランザクション開始 with transaction.atomic(): for i, image_data in enumerate(images_data): try: uploaded_image = _process_single_image( image_data, event_code, team_name, cp_number, upload_source, device_platform, entry, i ) uploaded_files.append({ 'original_filename': uploaded_image.original_filename, 'server_filename': uploaded_image.server_filename, 'file_url': uploaded_image.file_url, 'file_size': uploaded_image.file_size }) total_upload_size += uploaded_image.file_size except Exception as e: logger.error(f"Failed to process image {i}: {e}") failed_files.append({ 'filename': image_data.get('filename', f'image_{i}'), 'error': str(e) }) # 処理時間計算 processing_time_ms = int((time.time() - start_time) * 1000) # レスポンス作成 response_data = { 'status': 'success' if not failed_files else 'partial_success', 'uploaded_count': len(uploaded_files), 'failed_count': len(failed_files), 'uploaded_files': uploaded_files, 'failed_files': failed_files, 'total_upload_size': total_upload_size, 'processing_time_ms': processing_time_ms } if failed_files: response_data['message'] = f"{len(uploaded_files)}個のファイルがアップロードされ、{len(failed_files)}個が失敗しました" else: response_data['message'] = f"{len(uploaded_files)}個のファイルが正常にアップロードされました" return Response(response_data, status=status.HTTP_200_OK) except Exception as e: logger.error(f"Multi image upload error: {e}") return Response({ 'status': 'error', 'message': 'サーバーエラーが発生しました', 'error_details': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET']) @permission_classes([IsAuthenticated]) def image_list(request): """ アップロード済み画像一覧取得 GET /api/images/list/ Parameters: - entry_id: エントリーID(オプション) - event_code: イベントコード(オプション) - limit: 取得数上限(デフォルト50) - offset: オフセット(デフォルト0) """ try: entry_id = request.GET.get('entry_id') event_code = request.GET.get('event_code') limit = int(request.GET.get('limit', 50)) offset = int(request.GET.get('offset', 0)) # 基本クエリ queryset = UploadedImage.objects.all() # フィルタリング if entry_id: queryset = queryset.filter(entry_id=entry_id) if event_code: queryset = queryset.filter(entry__event_name=event_code) # 並び順と取得数制限 queryset = queryset.order_by('-uploaded_at')[offset:offset+limit] # シリアライズ serializer = UploadedImageSerializer(queryset, many=True) return Response({ 'images': serializer.data, 'count': len(serializer.data), 'limit': limit, 'offset': offset }) except Exception as e: logger.error(f"Image list error: {e}") return Response({ 'error': 'Failed to get image list', 'message': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET', 'DELETE']) @permission_classes([IsAuthenticated]) def image_detail(request, image_id): """ 画像詳細取得・削除 GET /api/images/{image_id}/ - 画像詳細取得 DELETE /api/images/{image_id}/ - 画像削除 """ try: image = UploadedImage.objects.get(id=image_id) if request.method == 'GET': serializer = UploadedImageSerializer(image) return Response(serializer.data) elif request.method == 'DELETE': # ファイル削除 if image.image_file and os.path.exists(image.image_file.path): os.remove(image.image_file.path) if image.thumbnail and os.path.exists(image.thumbnail.path): os.remove(image.thumbnail.path) # データベースレコード削除 image.delete() return Response({ 'message': 'Image deleted successfully' }) except UploadedImage.DoesNotExist: return Response({ 'error': 'Image not found' }, status=status.HTTP_404_NOT_FOUND) except Exception as e: logger.error(f"Image detail error: {e}") return Response({ 'error': 'Internal server error', 'message': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def _process_single_image(image_data, event_code, team_name, cp_number, upload_source, device_platform, entry, index): """単一画像の処理""" # Base64デコード try: if ',' in image_data['file_data']: # data:image/jpeg;base64,... 形式の場合 file_data = image_data['file_data'].split(',')[1] else: file_data = image_data['file_data'] image_binary = base64.b64decode(file_data) except Exception as e: raise ValueError(f"Base64デコードに失敗しました: {e}") # ファイル名生成 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') file_extension = _get_file_extension(image_data['mime_type']) server_filename = f"{event_code}_{team_name}_cp{cp_number}_{timestamp}_{index:03d}{file_extension}" # ディレクトリ作成 upload_dir = f"uploads/{datetime.now().strftime('%Y/%m/%d')}" full_upload_dir = os.path.join(settings.MEDIA_ROOT, upload_dir) os.makedirs(full_upload_dir, exist_ok=True) # ファイル保存 file_path = os.path.join(upload_dir, server_filename) full_file_path = os.path.join(settings.MEDIA_ROOT, file_path) with open(full_file_path, 'wb') as f: f.write(image_binary) # ファイルURL生成 file_url = f"{settings.MEDIA_URL}{file_path}" # HEICからJPEGへの変換(iOS対応) if image_data['mime_type'] == 'image/heic' and device_platform == 'ios': try: file_url, server_filename = _convert_heic_to_jpeg(full_file_path, file_path) except Exception as e: logger.warning(f"HEIC conversion failed: {e}") # サムネイル生成 thumbnail_url = _generate_thumbnail(full_file_path, file_path) # データベース保存 uploaded_image = UploadedImage.objects.create( original_filename=image_data['filename'], server_filename=server_filename, file_url=file_url, file_size=image_data['file_size'], mime_type=image_data['mime_type'], event_code=event_code, team_name=team_name, cp_number=cp_number, upload_source=upload_source, device_platform=device_platform, capture_timestamp=image_data.get('capture_timestamp'), device_info=image_data.get('device_info'), processing_status='processed', thumbnail_url=thumbnail_url, entry=entry ) return uploaded_image def _get_file_extension(mime_type): """MIMEタイプからファイル拡張子を取得""" mime_to_ext = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/heic': '.heic', 'image/webp': '.webp' } return mime_to_ext.get(mime_type, '.jpg') def _convert_heic_to_jpeg(heic_path, original_path): """HEICファイルをJPEGに変換""" try: # PIL-HEICライブラリが必要(要インストール) from PIL import Image # HEICファイルを開いてJPEGで保存 with Image.open(heic_path) as img: jpeg_path = heic_path.replace('.heic', '.jpg') rgb_img = img.convert('RGB') rgb_img.save(jpeg_path, 'JPEG', quality=85) # 元のHEICファイルを削除 os.remove(heic_path) # 新しいファイル情報を返す new_file_path = original_path.replace('.heic', '.jpg') new_file_url = f"{settings.MEDIA_URL}{new_file_path}" new_filename = os.path.basename(new_file_path) return new_file_url, new_filename except ImportError: logger.warning("PIL-HEIC not available, keeping original HEIC file") return f"{settings.MEDIA_URL}{original_path}", os.path.basename(original_path) def _generate_thumbnail(image_path, original_path): """サムネイル画像生成""" try: with Image.open(image_path) as img: # サムネイルサイズ(300x300) img.thumbnail((300, 300), Image.Resampling.LANCZOS) # サムネイルファイル名 path_parts = original_path.split('.') thumbnail_path = f"{'.'.join(path_parts[:-1])}_thumb.{path_parts[-1]}" thumbnail_full_path = os.path.join(settings.MEDIA_ROOT, thumbnail_path) # サムネイル保存 img.save(thumbnail_full_path, quality=75) return f"{settings.MEDIA_URL}{thumbnail_path}" except Exception as e: logger.warning(f"Thumbnail generation failed: {e}") return None @api_view(['GET']) @permission_classes([AllowAny]) def uploaded_images_list(request): """アップロード済み画像一覧取得""" event_code = request.GET.get('event_code') team_name = request.GET.get('team_name') cp_number = request.GET.get('cp_number') queryset = UploadedImage.objects.all().order_by('-upload_timestamp') # フィルタリング if event_code: queryset = queryset.filter(event_code=event_code) if team_name: queryset = queryset.filter(team_name=team_name) if cp_number: queryset = queryset.filter(cp_number=cp_number) # ページネーション(50件ずつ) page = int(request.GET.get('page', 1)) page_size = 50 start_index = (page - 1) * page_size end_index = start_index + page_size total_count = queryset.count() images = queryset[start_index:end_index] serializer = UploadedImageSerializer(images, many=True) return Response({ 'images': serializer.data, 'pagination': { 'total_count': total_count, 'page': page, 'page_size': page_size, 'has_next': end_index < total_count, 'has_previous': page > 1 } })