2025-09-02 23:14:14 +09:00
"""
写真一括アップロード ・ 通過履歴校正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
2025-09-06 01:10:28 +09:00
from PIL import Image
from PIL . ExifTags import TAGS , GPSTAGS
import math
2025-09-06 01:23:28 +09:00
import tempfile
import json
2025-09-02 23:14:14 +09:00
2025-09-06 01:23:28 +09:00
from . . models import NewEvent2 , Entry , GpsLog , Location2025 , GpsCheckin , CheckinImages
2025-09-02 23:14:14 +09:00
# ログ設定
logger = logging . getLogger ( __name__ )
2025-09-06 01:23:28 +09:00
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 :
2025-09-06 01:29:52 +09:00
# 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 '
}
2025-09-06 01:23:28 +09:00
# S3クライアントの設定( 環境変数から取得)
import boto3
2025-09-06 01:29:52 +09:00
from botocore . exceptions import ClientError , NoCredentialsError , PartialCredentialsError
2025-09-06 01:23:28 +09:00
s3_client = boto3 . client (
' s3 ' ,
2025-09-06 01:29:52 +09:00
aws_access_key_id = aws_access_key ,
aws_secret_access_key = aws_secret_key ,
2025-09-06 01:23:28 +09:00
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 :
2025-09-06 01:29:52 +09:00
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 )
}
2025-09-06 01:23:28 +09:00
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 :
2025-09-06 01:29:52 +09:00
# S3 URLがない場合はローカルパスまたは一時的なプレースホルダーを使用
image_url = s3_url if s3_url else f " local://bulk_upload/ { original_filename } "
2025-09-06 01:23:28 +09:00
# CheckinImagesレコードを作成
checkin_image = CheckinImages . objects . create (
user = user ,
2025-09-06 01:29:52 +09:00
checkinimage = image_url , # S3のURLまたはローカルパスを保存
2025-09-06 01:23:28 +09:00
checkintime = timezone . now ( ) ,
team_name = f " Team_ { gps_checkin . zekken } " , # ゼッケン番号からチーム名を生成
event_code = gps_checkin . event_code ,
cp_number = gps_checkin . cp_number
)
2025-09-06 01:29:52 +09:00
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 } " )
2025-09-06 01:23:28 +09:00
return checkin_image
except Exception as e :
logger . error ( f " [CHECKIN_IMAGE] ❌ Error creating CheckinImages record: { str ( e ) } " )
return None
2025-09-06 01:10:28 +09:00
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
2025-09-06 01:23:28 +09:00
def create_checkin_from_photo ( entry , checkpoint , photo_datetime , zekken_number , event_code , uploaded_file , exif_data , request_id , user ) :
2025-09-06 01:10:28 +09:00
"""
2025-09-06 01:23:28 +09:00
写真情報からチェックインデータを作成し 、 S3アップロードとCheckinImages登録も行う
2025-09-06 01:10:28 +09:00
Args :
entry : Entryオブジェクト
checkpoint : Location2025オブジェクト
photo_datetime : 撮影日時
zekken_number : ゼッケン番号
event_code : イベントコード
2025-09-06 01:23:28 +09:00
uploaded_file : アップロードされたファイル
exif_data : EXIF情報辞書
2025-09-06 01:10:28 +09:00
request_id : リクエストID
2025-09-06 01:23:28 +09:00
user : ユーザーオブジェクト
2025-09-06 01:10:28 +09:00
Returns :
2025-09-06 01:23:28 +09:00
dict : {
' gps_checkin ' : GpsCheckinオブジェクト ,
' checkin_image ' : CheckinImagesオブジェクト ,
' s3_info ' : S3アップロード情報 ,
' created ' : bool
}
2025-09-06 01:10:28 +09:00
"""
try :
# 既存のチェックインをチェック(重複防止)
existing_checkin = GpsCheckin . objects . filter (
zekken = str ( zekken_number ) ,
event_code = event_code ,
cp_number = checkpoint . cp_number
) . first ( )
2025-09-06 01:23:28 +09:00
gps_checkin = None
created = False
2025-09-06 01:10:28 +09:00
if existing_checkin :
2025-09-06 01:23:28 +09:00
logger . info ( f " [BULK_UPLOAD] 📍 Existing checkin found - ID: { request_id } , CP: { checkpoint . cp_number } , existing_id: { existing_checkin . id } " )
gps_checkin = existing_checkin
2025-09-06 01:10:28 +09:00
else :
2025-09-06 01:23:28 +09:00
# 新規チェックインの作成
# 撮影時刻を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
2025-09-06 01:10:28 +09:00
)
2025-09-06 01:23:28 +09:00
checkin_image = None
2025-09-06 01:29:52 +09:00
# 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
)
2025-09-06 01:23:28 +09:00
if s3_result [ ' success ' ] :
2025-09-06 01:29:52 +09:00
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 ' ] } " )
2025-09-06 01:10:28 +09:00
2025-09-06 01:23:28 +09:00
return {
' gps_checkin ' : gps_checkin ,
' checkin_image ' : checkin_image ,
' s3_info ' : s3_result ,
' created ' : created
}
2025-09-06 01:10:28 +09:00
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
2025-09-06 00:45:51 +09:00
@api_view ( [ ' POST ' , ' GET ' ] )
2025-09-02 23:14:14 +09:00
@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 ' ) )
2025-09-06 00:45:51 +09:00
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 " ] ,
2025-09-06 01:10:28 +09:00
" optional_params " : [ " auto_process " , " skip_team_validation " ] ,
" features " : [
" 写真からEXIF情報を抽出 " ,
" GPS座標から最寄りチェックポイントを自動検索 " ,
" 撮影時刻を使用してチェックインデータを自動作成 " ,
" チーム検証機能 "
] ,
2025-09-06 00:45:51 +09:00
" user " : request . user . email if request . user . is_authenticated else ' Not authenticated '
} , status = status . HTTP_200_OK )
2025-09-02 23:14:14 +09:00
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 '
2025-09-06 00:50:30 +09:00
skip_team_validation = request . POST . get ( ' skip_team_validation ' , ' false ' ) . lower ( ) == ' true '
2025-09-02 23:14:14 +09:00
# アップロードされた写真ファイルの取得
uploaded_files = request . FILES . getlist ( ' photos ' )
2025-09-06 00:50:30 +09:00
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 } " )
2025-09-02 23:14:14 +09:00
# 必須パラメータの検証
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 } " )
# チームの存在確認とオーナー権限の検証
2025-09-06 00:50:30 +09:00
# zekken_numberは文字列と数値の両方で検索を試行
entry = None
2025-09-02 23:14:14 +09:00
2025-09-06 00:50:30 +09:00
# まず数値として検索
if zekken_number . isdigit ( ) :
entry = Entry . objects . filter (
event = event ,
zekken_number = int ( zekken_number )
) . select_related ( ' team ' ) . first ( )
# 見つからない場合は文字列として検索
2025-09-02 23:14:14 +09:00
if not entry :
2025-09-06 00:50:30 +09:00
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 :
2025-09-02 23:14:14 +09:00
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 )
2025-09-06 00:50:30 +09:00
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 } " )
2025-09-02 23:14:14 +09:00
2025-09-06 00:50:30 +09:00
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 } " )
2025-09-02 23:14:14 +09:00
2025-09-06 00:50:30 +09:00
# オーナー権限の確認 (テストモードではスキップ)
if not skip_team_validation and hasattr ( entry , ' owner ' ) and entry . owner != request . user :
2025-09-02 23:14:14 +09:00
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 } "
} )
2025-09-06 01:10:28 +09:00
# EXIF情報の抽出とチェックイン作成
2025-09-02 23:14:14 +09:00
if auto_process :
2025-09-06 01:10:28 +09:00
logger . info ( f " [BULK_UPLOAD] 🔍 Starting EXIF processing for { uploaded_file . name } " )
2025-09-02 23:14:14 +09:00
2025-09-06 01:10:28 +09:00
# ファイルポインタを先頭に戻す
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 :
2025-09-06 01:23:28 +09:00
# ファイルポインタを先頭に戻す( S3アップロード用)
uploaded_file . seek ( 0 )
# チェックインデータを作成( S3アップロードとCheckinImages登録も含む)
checkin_result = create_checkin_from_photo (
2025-09-06 01:10:28 +09:00
entry ,
nearest_checkpoint ,
exif_data [ ' datetime ' ] ,
zekken_number ,
event_code ,
2025-09-06 01:23:28 +09:00
uploaded_file ,
exif_data ,
request_id ,
request . user
2025-09-06 01:10:28 +09:00
)
2025-09-06 01:23:28 +09:00
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 ' ]
2025-09-06 01:10:28 +09:00
file_result . update ( {
" auto_process_status " : " success " ,
" auto_process_message " : f " チェックイン作成完了 " ,
" checkin_info " : {
" checkpoint_name " : nearest_checkpoint . location ,
" cp_number " : nearest_checkpoint . cp_number ,
2025-09-06 01:23:28 +09:00
" 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
2025-09-06 01:10:28 +09:00
} ,
" 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
}
} )
2025-09-02 23:14:14 +09:00
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 " ,
2025-09-06 01:10:28 +09:00
" message " : " 写真の一括アップロードとチェックイン処理が完了しました " ,
2025-09-02 23:14:14 +09:00
" 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 ,
2025-09-06 01:10:28 +09:00
" 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 ' ] )
}
2025-09-02 23:14:14 +09:00
} )
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 )