""" 写真一括アップロード・通過履歴校正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 PIL import Image from PIL.ExifTags import TAGS, GPSTAGS import math import tempfile import json from ..models import NewEvent2, Entry, GpsLog, Location2025, GpsCheckin, CheckinImages # ログ設定 logger = logging.getLogger(__name__) def upload_photo_to_s3(uploaded_file, event_code, zekken_number, cp_number=None, request_id=None): """ アップロードされた写真をS3にアップロードする Args: uploaded_file: アップロードされたファイル event_code: イベントコード zekken_number: ゼッケン番号 cp_number: チェックポイント番号 request_id: リクエストID Returns: dict: { 'success': bool, 's3_url': str, 's3_key': str, 'error': str } """ try: # S3のバケット設定を取得 bucket_name = getattr(settings, 'AWS_STORAGE_BUCKET_NAME', 'rogaining-images') # S3キーの生成 timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") file_extension = os.path.splitext(uploaded_file.name)[1].lower() if cp_number: s3_key = f"checkin_photos/{event_code}/{zekken_number}/CP{cp_number}_{timestamp}_{request_id}{file_extension}" else: s3_key = f"bulk_upload/{event_code}/{zekken_number}/{timestamp}_{request_id}_{uploaded_file.name}" # 一時ファイルとして保存 with tempfile.NamedTemporaryFile(delete=False, suffix=file_extension) as temp_file: for chunk in uploaded_file.chunks(): temp_file.write(chunk) temp_file_path = temp_file.name try: # AWS認証情報の確認 aws_access_key = getattr(settings, 'AWS_ACCESS_KEY_ID', None) aws_secret_key = getattr(settings, 'AWS_SECRET_ACCESS_KEY', None) if not aws_access_key or not aws_secret_key: logger.warning(f"[S3_UPLOAD] ❌ AWS credentials not configured, falling back to local storage") return { 'success': False, 's3_url': None, 's3_key': None, 'error': 'AWS credentials not configured, using local storage instead' } # S3クライアントの設定(環境変数から取得) import boto3 from botocore.exceptions import ClientError, NoCredentialsError, PartialCredentialsError s3_client = boto3.client( 's3', aws_access_key_id=aws_access_key, aws_secret_access_key=aws_secret_key, region_name=getattr(settings, 'AWS_S3_REGION_NAME', 'ap-northeast-1') ) # S3にアップロード s3_client.upload_file( temp_file_path, bucket_name, s3_key, ExtraArgs={ 'ContentType': uploaded_file.content_type, 'Metadata': { 'event_code': event_code, 'zekken_number': str(zekken_number), 'cp_number': str(cp_number) if cp_number else '', 'original_filename': uploaded_file.name, 'upload_source': 'bulk_upload_api' } } ) # S3 URLの生成 s3_url = f"https://{bucket_name}.s3.{getattr(settings, 'AWS_S3_REGION_NAME', 'ap-northeast-1')}.amazonaws.com/{s3_key}" logger.info(f"[S3_UPLOAD] ✅ Successfully uploaded to S3 - key: {s3_key}") return { 'success': True, 's3_url': s3_url, 's3_key': s3_key, 'error': None } finally: # 一時ファイルを削除 if os.path.exists(temp_file_path): os.unlink(temp_file_path) except ImportError: logger.warning(f"[S3_UPLOAD] ❌ boto3 not available, saving locally") return { 'success': False, 's3_url': None, 's3_key': None, 'error': 'S3 upload not available (boto3 not installed)' } except Exception as e: error_message = str(e) if 'credentials' in error_message.lower(): logger.warning(f"[S3_UPLOAD] ❌ AWS credentials error: {error_message}") return { 'success': False, 's3_url': None, 's3_key': None, 'error': f'AWS credentials error: {error_message}' } else: logger.error(f"[S3_UPLOAD] ❌ Error uploading to S3: {error_message}") return { 'success': False, 's3_url': None, 's3_key': None, 'error': str(e) } def create_checkin_image_record(gps_checkin, s3_url, s3_key, original_filename, exif_data, request_id, user): """ CheckinImagesテーブルにレコードを作成する Args: gps_checkin: GpsCheckinオブジェクト s3_url: S3のURL s3_key: S3のキー original_filename: 元のファイル名 exif_data: EXIF情報 request_id: リクエストID user: ユーザーオブジェクト Returns: CheckinImagesオブジェクトまたはNone """ try: # S3 URLがない場合はローカルパスまたは一時的なプレースホルダーを使用 image_url = s3_url if s3_url else f"local://bulk_upload/{original_filename}" # CheckinImagesレコードを作成 checkin_image = CheckinImages.objects.create( user=user, checkinimage=image_url, # S3のURLまたはローカルパスを保存 checkintime=timezone.now(), team_name=f"Team_{gps_checkin.zekken}", # ゼッケン番号からチーム名を生成 event_code=gps_checkin.event_code, cp_number=gps_checkin.cp_number ) if s3_url: logger.info(f"[CHECKIN_IMAGE] ✅ Created CheckinImages record with S3 URL - ID: {checkin_image.id}, checkin_id: {gps_checkin.id}") else: logger.info(f"[CHECKIN_IMAGE] ✅ Created CheckinImages record with local path - ID: {checkin_image.id}, checkin_id: {gps_checkin.id}") return checkin_image except Exception as e: logger.error(f"[CHECKIN_IMAGE] ❌ Error creating CheckinImages record: {str(e)}") return None def extract_exif_data(image_file): """ 画像ファイルからEXIF情報を抽出する Returns: dict: { 'latitude': float, 'longitude': float, 'datetime': datetime, 'has_gps': bool } """ try: # PIL Imageオブジェクトを作成 image = Image.open(image_file) # EXIF情報を取得 exif_data = image._getexif() if not exif_data: logger.warning(f"No EXIF data found in image: {image_file.name}") return {'has_gps': False, 'latitude': None, 'longitude': None, 'datetime': None} # GPS情報とDatetime情報を抽出 gps_info = {} datetime_original = None for tag_id, value in exif_data.items(): tag = TAGS.get(tag_id, tag_id) if tag == "GPSInfo": # GPS情報の詳細を展開 for gps_tag_id, gps_value in value.items(): gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id) gps_info[gps_tag] = gps_value elif tag == "DateTime" or tag == "DateTimeOriginal": try: datetime_original = datetime.strptime(str(value), '%Y:%m:%d %H:%M:%S') except ValueError: logger.warning(f"Failed to parse datetime: {value}") # GPS座標の変換 latitude = None longitude = None if 'GPSLatitude' in gps_info and 'GPSLongitude' in gps_info: lat_deg, lat_min, lat_sec = gps_info['GPSLatitude'] lon_deg, lon_min, lon_sec = gps_info['GPSLongitude'] # 度分秒を小数度に変換 latitude = float(lat_deg) + float(lat_min)/60 + float(lat_sec)/3600 longitude = float(lon_deg) + float(lon_min)/60 + float(lon_sec)/3600 # 南緯・西経の場合は負の値にする if gps_info.get('GPSLatitudeRef') == 'S': latitude = -latitude if gps_info.get('GPSLongitudeRef') == 'W': longitude = -longitude logger.info(f"EXIF extracted from {image_file.name}: lat={latitude}, lon={longitude}, datetime={datetime_original}") return { 'has_gps': latitude is not None and longitude is not None, 'latitude': latitude, 'longitude': longitude, 'datetime': datetime_original } except Exception as e: logger.error(f"Error extracting EXIF from {image_file.name}: {str(e)}") return {'has_gps': False, 'latitude': None, 'longitude': None, 'datetime': None} def find_nearest_checkpoint(latitude, longitude, event_code, max_distance_m=100): """ 指定された座標から最も近いチェックポイントを検索する Args: latitude: 緯度 longitude: 経度 event_code: イベントコード max_distance_m: 最大距離(メートル) Returns: Location2025オブジェクトまたはNone """ try: # 該当イベントのチェックポイントを取得 checkpoints = Location2025.objects.filter(event__event_name=event_code) if not checkpoints.exists(): logger.warning(f"No checkpoints found for event: {event_code}") return None # 最も近いチェックポイントを検索 nearest_cp = None min_distance = float('inf') for cp in checkpoints: if cp.latitude and cp.longitude: # ハーバーサイン距離の計算 distance = calculate_distance(latitude, longitude, cp.latitude, cp.longitude) if distance < min_distance and distance <= max_distance_m: min_distance = distance nearest_cp = cp if nearest_cp: logger.info(f"Found nearest checkpoint: {nearest_cp.location} (CP{nearest_cp.cp_number}) at distance {min_distance:.1f}m") else: logger.info(f"No checkpoint found within {max_distance_m}m of lat={latitude}, lon={longitude}") return nearest_cp except Exception as e: logger.error(f"Error finding nearest checkpoint: {str(e)}") return None def calculate_distance(lat1, lon1, lat2, lon2): """ ハーバーサイン公式を使用して2点間の距離を計算(メートル単位) """ R = 6371000 # 地球の半径(メートル) lat1_rad = math.radians(lat1) lat2_rad = math.radians(lat2) delta_lat = math.radians(lat2 - lat1) delta_lon = math.radians(lon2 - lon1) a = (math.sin(delta_lat/2) * math.sin(delta_lat/2) + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon/2) * math.sin(delta_lon/2)) c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) distance = R * c return distance def create_checkin_from_photo(entry, checkpoint, photo_datetime, zekken_number, event_code, uploaded_file, exif_data, request_id, user): """ 写真情報からチェックインデータを作成し、S3アップロードとCheckinImages登録も行う Args: entry: Entryオブジェクト checkpoint: Location2025オブジェクト photo_datetime: 撮影日時 zekken_number: ゼッケン番号 event_code: イベントコード uploaded_file: アップロードされたファイル exif_data: EXIF情報辞書 request_id: リクエストID user: ユーザーオブジェクト Returns: dict: { 'gps_checkin': GpsCheckinオブジェクト, 'checkin_image': CheckinImagesオブジェクト, 's3_info': S3アップロード情報, 'created': bool } """ try: # 既存のチェックインをチェック(重複防止) existing_checkin = GpsCheckin.objects.filter( zekken=str(zekken_number), event_code=event_code, cp_number=checkpoint.cp_number ).first() gps_checkin = None created = False if existing_checkin: logger.info(f"[BULK_UPLOAD] 📍 Existing checkin found - ID: {request_id}, CP: {checkpoint.cp_number}, existing_id: {existing_checkin.id}") gps_checkin = existing_checkin else: # 新規チェックインの作成 # 撮影時刻をJSTに変換 if photo_datetime: # 撮影時刻をUTCとして扱い、JSTに変換 create_at = timezone.make_aware(photo_datetime, timezone.utc) else: create_at = timezone.now() # シリアル番号を決定(既存のチェックイン数+1) existing_count = GpsCheckin.objects.filter( zekken=str(zekken_number), event_code=event_code ).count() serial_number = existing_count + 1 # チェックインデータを作成 gps_checkin = GpsCheckin.objects.create( zekken=str(zekken_number), event_code=event_code, cp_number=checkpoint.cp_number, serial_number=serial_number, create_at=create_at, update_at=timezone.now(), validate_location=True, # 写真から作成されたものは自動承認 buy_flag=False, points=checkpoint.checkin_point if checkpoint.checkin_point else 0, create_user=f"bulk_upload_{request_id}", # 一括アップロードで作成されたことを示す update_user=f"bulk_upload_{request_id}", ) logger.info(f"[BULK_UPLOAD] ✅ Created checkin - ID: {request_id}, checkin_id: {gps_checkin.id}, CP: {checkpoint.cp_number}, time: {create_at}, points: {gps_checkin.points}") created = True # S3に写真をアップロード logger.info(f"[BULK_UPLOAD] 📤 Uploading photo to S3 - ID: {request_id}, CP: {checkpoint.cp_number}") s3_result = upload_photo_to_s3( uploaded_file, event_code, zekken_number, checkpoint.cp_number, request_id ) checkin_image = None # S3アップロードが成功した場合も失敗した場合もCheckinImagesレコードを作成 checkin_image = create_checkin_image_record( gps_checkin, s3_result['s3_url'], # S3アップロードが失敗した場合はNone s3_result['s3_key'], uploaded_file.name, exif_data, request_id, user ) if s3_result['success']: logger.info(f"[BULK_UPLOAD] ✅ Photo uploaded to S3 and CheckinImages record created - ID: {request_id}") else: logger.warning(f"[BULK_UPLOAD] ⚠️ S3 upload failed but CheckinImages record created with local path - ID: {request_id}, error: {s3_result['error']}") return { 'gps_checkin': gps_checkin, 'checkin_image': checkin_image, 's3_info': s3_result, 'created': created } except Exception as e: logger.error(f"[BULK_UPLOAD] ❌ Error creating checkin - ID: {request_id}, CP: {checkpoint.cp_number if checkpoint else 'None'}, error: {str(e)}") return None @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", "skip_team_validation"], "features": [ "写真からEXIF情報を抽出", "GPS座標から最寄りチェックポイントを自動検索", "撮影時刻を使用してチェックインデータを自動作成", "チーム検証機能" ], "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' skip_team_validation = request.POST.get('skip_team_validation', 'false').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}, skip_team_validation: {skip_team_validation}") # 必須パラメータの検証 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}") # チームの存在確認とオーナー権限の検証 # zekken_numberは文字列と数値の両方で検索を試行 entry = None # まず数値として検索 if zekken_number.isdigit(): entry = Entry.objects.filter( event=event, zekken_number=int(zekken_number) ).select_related('team').first() # 見つからない場合は文字列として検索 if not entry: entry = Entry.objects.filter( event=event, zekken_label=zekken_number ).select_related('team').first() # さらに見つからない場合は文字列での zekken_number 検索 if not entry: entry = Entry.objects.filter( event=event, zekken_number=zekken_number ).select_related('team').first() logger.info(f"[BULK_UPLOAD] 🔍 Team search - ID: {request_id}, searching for zekken: '{zekken_number}', found entry: {entry.id if entry else 'None'}") # チーム検証のスキップまたは失敗処理 if not entry and not skip_team_validation: 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) elif not entry and skip_team_validation: logger.warning(f"[BULK_UPLOAD] ⚠️ Team not found but validation skipped - ID: {request_id}, zekken_number: '{zekken_number}', event_code: '{event_code}'") # ダミーエントリ情報を作成(テスト用) entry = type('Entry', (), { 'id': f'test_{request_id}', 'team': type('Team', (), { 'team_name': f'Test Team {zekken_number}', 'owner': request.user })(), 'event': event })() logger.info(f"[BULK_UPLOAD] 🧪 Using test entry - ID: {request_id}, test_entry_id: {entry.id}") if hasattr(entry, 'id') and str(entry.id).startswith('test_'): logger.info(f"[BULK_UPLOAD] ✅ Test team found - ID: {request_id}, team_name: '{entry.team.team_name}', zekken: {zekken_number}, test_entry_id: {entry.id}") else: logger.info(f"[BULK_UPLOAD] ✅ Team found - ID: {request_id}, team_name: '{entry.team.team_name}', zekken: {zekken_number}, entry_id: {entry.id}") # オーナー権限の確認 (テストモードではスキップ) if not skip_team_validation and hasattr(entry, 'owner') and 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: logger.info(f"[BULK_UPLOAD] 🔍 Starting EXIF processing for {uploaded_file.name}") # ファイルポインタを先頭に戻す uploaded_file.seek(0) # EXIF情報の抽出 exif_data = extract_exif_data(uploaded_file) if exif_data['has_gps']: logger.info(f"[BULK_UPLOAD] 📍 GPS data found - lat: {exif_data['latitude']}, lon: {exif_data['longitude']}, datetime: {exif_data['datetime']}") # 最も近いチェックポイントを検索 nearest_checkpoint = find_nearest_checkpoint( exif_data['latitude'], exif_data['longitude'], event_code ) if nearest_checkpoint: # ファイルポインタを先頭に戻す(S3アップロード用) uploaded_file.seek(0) # チェックインデータを作成(S3アップロードとCheckinImages登録も含む) checkin_result = create_checkin_from_photo( entry, nearest_checkpoint, exif_data['datetime'], zekken_number, event_code, uploaded_file, exif_data, request_id, request.user ) if checkin_result and checkin_result['gps_checkin']: gps_checkin = checkin_result['gps_checkin'] checkin_image = checkin_result['checkin_image'] s3_info = checkin_result['s3_info'] file_result.update({ "auto_process_status": "success", "auto_process_message": f"チェックイン作成完了", "checkin_info": { "checkpoint_name": nearest_checkpoint.location, "cp_number": nearest_checkpoint.cp_number, "points": gps_checkin.points, "checkin_time": gps_checkin.create_at.strftime("%Y-%m-%d %H:%M:%S"), "checkin_id": gps_checkin.id, "was_existing": not checkin_result['created'] }, "s3_info": { "uploaded": s3_info['success'], "s3_url": s3_info['s3_url'], "error": s3_info['error'] }, "checkin_image_info": { "created": checkin_image is not None, "image_id": checkin_image.id if checkin_image else None }, "gps_info": { "latitude": exif_data['latitude'], "longitude": exif_data['longitude'], "photo_datetime": exif_data['datetime'].strftime("%Y-%m-%d %H:%M:%S") if exif_data['datetime'] else None } }) else: file_result.update({ "auto_process_status": "failed", "auto_process_message": "チェックイン作成に失敗しました", "checkpoint_info": { "checkpoint_name": nearest_checkpoint.location, "cp_number": nearest_checkpoint.cp_number } }) else: file_result.update({ "auto_process_status": "no_checkpoint", "auto_process_message": "近くにチェックポイントが見つかりませんでした", "gps_info": { "latitude": exif_data['latitude'], "longitude": exif_data['longitude'], "photo_datetime": exif_data['datetime'].strftime("%Y-%m-%d %H:%M:%S") if exif_data['datetime'] else None } }) else: file_result.update({ "auto_process_status": "no_gps", "auto_process_message": "GPS情報が見つかりませんでした", "exif_info": { "has_datetime": exif_data['datetime'] is not None, "photo_datetime": exif_data['datetime'].strftime("%Y-%m-%d %H:%M:%S") if exif_data['datetime'] else None } }) 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, "processing_summary": { "gps_found": len([f for f in processed_files if f.get('auto_process_status') == 'success']), "checkins_created": len([f for f in processed_files if f.get('checkin_info')]), "no_gps": len([f for f in processed_files if f.get('auto_process_status') == 'no_gps']), "no_checkpoint": len([f for f in processed_files if f.get('auto_process_status') == 'no_checkpoint']) } }) 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)