""" 写真一括アップロード・通過履歴校正API スマホアルバムから複数の写真をアップロードし、EXIF情報から自動的に通過履歴を生成・校正する """ import logging import uuid import os from datetime import datetime from django.http import JsonResponse from django.utils import timezone from django.db import transaction from django.core.files.storage import default_storage from django.conf import settings from rest_framework.decorators import api_view, permission_classes, parser_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from rest_framework.parsers import MultiPartParser, FormParser from knox.auth import TokenAuthentication from ..models import NewEvent2, Entry, GpsLog, Location2025 # ログ設定 logger = logging.getLogger(__name__) @api_view(['POST', 'GET']) @permission_classes([IsAuthenticated]) @parser_classes([MultiPartParser, FormParser]) def bulk_upload_checkin_photos(request): """ スマホアルバムから複数の写真を一括アップロードし、EXIF情報から通過履歴を校正する パラメータ: - event_code: イベントコード (必須) - zekken_number: ゼッケン番号 (必須) - photos: アップロードする写真ファイルのリスト (必須) - auto_process: 自動処理を行うかどうか (デフォルト: true) """ # リクエストID生成(デバッグ用) request_id = str(uuid.uuid4())[:8] client_ip = request.META.get('HTTP_X_FORWARDED_FOR', request.META.get('REMOTE_ADDR', 'Unknown')) logger.info(f"[BULK_UPLOAD] 🎯 API ACCESS CONFIRMED - bulk_upload_checkin_photos called successfully - ID: {request_id}, Method: {request.method}, User: {request.user.email if request.user.is_authenticated else 'Anonymous'}, Client IP: {client_ip}") logger.info(f"[BULK_UPLOAD] 🔍 Request details - Content-Type: {request.content_type}, POST data keys: {list(request.POST.keys())}, FILES count: {len(request.FILES)}") # GETリクエストの場合は、APIが動作していることを確認するための情報を返す if request.method == 'GET': logger.info(f"[BULK_UPLOAD] 📋 GET request received - returning API status") return Response({ "status": "ACTIVE", "message": "一括写真アップロードAPIが動作中です", "endpoint": "/api/bulk_upload_checkin_photos/", "method": "POST", "required_params": ["event_code", "zekken_number", "photos"], "optional_params": ["auto_process"], "user": request.user.email if request.user.is_authenticated else 'Not authenticated' }, status=status.HTTP_200_OK) try: # リクエストデータの取得 event_code = request.POST.get('event_code') zekken_number = request.POST.get('zekken_number') auto_process = request.POST.get('auto_process', 'true').lower() == 'true' # アップロードされた写真ファイルの取得 uploaded_files = request.FILES.getlist('photos') logger.info(f"[BULK_UPLOAD] 📝 Request data - ID: {request_id}, event_code: '{event_code}', zekken_number: '{zekken_number}', files_count: {len(uploaded_files)}, auto_process: {auto_process}") # 必須パラメータの検証 if not all([event_code, zekken_number]): logger.warning(f"[BULK_UPLOAD] ❌ Missing required parameters - ID: {request_id}") return Response({ "status": "ERROR", "message": "イベントコードとゼッケン番号が必要です" }, status=status.HTTP_400_BAD_REQUEST) if not uploaded_files: logger.warning(f"[BULK_UPLOAD] ❌ No files uploaded - ID: {request_id}") return Response({ "status": "ERROR", "message": "アップロードする写真が必要です" }, status=status.HTTP_400_BAD_REQUEST) # ファイル数制限の確認 max_files = getattr(settings, 'BULK_UPLOAD_MAX_FILES', 50) if len(uploaded_files) > max_files: logger.warning(f"[BULK_UPLOAD] ❌ Too many files - ID: {request_id}, count: {len(uploaded_files)}, max: {max_files}") return Response({ "status": "ERROR", "message": f"一度にアップロードできる写真は最大{max_files}枚です" }, status=status.HTTP_400_BAD_REQUEST) # イベントの存在確認 event = NewEvent2.objects.filter(event_name=event_code).first() if not event: logger.warning(f"[BULK_UPLOAD] ❌ Event not found - ID: {request_id}, event_code: '{event_code}'") return Response({ "status": "ERROR", "message": "指定されたイベントが見つかりません" }, status=status.HTTP_404_NOT_FOUND) logger.info(f"[BULK_UPLOAD] ✅ Event found - ID: {request_id}, event: '{event_code}', event_id: {event.id}") # チームの存在確認とオーナー権限の検証 entry = Entry.objects.filter( event=event, team__zekken_number=zekken_number ).first() if not entry: logger.warning(f"[BULK_UPLOAD] ❌ Team not found - ID: {request_id}, zekken_number: '{zekken_number}', event_code: '{event_code}'") return Response({ "status": "ERROR", "message": "指定されたゼッケン番号のチームが見つかりません" }, status=status.HTTP_404_NOT_FOUND) logger.info(f"[BULK_UPLOAD] ✅ Team found - ID: {request_id}, team_name: '{entry.team.team_name}', zekken: {zekken_number}, entry_id: {entry.id}") # オーナー権限の確認 if entry.owner != request.user: logger.warning(f"[BULK_UPLOAD] ❌ Permission denied - ID: {request_id}, user: {request.user.email}, team_owner: {entry.owner.email}") return Response({ "status": "ERROR", "message": "このチームの写真をアップロードする権限がありません" }, status=status.HTTP_403_FORBIDDEN) # 写真処理の準備 processed_files = [] successful_uploads = 0 failed_uploads = 0 # アップロードディレクトリの準備 upload_dir = f"bulk_checkin_photos/{event_code}/{zekken_number}/" os.makedirs(os.path.join(settings.MEDIA_ROOT, upload_dir), exist_ok=True) with transaction.atomic(): for index, uploaded_file in enumerate(uploaded_files): file_result = { "filename": uploaded_file.name, "file_index": index + 1, "file_size": uploaded_file.size, "upload_time": timezone.now().strftime("%Y-%m-%d %H:%M:%S") } try: # ファイル形式の確認 allowed_extensions = ['.jpg', '.jpeg', '.png', '.heic'] file_extension = os.path.splitext(uploaded_file.name)[1].lower() if file_extension not in allowed_extensions: file_result.update({ "status": "failed", "error": f"サポートされていないファイル形式: {file_extension}" }) failed_uploads += 1 processed_files.append(file_result) continue # ファイルサイズの確認 max_size = getattr(settings, 'BULK_UPLOAD_MAX_FILE_SIZE', 10 * 1024 * 1024) # 10MB if uploaded_file.size > max_size: file_result.update({ "status": "failed", "error": f"ファイルサイズが大きすぎます: {uploaded_file.size / (1024*1024):.1f}MB" }) failed_uploads += 1 processed_files.append(file_result) continue # ファイルの保存 timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") safe_filename = f"{timestamp}_{index+1:03d}_{uploaded_file.name}" file_path = os.path.join(upload_dir, safe_filename) # ファイル保存 saved_path = default_storage.save(file_path, uploaded_file) full_path = os.path.join(settings.MEDIA_ROOT, saved_path) file_result.update({ "status": "uploaded", "saved_path": saved_path, "file_url": f"{settings.MEDIA_URL}{saved_path}" }) # EXIF情報の抽出(今後実装予定) if auto_process: # TODO: EXIF情報の抽出とGPS座標取得 # TODO: 撮影時刻の取得 # TODO: 近接チェックポイントの検索 # TODO: 自動チェックイン処理 file_result.update({ "auto_process_status": "pending", "auto_process_message": "EXIF解析機能は今後実装予定です" }) successful_uploads += 1 logger.info(f"[BULK_UPLOAD] ✅ File uploaded - ID: {request_id}, filename: {uploaded_file.name}, size: {uploaded_file.size}") except Exception as file_error: file_result.update({ "status": "failed", "error": f"ファイル処理エラー: {str(file_error)}" }) failed_uploads += 1 logger.error(f"[BULK_UPLOAD] ❌ File processing error - ID: {request_id}, filename: {uploaded_file.name}, error: {str(file_error)}") processed_files.append(file_result) # 処理結果のサマリー logger.info(f"[BULK_UPLOAD] ✅ Upload completed - ID: {request_id}, successful: {successful_uploads}, failed: {failed_uploads}") # 成功レスポンス return Response({ "status": "OK", "message": "写真の一括アップロードが完了しました", "upload_summary": { "total_files": len(uploaded_files), "successful_uploads": successful_uploads, "failed_uploads": failed_uploads, "upload_time": timezone.now().strftime("%Y-%m-%d %H:%M:%S") }, "team_info": { "team_name": entry.team.team_name, "zekken_number": zekken_number, "event_code": event_code }, "processed_files": processed_files, "auto_process_enabled": auto_process, "next_steps": [ "アップロードされた写真のEXIF情報解析", "GPS座標からチェックポイント自動判定", "通過履歴の自動生成と校正", "ユーザーによる確認と承認" ] }) except Exception as e: logger.error(f"[BULK_UPLOAD] 💥 Unexpected error - ID: {request_id}, error: {str(e)}", exc_info=True) return Response({ "status": "ERROR", "message": "サーバーエラーが発生しました" }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_bulk_upload_status(request): """ 一括アップロードの処理状況を取得する パラメータ: - event_code: イベントコード (必須) - zekken_number: ゼッケン番号 (必須) """ request_id = str(uuid.uuid4())[:8] logger.info(f"[BULK_STATUS] 🎯 API call started - ID: {request_id}, User: {request.user.email}") try: event_code = request.GET.get('event_code') zekken_number = request.GET.get('zekken_number') if not all([event_code, zekken_number]): return Response({ "status": "ERROR", "message": "イベントコードとゼッケン番号が必要です" }, status=status.HTTP_400_BAD_REQUEST) # チーム権限の確認 event = NewEvent2.objects.filter(event_name=event_code).first() if not event: return Response({ "status": "ERROR", "message": "指定されたイベントが見つかりません" }, status=status.HTTP_404_NOT_FOUND) entry = Entry.objects.filter(event=event, team__zekken_number=zekken_number).first() if not entry or entry.owner != request.user: return Response({ "status": "ERROR", "message": "このチームの情報にアクセスする権限がありません" }, status=status.HTTP_403_FORBIDDEN) # TODO: 実際の処理状況を取得 # TODO: アップロードされたファイル一覧 # TODO: EXIF解析状況 # TODO: 自動チェックイン生成状況 return Response({ "status": "OK", "team_info": { "team_name": entry.team.team_name, "zekken_number": zekken_number, "event_code": event_code }, "upload_status": { "total_uploaded_files": 0, "processed_files": 0, "pending_files": 0, "auto_checkins_generated": 0, "manual_review_required": 0 }, "implementation_status": "基本機能実装完了、詳細処理は今後実装予定" }) except Exception as e: logger.error(f"[BULK_STATUS] 💥 Unexpected error - ID: {request_id}, error: {str(e)}", exc_info=True) return Response({ "status": "ERROR", "message": "サーバーエラーが発生しました" }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)