Finish basic API implementation
This commit is contained in:
242
rog/app_version_views.py
Normal file
242
rog/app_version_views.py
Normal file
@ -0,0 +1,242 @@
|
||||
"""
|
||||
App Version Check API Views
|
||||
アプリバージョンチェック機能
|
||||
"""
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views import View
|
||||
from django.http import JsonResponse
|
||||
import json
|
||||
import logging
|
||||
|
||||
from .models import AppVersion
|
||||
from .serializers import AppVersionCheckSerializer, AppVersionResponseSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def app_version_check(request):
|
||||
"""
|
||||
アプリバージョンチェックAPI
|
||||
|
||||
POST /api/app/version-check
|
||||
|
||||
Request:
|
||||
{
|
||||
"current_version": "1.2.3",
|
||||
"platform": "android",
|
||||
"build_number": "123"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"latest_version": "1.3.0",
|
||||
"update_required": false,
|
||||
"update_available": true,
|
||||
"update_message": "新機能が追加されました。更新をお勧めします。",
|
||||
"download_url": "https://play.google.com/store/apps/details?id=com.example.app",
|
||||
"release_date": "2025-08-25T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# リクエストデータの検証
|
||||
serializer = AppVersionCheckSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response({
|
||||
'error': 'Invalid request data',
|
||||
'details': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
current_version = serializer.validated_data['current_version']
|
||||
platform = serializer.validated_data['platform']
|
||||
build_number = serializer.validated_data.get('build_number')
|
||||
|
||||
# 最新バージョン情報を取得
|
||||
latest_version_obj = AppVersion.get_latest_version(platform)
|
||||
|
||||
if not latest_version_obj:
|
||||
return Response({
|
||||
'error': 'No version information available for this platform'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# バージョン比較
|
||||
comparison = AppVersion.compare_versions(current_version, latest_version_obj.version)
|
||||
|
||||
# レスポンスデータ作成
|
||||
response_data = {
|
||||
'latest_version': latest_version_obj.version,
|
||||
'update_required': False,
|
||||
'update_available': False,
|
||||
'update_message': latest_version_obj.update_message or 'アプリは最新版です',
|
||||
'download_url': latest_version_obj.download_url or '',
|
||||
'release_date': latest_version_obj.release_date
|
||||
}
|
||||
|
||||
if comparison < 0: # current_version < latest_version
|
||||
response_data['update_available'] = True
|
||||
|
||||
# 強制更新が必要かチェック
|
||||
if latest_version_obj.is_required:
|
||||
response_data['update_required'] = True
|
||||
response_data['update_message'] = (
|
||||
latest_version_obj.update_message or
|
||||
'このバージョンは古すぎるため、更新が必要です。'
|
||||
)
|
||||
else:
|
||||
response_data['update_message'] = (
|
||||
latest_version_obj.update_message or
|
||||
'新しいバージョンが利用可能です。更新をお勧めします。'
|
||||
)
|
||||
|
||||
# レスポンス検証
|
||||
response_serializer = AppVersionResponseSerializer(data=response_data)
|
||||
if response_serializer.is_valid():
|
||||
return Response(response_serializer.validated_data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
logger.error(f"Response serialization error: {response_serializer.errors}")
|
||||
return Response({
|
||||
'error': 'Internal server error'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"App version check error: {e}")
|
||||
return Response({
|
||||
'error': 'Internal server error',
|
||||
'message': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class AppVersionManagementView(View):
|
||||
"""アプリバージョン管理ビュー(管理者用)"""
|
||||
|
||||
def get(self, request):
|
||||
"""全バージョン情報取得"""
|
||||
try:
|
||||
platform = request.GET.get('platform')
|
||||
|
||||
queryset = AppVersion.objects.all().order_by('-created_at')
|
||||
if platform:
|
||||
queryset = queryset.filter(platform=platform)
|
||||
|
||||
versions = []
|
||||
for version in queryset:
|
||||
versions.append({
|
||||
'id': version.id,
|
||||
'version': version.version,
|
||||
'platform': version.platform,
|
||||
'build_number': version.build_number,
|
||||
'is_latest': version.is_latest,
|
||||
'is_required': version.is_required,
|
||||
'update_message': version.update_message,
|
||||
'download_url': version.download_url,
|
||||
'release_date': version.release_date.isoformat(),
|
||||
'created_at': version.created_at.isoformat()
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'versions': versions,
|
||||
'total': len(versions)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Version list error: {e}")
|
||||
return JsonResponse({
|
||||
'error': 'Failed to fetch versions'
|
||||
}, status=500)
|
||||
|
||||
def post(self, request):
|
||||
"""新バージョン登録"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
# 必須フィールドチェック
|
||||
required_fields = ['version', 'platform']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return JsonResponse({
|
||||
'error': f'Missing required field: {field}'
|
||||
}, status=400)
|
||||
|
||||
# バージョンオブジェクト作成
|
||||
version_obj = AppVersion(
|
||||
version=data['version'],
|
||||
platform=data['platform'],
|
||||
build_number=data.get('build_number'),
|
||||
is_latest=data.get('is_latest', False),
|
||||
is_required=data.get('is_required', False),
|
||||
update_message=data.get('update_message'),
|
||||
download_url=data.get('download_url')
|
||||
)
|
||||
|
||||
version_obj.save()
|
||||
|
||||
return JsonResponse({
|
||||
'message': 'Version created successfully',
|
||||
'id': version_obj.id,
|
||||
'version': version_obj.version,
|
||||
'platform': version_obj.platform
|
||||
}, status=201)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'error': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Version creation error: {e}")
|
||||
return JsonResponse({
|
||||
'error': 'Failed to create version'
|
||||
}, status=500)
|
||||
|
||||
def put(self, request):
|
||||
"""バージョン情報更新"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
version_id = data.get('id')
|
||||
|
||||
if not version_id:
|
||||
return JsonResponse({
|
||||
'error': 'Version ID is required'
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
version_obj = AppVersion.objects.get(id=version_id)
|
||||
except AppVersion.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'error': 'Version not found'
|
||||
}, status=404)
|
||||
|
||||
# フィールド更新
|
||||
updateable_fields = [
|
||||
'build_number', 'is_latest', 'is_required',
|
||||
'update_message', 'download_url'
|
||||
]
|
||||
|
||||
for field in updateable_fields:
|
||||
if field in data:
|
||||
setattr(version_obj, field, data[field])
|
||||
|
||||
version_obj.save()
|
||||
|
||||
return JsonResponse({
|
||||
'message': 'Version updated successfully',
|
||||
'id': version_obj.id,
|
||||
'version': version_obj.version
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'error': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Version update error: {e}")
|
||||
return JsonResponse({
|
||||
'error': 'Failed to update version'
|
||||
}, status=500)
|
||||
357
rog/gpx_route_views.py
Normal file
357
rog/gpx_route_views.py
Normal file
@ -0,0 +1,357 @@
|
||||
"""
|
||||
GPX Test Route API Views
|
||||
GPXシミュレーション用のテストルートデータ取得
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import NewEvent2, Location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
def gpx_test_data(request):
|
||||
"""
|
||||
GPXシミュレーション用のテストルートデータ取得
|
||||
|
||||
GET /api/routes/gpx-test-data
|
||||
|
||||
Parameters:
|
||||
- event_code: イベントコード
|
||||
- route_type: ルートタイプ (sample, short, long)
|
||||
"""
|
||||
|
||||
try:
|
||||
event_code = request.GET.get('event_code')
|
||||
route_type = request.GET.get('route_type', 'sample')
|
||||
|
||||
if not event_code:
|
||||
return Response({
|
||||
'error': 'event_code parameter is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# イベントの存在確認
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_name=event_code)
|
||||
except NewEvent2.DoesNotExist:
|
||||
return Response({
|
||||
'error': f'Event "{event_code}" not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# ルートタイプに応じたテストデータ生成
|
||||
routes = []
|
||||
|
||||
if route_type == 'sample':
|
||||
routes = _generate_sample_route(event_code)
|
||||
elif route_type == 'short':
|
||||
routes = _generate_short_route(event_code)
|
||||
elif route_type == 'long':
|
||||
routes = _generate_long_route(event_code)
|
||||
else:
|
||||
routes = _generate_sample_route(event_code)
|
||||
|
||||
return Response({
|
||||
'routes': routes,
|
||||
'event_code': event_code,
|
||||
'route_type': route_type,
|
||||
'generated_at': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GPX test data error: {e}")
|
||||
return Response({
|
||||
'error': 'Internal server error',
|
||||
'message': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
def _generate_sample_route(event_code):
|
||||
"""サンプルルート生成"""
|
||||
|
||||
# 岐阜市内の主要ポイント
|
||||
waypoints = [
|
||||
{
|
||||
"lat": 35.4122,
|
||||
"lng": 136.7514,
|
||||
"timestamp": "2025-09-15T10:00:00Z",
|
||||
"cp_number": 1,
|
||||
"description": "岐阜公園",
|
||||
"elevation": 15
|
||||
},
|
||||
{
|
||||
"lat": 35.4089,
|
||||
"lng": 136.7581,
|
||||
"timestamp": "2025-09-15T10:15:00Z",
|
||||
"cp_number": 2,
|
||||
"description": "岐阜城天守閣",
|
||||
"elevation": 329
|
||||
},
|
||||
{
|
||||
"lat": 35.4091,
|
||||
"lng": 136.7456,
|
||||
"timestamp": "2025-09-15T10:30:00Z",
|
||||
"cp_number": 3,
|
||||
"description": "長良川うかいミュージアム",
|
||||
"elevation": 12
|
||||
},
|
||||
{
|
||||
"lat": 35.4187,
|
||||
"lng": 136.7598,
|
||||
"timestamp": "2025-09-15T10:45:00Z",
|
||||
"cp_number": 4,
|
||||
"description": "岐阜市歴史博物館",
|
||||
"elevation": 18
|
||||
},
|
||||
{
|
||||
"lat": 35.4122,
|
||||
"lng": 136.7514,
|
||||
"timestamp": "2025-09-15T11:00:00Z",
|
||||
"cp_number": 0,
|
||||
"description": "岐阜公園(ゴール)",
|
||||
"elevation": 15
|
||||
}
|
||||
]
|
||||
|
||||
gpx_data = _generate_gpx_xml(waypoints, "岐阜市内サンプルルート")
|
||||
|
||||
return [{
|
||||
"route_name": "岐阜市内サンプルルート",
|
||||
"description": "チェックポイント1-4を巡回するテストルート",
|
||||
"estimated_time": "60分",
|
||||
"total_distance": "約3.2km",
|
||||
"elevation_gain": "約314m",
|
||||
"difficulty": "中級",
|
||||
"waypoints": waypoints,
|
||||
"gpx_data": gpx_data
|
||||
}]
|
||||
|
||||
|
||||
def _generate_short_route(event_code):
|
||||
"""短距離ルート生成"""
|
||||
|
||||
waypoints = [
|
||||
{
|
||||
"lat": 35.4122,
|
||||
"lng": 136.7514,
|
||||
"timestamp": "2025-09-15T10:00:00Z",
|
||||
"cp_number": 1,
|
||||
"description": "岐阜公園(スタート)",
|
||||
"elevation": 15
|
||||
},
|
||||
{
|
||||
"lat": 35.4150,
|
||||
"lng": 136.7545,
|
||||
"timestamp": "2025-09-15T10:10:00Z",
|
||||
"cp_number": 2,
|
||||
"description": "信長の居館跡",
|
||||
"elevation": 25
|
||||
},
|
||||
{
|
||||
"lat": 35.4122,
|
||||
"lng": 136.7514,
|
||||
"timestamp": "2025-09-15T10:20:00Z",
|
||||
"cp_number": 0,
|
||||
"description": "岐阜公園(ゴール)",
|
||||
"elevation": 15
|
||||
}
|
||||
]
|
||||
|
||||
gpx_data = _generate_gpx_xml(waypoints, "岐阜公園周辺ショートルート")
|
||||
|
||||
return [{
|
||||
"route_name": "岐阜公園周辺ショートルート",
|
||||
"description": "初心者向けの短距離ルート",
|
||||
"estimated_time": "20分",
|
||||
"total_distance": "約0.8km",
|
||||
"elevation_gain": "約10m",
|
||||
"difficulty": "初級",
|
||||
"waypoints": waypoints,
|
||||
"gpx_data": gpx_data
|
||||
}]
|
||||
|
||||
|
||||
def _generate_long_route(event_code):
|
||||
"""長距離ルート生成"""
|
||||
|
||||
waypoints = [
|
||||
{
|
||||
"lat": 35.4122,
|
||||
"lng": 136.7514,
|
||||
"timestamp": "2025-09-15T10:00:00Z",
|
||||
"cp_number": 1,
|
||||
"description": "岐阜公園(スタート)",
|
||||
"elevation": 15
|
||||
},
|
||||
{
|
||||
"lat": 35.4089,
|
||||
"lng": 136.7581,
|
||||
"timestamp": "2025-09-15T10:20:00Z",
|
||||
"cp_number": 2,
|
||||
"description": "岐阜城天守閣",
|
||||
"elevation": 329
|
||||
},
|
||||
{
|
||||
"lat": 35.3978,
|
||||
"lng": 136.7456,
|
||||
"timestamp": "2025-09-15T10:45:00Z",
|
||||
"cp_number": 3,
|
||||
"description": "長良川河川敷",
|
||||
"elevation": 8
|
||||
},
|
||||
{
|
||||
"lat": 35.4234,
|
||||
"lng": 136.7345,
|
||||
"timestamp": "2025-09-15T11:15:00Z",
|
||||
"cp_number": 4,
|
||||
"description": "金華橋",
|
||||
"elevation": 10
|
||||
},
|
||||
{
|
||||
"lat": 35.4391,
|
||||
"lng": 136.7598,
|
||||
"timestamp": "2025-09-15T11:45:00Z",
|
||||
"cp_number": 5,
|
||||
"description": "護国神社",
|
||||
"elevation": 35
|
||||
},
|
||||
{
|
||||
"lat": 35.4187,
|
||||
"lng": 136.7698,
|
||||
"timestamp": "2025-09-15T12:10:00Z",
|
||||
"cp_number": 6,
|
||||
"description": "岐阜メモリアルセンター",
|
||||
"elevation": 22
|
||||
},
|
||||
{
|
||||
"lat": 35.4122,
|
||||
"lng": 136.7514,
|
||||
"timestamp": "2025-09-15T12:30:00Z",
|
||||
"cp_number": 0,
|
||||
"description": "岐阜公園(ゴール)",
|
||||
"elevation": 15
|
||||
}
|
||||
]
|
||||
|
||||
gpx_data = _generate_gpx_xml(waypoints, "岐阜市内ロングルート")
|
||||
|
||||
return [{
|
||||
"route_name": "岐阜市内ロングルート",
|
||||
"description": "上級者向けの長距離チャレンジルート",
|
||||
"estimated_time": "150分",
|
||||
"total_distance": "約8.5km",
|
||||
"elevation_gain": "約321m",
|
||||
"difficulty": "上級",
|
||||
"waypoints": waypoints,
|
||||
"gpx_data": gpx_data
|
||||
}]
|
||||
|
||||
|
||||
def _generate_gpx_xml(waypoints, route_name):
|
||||
"""GPXファイル形式のXMLを生成"""
|
||||
|
||||
gpx_header = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="GifuRogaining" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<name>{}</name>
|
||||
<desc>Generated test route for rogaining simulation</desc>
|
||||
<time>{}</time>
|
||||
</metadata>'''.format(route_name, datetime.now().isoformat())
|
||||
|
||||
# トラックセグメント
|
||||
track_points = []
|
||||
for waypoint in waypoints:
|
||||
track_points.append(''' <trkpt lat="{}" lon="{}">
|
||||
<ele>{}</ele>
|
||||
<time>{}</time>
|
||||
<name>CP{} - {}</name>
|
||||
</trkpt>'''.format(
|
||||
waypoint['lat'],
|
||||
waypoint['lng'],
|
||||
waypoint.get('elevation', 0),
|
||||
waypoint['timestamp'],
|
||||
waypoint['cp_number'],
|
||||
waypoint['description']
|
||||
))
|
||||
|
||||
# ウェイポイント
|
||||
waypoint_elements = []
|
||||
for waypoint in waypoints:
|
||||
waypoint_elements.append(''' <wpt lat="{}" lon="{}">
|
||||
<ele>{}</ele>
|
||||
<time>{}</time>
|
||||
<name>CP{}</name>
|
||||
<desc>{}</desc>
|
||||
<sym>Flag, Blue</sym>
|
||||
</wpt>'''.format(
|
||||
waypoint['lat'],
|
||||
waypoint['lng'],
|
||||
waypoint.get('elevation', 0),
|
||||
waypoint['timestamp'],
|
||||
waypoint['cp_number'],
|
||||
waypoint['description']
|
||||
))
|
||||
|
||||
gpx_content = f'''{gpx_header}
|
||||
|
||||
<trk>
|
||||
<name>{route_name}</name>
|
||||
<desc>Test route for rogaining simulation</desc>
|
||||
<trkseg>
|
||||
{chr(10).join(track_points)}
|
||||
</trkseg>
|
||||
</trk>
|
||||
|
||||
{chr(10).join(waypoint_elements)}
|
||||
|
||||
</gpx>'''
|
||||
|
||||
return gpx_content
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
def available_routes(request):
|
||||
"""利用可能なテストルート一覧取得"""
|
||||
|
||||
event_code = request.GET.get('event_code')
|
||||
|
||||
routes_info = [
|
||||
{
|
||||
"route_type": "sample",
|
||||
"name": "岐阜市内サンプルルート",
|
||||
"description": "標準的なテストルート",
|
||||
"estimated_time": "60分",
|
||||
"difficulty": "中級",
|
||||
"checkpoint_count": 4
|
||||
},
|
||||
{
|
||||
"route_type": "short",
|
||||
"name": "岐阜公園周辺ショートルート",
|
||||
"description": "初心者向けの短距離ルート",
|
||||
"estimated_time": "20分",
|
||||
"difficulty": "初級",
|
||||
"checkpoint_count": 2
|
||||
},
|
||||
{
|
||||
"route_type": "long",
|
||||
"name": "岐阜市内ロングルート",
|
||||
"description": "上級者向けの長距離ルート",
|
||||
"estimated_time": "150分",
|
||||
"difficulty": "上級",
|
||||
"checkpoint_count": 6
|
||||
}
|
||||
]
|
||||
|
||||
return Response({
|
||||
"available_routes": routes_info,
|
||||
"event_code": event_code,
|
||||
"total_routes": len(routes_info)
|
||||
})
|
||||
240
rog/location_checkin_view.py
Normal file
240
rog/location_checkin_view.py
Normal file
@ -0,0 +1,240 @@
|
||||
"""
|
||||
Location checkin view with evaluation_value based interaction logic
|
||||
"""
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class LocationCheckinView(View):
|
||||
"""
|
||||
evaluation_valueに基づく拡張チェックイン処理
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
ロケーションチェックイン処理
|
||||
|
||||
Request body:
|
||||
{
|
||||
"location_id": int,
|
||||
"latitude": float,
|
||||
"longitude": float,
|
||||
"photo": str (base64) - evaluation_value=1の場合必須,
|
||||
"qr_code_data": str - evaluation_value=2の場合必須,
|
||||
"quiz_answer": str - evaluation_value=2の場合必須
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
location_id = data.get('location_id')
|
||||
user_lat = data.get('latitude')
|
||||
user_lon = data.get('longitude')
|
||||
|
||||
if not all([location_id, user_lat, user_lon]):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'location_id, latitude, longitude are required'
|
||||
}, status=400)
|
||||
|
||||
# ロケーション取得
|
||||
from .models import Location
|
||||
try:
|
||||
location = Location.objects.get(id=location_id)
|
||||
except Location.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Location not found'
|
||||
}, status=404)
|
||||
|
||||
# 距離チェック
|
||||
if not self._is_within_checkin_radius(location, user_lat, user_lon):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Too far from location',
|
||||
'required_radius': location.checkin_radius or 15.0
|
||||
}, status=400)
|
||||
|
||||
# evaluation_valueに基づく要件検証
|
||||
from .location_interaction import validate_interaction_requirements
|
||||
validation_result = validate_interaction_requirements(location, data)
|
||||
|
||||
if not validation_result['valid']:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Interaction requirements not met',
|
||||
'errors': validation_result['errors']
|
||||
}, status=400)
|
||||
|
||||
# インタラクション処理
|
||||
interaction_result = self._process_interaction(location, data)
|
||||
|
||||
# ポイント計算
|
||||
from .location_interaction import get_point_calculation
|
||||
point_info = get_point_calculation(location, interaction_result)
|
||||
|
||||
# チェックイン記録保存
|
||||
checkin_record = self._save_checkin_record(
|
||||
request.user, location, user_lat, user_lon,
|
||||
interaction_result, point_info
|
||||
)
|
||||
|
||||
# レスポンス
|
||||
response_data = {
|
||||
'success': True,
|
||||
'checkin_id': checkin_record.id,
|
||||
'points_awarded': point_info['points_awarded'],
|
||||
'point_type': point_info['point_type'],
|
||||
'message': point_info['message'],
|
||||
'location_name': location.location_name,
|
||||
'interaction_type': location.evaluation_value or "0",
|
||||
}
|
||||
|
||||
# インタラクション結果の詳細を追加
|
||||
if interaction_result:
|
||||
response_data['interaction_result'] = interaction_result
|
||||
|
||||
return JsonResponse(response_data)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Checkin error: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Internal server error'
|
||||
}, status=500)
|
||||
|
||||
def _is_within_checkin_radius(self, location, user_lat, user_lon):
|
||||
"""チェックイン範囲内かどうかを判定"""
|
||||
from math import radians, cos, sin, asin, sqrt
|
||||
|
||||
# ロケーションの座標を取得
|
||||
if location.geom and location.geom.coords:
|
||||
loc_lon, loc_lat = location.geom.coords[0][:2]
|
||||
else:
|
||||
loc_lat = location.latitude
|
||||
loc_lon = location.longitude
|
||||
|
||||
if not all([loc_lat, loc_lon]):
|
||||
return False
|
||||
|
||||
# Haversine公式で距離計算
|
||||
def haversine(lon1, lat1, lon2, lat2):
|
||||
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
|
||||
dlon = lon2 - lon1
|
||||
dlat = lat2 - lat1
|
||||
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
||||
c = 2 * asin(sqrt(a))
|
||||
r = 6371000 # 地球の半径(メートル)
|
||||
return c * r
|
||||
|
||||
distance = haversine(loc_lon, loc_lat, user_lon, user_lat)
|
||||
allowed_radius = location.checkin_radius or 15.0
|
||||
|
||||
return distance <= allowed_radius
|
||||
|
||||
def _process_interaction(self, location, data):
|
||||
"""evaluation_valueに基づくインタラクション処理"""
|
||||
evaluation_value = location.evaluation_value or "0"
|
||||
result = {}
|
||||
|
||||
if evaluation_value == "1":
|
||||
# 写真撮影処理
|
||||
photo_data = data.get('photo')
|
||||
if photo_data:
|
||||
result['photo_saved'] = True
|
||||
result['photo_filename'] = self._save_photo(photo_data, location)
|
||||
|
||||
elif evaluation_value == "2":
|
||||
# QRコード + クイズ処理
|
||||
qr_data = data.get('qr_code_data')
|
||||
quiz_answer = data.get('quiz_answer')
|
||||
|
||||
if qr_data and quiz_answer:
|
||||
result['qr_scanned'] = True
|
||||
result['quiz_answer'] = quiz_answer
|
||||
result['quiz_correct'] = self._check_quiz_answer(qr_data, quiz_answer)
|
||||
|
||||
return result
|
||||
|
||||
def _save_photo(self, photo_data, location):
|
||||
"""写真データを保存(実装は要調整)"""
|
||||
import base64
|
||||
import os
|
||||
from django.conf import settings
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
# Base64デコード
|
||||
photo_binary = base64.b64decode(photo_data)
|
||||
|
||||
# ファイル名生成
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"checkin_{location.id}_{timestamp}.jpg"
|
||||
|
||||
# 保存先ディレクトリ
|
||||
photo_dir = os.path.join(settings.MEDIA_ROOT, 'checkin_photos')
|
||||
os.makedirs(photo_dir, exist_ok=True)
|
||||
|
||||
# ファイル保存
|
||||
file_path = os.path.join(photo_dir, filename)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(photo_binary)
|
||||
|
||||
return filename
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Photo save error: {e}")
|
||||
return None
|
||||
|
||||
def _check_quiz_answer(self, qr_data, quiz_answer):
|
||||
"""クイズ回答の正答チェック(実装は要調整)"""
|
||||
# QRコードデータから正答を取得
|
||||
# 実際の実装では、QRコードに含まれるクイズIDから正答を取得
|
||||
try:
|
||||
import json
|
||||
qr_info = json.loads(qr_data)
|
||||
correct_answer = qr_info.get('correct_answer', '').lower()
|
||||
user_answer = quiz_answer.lower().strip()
|
||||
|
||||
return correct_answer == user_answer
|
||||
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# QRデータの形式が不正な場合はデフォルトで不正解
|
||||
return False
|
||||
|
||||
def _save_checkin_record(self, user, location, lat, lon, interaction_result, point_info):
|
||||
"""チェックイン記録を保存"""
|
||||
from .models import Useractions
|
||||
from datetime import datetime
|
||||
|
||||
# Useractionsレコード作成/更新
|
||||
checkin_record, created = Useractions.objects.get_or_create(
|
||||
user=user,
|
||||
location=location,
|
||||
defaults={
|
||||
'checkin': True,
|
||||
'created_at': datetime.now(),
|
||||
'last_updated_at': datetime.now()
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
checkin_record.checkin = True
|
||||
checkin_record.last_updated_at = datetime.now()
|
||||
checkin_record.save()
|
||||
|
||||
return checkin_record
|
||||
192
rog/location_interaction.py
Normal file
192
rog/location_interaction.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
Location evaluation_value に基づく処理ロジック
|
||||
|
||||
evaluation_value の値に応じた処理を定義:
|
||||
- 0: 通常ポイント (通常のチェックイン)
|
||||
- 1: 写真撮影 + 買い物ポイント
|
||||
- 2: QRコードスキャン + クイズ回答
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class LocationInteractionType:
|
||||
"""ロケーションインタラクションタイプの定数"""
|
||||
NORMAL_CHECKIN = "0"
|
||||
PHOTO_SHOPPING = "1"
|
||||
QR_QUIZ = "2"
|
||||
|
||||
CHOICES = [
|
||||
(NORMAL_CHECKIN, _("通常ポイント")),
|
||||
(PHOTO_SHOPPING, _("写真撮影 + 買い物ポイント")),
|
||||
(QR_QUIZ, _("QRコードスキャン + クイズ回答")),
|
||||
]
|
||||
|
||||
|
||||
def get_interaction_type(location):
|
||||
"""
|
||||
Locationオブジェクトから適切なインタラクションタイプを取得
|
||||
|
||||
Args:
|
||||
location: Locationモデルのインスタンス
|
||||
|
||||
Returns:
|
||||
dict: インタラクション情報
|
||||
"""
|
||||
evaluation_value = location.evaluation_value or "0"
|
||||
|
||||
interaction_info = {
|
||||
'type': evaluation_value,
|
||||
'requires_photo': False,
|
||||
'requires_qr_code': False,
|
||||
'point_type': 'checkin',
|
||||
'description': '',
|
||||
'instructions': '',
|
||||
}
|
||||
|
||||
if evaluation_value == LocationInteractionType.NORMAL_CHECKIN:
|
||||
interaction_info.update({
|
||||
'point_type': 'checkin',
|
||||
'description': '通常のチェックイン',
|
||||
'instructions': 'この場所でチェックインしてポイントを獲得してください',
|
||||
})
|
||||
|
||||
elif evaluation_value == LocationInteractionType.PHOTO_SHOPPING:
|
||||
interaction_info.update({
|
||||
'requires_photo': True,
|
||||
'point_type': 'buy',
|
||||
'description': '写真撮影 + 買い物ポイント',
|
||||
'instructions': '商品の写真を撮影してください。買い物をすることでポイントを獲得できます',
|
||||
})
|
||||
|
||||
elif evaluation_value == LocationInteractionType.QR_QUIZ:
|
||||
interaction_info.update({
|
||||
'requires_qr_code': True,
|
||||
'point_type': 'quiz',
|
||||
'description': 'QRコードスキャン + クイズ回答',
|
||||
'instructions': 'QRコードをスキャンしてクイズに答えてください',
|
||||
})
|
||||
|
||||
else:
|
||||
# 未知の値の場合はデフォルト処理
|
||||
interaction_info.update({
|
||||
'point_type': 'checkin',
|
||||
'description': '通常のチェックイン',
|
||||
'instructions': 'この場所でチェックインしてポイントを獲得してください',
|
||||
})
|
||||
|
||||
return interaction_info
|
||||
|
||||
|
||||
def should_use_qr_code(location):
|
||||
"""
|
||||
ロケーションでQRコードを使用すべきかを判定
|
||||
|
||||
Args:
|
||||
location: Locationモデルのインスタンス
|
||||
|
||||
Returns:
|
||||
bool: QRコード使用フラグ
|
||||
"""
|
||||
# use_qr_codeフラグが設定されている場合、またはevaluation_value=2の場合
|
||||
return (getattr(location, 'use_qr_code', False) or
|
||||
location.evaluation_value == LocationInteractionType.QR_QUIZ)
|
||||
|
||||
|
||||
def get_point_calculation(location, interaction_result=None):
|
||||
"""
|
||||
ロケーションでのポイント計算
|
||||
|
||||
Args:
|
||||
location: Locationモデルのインスタンス
|
||||
interaction_result: インタラクション結果 (写真、クイズ回答など)
|
||||
|
||||
Returns:
|
||||
dict: ポイント情報
|
||||
"""
|
||||
evaluation_value = location.evaluation_value or "0"
|
||||
base_checkin_point = location.checkin_point or 10
|
||||
buy_point = location.buy_point or 0
|
||||
|
||||
point_info = {
|
||||
'points_awarded': 0,
|
||||
'point_type': 'checkin',
|
||||
'bonus_applied': False,
|
||||
'message': '',
|
||||
}
|
||||
|
||||
if evaluation_value == LocationInteractionType.NORMAL_CHECKIN:
|
||||
# 通常ポイント
|
||||
point_info.update({
|
||||
'points_awarded': base_checkin_point,
|
||||
'point_type': 'checkin',
|
||||
'message': f'チェックインポイント {base_checkin_point}pt を獲得しました!',
|
||||
})
|
||||
|
||||
elif evaluation_value == LocationInteractionType.PHOTO_SHOPPING:
|
||||
# 写真撮影 + 買い物ポイント
|
||||
total_points = base_checkin_point + buy_point
|
||||
point_info.update({
|
||||
'points_awarded': total_points,
|
||||
'point_type': 'buy',
|
||||
'bonus_applied': True,
|
||||
'message': f'写真撮影ボーナス込みで {total_points}pt を獲得しました! (基本: {base_checkin_point}pt + ボーナス: {buy_point}pt)',
|
||||
})
|
||||
|
||||
elif evaluation_value == LocationInteractionType.QR_QUIZ:
|
||||
# QRクイズの場合、正答によってポイントが変わる
|
||||
if interaction_result and interaction_result.get('quiz_correct', False):
|
||||
bonus_points = 20 # クイズ正答ボーナス
|
||||
total_points = base_checkin_point + bonus_points
|
||||
point_info.update({
|
||||
'points_awarded': total_points,
|
||||
'point_type': 'quiz',
|
||||
'bonus_applied': True,
|
||||
'message': f'クイズ正答ボーナス込みで {total_points}pt を獲得しました! (基本: {base_checkin_point}pt + ボーナス: {bonus_points}pt)',
|
||||
})
|
||||
else:
|
||||
# 不正解またはクイズ未実施
|
||||
point_info.update({
|
||||
'points_awarded': base_checkin_point,
|
||||
'point_type': 'checkin',
|
||||
'message': f'基本ポイント {base_checkin_point}pt を獲得しました',
|
||||
})
|
||||
|
||||
return point_info
|
||||
|
||||
|
||||
def validate_interaction_requirements(location, request_data):
|
||||
"""
|
||||
インタラクション要件の検証
|
||||
|
||||
Args:
|
||||
location: Locationモデルのインスタンス
|
||||
request_data: リクエストデータ
|
||||
|
||||
Returns:
|
||||
dict: 検証結果
|
||||
"""
|
||||
evaluation_value = location.evaluation_value or "0"
|
||||
validation_result = {
|
||||
'valid': True,
|
||||
'errors': [],
|
||||
'warnings': [],
|
||||
}
|
||||
|
||||
if evaluation_value == LocationInteractionType.PHOTO_SHOPPING:
|
||||
# 写真が必要
|
||||
if not request_data.get('photo'):
|
||||
validation_result['valid'] = False
|
||||
validation_result['errors'].append('写真の撮影が必要です')
|
||||
|
||||
elif evaluation_value == LocationInteractionType.QR_QUIZ:
|
||||
# QRコードスキャンとクイズ回答が必要
|
||||
if not request_data.get('qr_code_data'):
|
||||
validation_result['valid'] = False
|
||||
validation_result['errors'].append('QRコードのスキャンが必要です')
|
||||
|
||||
if not request_data.get('quiz_answer'):
|
||||
validation_result['valid'] = False
|
||||
validation_result['errors'].append('クイズの回答が必要です')
|
||||
|
||||
return validation_result
|
||||
265
rog/models.py
265
rog/models.py
@ -6,6 +6,11 @@ from pyexpat import model
|
||||
from sre_constants import CH_LOCALE
|
||||
from typing import ChainMap
|
||||
from django.contrib.gis.db import models
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
try:
|
||||
from django.db.models import JSONField
|
||||
except ImportError:
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save, post_delete, pre_save
|
||||
@ -308,6 +313,210 @@ class TempUser(models.Model):
|
||||
def is_valid(self):
|
||||
return timezone.now() <= self.expires_at
|
||||
|
||||
|
||||
class AppVersion(models.Model):
|
||||
"""アプリバージョン管理モデル"""
|
||||
|
||||
PLATFORM_CHOICES = [
|
||||
('android', 'Android'),
|
||||
('ios', 'iOS'),
|
||||
]
|
||||
|
||||
version = models.CharField(max_length=20, help_text="セマンティックバージョン (1.2.3)")
|
||||
platform = models.CharField(max_length=10, choices=PLATFORM_CHOICES)
|
||||
build_number = models.CharField(max_length=20, blank=True, null=True)
|
||||
is_latest = models.BooleanField(default=False, help_text="最新版フラグ")
|
||||
is_required = models.BooleanField(default=False, help_text="強制更新フラグ")
|
||||
update_message = models.TextField(blank=True, null=True, help_text="ユーザー向け更新メッセージ")
|
||||
download_url = models.URLField(blank=True, null=True, help_text="アプリストアURL")
|
||||
release_date = models.DateTimeField(default=timezone.now)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'app_versions'
|
||||
unique_together = ['version', 'platform']
|
||||
indexes = [
|
||||
models.Index(fields=['platform'], name='idx_app_versions_platform'),
|
||||
models.Index(
|
||||
fields=['is_latest'],
|
||||
condition=models.Q(is_latest=True),
|
||||
name='idx_app_versions_latest_true'
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.platform} {self.version}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""最新版フラグが設定された場合、同一プラットフォームの他のバージョンを非最新にする"""
|
||||
if self.is_latest:
|
||||
AppVersion.objects.filter(
|
||||
platform=self.platform,
|
||||
is_latest=True
|
||||
).exclude(pk=self.pk).update(is_latest=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def compare_versions(cls, version1, version2):
|
||||
"""セマンティックバージョンの比較"""
|
||||
def version_tuple(v):
|
||||
return tuple(map(int, v.split('.')))
|
||||
|
||||
v1 = version_tuple(version1)
|
||||
v2 = version_tuple(version2)
|
||||
|
||||
if v1 < v2:
|
||||
return -1
|
||||
elif v1 > v2:
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def get_latest_version(cls, platform):
|
||||
"""指定プラットフォームの最新バージョンを取得"""
|
||||
try:
|
||||
return cls.objects.filter(platform=platform, is_latest=True).first()
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class CheckinExtended(models.Model):
|
||||
"""チェックイン拡張情報モデル"""
|
||||
|
||||
VALIDATION_STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('requires_review', 'Requires Review'),
|
||||
]
|
||||
|
||||
gpslog = models.ForeignKey('GpsCheckin', on_delete=models.CASCADE, related_name='extended_info')
|
||||
|
||||
# GPS拡張情報
|
||||
gps_latitude = models.DecimalField(max_digits=10, decimal_places=8, null=True, blank=True)
|
||||
gps_longitude = models.DecimalField(max_digits=11, decimal_places=8, null=True, blank=True)
|
||||
gps_accuracy = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True, help_text="GPS精度(メートル)")
|
||||
gps_timestamp = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# カメラメタデータ
|
||||
camera_capture_time = models.DateTimeField(null=True, blank=True)
|
||||
device_info = models.TextField(blank=True, null=True)
|
||||
|
||||
# 審査・検証情報
|
||||
validation_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=VALIDATION_STATUS_CHOICES,
|
||||
default='pending'
|
||||
)
|
||||
validation_comment = models.TextField(blank=True, null=True)
|
||||
validated_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
validated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# スコア情報
|
||||
bonus_points = models.IntegerField(default=0)
|
||||
scoring_breakdown = JSONField(default=dict, blank=True)
|
||||
|
||||
# システム情報
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'rog_checkin_extended'
|
||||
indexes = [
|
||||
models.Index(fields=['validation_status'], name='idx_checkin_ext_valid'),
|
||||
models.Index(fields=['created_at'], name='idx_checkin_ext_created'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"CheckinExtended {self.gpslog_id} - {self.validation_status}"
|
||||
|
||||
|
||||
class UploadedImage(models.Model):
|
||||
"""画像アップロード管理モデル - マルチアップロード対応"""
|
||||
|
||||
UPLOAD_SOURCE_CHOICES = [
|
||||
('direct', 'Direct'),
|
||||
('sharing_intent', 'Sharing Intent'),
|
||||
('bulk_upload', 'Bulk Upload'),
|
||||
]
|
||||
|
||||
PLATFORM_CHOICES = [
|
||||
('ios', 'iOS'),
|
||||
('android', 'Android'),
|
||||
('web', 'Web'),
|
||||
]
|
||||
|
||||
PROCESSING_STATUS_CHOICES = [
|
||||
('uploaded', 'Uploaded'),
|
||||
('processing', 'Processing'),
|
||||
('processed', 'Processed'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
MIME_TYPE_CHOICES = [
|
||||
('image/jpeg', 'JPEG'),
|
||||
('image/png', 'PNG'),
|
||||
('image/heic', 'HEIC'),
|
||||
('image/webp', 'WebP'),
|
||||
]
|
||||
|
||||
# 基本情報
|
||||
original_filename = models.CharField(max_length=255)
|
||||
server_filename = models.CharField(max_length=255, unique=True)
|
||||
file_url = models.URLField()
|
||||
file_size = models.BigIntegerField()
|
||||
mime_type = models.CharField(max_length=50, choices=MIME_TYPE_CHOICES)
|
||||
|
||||
# 関連情報
|
||||
event_code = models.CharField(max_length=50, blank=True, null=True)
|
||||
team_name = models.CharField(max_length=255, blank=True, null=True)
|
||||
cp_number = models.IntegerField(blank=True, null=True)
|
||||
|
||||
# アップロード情報
|
||||
upload_source = models.CharField(max_length=50, choices=UPLOAD_SOURCE_CHOICES, default='direct')
|
||||
device_platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES, blank=True, null=True)
|
||||
|
||||
# メタデータ
|
||||
capture_timestamp = models.DateTimeField(blank=True, null=True)
|
||||
upload_timestamp = models.DateTimeField(auto_now_add=True)
|
||||
device_info = models.TextField(blank=True, null=True)
|
||||
|
||||
# 処理状況
|
||||
processing_status = models.CharField(max_length=20, choices=PROCESSING_STATUS_CHOICES, default='uploaded')
|
||||
thumbnail_url = models.URLField(blank=True, null=True)
|
||||
|
||||
# 外部キー
|
||||
gpslog = models.ForeignKey('GpsCheckin', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
entry = models.ForeignKey('Entry', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
# システム情報
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'rog_uploaded_images'
|
||||
indexes = [
|
||||
models.Index(fields=['event_code', 'team_name'], name='idx_uploaded_event_team'),
|
||||
models.Index(fields=['cp_number'], name='idx_uploaded_cp_number'),
|
||||
models.Index(fields=['upload_timestamp'], name='idx_uploaded_timestamp'),
|
||||
models.Index(fields=['processing_status'], name='idx_uploaded_status'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.original_filename} - {self.event_code} - CP{self.cp_number}"
|
||||
|
||||
def clean(self):
|
||||
"""バリデーション"""
|
||||
if self.file_size and (self.file_size <= 0 or self.file_size > 10485760): # 10MB
|
||||
raise ValidationError("ファイルサイズは10MB以下である必要があります")
|
||||
|
||||
@property
|
||||
def file_size_mb(self):
|
||||
"""ファイルサイズをMB単位で取得"""
|
||||
return round(self.file_size / 1024 / 1024, 2) if self.file_size else 0
|
||||
|
||||
|
||||
class NewEvent2(models.Model):
|
||||
# 既存フィールド
|
||||
event_name = models.CharField(max_length=255, unique=True)
|
||||
@ -318,6 +527,21 @@ class NewEvent2(models.Model):
|
||||
|
||||
#// Added @2024-10-21
|
||||
public = models.BooleanField(default=False)
|
||||
|
||||
# Status field for enhanced event management (2025-08-27)
|
||||
STATUS_CHOICES = [
|
||||
('public', 'Public'),
|
||||
('private', 'Private'),
|
||||
('draft', 'Draft'),
|
||||
('closed', 'Closed'),
|
||||
]
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
help_text="イベントステータス"
|
||||
)
|
||||
|
||||
hour_3 = models.BooleanField(default=False)
|
||||
hour_5 = models.BooleanField(default=True)
|
||||
class_general = models.BooleanField(default=True)
|
||||
@ -344,7 +568,32 @@ class NewEvent2(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.deadlineDateTime:
|
||||
self.deadlineDateTime = self.end_datetime #- timedelta(days=7)
|
||||
|
||||
# publicフィールドからstatusフィールドへの自動移行
|
||||
if self.pk is None and self.status == 'draft': # 新規作成時
|
||||
if self.public:
|
||||
self.status = 'public'
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def deadline_datetime(self):
|
||||
"""API応答用のフィールド名統一"""
|
||||
return self.deadlineDateTime
|
||||
|
||||
def is_accessible_by_user(self, user):
|
||||
"""ユーザーがこのイベントにアクセス可能かチェック"""
|
||||
if self.status == 'public':
|
||||
return True
|
||||
elif self.status == 'private':
|
||||
# スタッフ権限チェック(後で実装)
|
||||
return hasattr(user, 'staff_privileges') and user.staff_privileges
|
||||
elif self.status == 'draft':
|
||||
# ドラフトは管理者のみ
|
||||
return user.is_staff or user.is_superuser
|
||||
elif self.status == 'closed':
|
||||
return False
|
||||
return False
|
||||
|
||||
class NewEvent(models.Model):
|
||||
event_name = models.CharField(max_length=255, primary_key=True)
|
||||
@ -460,6 +709,22 @@ class Entry(models.Model):
|
||||
is_active = models.BooleanField(default=True) # 新しく追加
|
||||
hasParticipated = models.BooleanField(default=False) # 新しく追加
|
||||
hasGoaled = models.BooleanField(default=False) # 新しく追加
|
||||
|
||||
# API変更要求書対応: スタッフ権限管理 (2025-08-27)
|
||||
staff_privileges = models.BooleanField(default=False, help_text="スタッフ権限フラグ")
|
||||
can_access_private_events = models.BooleanField(default=False, help_text="非公開イベント参加権限")
|
||||
|
||||
VALIDATION_STATUS_CHOICES = [
|
||||
('approved', 'Approved'),
|
||||
('pending', 'Pending'),
|
||||
('rejected', 'Rejected'),
|
||||
]
|
||||
team_validation_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=VALIDATION_STATUS_CHOICES,
|
||||
default='approved',
|
||||
help_text="チーム承認状況"
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
|
||||
424
rog/multi_image_upload_views.py
Normal file
424
rog/multi_image_upload_views.py
Normal file
@ -0,0 +1,424 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
})
|
||||
@ -14,7 +14,7 @@ from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
from rest_framework_gis.serializers import GeoFeatureModelSerializer
|
||||
from sqlalchemy.sql.functions import mode
|
||||
from .models import Location, Location_line, Location_polygon, JpnAdminMainPerf, Useractions, GifuAreas, RogUser, UserTracks, GoalImages, CheckinImages,CustomUser,NewEvent,NewEvent2, Team, NewCategory, Category, Entry, Member, TempUser,EntryMember
|
||||
from .models import Location, Location_line, Location_polygon, JpnAdminMainPerf, Useractions, GifuAreas, RogUser, UserTracks, GoalImages, CheckinImages,CustomUser,NewEvent,NewEvent2, Team, NewCategory, Category, Entry, Member, TempUser,EntryMember, AppVersion, UploadedImage
|
||||
from drf_extra_fields.fields import Base64ImageField
|
||||
|
||||
#from django.contrib.auth.models import User
|
||||
@ -37,10 +37,54 @@ class LocationCatSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class LocationSerializer(GeoFeatureModelSerializer):
|
||||
# evaluation_valueに基づくインタラクション情報を追加
|
||||
interaction_type = serializers.SerializerMethodField()
|
||||
requires_photo = serializers.SerializerMethodField()
|
||||
requires_qr_code = serializers.SerializerMethodField()
|
||||
interaction_instructions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model=Location
|
||||
geo_field='geom'
|
||||
fields="__all__"
|
||||
|
||||
def get_interaction_type(self, obj):
|
||||
"""evaluation_valueに基づくインタラクションタイプを返す"""
|
||||
try:
|
||||
from .location_interaction import get_interaction_type
|
||||
return get_interaction_type(obj)['type']
|
||||
except ImportError:
|
||||
return obj.evaluation_value or "0"
|
||||
|
||||
def get_requires_photo(self, obj):
|
||||
"""写真撮影が必要かどうかを返す"""
|
||||
try:
|
||||
from .location_interaction import get_interaction_type
|
||||
return get_interaction_type(obj)['requires_photo']
|
||||
except ImportError:
|
||||
return obj.evaluation_value == "1"
|
||||
|
||||
def get_requires_qr_code(self, obj):
|
||||
"""QRコードスキャンが必要かどうかを返す"""
|
||||
try:
|
||||
from .location_interaction import should_use_qr_code
|
||||
return should_use_qr_code(obj)
|
||||
except ImportError:
|
||||
return obj.evaluation_value == "2" or getattr(obj, 'use_qr_code', False)
|
||||
|
||||
def get_interaction_instructions(self, obj):
|
||||
"""インタラクション手順を返す"""
|
||||
try:
|
||||
from .location_interaction import get_interaction_type
|
||||
return get_interaction_type(obj)['instructions']
|
||||
except ImportError:
|
||||
evaluation_value = obj.evaluation_value or "0"
|
||||
if evaluation_value == "1":
|
||||
return "商品の写真を撮影してください。買い物をすることでポイントを獲得できます"
|
||||
elif evaluation_value == "2":
|
||||
return "QRコードをスキャンしてクイズに答えてください"
|
||||
else:
|
||||
return "この場所でチェックインしてポイントを獲得してください"
|
||||
|
||||
|
||||
class Location_lineSerializer(GeoFeatureModelSerializer):
|
||||
@ -343,9 +387,29 @@ class NewCategorySerializer(serializers.ModelSerializer):
|
||||
#fields = ['id','category_name', 'category_number']
|
||||
|
||||
class NewEvent2Serializer(serializers.ModelSerializer):
|
||||
# API変更要求書対応: deadline_datetime フィールド追加
|
||||
deadline_datetime = serializers.DateTimeField(source='deadlineDateTime', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = NewEvent2
|
||||
fields = ['id','event_name', 'start_datetime', 'end_datetime', 'deadlineDateTime', 'public', 'hour_3', 'hour_5', 'class_general','class_family','class_solo_male','class_solo_female']
|
||||
fields = [
|
||||
'id', 'event_name', 'start_datetime', 'end_datetime',
|
||||
'deadlineDateTime', 'deadline_datetime', 'status', 'public',
|
||||
'hour_3', 'hour_5', 'class_general', 'class_family',
|
||||
'class_solo_male', 'class_solo_female'
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""レスポンス形式を調整"""
|
||||
data = super().to_representation(instance)
|
||||
|
||||
# publicフィールドからstatusへの移行サポート
|
||||
if not data.get('status') and data.get('public'):
|
||||
data['status'] = 'public'
|
||||
elif not data.get('status'):
|
||||
data['status'] = 'draft'
|
||||
|
||||
return data
|
||||
|
||||
class NewEventSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@ -450,8 +514,13 @@ class EntrySerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Entry
|
||||
fields = ['id','team', 'event', 'category', 'date','zekken_number','owner','is_active', 'hasParticipated', 'hasGoaled']
|
||||
read_only_fields = ['id','owner']
|
||||
fields = [
|
||||
'id', 'team', 'event', 'category', 'date', 'zekken_number', 'owner',
|
||||
'is_active', 'hasParticipated', 'hasGoaled',
|
||||
# API変更要求書対応: 新フィールド追加
|
||||
'staff_privileges', 'can_access_private_events', 'team_validation_status'
|
||||
]
|
||||
read_only_fields = ['id', 'owner']
|
||||
|
||||
def validate_date(self, value):
|
||||
if isinstance(value, str):
|
||||
@ -911,4 +980,120 @@ class LoginUserSerializer_old(serializers.Serializer):
|
||||
raise serializers.ValidationError('アカウントが有効化されていません。')
|
||||
else:
|
||||
raise serializers.ValidationError('認証情報が正しくありません。')
|
||||
|
||||
|
||||
class AppVersionSerializer(serializers.ModelSerializer):
|
||||
"""アプリバージョン管理シリアライザー"""
|
||||
|
||||
class Meta:
|
||||
model = AppVersion
|
||||
fields = [
|
||||
'id', 'version', 'platform', 'build_number',
|
||||
'is_latest', 'is_required', 'update_message',
|
||||
'download_url', 'release_date', 'created_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class AppVersionCheckSerializer(serializers.Serializer):
|
||||
"""アプリバージョンチェック用シリアライザー"""
|
||||
|
||||
current_version = serializers.CharField(max_length=20, help_text="現在のアプリバージョン")
|
||||
platform = serializers.ChoiceField(
|
||||
choices=[('android', 'Android'), ('ios', 'iOS')],
|
||||
help_text="プラットフォーム"
|
||||
)
|
||||
build_number = serializers.CharField(max_length=20, required=False, help_text="ビルド番号")
|
||||
|
||||
|
||||
class AppVersionResponseSerializer(serializers.Serializer):
|
||||
"""アプリバージョンチェックレスポンス用シリアライザー"""
|
||||
|
||||
latest_version = serializers.CharField(help_text="最新バージョン")
|
||||
update_required = serializers.BooleanField(help_text="強制更新が必要かどうか")
|
||||
update_available = serializers.BooleanField(help_text="更新が利用可能かどうか")
|
||||
update_message = serializers.CharField(help_text="更新メッセージ")
|
||||
download_url = serializers.URLField(help_text="ダウンロードURL")
|
||||
release_date = serializers.DateTimeField(help_text="リリース日時")
|
||||
|
||||
|
||||
class UploadedImageSerializer(serializers.ModelSerializer):
|
||||
"""画像アップロード情報シリアライザー"""
|
||||
|
||||
file_size_mb = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = UploadedImage
|
||||
fields = [
|
||||
'id', 'original_filename', 'server_filename', 'file_url',
|
||||
'file_size', 'file_size_mb', 'mime_type', 'event_code',
|
||||
'team_name', 'cp_number', 'upload_source', 'device_platform',
|
||||
'capture_timestamp', 'upload_timestamp', 'device_info',
|
||||
'processing_status', 'thumbnail_url', 'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'server_filename', 'file_url', 'upload_timestamp', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class MultiImageUploadSerializer(serializers.Serializer):
|
||||
"""マルチ画像アップロード用シリアライザー"""
|
||||
|
||||
event_code = serializers.CharField(max_length=50)
|
||||
team_name = serializers.CharField(max_length=255)
|
||||
cp_number = serializers.IntegerField()
|
||||
images = serializers.ListField(
|
||||
child=serializers.DictField(),
|
||||
max_length=10, # 最大10ファイル
|
||||
help_text="アップロードする画像情報のリスト"
|
||||
)
|
||||
upload_source = serializers.ChoiceField(
|
||||
choices=['direct', 'sharing_intent', 'bulk_upload'],
|
||||
default='direct'
|
||||
)
|
||||
device_platform = serializers.ChoiceField(
|
||||
choices=['ios', 'android', 'web'],
|
||||
required=False
|
||||
)
|
||||
|
||||
def validate_images(self, value):
|
||||
"""画像データの検証"""
|
||||
if not value:
|
||||
raise serializers.ValidationError("画像が指定されていません")
|
||||
|
||||
total_size = 0
|
||||
for image_data in value:
|
||||
# 必須フィールドチェック
|
||||
required_fields = ['file_data', 'filename', 'mime_type', 'file_size']
|
||||
for field in required_fields:
|
||||
if field not in image_data:
|
||||
raise serializers.ValidationError(f"画像データに{field}が含まれていません")
|
||||
|
||||
# ファイルサイズチェック
|
||||
file_size = image_data.get('file_size', 0)
|
||||
if file_size > 10485760: # 10MB
|
||||
raise serializers.ValidationError(f"ファイル{image_data['filename']}のサイズが10MBを超えています")
|
||||
|
||||
total_size += file_size
|
||||
|
||||
# MIMEタイプチェック
|
||||
allowed_types = ['image/jpeg', 'image/png', 'image/heic', 'image/webp']
|
||||
if image_data.get('mime_type') not in allowed_types:
|
||||
raise serializers.ValidationError(f"サポートされていないファイル形式: {image_data.get('mime_type')}")
|
||||
|
||||
# 合計サイズチェック(50MB)
|
||||
if total_size > 52428800:
|
||||
raise serializers.ValidationError("合計ファイルサイズが50MBを超えています")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class MultiImageUploadResponseSerializer(serializers.Serializer):
|
||||
"""マルチ画像アップロードレスポンス用シリアライザー"""
|
||||
|
||||
status = serializers.CharField()
|
||||
uploaded_count = serializers.IntegerField()
|
||||
failed_count = serializers.IntegerField()
|
||||
uploaded_files = serializers.ListField(
|
||||
child=serializers.DictField()
|
||||
)
|
||||
total_upload_size = serializers.IntegerField()
|
||||
processing_time_ms = serializers.IntegerField()
|
||||
|
||||
18
rog/urls.py
18
rog/urls.py
@ -19,6 +19,9 @@ from .views_apis.api_bulk_upload import bulk_upload_photos, confirm_checkin_vali
|
||||
from .views_apis.api_admin_validation import get_event_participants_ranking, get_participant_validation_details, get_event_zekken_list
|
||||
from .views_apis.api_simulator import rogaining_simulator
|
||||
from .views_apis.api_test import test_gifuroge,practice
|
||||
from .app_version_views import app_version_check, AppVersionManagementView
|
||||
from .multi_image_upload_views import multi_image_upload, image_list, image_detail
|
||||
from .gpx_route_views import gpx_test_data, available_routes
|
||||
|
||||
|
||||
from django.urls import path, include
|
||||
@ -79,6 +82,8 @@ urlpatterns += [
|
||||
path('insubperf', LocationsInSubPerf, name='location_subperf'),
|
||||
path('inbound', LocationInBound, name='location_bound'),
|
||||
path('inbound2', LocationInBound2, name='location_bound'),
|
||||
path('location-checkin/', views.LocationCheckinView.as_view(), name='location_checkin'),
|
||||
path('location-checkin-test/', views.location_checkin_test, name='location_checkin_test'),
|
||||
path('customarea/', CustomAreaLocations, name='custom_area_location'),
|
||||
path('subperfinmain/', SubPerfInMainPerf, name="sub_perf"),
|
||||
path('allgifuareas/', GetAllGifuAreas, name="gifu_area"),
|
||||
@ -236,6 +241,19 @@ urlpatterns += [
|
||||
path('participant-validation-details/', get_participant_validation_details, name='get_participant_validation_details'),
|
||||
path('event-zekken-list/', get_event_zekken_list, name='get_event_zekken_list'),
|
||||
|
||||
# App Version Management
|
||||
path('app/version-check/', app_version_check, name='app_version_check'),
|
||||
path('app/version-management/', AppVersionManagementView.as_view(), name='app_version_management'),
|
||||
|
||||
# Multi-Image Upload API
|
||||
path('api/images/multi-upload/', multi_image_upload, name='multi_image_upload'),
|
||||
path('api/images/list/', image_list, name='image_list'),
|
||||
path('api/images/<int:image_id>/', image_detail, name='image_detail'),
|
||||
|
||||
# GPX Route Test Data API
|
||||
path('api/routes/gpx-test-data/', gpx_test_data, name='gpx_test_data'),
|
||||
path('api/routes/available/', available_routes, name='available_routes'),
|
||||
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
@ -3892,3 +3892,11 @@ def index_view(request):
|
||||
"<h1>System Error</h1><p>Failed to load supervisor interface</p>",
|
||||
status=500
|
||||
)
|
||||
|
||||
# Import LocationCheckinView for evaluation_value-based interactions
|
||||
from .location_checkin_view import LocationCheckinView
|
||||
|
||||
def location_checkin_test(request):
|
||||
"""ロケーションチェックインのテストページ"""
|
||||
from django.shortcuts import render
|
||||
return render(request, 'location_checkin_test.html')
|
||||
|
||||
@ -329,6 +329,9 @@ def checkin_from_rogapp(request):
|
||||
- team_name: チーム名
|
||||
- cp_number: チェックポイント番号
|
||||
- image: 画像URL
|
||||
- buy_flag: 購入フラグ (新規)
|
||||
- gps_coordinates: GPS座標情報 (新規)
|
||||
- camera_metadata: カメラメタデータ (新規)
|
||||
"""
|
||||
logger.info("checkin_from_rogapp called")
|
||||
|
||||
@ -338,6 +341,11 @@ def checkin_from_rogapp(request):
|
||||
cp_number = request.data.get('cp_number')
|
||||
image_url = request.data.get('image')
|
||||
|
||||
# API変更要求書対応: 新パラメータ追加
|
||||
buy_flag = request.data.get('buy_flag', False)
|
||||
gps_coordinates = request.data.get('gps_coordinates', {})
|
||||
camera_metadata = request.data.get('camera_metadata', {})
|
||||
|
||||
logger.debug(f"Parameters: event_code={event_code}, team_name={team_name}, "
|
||||
f"cp_number={cp_number}, image={image_url}")
|
||||
|
||||
@ -420,6 +428,37 @@ def checkin_from_rogapp(request):
|
||||
|
||||
# 獲得ポイントの計算(イベントCPが定義されている場合)
|
||||
point_value = event_cp.cp_point if event_cp else 0
|
||||
bonus_points = 0
|
||||
scoring_breakdown = {
|
||||
"base_points": point_value,
|
||||
"camera_bonus": 0,
|
||||
"total_points": point_value
|
||||
}
|
||||
|
||||
# カメラボーナス計算
|
||||
if image_url and event_cp and hasattr(event_cp, 'evaluation_value'):
|
||||
if event_cp.evaluation_value == "1": # 写真撮影必須ポイント
|
||||
bonus_points += 5
|
||||
scoring_breakdown["camera_bonus"] = 5
|
||||
scoring_breakdown["total_points"] += 5
|
||||
|
||||
# 拡張情報があれば保存
|
||||
if gps_coordinates or camera_metadata:
|
||||
try:
|
||||
from ..models import CheckinExtended
|
||||
CheckinExtended.objects.create(
|
||||
gpslog=checkpoint,
|
||||
gps_latitude=gps_coordinates.get('latitude'),
|
||||
gps_longitude=gps_coordinates.get('longitude'),
|
||||
gps_accuracy=gps_coordinates.get('accuracy'),
|
||||
gps_timestamp=gps_coordinates.get('timestamp'),
|
||||
camera_capture_time=camera_metadata.get('capture_time'),
|
||||
device_info=camera_metadata.get('device_info'),
|
||||
bonus_points=bonus_points,
|
||||
scoring_breakdown=scoring_breakdown
|
||||
)
|
||||
except Exception as ext_error:
|
||||
logger.warning(f"Failed to save extended checkin info: {ext_error}")
|
||||
|
||||
return Response({
|
||||
"status": "OK",
|
||||
@ -428,7 +467,11 @@ def checkin_from_rogapp(request):
|
||||
"cp_number": cp_number,
|
||||
"checkpoint_id": checkpoint.id,
|
||||
"checkin_time": checkpoint.checkin_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"point_value": point_value
|
||||
"point_value": point_value,
|
||||
"bonus_points": bonus_points,
|
||||
"scoring_breakdown": scoring_breakdown,
|
||||
"validation_status": "pending",
|
||||
"requires_manual_review": bool(gps_coordinates.get('accuracy', 0) > 10) # 10m以上は要審査
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user