Finish basic API implementation

This commit is contained in:
2025-08-27 15:01:06 +09:00
parent fff9bce9e7
commit cc9edb9932
19 changed files with 3844 additions and 5 deletions

242
rog/app_version_views.py Normal file
View 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
View 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)
})

View 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
View 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

View File

@ -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:

View 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
}
})

View File

@ -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()

View File

@ -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:

View File

@ -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')

View File

@ -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: