425 lines
14 KiB
Python
425 lines
14 KiB
Python
|
|
"""
|
|||
|
|
Multi Image Upload API Views
|
|||
|
|
複数画像一括アップロード機能
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import base64
|
|||
|
|
import uuid
|
|||
|
|
import time
|
|||
|
|
import logging
|
|||
|
|
from datetime import datetime
|
|||
|
|
from django.conf import settings
|
|||
|
|
from django.core.files.base import ContentFile
|
|||
|
|
from django.core.files.storage import default_storage
|
|||
|
|
from rest_framework import status
|
|||
|
|
from rest_framework.decorators import api_view, permission_classes
|
|||
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|||
|
|
from rest_framework.response import Response
|
|||
|
|
from django.db import transaction
|
|||
|
|
from PIL import Image
|
|||
|
|
import io
|
|||
|
|
|
|||
|
|
from .models import UploadedImage, NewEvent2, Entry
|
|||
|
|
from .serializers import (
|
|||
|
|
MultiImageUploadSerializer,
|
|||
|
|
MultiImageUploadResponseSerializer,
|
|||
|
|
UploadedImageSerializer
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(['POST'])
|
|||
|
|
@permission_classes([AllowAny])
|
|||
|
|
def multi_image_upload(request):
|
|||
|
|
"""
|
|||
|
|
複数画像一括アップロードAPI
|
|||
|
|
|
|||
|
|
POST /api/images/multi-upload
|
|||
|
|
|
|||
|
|
Request:
|
|||
|
|
{
|
|||
|
|
"event_code": "岐阜ロゲイニング2025",
|
|||
|
|
"team_name": "チーム名",
|
|||
|
|
"cp_number": 1,
|
|||
|
|
"images": [
|
|||
|
|
{
|
|||
|
|
"file_data": "base64_encoded_image_data",
|
|||
|
|
"filename": "checkpoint1_photo1.jpg",
|
|||
|
|
"mime_type": "image/jpeg",
|
|||
|
|
"file_size": 2048576,
|
|||
|
|
"capture_timestamp": "2025-09-15T11:30:00Z"
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
"upload_source": "sharing_intent",
|
|||
|
|
"device_platform": "ios"
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
start_time = time.time()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# リクエストデータ検証
|
|||
|
|
serializer = MultiImageUploadSerializer(data=request.data)
|
|||
|
|
if not serializer.is_valid():
|
|||
|
|
return Response({
|
|||
|
|
'status': 'error',
|
|||
|
|
'message': 'Invalid request data',
|
|||
|
|
'errors': serializer.errors
|
|||
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
|
|||
|
|
validated_data = serializer.validated_data
|
|||
|
|
event_code = validated_data['event_code']
|
|||
|
|
team_name = validated_data['team_name']
|
|||
|
|
cp_number = validated_data['cp_number']
|
|||
|
|
images_data = validated_data['images']
|
|||
|
|
upload_source = validated_data.get('upload_source', 'direct')
|
|||
|
|
device_platform = validated_data.get('device_platform')
|
|||
|
|
|
|||
|
|
# イベントの存在確認
|
|||
|
|
try:
|
|||
|
|
event = NewEvent2.objects.get(event_name=event_code)
|
|||
|
|
except NewEvent2.DoesNotExist:
|
|||
|
|
return Response({
|
|||
|
|
'status': 'error',
|
|||
|
|
'message': f'イベント "{event_code}" が見つかりません'
|
|||
|
|
}, status=status.HTTP_404_NOT_FOUND)
|
|||
|
|
|
|||
|
|
# エントリーの存在確認
|
|||
|
|
try:
|
|||
|
|
entry = Entry.objects.filter(
|
|||
|
|
event=event,
|
|||
|
|
team__team_name=team_name
|
|||
|
|
).first()
|
|||
|
|
except Entry.DoesNotExist:
|
|||
|
|
entry = None
|
|||
|
|
|
|||
|
|
uploaded_files = []
|
|||
|
|
failed_files = []
|
|||
|
|
total_upload_size = 0
|
|||
|
|
|
|||
|
|
# トランザクション開始
|
|||
|
|
with transaction.atomic():
|
|||
|
|
for i, image_data in enumerate(images_data):
|
|||
|
|
try:
|
|||
|
|
uploaded_image = _process_single_image(
|
|||
|
|
image_data,
|
|||
|
|
event_code,
|
|||
|
|
team_name,
|
|||
|
|
cp_number,
|
|||
|
|
upload_source,
|
|||
|
|
device_platform,
|
|||
|
|
entry,
|
|||
|
|
i
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
uploaded_files.append({
|
|||
|
|
'original_filename': uploaded_image.original_filename,
|
|||
|
|
'server_filename': uploaded_image.server_filename,
|
|||
|
|
'file_url': uploaded_image.file_url,
|
|||
|
|
'file_size': uploaded_image.file_size
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
total_upload_size += uploaded_image.file_size
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to process image {i}: {e}")
|
|||
|
|
failed_files.append({
|
|||
|
|
'filename': image_data.get('filename', f'image_{i}'),
|
|||
|
|
'error': str(e)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# 処理時間計算
|
|||
|
|
processing_time_ms = int((time.time() - start_time) * 1000)
|
|||
|
|
|
|||
|
|
# レスポンス作成
|
|||
|
|
response_data = {
|
|||
|
|
'status': 'success' if not failed_files else 'partial_success',
|
|||
|
|
'uploaded_count': len(uploaded_files),
|
|||
|
|
'failed_count': len(failed_files),
|
|||
|
|
'uploaded_files': uploaded_files,
|
|||
|
|
'failed_files': failed_files,
|
|||
|
|
'total_upload_size': total_upload_size,
|
|||
|
|
'processing_time_ms': processing_time_ms
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if failed_files:
|
|||
|
|
response_data['message'] = f"{len(uploaded_files)}個のファイルがアップロードされ、{len(failed_files)}個が失敗しました"
|
|||
|
|
else:
|
|||
|
|
response_data['message'] = f"{len(uploaded_files)}個のファイルが正常にアップロードされました"
|
|||
|
|
|
|||
|
|
return Response(response_data, status=status.HTTP_200_OK)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Multi image upload error: {e}")
|
|||
|
|
return Response({
|
|||
|
|
'status': 'error',
|
|||
|
|
'message': 'サーバーエラーが発生しました',
|
|||
|
|
'error_details': str(e)
|
|||
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(['GET'])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def image_list(request):
|
|||
|
|
"""
|
|||
|
|
アップロード済み画像一覧取得
|
|||
|
|
|
|||
|
|
GET /api/images/list/
|
|||
|
|
|
|||
|
|
Parameters:
|
|||
|
|
- entry_id: エントリーID(オプション)
|
|||
|
|
- event_code: イベントコード(オプション)
|
|||
|
|
- limit: 取得数上限(デフォルト50)
|
|||
|
|
- offset: オフセット(デフォルト0)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
entry_id = request.GET.get('entry_id')
|
|||
|
|
event_code = request.GET.get('event_code')
|
|||
|
|
limit = int(request.GET.get('limit', 50))
|
|||
|
|
offset = int(request.GET.get('offset', 0))
|
|||
|
|
|
|||
|
|
# 基本クエリ
|
|||
|
|
queryset = UploadedImage.objects.all()
|
|||
|
|
|
|||
|
|
# フィルタリング
|
|||
|
|
if entry_id:
|
|||
|
|
queryset = queryset.filter(entry_id=entry_id)
|
|||
|
|
if event_code:
|
|||
|
|
queryset = queryset.filter(entry__event_name=event_code)
|
|||
|
|
|
|||
|
|
# 並び順と取得数制限
|
|||
|
|
queryset = queryset.order_by('-uploaded_at')[offset:offset+limit]
|
|||
|
|
|
|||
|
|
# シリアライズ
|
|||
|
|
serializer = UploadedImageSerializer(queryset, many=True)
|
|||
|
|
|
|||
|
|
return Response({
|
|||
|
|
'images': serializer.data,
|
|||
|
|
'count': len(serializer.data),
|
|||
|
|
'limit': limit,
|
|||
|
|
'offset': offset
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Image list error: {e}")
|
|||
|
|
return Response({
|
|||
|
|
'error': 'Failed to get image list',
|
|||
|
|
'message': str(e)
|
|||
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(['GET', 'DELETE'])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def image_detail(request, image_id):
|
|||
|
|
"""
|
|||
|
|
画像詳細取得・削除
|
|||
|
|
|
|||
|
|
GET /api/images/{image_id}/ - 画像詳細取得
|
|||
|
|
DELETE /api/images/{image_id}/ - 画像削除
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
image = UploadedImage.objects.get(id=image_id)
|
|||
|
|
|
|||
|
|
if request.method == 'GET':
|
|||
|
|
serializer = UploadedImageSerializer(image)
|
|||
|
|
return Response(serializer.data)
|
|||
|
|
|
|||
|
|
elif request.method == 'DELETE':
|
|||
|
|
# ファイル削除
|
|||
|
|
if image.image_file and os.path.exists(image.image_file.path):
|
|||
|
|
os.remove(image.image_file.path)
|
|||
|
|
if image.thumbnail and os.path.exists(image.thumbnail.path):
|
|||
|
|
os.remove(image.thumbnail.path)
|
|||
|
|
|
|||
|
|
# データベースレコード削除
|
|||
|
|
image.delete()
|
|||
|
|
|
|||
|
|
return Response({
|
|||
|
|
'message': 'Image deleted successfully'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
except UploadedImage.DoesNotExist:
|
|||
|
|
return Response({
|
|||
|
|
'error': 'Image not found'
|
|||
|
|
}, status=status.HTTP_404_NOT_FOUND)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Image detail error: {e}")
|
|||
|
|
return Response({
|
|||
|
|
'error': 'Internal server error',
|
|||
|
|
'message': str(e)
|
|||
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _process_single_image(image_data, event_code, team_name, cp_number,
|
|||
|
|
upload_source, device_platform, entry, index):
|
|||
|
|
"""単一画像の処理"""
|
|||
|
|
|
|||
|
|
# Base64デコード
|
|||
|
|
try:
|
|||
|
|
if ',' in image_data['file_data']:
|
|||
|
|
# data:image/jpeg;base64,... 形式の場合
|
|||
|
|
file_data = image_data['file_data'].split(',')[1]
|
|||
|
|
else:
|
|||
|
|
file_data = image_data['file_data']
|
|||
|
|
|
|||
|
|
image_binary = base64.b64decode(file_data)
|
|||
|
|
except Exception as e:
|
|||
|
|
raise ValueError(f"Base64デコードに失敗しました: {e}")
|
|||
|
|
|
|||
|
|
# ファイル名生成
|
|||
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|||
|
|
file_extension = _get_file_extension(image_data['mime_type'])
|
|||
|
|
server_filename = f"{event_code}_{team_name}_cp{cp_number}_{timestamp}_{index:03d}{file_extension}"
|
|||
|
|
|
|||
|
|
# ディレクトリ作成
|
|||
|
|
upload_dir = f"uploads/{datetime.now().strftime('%Y/%m/%d')}"
|
|||
|
|
full_upload_dir = os.path.join(settings.MEDIA_ROOT, upload_dir)
|
|||
|
|
os.makedirs(full_upload_dir, exist_ok=True)
|
|||
|
|
|
|||
|
|
# ファイル保存
|
|||
|
|
file_path = os.path.join(upload_dir, server_filename)
|
|||
|
|
full_file_path = os.path.join(settings.MEDIA_ROOT, file_path)
|
|||
|
|
|
|||
|
|
with open(full_file_path, 'wb') as f:
|
|||
|
|
f.write(image_binary)
|
|||
|
|
|
|||
|
|
# ファイルURL生成
|
|||
|
|
file_url = f"{settings.MEDIA_URL}{file_path}"
|
|||
|
|
|
|||
|
|
# HEICからJPEGへの変換(iOS対応)
|
|||
|
|
if image_data['mime_type'] == 'image/heic' and device_platform == 'ios':
|
|||
|
|
try:
|
|||
|
|
file_url, server_filename = _convert_heic_to_jpeg(full_file_path, file_path)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"HEIC conversion failed: {e}")
|
|||
|
|
|
|||
|
|
# サムネイル生成
|
|||
|
|
thumbnail_url = _generate_thumbnail(full_file_path, file_path)
|
|||
|
|
|
|||
|
|
# データベース保存
|
|||
|
|
uploaded_image = UploadedImage.objects.create(
|
|||
|
|
original_filename=image_data['filename'],
|
|||
|
|
server_filename=server_filename,
|
|||
|
|
file_url=file_url,
|
|||
|
|
file_size=image_data['file_size'],
|
|||
|
|
mime_type=image_data['mime_type'],
|
|||
|
|
event_code=event_code,
|
|||
|
|
team_name=team_name,
|
|||
|
|
cp_number=cp_number,
|
|||
|
|
upload_source=upload_source,
|
|||
|
|
device_platform=device_platform,
|
|||
|
|
capture_timestamp=image_data.get('capture_timestamp'),
|
|||
|
|
device_info=image_data.get('device_info'),
|
|||
|
|
processing_status='processed',
|
|||
|
|
thumbnail_url=thumbnail_url,
|
|||
|
|
entry=entry
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return uploaded_image
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_file_extension(mime_type):
|
|||
|
|
"""MIMEタイプからファイル拡張子を取得"""
|
|||
|
|
mime_to_ext = {
|
|||
|
|
'image/jpeg': '.jpg',
|
|||
|
|
'image/png': '.png',
|
|||
|
|
'image/heic': '.heic',
|
|||
|
|
'image/webp': '.webp'
|
|||
|
|
}
|
|||
|
|
return mime_to_ext.get(mime_type, '.jpg')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _convert_heic_to_jpeg(heic_path, original_path):
|
|||
|
|
"""HEICファイルをJPEGに変換"""
|
|||
|
|
try:
|
|||
|
|
# PIL-HEICライブラリが必要(要インストール)
|
|||
|
|
from PIL import Image
|
|||
|
|
|
|||
|
|
# HEICファイルを開いてJPEGで保存
|
|||
|
|
with Image.open(heic_path) as img:
|
|||
|
|
jpeg_path = heic_path.replace('.heic', '.jpg')
|
|||
|
|
rgb_img = img.convert('RGB')
|
|||
|
|
rgb_img.save(jpeg_path, 'JPEG', quality=85)
|
|||
|
|
|
|||
|
|
# 元のHEICファイルを削除
|
|||
|
|
os.remove(heic_path)
|
|||
|
|
|
|||
|
|
# 新しいファイル情報を返す
|
|||
|
|
new_file_path = original_path.replace('.heic', '.jpg')
|
|||
|
|
new_file_url = f"{settings.MEDIA_URL}{new_file_path}"
|
|||
|
|
new_filename = os.path.basename(new_file_path)
|
|||
|
|
|
|||
|
|
return new_file_url, new_filename
|
|||
|
|
|
|||
|
|
except ImportError:
|
|||
|
|
logger.warning("PIL-HEIC not available, keeping original HEIC file")
|
|||
|
|
return f"{settings.MEDIA_URL}{original_path}", os.path.basename(original_path)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _generate_thumbnail(image_path, original_path):
|
|||
|
|
"""サムネイル画像生成"""
|
|||
|
|
try:
|
|||
|
|
with Image.open(image_path) as img:
|
|||
|
|
# サムネイルサイズ(300x300)
|
|||
|
|
img.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
|||
|
|
|
|||
|
|
# サムネイルファイル名
|
|||
|
|
path_parts = original_path.split('.')
|
|||
|
|
thumbnail_path = f"{'.'.join(path_parts[:-1])}_thumb.{path_parts[-1]}"
|
|||
|
|
thumbnail_full_path = os.path.join(settings.MEDIA_ROOT, thumbnail_path)
|
|||
|
|
|
|||
|
|
# サムネイル保存
|
|||
|
|
img.save(thumbnail_full_path, quality=75)
|
|||
|
|
|
|||
|
|
return f"{settings.MEDIA_URL}{thumbnail_path}"
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"Thumbnail generation failed: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(['GET'])
|
|||
|
|
@permission_classes([AllowAny])
|
|||
|
|
def uploaded_images_list(request):
|
|||
|
|
"""アップロード済み画像一覧取得"""
|
|||
|
|
|
|||
|
|
event_code = request.GET.get('event_code')
|
|||
|
|
team_name = request.GET.get('team_name')
|
|||
|
|
cp_number = request.GET.get('cp_number')
|
|||
|
|
|
|||
|
|
queryset = UploadedImage.objects.all().order_by('-upload_timestamp')
|
|||
|
|
|
|||
|
|
# フィルタリング
|
|||
|
|
if event_code:
|
|||
|
|
queryset = queryset.filter(event_code=event_code)
|
|||
|
|
if team_name:
|
|||
|
|
queryset = queryset.filter(team_name=team_name)
|
|||
|
|
if cp_number:
|
|||
|
|
queryset = queryset.filter(cp_number=cp_number)
|
|||
|
|
|
|||
|
|
# ページネーション(50件ずつ)
|
|||
|
|
page = int(request.GET.get('page', 1))
|
|||
|
|
page_size = 50
|
|||
|
|
start_index = (page - 1) * page_size
|
|||
|
|
end_index = start_index + page_size
|
|||
|
|
|
|||
|
|
total_count = queryset.count()
|
|||
|
|
images = queryset[start_index:end_index]
|
|||
|
|
|
|||
|
|
serializer = UploadedImageSerializer(images, many=True)
|
|||
|
|
|
|||
|
|
return Response({
|
|||
|
|
'images': serializer.data,
|
|||
|
|
'pagination': {
|
|||
|
|
'total_count': total_count,
|
|||
|
|
'page': page,
|
|||
|
|
'page_size': page_size,
|
|||
|
|
'has_next': end_index < total_count,
|
|||
|
|
'has_previous': page > 1
|
|||
|
|
}
|
|||
|
|
})
|