Files
rogaining_srv/rog/views_apis/api_bulk_photo_upload.py
2025-09-06 01:29:52 +09:00

861 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
写真一括アップロード・通過履歴校正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)