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