""" 写真一括アップロード・通過履歴校正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 from ..models import NewEvent2, Entry, GpsLog, Location2025, GpsCheckin # ログ設定 logger = logging.getLogger(__name__) 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, photo_filename, request_id): """ 写真情報からチェックインデータを作成する Args: entry: Entryオブジェクト checkpoint: Location2025オブジェクト photo_datetime: 撮影日時 zekken_number: ゼッケン番号 event_code: イベントコード photo_filename: 写真ファイル名 request_id: リクエストID Returns: GpsCheckinオブジェクトまたはNone """ try: # 既存のチェックインをチェック(重複防止) existing_checkin = GpsCheckin.objects.filter( zekken=str(zekken_number), event_code=event_code, cp_number=checkpoint.cp_number ).first() if existing_checkin: logger.info(f"[BULK_UPLOAD] 📍 Existing checkin found - ID: {request_id}, CP: {checkpoint.cp}, existing_id: {existing_checkin.id}") return existing_checkin # 新規チェックインの作成 # 撮影時刻を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 # チェックインデータを作成 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}", photo_source=photo_filename # 元画像ファイル名を記録(カスタムフィールドがあれば) ) logger.info(f"[BULK_UPLOAD] ✅ Created checkin - ID: {request_id}, checkin_id: {checkin.id}, CP: {checkpoint.cp_number}, time: {create_at}, points: {checkin.points}") return checkin 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: # チェックインデータを作成 checkin = create_checkin_from_photo( entry, nearest_checkpoint, exif_data['datetime'], zekken_number, event_code, uploaded_file.name, request_id ) if checkin: 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": checkin.points, "checkin_time": checkin.create_at.strftime("%Y-%m-%d %H:%M:%S"), "checkin_id": checkin.id }, "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)