almost finish migrate new circumstances
This commit is contained in:
169
rog/admin.py
169
rog/admin.py
@ -4,7 +4,7 @@ from django.shortcuts import render,redirect
|
||||
from leaflet.admin import LeafletGeoAdmin
|
||||
from leaflet.admin import LeafletGeoAdminMixin
|
||||
from leaflet_admin_list.admin import LeafletAdminListMixin
|
||||
from .models import RogUser, Location, SystemSettings, JoinedEvent, Favorite, TravelList, TravelPoint, ShapeLayers, Event, Location_line, Location_polygon, JpnAdminMainPerf, Useractions, CustomUser, GifuAreas, UserTracks, templocation, UserUpload, EventUser, GoalImages, CheckinImages, NewEvent2, Team, NewCategory, Entry, Member, TempUser,GifurogeRegister
|
||||
from .models import RogUser, Location, Location2025, SystemSettings, JoinedEvent, Favorite, TravelList, TravelPoint, ShapeLayers, Event, Location_line, Location_polygon, JpnAdminMainPerf, Useractions, CustomUser, GifuAreas, UserTracks, templocation, UserUpload, EventUser, GoalImages, CheckinImages, NewEvent2, Team, NewCategory, Entry, Member, TempUser, GifurogeRegister, GpsLog, GpsCheckin, Checkpoint, Waypoint
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.urls import path,reverse
|
||||
from django.shortcuts import render
|
||||
@ -1007,3 +1007,170 @@ admin.site.register(templocation, TempLocationAdmin)
|
||||
admin.site.register(GoalImages, admin.ModelAdmin)
|
||||
admin.site.register(CheckinImages, admin.ModelAdmin)
|
||||
|
||||
# GpsLogとその他の新しいモデルの登録
|
||||
@admin.register(GpsLog)
|
||||
class GpsLogAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'serial_number', 'zekken_number', 'event_code', 'cp_number', 'checkin_time']
|
||||
list_filter = ['event_code', 'checkin_time', 'buy_flag', 'is_service_checked']
|
||||
search_fields = ['zekken_number', 'event_code', 'cp_number']
|
||||
readonly_fields = ['checkin_time', 'create_at', 'update_at']
|
||||
|
||||
@admin.register(GpsCheckin)
|
||||
class GpsCheckinAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'zekken_number', 'event_code', 'cp_number', 'create_at']
|
||||
list_filter = ['event_code', 'create_at', 'validate_location']
|
||||
search_fields = ['zekken_number', 'event_code', 'cp_number']
|
||||
readonly_fields = ['create_at']
|
||||
|
||||
@admin.register(Checkpoint)
|
||||
class CheckpointAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'cp_name', 'cp_number', 'photo_point', 'buy_point']
|
||||
search_fields = ['cp_name', 'cp_number']
|
||||
list_filter = ['photo_point', 'buy_point']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
@admin.register(Waypoint)
|
||||
class WaypointAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'entry', 'latitude', 'longitude', 'recorded_at']
|
||||
search_fields = ['entry__team_name']
|
||||
list_filter = ['recorded_at']
|
||||
readonly_fields = ['created_at']
|
||||
|
||||
|
||||
@admin.register(Location2025)
|
||||
class Location2025Admin(LeafletGeoAdmin):
|
||||
"""Location2025の管理画面"""
|
||||
list_display = [
|
||||
'cp_number', 'cp_name', 'event', 'total_point', 'is_active',
|
||||
'csv_upload_date', 'created_at'
|
||||
]
|
||||
list_filter = [
|
||||
'event', 'is_active', 'shop_closed', 'shop_shutdown',
|
||||
'csv_upload_date', 'created_at'
|
||||
]
|
||||
search_fields = ['cp_name', 'address', 'description']
|
||||
readonly_fields = [
|
||||
'csv_source_file', 'csv_upload_date', 'csv_upload_user',
|
||||
'created_at', 'updated_at', 'created_by', 'updated_by'
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('基本情報', {
|
||||
'fields': ('cp_number', 'event', 'cp_name', 'is_active', 'sort_order')
|
||||
}),
|
||||
('位置情報', {
|
||||
'fields': ('latitude', 'longitude', 'location', 'address')
|
||||
}),
|
||||
('ポイント設定', {
|
||||
'fields': ('cp_point', 'photo_point', 'buy_point')
|
||||
}),
|
||||
('チェックイン設定', {
|
||||
'fields': ('checkin_radius', 'auto_checkin')
|
||||
}),
|
||||
('営業情報', {
|
||||
'fields': ('shop_closed', 'shop_shutdown', 'opening_hours')
|
||||
}),
|
||||
('詳細情報', {
|
||||
'fields': ('phone', 'website', 'description')
|
||||
}),
|
||||
('CSV情報', {
|
||||
'fields': ('csv_source_file', 'csv_upload_date', 'csv_upload_user'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('管理情報', {
|
||||
'fields': ('created_at', 'updated_at', 'created_by', 'updated_by'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
# CSV一括アップロード機能
|
||||
change_list_template = 'admin/location2025/change_list.html'
|
||||
|
||||
def get_urls(self):
|
||||
from django.urls import path
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('upload-csv/', self.upload_csv_view, name='location2025_upload_csv'),
|
||||
path('export-csv/', self.export_csv_view, name='location2025_export_csv'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def upload_csv_view(self, request):
|
||||
"""CSVアップロード画面"""
|
||||
if request.method == 'POST':
|
||||
if 'csv_file' in request.FILES and 'event' in request.POST:
|
||||
csv_file = request.FILES['csv_file']
|
||||
event_id = request.POST['event']
|
||||
|
||||
try:
|
||||
from .models import NewEvent2
|
||||
event = NewEvent2.objects.get(id=event_id)
|
||||
|
||||
# CSVインポート実行
|
||||
result = Location2025.import_from_csv(
|
||||
csv_file,
|
||||
event,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
# 結果メッセージ
|
||||
if result['errors']:
|
||||
messages.warning(
|
||||
request,
|
||||
f"インポート完了: 作成{result['created']}件, 更新{result['updated']}件, "
|
||||
f"エラー{len(result['errors'])}件 - {'; '.join(result['errors'][:5])}"
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f"CSVインポートが完了しました。作成: {result['created']}件, 更新: {result['updated']}件"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"CSVインポートエラー: {str(e)}")
|
||||
|
||||
return redirect('..')
|
||||
|
||||
# フォーム表示
|
||||
from .models import NewEvent2
|
||||
events = NewEvent2.objects.filter(event_active=True).order_by('-created_at')
|
||||
|
||||
return render(request, 'admin/location2025/upload_csv.html', {
|
||||
'events': events,
|
||||
'title': 'チェックポイントCSVアップロード'
|
||||
})
|
||||
|
||||
def export_csv_view(self, request):
|
||||
"""CSVエクスポート"""
|
||||
import csv
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||||
response['Content-Disposition'] = f'attachment; filename="checkpoints_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
|
||||
|
||||
# BOM付きUTF-8で出力
|
||||
response.write('\ufeff')
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow([
|
||||
'cp_number', 'cp_name', 'latitude', 'longitude', 'cp_point',
|
||||
'photo_point', 'buy_point', 'address', 'phone', 'description'
|
||||
])
|
||||
|
||||
queryset = self.get_queryset(request)
|
||||
for obj in queryset:
|
||||
writer.writerow([
|
||||
obj.cp_number, obj.cp_name, obj.latitude, obj.longitude,
|
||||
obj.cp_point, obj.photo_point, obj.buy_point,
|
||||
obj.address, obj.phone, obj.description
|
||||
])
|
||||
|
||||
return response
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""保存時にユーザー情報を自動設定"""
|
||||
if not change: # 新規作成時
|
||||
obj.created_by = request.user
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
354
rog/management/commands/migrate_mobserver_data.py
Normal file
354
rog/management/commands/migrate_mobserver_data.py
Normal file
@ -0,0 +1,354 @@
|
||||
import logging
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connections, transaction, connection
|
||||
from django.db.models import Q
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.utils import timezone
|
||||
from rog.models import Team, NewEvent2, Checkpoint, GpsCheckin, GpsLog, Entry
|
||||
from datetime import datetime
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'MobServerデータベースからDjangoモデルにデータを移行します'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
dest='dry_run',
|
||||
help='実際の移行を行わず、処理内容のみを表示します',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=100,
|
||||
help='バッチサイズ(デフォルト: 100)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
batch_size = options['batch_size']
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('ドライランモードで実行中...'))
|
||||
|
||||
# MobServerデータベース接続を取得
|
||||
mobserver_conn = connections['mobserver']
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.migrate_events(mobserver_conn, dry_run, batch_size)
|
||||
self.migrate_teams(mobserver_conn, dry_run, batch_size)
|
||||
self.migrate_checkpoints(mobserver_conn, dry_run, batch_size)
|
||||
self.migrate_gps_logs(mobserver_conn, dry_run, batch_size)
|
||||
|
||||
if dry_run:
|
||||
raise transaction.TransactionManagementError("ドライランのためロールバックします")
|
||||
|
||||
except transaction.TransactionManagementError:
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.SUCCESS('ドライランが完了しました(変更は保存されていません)'))
|
||||
else:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'データ移行エラー: {e}')
|
||||
self.stdout.write(self.style.ERROR(f'エラーが発生しました: {e}'))
|
||||
raise
|
||||
|
||||
def migrate_events(self, conn, dry_run, batch_size):
|
||||
"""イベント情報を移行"""
|
||||
self.stdout.write('イベント情報を移行中...')
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT * FROM event_table")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
events_migrated = 0
|
||||
batch_data = []
|
||||
|
||||
for row in rows:
|
||||
(event_code, event_name, start_time, event_day) = row
|
||||
|
||||
# start_timeのデータクリーニング
|
||||
cleaned_start_time = start_time
|
||||
if start_time and isinstance(start_time, str):
|
||||
# セミコロンをコロンに置換
|
||||
cleaned_start_time = start_time.replace(';', ':')
|
||||
# タイムゾーン情報を含む場合は時間部分のみ抽出
|
||||
if '+' in cleaned_start_time or 'T' in cleaned_start_time:
|
||||
try:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(cleaned_start_time.replace('Z', '+00:00'))
|
||||
cleaned_start_time = dt.strftime('%H:%M:%S')
|
||||
except:
|
||||
cleaned_start_time = None
|
||||
|
||||
if not dry_run:
|
||||
batch_data.append(NewEvent2(
|
||||
event_code=event_code,
|
||||
event_name=event_name,
|
||||
event_day=event_day,
|
||||
start_time=cleaned_start_time,
|
||||
))
|
||||
|
||||
events_migrated += 1
|
||||
|
||||
# バッチ処理
|
||||
if len(batch_data) >= batch_size:
|
||||
if not dry_run:
|
||||
NewEvent2.objects.bulk_create(batch_data, ignore_conflicts=True)
|
||||
batch_data = []
|
||||
|
||||
# 残りのデータを処理
|
||||
if batch_data and not dry_run:
|
||||
NewEvent2.objects.bulk_create(batch_data, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'{events_migrated}件のイベントを移行しました')
|
||||
|
||||
def migrate_teams(self, conn, dry_run, batch_size):
|
||||
"""チーム情報を移行"""
|
||||
self.stdout.write('チーム情報を移行中...')
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT * FROM team_table")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
teams_migrated = 0
|
||||
batch_data = []
|
||||
|
||||
for row in rows:
|
||||
(zekken_number, event_code, team_name, class_name, password, trial) = row
|
||||
|
||||
# 対応するイベントを取得
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_code=event_code)
|
||||
except NewEvent2.DoesNotExist:
|
||||
self.stdout.write(self.style.WARNING(f' 警告: イベント {event_code} が見つかりません。スキップします。'))
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
batch_data.append(Team(
|
||||
zekken_number=zekken_number,
|
||||
team_name=team_name,
|
||||
event=event,
|
||||
class_name=class_name,
|
||||
password=password,
|
||||
trial=trial,
|
||||
))
|
||||
|
||||
teams_migrated += 1
|
||||
|
||||
# バッチ処理
|
||||
if len(batch_data) >= batch_size:
|
||||
if not dry_run:
|
||||
Team.objects.bulk_create(batch_data, ignore_conflicts=True)
|
||||
batch_data = []
|
||||
|
||||
# 残りのデータを処理
|
||||
if batch_data and not dry_run:
|
||||
Team.objects.bulk_create(batch_data, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'{teams_migrated}件のチームを移行しました')
|
||||
|
||||
def migrate_checkpoints(self, conn, dry_run, batch_size):
|
||||
"""チェックポイント情報を移行"""
|
||||
self.stdout.write('チェックポイント情報を移行中...')
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT * FROM checkpoint_table")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
checkpoints_migrated = 0
|
||||
batch_data = []
|
||||
|
||||
for row in rows:
|
||||
(cp_number, event_code, cp_name, latitude, longitude,
|
||||
photo_point, buy_point, sample_photo, colabo_company_memo) = row
|
||||
|
||||
# 対応するイベントを取得
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_code=event_code)
|
||||
except NewEvent2.DoesNotExist:
|
||||
continue
|
||||
|
||||
# 位置情報の処理
|
||||
location = None
|
||||
if latitude is not None and longitude is not None:
|
||||
try:
|
||||
location = Point(longitude, latitude) # Pointは(longitude, latitude)の順序
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
if not dry_run:
|
||||
batch_data.append(Checkpoint(
|
||||
cp_number=cp_number,
|
||||
event=event,
|
||||
cp_name=cp_name,
|
||||
location=location,
|
||||
photo_point=photo_point or 0,
|
||||
buy_point=buy_point or 0,
|
||||
sample_photo=sample_photo,
|
||||
colabo_company_memo=colabo_company_memo,
|
||||
))
|
||||
|
||||
checkpoints_migrated += 1
|
||||
|
||||
# バッチ処理
|
||||
if len(batch_data) >= batch_size:
|
||||
if not dry_run:
|
||||
Checkpoint.objects.bulk_create(batch_data, ignore_conflicts=True)
|
||||
batch_data = []
|
||||
|
||||
# 残りのデータを処理
|
||||
if batch_data and not dry_run:
|
||||
Checkpoint.objects.bulk_create(batch_data, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'{checkpoints_migrated}件のチェックポイントを移行しました')
|
||||
|
||||
def migrate_gps_logs(self, conn, dry_run, batch_size):
|
||||
"""GPS位置情報を移行"""
|
||||
print('GPS位置情報を移行中...')
|
||||
|
||||
# チームとイベントのマッピングを作成
|
||||
team_to_event_map = {}
|
||||
for team in Team.objects.select_related('event'):
|
||||
if team.event: # eventがNoneでないことを確認
|
||||
team_to_event_map[team.zekken_number] = team.event.id
|
||||
|
||||
# チェックポイントのマッピングを作成
|
||||
checkpoint_id_map = {}
|
||||
for checkpoint in Checkpoint.objects.select_related('event'):
|
||||
if checkpoint.event: # eventがNoneでないことを確認
|
||||
key = (checkpoint.event.event_code, checkpoint.cp_number)
|
||||
checkpoint_id_map[key] = checkpoint.id
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT * FROM gps_information")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
logs_migrated = 0
|
||||
batch_data = []
|
||||
|
||||
for row in rows:
|
||||
(serial_number, zekken_number, event_code, cp_number,
|
||||
image_address, goal_time, late_point, create_at, create_user,
|
||||
update_at, update_user, buy_flag, minus_photo_flag, colabo_company_memo) = row
|
||||
|
||||
# 対応するチームを取得
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_code=event_code)
|
||||
team = Team.objects.get(zekken_number=zekken_number, event=event)
|
||||
# teamが存在し、eventも存在することを確認
|
||||
if not team or not team.event:
|
||||
continue
|
||||
except (NewEvent2.DoesNotExist, Team.DoesNotExist):
|
||||
continue
|
||||
|
||||
# 対応するチェックポイントを取得(存在する場合)
|
||||
checkpoint = None
|
||||
if cp_number is not None and cp_number != -1:
|
||||
try:
|
||||
checkpoint = Checkpoint.objects.get(cp_number=cp_number, event=event)
|
||||
except Checkpoint.DoesNotExist:
|
||||
pass
|
||||
|
||||
# checkin_timeの設定(必須フィールド)
|
||||
checkin_time = timezone.now() # デフォルト値
|
||||
|
||||
if goal_time:
|
||||
try:
|
||||
# goal_timeはHH:MM形式と仮定
|
||||
from datetime import datetime, time
|
||||
parsed_time = datetime.strptime(goal_time, '%H:%M').time()
|
||||
if create_at:
|
||||
checkin_time = timezone.make_aware(datetime.combine(create_at.date(), parsed_time))
|
||||
else:
|
||||
checkin_time = timezone.make_aware(datetime.combine(datetime.now().date(), parsed_time))
|
||||
except:
|
||||
checkin_time = timezone.make_aware(create_at) if create_at else timezone.now()
|
||||
elif create_at:
|
||||
checkin_time = timezone.make_aware(create_at) if timezone.is_naive(create_at) else create_at
|
||||
|
||||
if not dry_run:
|
||||
# GpsCheckinテーブル用のデータ
|
||||
batch_data.append({
|
||||
'event_code': event_code,
|
||||
'zekken': zekken_number,
|
||||
'serial_number': serial_number,
|
||||
'cp_number': cp_number or 0,
|
||||
'lat': None, # 実際のMobServerデータベースから取得
|
||||
'lng': None, # 実際のMobServerデータベースから取得
|
||||
'checkin_time': checkin_time,
|
||||
'record_time': timezone.make_aware(create_at) if create_at and timezone.is_naive(create_at) else (create_at or timezone.now()),
|
||||
'location': "", # PostGISポイントは後で設定
|
||||
'mobserver_id': serial_number,
|
||||
'event_id': team_to_event_map.get(zekken_number),
|
||||
'team_id': team.id,
|
||||
'checkpoint_id': checkpoint.id if checkpoint else None
|
||||
})
|
||||
|
||||
logs_migrated += 1
|
||||
|
||||
# バッチ処理
|
||||
if len(batch_data) >= batch_size:
|
||||
if not dry_run:
|
||||
self.bulk_insert_gps_logs(batch_data)
|
||||
batch_data = []
|
||||
|
||||
# 残りのデータを処理
|
||||
if batch_data and not dry_run:
|
||||
self.bulk_insert_gps_logs(batch_data)
|
||||
|
||||
print(f'{logs_migrated}件のGPS位置情報を移行しました')
|
||||
|
||||
def bulk_insert_gps_logs(self, batch_data):
|
||||
"""
|
||||
GpsCheckinテーブルに直接SQLを使って挿入
|
||||
"""
|
||||
if not batch_data:
|
||||
return
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# DjangoのGpsCheckinテーブルに挿入
|
||||
insert_sql = """
|
||||
INSERT INTO rog_gpscheckin (
|
||||
event_code, zekken, serial_number, cp_number, lat, lng,
|
||||
checkin_time, record_time, location, mobserver_id,
|
||||
event_id, team_id, checkpoint_id
|
||||
) VALUES %s
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
# locationフィールドを除外してバリューを準備
|
||||
clean_values = []
|
||||
for data in batch_data:
|
||||
# lat/lngがある場合はPostGISポイントを作成、ない場合はNULL
|
||||
if data['lat'] is not None and data['lng'] is not None:
|
||||
location_point = f"ST_GeomFromText('POINT({data['lng']} {data['lat']})', 4326)"
|
||||
else:
|
||||
location_point = None
|
||||
|
||||
clean_values.append((
|
||||
data['event_code'],
|
||||
data['zekken'],
|
||||
data['serial_number'],
|
||||
data['cp_number'],
|
||||
data['lat'],
|
||||
data['lng'],
|
||||
data['checkin_time'],
|
||||
data['record_time'],
|
||||
location_point,
|
||||
data['mobserver_id'],
|
||||
data['event_id'],
|
||||
data['team_id'],
|
||||
data['checkpoint_id']
|
||||
))
|
||||
|
||||
try:
|
||||
execute_values(cursor, insert_sql, clean_values, template=None, page_size=100)
|
||||
except Exception as e:
|
||||
logger.error(f"データ移行エラー: {e}")
|
||||
raise
|
||||
331
rog/migration_scripts_fixed.py
Normal file
331
rog/migration_scripts_fixed.py
Normal file
@ -0,0 +1,331 @@
|
||||
"""
|
||||
修正版データ移行スクリプト
|
||||
gifurogeデータベースからrogdbデータベースへの正確な移行を行う
|
||||
UTCからJSTに変換して移行
|
||||
"""
|
||||
import psycopg2
|
||||
from PIL import Image
|
||||
import PIL.ExifTags
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
import os
|
||||
import re
|
||||
|
||||
def get_gps_from_image(image_path):
|
||||
"""
|
||||
画像ファイルからGPS情報を抽出する
|
||||
Returns: (latitude, longitude) または取得できない場合は (None, None)
|
||||
"""
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
exif = {
|
||||
PIL.ExifTags.TAGS[k]: v
|
||||
for k, v in img._getexif().items()
|
||||
if k in PIL.ExifTags.TAGS
|
||||
}
|
||||
|
||||
if 'GPSInfo' in exif:
|
||||
gps_info = exif['GPSInfo']
|
||||
|
||||
# 緯度の計算
|
||||
lat = gps_info[2]
|
||||
lat = lat[0] + lat[1]/60 + lat[2]/3600
|
||||
if gps_info[1] == 'S':
|
||||
lat = -lat
|
||||
|
||||
# 経度の計算
|
||||
lon = gps_info[4]
|
||||
lon = lon[0] + lon[1]/60 + lon[2]/3600
|
||||
if gps_info[3] == 'W':
|
||||
lon = -lon
|
||||
|
||||
return lat, lon
|
||||
except Exception as e:
|
||||
print(f"GPS情報の抽出に失敗: {e}")
|
||||
|
||||
return None, None
|
||||
|
||||
def convert_utc_to_jst(utc_datetime):
|
||||
"""
|
||||
UTCタイムスタンプをJSTに変換する
|
||||
Args:
|
||||
utc_datetime: UTC時刻のdatetimeオブジェクト
|
||||
Returns:
|
||||
JST時刻のdatetimeオブジェクト
|
||||
"""
|
||||
if utc_datetime is None:
|
||||
return None
|
||||
|
||||
# UTCタイムゾーンを設定
|
||||
if utc_datetime.tzinfo is None:
|
||||
utc_datetime = pytz.UTC.localize(utc_datetime)
|
||||
|
||||
# JSTに変換
|
||||
jst = pytz.timezone('Asia/Tokyo')
|
||||
jst_datetime = utc_datetime.astimezone(jst)
|
||||
|
||||
# タイムゾーン情報を削除してnaive datetimeとして返す
|
||||
return jst_datetime.replace(tzinfo=None)
|
||||
|
||||
def parse_goal_time(goal_time_str, event_date, create_at=None):
|
||||
"""
|
||||
goal_time文字列を正しいdatetimeに変換する
|
||||
Args:
|
||||
goal_time_str: "14:58" 形式の時刻文字列
|
||||
event_date: イベント日付
|
||||
create_at: goal_timeが空の場合に使用するタイムスタンプ
|
||||
Returns:
|
||||
datetime object または None
|
||||
"""
|
||||
# goal_timeが空の場合はcreate_atを使用(UTCからJSTに変換)
|
||||
if not goal_time_str or goal_time_str.strip() == '':
|
||||
if create_at:
|
||||
return convert_utc_to_jst(create_at)
|
||||
return None
|
||||
|
||||
try:
|
||||
# "HH:MM" または "HH:MM:SS" 形式の時刻をパース(JST時刻として扱う)
|
||||
if re.match(r'^\d{1,2}:\d{2}(:\d{2})?$', goal_time_str.strip()):
|
||||
time_parts = goal_time_str.strip().split(':')
|
||||
hour = int(time_parts[0])
|
||||
minute = int(time_parts[1])
|
||||
second = int(time_parts[2]) if len(time_parts) > 2 else 0
|
||||
|
||||
# イベント日付と結合(JST時刻として扱うため変換なし)
|
||||
result_datetime = event_date.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
||||
|
||||
# 深夜の場合は翌日に調整
|
||||
if hour < 6: # 午前6時以前は翌日とみなす
|
||||
result_datetime += timedelta(days=1)
|
||||
|
||||
return result_datetime
|
||||
|
||||
# すでにdatetime形式の場合
|
||||
elif 'T' in goal_time_str or ' ' in goal_time_str:
|
||||
return datetime.fromisoformat(goal_time_str.replace('T', ' ').replace('Z', ''))
|
||||
|
||||
except Exception as e:
|
||||
print(f"時刻パースエラー: {goal_time_str} -> {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_event_date(event_code, target_cur):
|
||||
"""
|
||||
イベントコードからイベント開催日を取得する
|
||||
"""
|
||||
# イベントコード別の実際の開催日を定義
|
||||
event_dates = {
|
||||
'FC岐阜': datetime(2024, 10, 25).date(),
|
||||
'美濃加茂': datetime(2024, 5, 19).date(),
|
||||
'岐阜市': datetime(2023, 11, 19).date(),
|
||||
'大垣2': datetime(2023, 5, 14).date(),
|
||||
'各務原': datetime(2023, 10, 15).date(),
|
||||
'郡上': datetime(2023, 10, 22).date(),
|
||||
'中津川': datetime(2024, 4, 14).date(),
|
||||
'下呂': datetime(2024, 1, 21).date(),
|
||||
'多治見': datetime(2023, 11, 26).date(),
|
||||
'大垣': datetime(2023, 4, 16).date(),
|
||||
'揖斐川': datetime(2023, 12, 3).date(),
|
||||
'養老ロゲ': datetime(2023, 4, 23).date(),
|
||||
'高山': datetime(2024, 3, 10).date(),
|
||||
'大垣3': datetime(2024, 8, 4).date(),
|
||||
'各務原2': datetime(2024, 11, 10).date(),
|
||||
'多治見2': datetime(2024, 12, 15).date(),
|
||||
'下呂2': datetime(2024, 12, 1).date(),
|
||||
'美濃加茂2': datetime(2024, 11, 3).date(),
|
||||
'郡上2': datetime(2024, 12, 8).date(),
|
||||
'関ケ原2': datetime(2024, 9, 29).date(),
|
||||
'養老2': datetime(2024, 11, 24).date(),
|
||||
'高山2': datetime(2024, 12, 22).date(),
|
||||
}
|
||||
|
||||
if event_code in event_dates:
|
||||
return event_dates[event_code]
|
||||
|
||||
# デフォルト日付
|
||||
return datetime(2024, 1, 1).date()
|
||||
|
||||
def get_foreign_keys(zekken_number, event_code, cp_number, target_cur):
|
||||
"""
|
||||
team_id, event_id, checkpoint_idを取得する
|
||||
"""
|
||||
team_id = None
|
||||
event_id = None
|
||||
checkpoint_id = None
|
||||
|
||||
# team_id を取得
|
||||
try:
|
||||
target_cur.execute("""
|
||||
SELECT t.id, t.event_id
|
||||
FROM rog_team t
|
||||
JOIN rog_newevent2 e ON t.event_id = e.id
|
||||
WHERE t.zekken_number = %s AND e.event_code = %s
|
||||
""", (zekken_number, event_code))
|
||||
result = target_cur.fetchone()
|
||||
if result:
|
||||
team_id, event_id = result
|
||||
except Exception as e:
|
||||
print(f"Team ID取得エラー: {e}")
|
||||
|
||||
# checkpoint_id を取得
|
||||
try:
|
||||
target_cur.execute("""
|
||||
SELECT c.id
|
||||
FROM rog_checkpoint c
|
||||
JOIN rog_newevent2 e ON c.event_id = e.id
|
||||
WHERE c.cp_number = %s AND e.event_code = %s
|
||||
""", (str(cp_number), event_code))
|
||||
result = target_cur.fetchone()
|
||||
if result:
|
||||
checkpoint_id = result[0]
|
||||
except Exception as e:
|
||||
print(f"Checkpoint ID取得エラー: {e}")
|
||||
|
||||
return team_id, event_id, checkpoint_id
|
||||
|
||||
def migrate_gps_data():
|
||||
"""
|
||||
GPSチェックインデータの移行
|
||||
"""
|
||||
# コンテナ環境用の接続情報
|
||||
source_db = {
|
||||
'dbname': 'gifuroge',
|
||||
'user': 'admin',
|
||||
'password': 'admin123456',
|
||||
'host': 'postgres-db', # Dockerサービス名
|
||||
'port': '5432'
|
||||
}
|
||||
|
||||
target_db = {
|
||||
'dbname': 'rogdb',
|
||||
'user': 'admin',
|
||||
'password': 'admin123456',
|
||||
'host': 'postgres-db', # Dockerサービス名
|
||||
'port': '5432'
|
||||
}
|
||||
|
||||
source_conn = None
|
||||
target_conn = None
|
||||
source_cur = None
|
||||
target_cur = None
|
||||
|
||||
try:
|
||||
print("ソースDBへの接続を試みています...")
|
||||
source_conn = psycopg2.connect(**source_db)
|
||||
source_cur = source_conn.cursor()
|
||||
print("ソースDBへの接続が成功しました")
|
||||
|
||||
print("ターゲットDBへの接続を試みています...")
|
||||
target_conn = psycopg2.connect(**target_db)
|
||||
target_cur = target_conn.cursor()
|
||||
print("ターゲットDBへの接続が成功しました")
|
||||
|
||||
# 既存のrog_gpscheckinデータをクリア
|
||||
print("既存のGPSチェックインデータをクリアしています...")
|
||||
target_cur.execute("DELETE FROM rog_gpscheckin")
|
||||
target_conn.commit()
|
||||
print("既存データのクリアが完了しました")
|
||||
|
||||
print("データの取得を開始します...")
|
||||
source_cur.execute("""
|
||||
SELECT serial_number, zekken_number, event_code, cp_number, image_address,
|
||||
goal_time, late_point, create_at, create_user,
|
||||
update_at, update_user, buy_flag, colabo_company_memo
|
||||
FROM gps_information
|
||||
ORDER BY event_code, zekken_number, serial_number
|
||||
""")
|
||||
|
||||
rows = source_cur.fetchall()
|
||||
print(f"取得したレコード数: {len(rows)}")
|
||||
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
|
||||
for row in rows:
|
||||
(serial_number, zekken_number, event_code, cp_number, image_address,
|
||||
goal_time, late_point, create_at, create_user,
|
||||
update_at, update_user, buy_flag, colabo_company_memo) = row
|
||||
|
||||
try:
|
||||
# 関連IDを取得
|
||||
team_id, event_id, checkpoint_id = get_foreign_keys(zekken_number, event_code, cp_number, target_cur)
|
||||
|
||||
if not team_id or not event_id:
|
||||
print(f"スキップ: team_id={team_id}, event_id={event_id} for {zekken_number}/{event_code}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# イベント日付を取得
|
||||
event_date = get_event_date(event_code, target_cur)
|
||||
|
||||
# 時刻を正しく変換(create_atも渡す)
|
||||
checkin_time = None
|
||||
record_time = None
|
||||
|
||||
if goal_time:
|
||||
parsed_time = parse_goal_time(goal_time, datetime.combine(event_date, datetime.min.time()), create_at)
|
||||
if parsed_time:
|
||||
checkin_time = parsed_time
|
||||
record_time = parsed_time
|
||||
|
||||
# goal_timeがない場合はcreate_atを使用(UTCからJSTに変換)
|
||||
if not checkin_time and create_at:
|
||||
checkin_time = convert_utc_to_jst(create_at)
|
||||
record_time = convert_utc_to_jst(create_at)
|
||||
elif not checkin_time:
|
||||
# 最後の手段としてデフォルト時刻
|
||||
checkin_time = datetime.combine(event_date, datetime.min.time()) + timedelta(hours=12)
|
||||
record_time = checkin_time
|
||||
|
||||
# GPS座標を取得
|
||||
latitude, longitude = None, None
|
||||
if image_address and os.path.exists(image_address):
|
||||
latitude, longitude = get_gps_from_image(image_address)
|
||||
|
||||
# rog_gpscheckinテーブルに挿入
|
||||
target_cur.execute("""
|
||||
INSERT INTO rog_gpscheckin (
|
||||
event_code, zekken, serial_number, cp_number,
|
||||
lat, lng, checkin_time, record_time,
|
||||
mobserver_id, event_id, team_id, checkpoint_id
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
)
|
||||
""", (
|
||||
event_code, zekken_number, serial_number, str(cp_number),
|
||||
latitude, longitude, checkin_time, record_time,
|
||||
serial_number, event_id, team_id, checkpoint_id
|
||||
))
|
||||
|
||||
processed_count += 1
|
||||
if processed_count % 100 == 0:
|
||||
print(f"処理済みレコード数: {processed_count}")
|
||||
target_conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"レコード処理エラー: {e} - {row}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
target_conn.commit()
|
||||
print(f"移行完了: {processed_count}件のレコードを処理しました")
|
||||
print(f"エラー件数: {error_count}件")
|
||||
|
||||
except Exception as e:
|
||||
print(f"エラーが発生しました: {e}")
|
||||
if target_conn:
|
||||
target_conn.rollback()
|
||||
|
||||
finally:
|
||||
if source_cur:
|
||||
source_cur.close()
|
||||
if target_cur:
|
||||
target_cur.close()
|
||||
if source_conn:
|
||||
source_conn.close()
|
||||
if target_conn:
|
||||
target_conn.close()
|
||||
print("すべての接続をクローズしました")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_gps_data()
|
||||
349
rog/models.py
349
rog/models.py
@ -309,10 +309,11 @@ class TempUser(models.Model):
|
||||
return timezone.now() <= self.expires_at
|
||||
|
||||
class NewEvent2(models.Model):
|
||||
# 既存フィールド
|
||||
event_name = models.CharField(max_length=255, unique=True)
|
||||
event_description=models.TextField(max_length=255,blank=True, null=True)
|
||||
start_datetime = models.DateTimeField(default=timezone.now)
|
||||
end_datetime = models.DateTimeField()
|
||||
end_datetime = models.DateTimeField(null=True, blank=True)
|
||||
deadlineDateTime = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
#// Added @2024-10-21
|
||||
@ -325,8 +326,19 @@ class NewEvent2(models.Model):
|
||||
class_solo_female = models.BooleanField(default=True)
|
||||
|
||||
self_rogaining = models.BooleanField(default=False)
|
||||
|
||||
# MobServer統合フィールド
|
||||
event_code = models.CharField(max_length=50, unique=True, blank=True, null=True) # event_table.event_code
|
||||
start_time = models.CharField(max_length=20, blank=True, null=True) # event_table.start_time
|
||||
event_day = models.CharField(max_length=20, blank=True, null=True) # event_table.event_day
|
||||
|
||||
# 会場情報統合
|
||||
venue_location = models.PointField(null=True, blank=True, srid=4326)
|
||||
venue_address = models.CharField(max_length=500, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
if self.event_code:
|
||||
return f"{self.event_code} - {self.event_name}"
|
||||
return f"{self.event_name} - From:{self.start_datetime} To:{self.end_datetime}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -347,16 +359,38 @@ def get_default_category():
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
# zekken_number = models.CharField(max_length=255, unique=True)
|
||||
# 既存フィールド
|
||||
team_name = models.CharField(max_length=255)
|
||||
owner = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='owned_teams', blank=True, null=True)
|
||||
category = models.ForeignKey('NewCategory', on_delete=models.SET_DEFAULT, default=get_default_category)
|
||||
|
||||
# MobServer統合フィールド
|
||||
zekken_number = models.CharField(max_length=20, blank=True, null=True) # team_table.zekken_number
|
||||
event = models.ForeignKey('NewEvent2', on_delete=models.CASCADE, blank=True, null=True) # team_table.event_code
|
||||
password = models.CharField(max_length=100, blank=True, null=True) # team_table.password
|
||||
class_name = models.CharField(max_length=100, blank=True, null=True) # team_table.class_name
|
||||
trial = models.BooleanField(default=False) # team_table.trial
|
||||
|
||||
# 地理情報
|
||||
location = models.PointField(null=True, blank=True, srid=4326)
|
||||
|
||||
# 統合管理フィールド
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, null=True, blank=True)
|
||||
|
||||
# class Meta:
|
||||
# unique_together = ('zekken_number', 'category')
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['zekken_number', 'event'],
|
||||
name='unique_team_per_event',
|
||||
condition=models.Q(zekken_number__isnull=False, event__isnull=False)
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.team_name}, owner:{self.owner.lastname} {self.owner.firstname}"
|
||||
if self.zekken_number and self.event:
|
||||
return f"{self.zekken_number}-{self.team_name} ({self.event.event_name})"
|
||||
return f"{self.team_name}, owner:{self.owner.lastname if self.owner else 'None'} {self.owner.firstname if self.owner else ''}"
|
||||
|
||||
|
||||
class Member(models.Model):
|
||||
@ -540,14 +574,53 @@ class CheckinImages(models.Model):
|
||||
event_code = models.CharField(_("event code"), max_length=255)
|
||||
cp_number = models.IntegerField(_("CP numner"))
|
||||
|
||||
class Checkpoint(models.Model):
|
||||
"""チェックポイント管理モデル(MobServer統合)"""
|
||||
# MobServer完全統合
|
||||
cp_number = models.IntegerField() # checkpoint_table.cp_number
|
||||
event = models.ForeignKey('NewEvent2', on_delete=models.CASCADE, blank=True, null=True)
|
||||
cp_name = models.CharField(max_length=200, blank=True, null=True) # checkpoint_table.cp_name
|
||||
|
||||
# 位置情報(PostGIS対応)
|
||||
location = models.PointField(srid=4326, blank=True, null=True) # latitude, longitude統合
|
||||
|
||||
# ポイント情報
|
||||
photo_point = models.IntegerField(default=0) # checkpoint_table.photo_point
|
||||
buy_point = models.IntegerField(default=0) # checkpoint_table.buy_point
|
||||
|
||||
# サンプル・メモ
|
||||
sample_photo = models.CharField(max_length=500, blank=True, null=True)
|
||||
colabo_company_memo = models.TextField(blank=True, null=True)
|
||||
|
||||
# 統合管理フィールド
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['cp_number', 'event'],
|
||||
name='unique_cp_per_event'
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=['event', 'cp_number'], name='idx_checkpoint_event_cp'),
|
||||
GistIndex(fields=['location'], name='idx_checkpoint_location'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"CP{self.cp_number} - {self.cp_name} ({self.event.event_code if self.event.event_code else self.event.event_name})"
|
||||
|
||||
class GpsCheckin(models.Model):
|
||||
id = models.AutoField(primary_key=True) # 明示的にidフィールドを追加
|
||||
path_order = models.IntegerField(
|
||||
null=False,
|
||||
default=0,
|
||||
help_text="チェックポイントの順序番号"
|
||||
)
|
||||
zekken_number = models.TextField(
|
||||
null=False,
|
||||
default='',
|
||||
help_text="ゼッケン番号"
|
||||
)
|
||||
event_id = models.IntegerField(
|
||||
@ -557,6 +630,7 @@ class GpsCheckin(models.Model):
|
||||
)
|
||||
event_code = models.TextField(
|
||||
null=False,
|
||||
default='',
|
||||
help_text="イベントコード"
|
||||
)
|
||||
cp_number = models.IntegerField(
|
||||
@ -637,6 +711,76 @@ class GpsCheckin(models.Model):
|
||||
blank=True,
|
||||
help_text="ポイント:このチェックインによる獲得ポイント。通常ポイントと買い物ポイントは分離される。ゴールの場合には減点なども含む。"
|
||||
)
|
||||
|
||||
# MobServer統合フィールド
|
||||
serial_number = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="MobServer gps_information.serial_number"
|
||||
)
|
||||
team = models.ForeignKey(
|
||||
'Team',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="統合チームリレーション"
|
||||
)
|
||||
checkpoint = models.ForeignKey(
|
||||
'Checkpoint',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="統合チェックポイントリレーション"
|
||||
)
|
||||
minus_photo_flag = models.BooleanField(
|
||||
default=False,
|
||||
help_text="MobServer gps_information.minus_photo_flag"
|
||||
)
|
||||
|
||||
# 通過審査管理フィールド
|
||||
validation_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('PENDING', '審査待ち'),
|
||||
('APPROVED', '承認'),
|
||||
('REJECTED', '却下'),
|
||||
('AUTO_APPROVED', '自動承認')
|
||||
],
|
||||
default='PENDING',
|
||||
help_text="通過審査ステータス"
|
||||
)
|
||||
validation_comment = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="審査コメント"
|
||||
)
|
||||
validated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="審査実施日時"
|
||||
)
|
||||
validated_by = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="審査実施者"
|
||||
)
|
||||
validation_comment = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="審査コメント・理由"
|
||||
)
|
||||
validated_by = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="審査者"
|
||||
)
|
||||
validated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="審査日時"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'gps_checkins'
|
||||
@ -800,6 +944,161 @@ class templocation(models.Model):
|
||||
def __str__(self):
|
||||
return self.location_name
|
||||
|
||||
|
||||
class Location2025(models.Model):
|
||||
"""
|
||||
2025年版チェックポイント管理モデル
|
||||
CSVアップロード対応の新しいチェックポイント管理システム
|
||||
"""
|
||||
# 基本情報
|
||||
cp_number = models.IntegerField(_('CP番号'), db_index=True)
|
||||
event = models.ForeignKey('NewEvent2', on_delete=models.CASCADE, verbose_name=_('イベント'))
|
||||
cp_name = models.CharField(_('CP名'), max_length=255)
|
||||
|
||||
# 位置情報
|
||||
latitude = models.FloatField(_('緯度'), null=True, blank=True)
|
||||
longitude = models.FloatField(_('経度'), null=True, blank=True)
|
||||
location = models.PointField(_('位置'), srid=4326, null=True, blank=True)
|
||||
|
||||
# ポイント情報
|
||||
cp_point = models.IntegerField(_('チェックポイント得点'), default=10)
|
||||
photo_point = models.IntegerField(_('写真ポイント'), default=0)
|
||||
buy_point = models.IntegerField(_('買い物ポイント'), default=0)
|
||||
|
||||
# チェックイン設定
|
||||
checkin_radius = models.FloatField(_('チェックイン範囲(m)'), default=15.0)
|
||||
auto_checkin = models.BooleanField(_('自動チェックイン'), default=False)
|
||||
|
||||
# 営業情報
|
||||
shop_closed = models.BooleanField(_('休業中'), default=False)
|
||||
shop_shutdown = models.BooleanField(_('閉業'), default=False)
|
||||
opening_hours = models.TextField(_('営業時間'), blank=True, null=True)
|
||||
|
||||
# 詳細情報
|
||||
address = models.CharField(_('住所'), max_length=512, blank=True, null=True)
|
||||
phone = models.CharField(_('電話番号'), max_length=32, blank=True, null=True)
|
||||
website = models.URLField(_('ウェブサイト'), blank=True, null=True)
|
||||
description = models.TextField(_('説明'), blank=True, null=True)
|
||||
|
||||
# 管理情報
|
||||
is_active = models.BooleanField(_('有効'), default=True, db_index=True)
|
||||
sort_order = models.IntegerField(_('表示順'), default=0)
|
||||
|
||||
# CSVアップロード関連
|
||||
csv_source_file = models.CharField(_('CSVファイル名'), max_length=255, blank=True, null=True)
|
||||
csv_upload_date = models.DateTimeField(_('CSVアップロード日時'), null=True, blank=True)
|
||||
csv_upload_user = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='location2025_csv_uploads', verbose_name=_('CSVアップロードユーザー'))
|
||||
|
||||
# タイムスタンプ
|
||||
created_at = models.DateTimeField(_('作成日時'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('更新日時'), auto_now=True)
|
||||
created_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='location2025_created', verbose_name=_('作成者'))
|
||||
updated_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='location2025_updated', verbose_name=_('更新者'))
|
||||
|
||||
class Meta:
|
||||
db_table = 'rog_location2025'
|
||||
verbose_name = _('チェックポイント2025')
|
||||
verbose_name_plural = _('チェックポイント2025')
|
||||
unique_together = ['cp_number', 'event']
|
||||
ordering = ['event', 'sort_order', 'cp_number']
|
||||
indexes = [
|
||||
models.Index(fields=['event', 'cp_number'], name='location2025_event_cp_idx'),
|
||||
models.Index(fields=['event', 'is_active'], name='location2025_event_active_idx'),
|
||||
models.Index(fields=['csv_upload_date'], name='location2025_csv_date_idx'),
|
||||
GistIndex(fields=['location'], name='location2025_location_gist_idx'),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 緯度経度からLocationフィールドを自動生成
|
||||
if self.latitude and self.longitude:
|
||||
from django.contrib.gis.geos import Point
|
||||
self.location = Point(self.longitude, self.latitude, srid=4326)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event.event_name} - CP{self.cp_number}: {self.cp_name}"
|
||||
|
||||
@property
|
||||
def total_point(self):
|
||||
"""総得点を計算"""
|
||||
return self.cp_point + self.photo_point + self.buy_point
|
||||
|
||||
@classmethod
|
||||
def import_from_csv(cls, csv_file, event, user=None):
|
||||
"""
|
||||
CSVファイルからチェックポイントデータをインポート
|
||||
|
||||
CSV形式:
|
||||
cp_number,cp_name,latitude,longitude,cp_point,photo_point,buy_point,address,phone,description
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from django.utils import timezone
|
||||
|
||||
if isinstance(csv_file, str):
|
||||
# ファイルパスの場合
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
csv_content = f.read()
|
||||
else:
|
||||
# アップロードされたファイルの場合
|
||||
csv_content = csv_file.read().decode('utf-8')
|
||||
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2):
|
||||
try:
|
||||
cp_number = int(row.get('cp_number', 0))
|
||||
if cp_number <= 0:
|
||||
errors.append(f"行{row_num}: CP番号が無効です")
|
||||
continue
|
||||
|
||||
defaults = {
|
||||
'cp_name': row.get('cp_name', f'CP{cp_number}'),
|
||||
'latitude': float(row['latitude']) if row.get('latitude') else None,
|
||||
'longitude': float(row['longitude']) if row.get('longitude') else None,
|
||||
'cp_point': int(row.get('cp_point', 10)),
|
||||
'photo_point': int(row.get('photo_point', 0)),
|
||||
'buy_point': int(row.get('buy_point', 0)),
|
||||
'address': row.get('address', ''),
|
||||
'phone': row.get('phone', ''),
|
||||
'description': row.get('description', ''),
|
||||
'csv_source_file': getattr(csv_file, 'name', 'uploaded_file.csv'),
|
||||
'csv_upload_date': timezone.now(),
|
||||
'csv_upload_user': user,
|
||||
'updated_by': user,
|
||||
}
|
||||
|
||||
if user:
|
||||
defaults['created_by'] = user
|
||||
|
||||
obj, created = cls.objects.update_or_create(
|
||||
cp_number=cp_number,
|
||||
event=event,
|
||||
defaults=defaults
|
||||
)
|
||||
|
||||
if created:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
except (ValueError, KeyError) as e:
|
||||
errors.append(f"行{row_num}: {str(e)}")
|
||||
continue
|
||||
|
||||
return {
|
||||
'created': created_count,
|
||||
'updated': updated_count,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
|
||||
class Location_line(models.Model):
|
||||
location_id=models.IntegerField(_('Location id'), blank=True, null=True)
|
||||
location_name=models.CharField(_('Location Name'), max_length=255)
|
||||
@ -1409,6 +1708,39 @@ def publish_data(sender, instance, created, **kwargs):
|
||||
|
||||
# 既存のモデルに追加=> 通過記録に相応しい名称に変更すべき
|
||||
|
||||
def get_default_entry():
|
||||
"""
|
||||
デフォルトのEntryを取得または作成する
|
||||
"""
|
||||
try:
|
||||
# NewEvent2のデフォルトイベントを取得
|
||||
default_event = NewEvent2.objects.first()
|
||||
if default_event:
|
||||
# デフォルトチームを取得
|
||||
default_team = Team.objects.first()
|
||||
if default_team:
|
||||
# 既存のEntryを取得
|
||||
entry = Entry.objects.filter(
|
||||
teams=default_team,
|
||||
event=default_event
|
||||
).first()
|
||||
if entry:
|
||||
return entry.id
|
||||
# 新しいEntryを作成
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
default_user = User.objects.first()
|
||||
if default_user:
|
||||
entry = Entry.objects.create(
|
||||
event=default_event,
|
||||
main_user=default_user
|
||||
)
|
||||
entry.teams.add(default_team)
|
||||
return entry.id
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class GpsLog(models.Model):
|
||||
"""
|
||||
@ -1417,12 +1749,9 @@ class GpsLog(models.Model):
|
||||
"""
|
||||
serial_number = models.IntegerField(null=False)
|
||||
|
||||
# 新規追加
|
||||
entry = models.ForeignKey(Entry, on_delete=models.CASCADE, related_name='checkpoints')
|
||||
|
||||
# Entry へ移行
|
||||
zekken_number = models.TextField(null=False)
|
||||
event_code = models.TextField(null=False)
|
||||
zekken_number = models.TextField(null=False, default='')
|
||||
event_code = models.TextField(null=False, default='')
|
||||
|
||||
cp_number = models.TextField(null=True, blank=True)
|
||||
image_address = models.TextField(null=True, blank=True)
|
||||
|
||||
222
rog/services/s3_service.py
Normal file
222
rog/services/s3_service.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""
|
||||
S3 Service for managing uploads and standard images
|
||||
"""
|
||||
import boto3
|
||||
import uuid
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class S3Service:
|
||||
"""AWS S3 service for handling image uploads and management"""
|
||||
|
||||
def __init__(self):
|
||||
self.s3_client = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
region_name=settings.AWS_S3_REGION_NAME
|
||||
)
|
||||
self.bucket_name = settings.AWS_STORAGE_BUCKET_NAME
|
||||
self.custom_domain = settings.AWS_S3_CUSTOM_DOMAIN
|
||||
|
||||
def upload_checkin_image(self, image_file, event_code, team_code, cp_number, is_goal=False):
|
||||
"""
|
||||
チェックイン画像またはゴール画像をS3にアップロード
|
||||
|
||||
Args:
|
||||
image_file: アップロードする画像ファイル
|
||||
event_code: イベントコード
|
||||
team_code: チームコード
|
||||
cp_number: チェックポイント番号
|
||||
is_goal: ゴール画像かどうか(デフォルト: False)
|
||||
"""
|
||||
try:
|
||||
# ファイル名を生成(UUID + タイムスタンプ)
|
||||
file_extension = image_file.name.split('.')[-1] if '.' in image_file.name else 'jpg'
|
||||
filename = f"{uuid.uuid4()}-{datetime.now().strftime('%Y-%m-%dT%H-%M-%S')}.{file_extension}"
|
||||
|
||||
# S3キーを生成(イベント/チーム/ファイル名)
|
||||
# ゴール画像の場合は専用フォルダに保存
|
||||
if is_goal:
|
||||
s3_key = f"{event_code}/goals/{team_code}/{filename}"
|
||||
else:
|
||||
s3_key = f"{event_code}/{team_code}/{filename}"
|
||||
|
||||
# メタデータをBase64エンコードして設定(S3メタデータはASCIIのみ対応)
|
||||
metadata = {
|
||||
'event_b64': base64.b64encode(event_code.encode('utf-8')).decode('ascii'),
|
||||
'team_b64': base64.b64encode(team_code.encode('utf-8')).decode('ascii'),
|
||||
'cp_number': str(cp_number),
|
||||
'uploaded_at': datetime.now().strftime('%Y-%m-%dT%H-%M-%S'),
|
||||
'image_type': 'goal' if is_goal else 'checkin'
|
||||
}
|
||||
|
||||
# S3にアップロード
|
||||
self.s3_client.upload_fileobj(
|
||||
image_file,
|
||||
self.bucket_name,
|
||||
s3_key,
|
||||
ExtraArgs={
|
||||
'ContentType': f'image/{file_extension}',
|
||||
'Metadata': metadata
|
||||
}
|
||||
)
|
||||
|
||||
# S3 URLを生成
|
||||
s3_url = f"https://{self.bucket_name}.s3.{settings.AWS_S3_REGION_NAME}.amazonaws.com/{s3_key}"
|
||||
|
||||
logger.info(f"{'Goal' if is_goal else 'Checkin'} image uploaded to S3: {s3_url}")
|
||||
return s3_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload image to S3: {e}")
|
||||
raise
|
||||
|
||||
def upload_standard_image(self, image_file, event_code, image_type):
|
||||
"""
|
||||
規定画像をS3にアップロード
|
||||
"""
|
||||
try:
|
||||
# ファイル拡張子を取得
|
||||
file_extension = image_file.name.split('.')[-1] if '.' in image_file.name else 'jpg'
|
||||
|
||||
# S3キーを生成(イベント/standards/タイプ.拡張子)
|
||||
s3_key = f"{event_code}/standards/{image_type}.{file_extension}"
|
||||
|
||||
# メタデータをBase64エンコードして設定
|
||||
metadata = {
|
||||
'event_b64': base64.b64encode(event_code.encode('utf-8')).decode('ascii'),
|
||||
'image_type': image_type,
|
||||
'uploaded_at': datetime.now().strftime('%Y-%m-%dT%H-%M-%S')
|
||||
}
|
||||
|
||||
# S3にアップロード
|
||||
self.s3_client.upload_fileobj(
|
||||
image_file,
|
||||
self.bucket_name,
|
||||
s3_key,
|
||||
ExtraArgs={
|
||||
'ContentType': f'image/{file_extension}',
|
||||
'Metadata': metadata
|
||||
}
|
||||
)
|
||||
|
||||
# S3 URLを生成
|
||||
s3_url = f"https://{self.bucket_name}.s3.{settings.AWS_S3_REGION_NAME}.amazonaws.com/{s3_key}"
|
||||
|
||||
logger.info(f"Standard image uploaded to S3: {s3_url}")
|
||||
return s3_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload standard image to S3: {e}")
|
||||
raise
|
||||
|
||||
def get_standard_image_url(self, event_code, image_type):
|
||||
"""
|
||||
Get URL for standard image
|
||||
|
||||
Args:
|
||||
event_code: Event code
|
||||
image_type: Type of image (goal, start, checkpoint, etc.)
|
||||
|
||||
Returns:
|
||||
str: S3 URL of standard image or None if not found
|
||||
"""
|
||||
# Try common image extensions
|
||||
extensions = ['.jpg', '.jpeg', '.png', '.gif']
|
||||
|
||||
for ext in extensions:
|
||||
s3_key = f"{event_code}/standards/{image_type}{ext}"
|
||||
try:
|
||||
# Check if object exists
|
||||
self.s3_client.head_object(Bucket=self.bucket_name, Key=s3_key)
|
||||
return f"https://{self.custom_domain}/{s3_key}"
|
||||
except self.s3_client.exceptions.NoSuchKey:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking standard image: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def delete_image(self, s3_url):
|
||||
"""
|
||||
Delete image from S3
|
||||
|
||||
Args:
|
||||
s3_url: Full S3 URL of the image
|
||||
|
||||
Returns:
|
||||
bool: True if deleted successfully
|
||||
"""
|
||||
try:
|
||||
# Extract S3 key from URL
|
||||
s3_key = self._extract_s3_key_from_url(s3_url)
|
||||
if not s3_key:
|
||||
logger.error(f"Invalid S3 URL: {s3_url}")
|
||||
return False
|
||||
|
||||
# Delete from S3
|
||||
self.s3_client.delete_object(Bucket=self.bucket_name, Key=s3_key)
|
||||
logger.info(f"Image deleted from S3: {s3_url}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete image from S3: {e}")
|
||||
return False
|
||||
|
||||
def list_event_images(self, event_code, limit=100):
|
||||
"""
|
||||
List all images for an event
|
||||
|
||||
Args:
|
||||
event_code: Event code
|
||||
limit: Maximum number of images to return
|
||||
|
||||
Returns:
|
||||
list: List of S3 URLs
|
||||
"""
|
||||
try:
|
||||
response = self.s3_client.list_objects_v2(
|
||||
Bucket=self.bucket_name,
|
||||
Prefix=f"{event_code}/",
|
||||
MaxKeys=limit
|
||||
)
|
||||
|
||||
urls = []
|
||||
if 'Contents' in response:
|
||||
for obj in response['Contents']:
|
||||
url = f"https://{self.custom_domain}/{obj['Key']}"
|
||||
urls.append(url)
|
||||
|
||||
return urls
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list event images: {e}")
|
||||
return []
|
||||
|
||||
def _get_file_extension(self, filename):
|
||||
"""Get file extension from filename"""
|
||||
if '.' in filename:
|
||||
return '.' + filename.split('.')[-1].lower()
|
||||
return '.jpg' # default extension
|
||||
|
||||
def _get_timestamp(self):
|
||||
"""Get current timestamp string"""
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
||||
|
||||
def _extract_s3_key_from_url(self, s3_url):
|
||||
"""Extract S3 key from full S3 URL"""
|
||||
try:
|
||||
# Remove domain part to get key
|
||||
if self.custom_domain in s3_url:
|
||||
return s3_url.split(self.custom_domain + '/')[-1]
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
19
rog/urls.py
19
rog/urls.py
@ -12,8 +12,11 @@ from .views_apis.api_routes import top_users_routes,generate_route_image
|
||||
from .views_apis.api_events import get_start_point,analyze_point
|
||||
from .views_apis.api_monitor import realtime_monitor, realtime_monitor_zekken_narrow
|
||||
from .views_apis.api_ranking import get_ranking,all_ranking_top3
|
||||
from .views_apis.api_photos import get_photo_list, get_photo_list_prod
|
||||
from .views_apis.api_photos import get_photo_list, get_photo_list_prod, get_team_photos
|
||||
from .views_apis.s3_views import upload_checkin_image, upload_standard_image, get_standard_image, list_event_images, delete_image
|
||||
from .views_apis.api_scoreboard import get_scoreboard,download_scoreboard,reprint,make_all_scoreboard,make_cp_list_sheet
|
||||
from .views_apis.api_bulk_upload import bulk_upload_photos, confirm_checkin_validation
|
||||
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
|
||||
|
||||
@ -202,6 +205,7 @@ urlpatterns += [
|
||||
## PhotoList
|
||||
path('get_photo_list', get_photo_list, name='get_photo_list'),
|
||||
path('get_photo_list_prod', get_photo_list_prod, name='get_photo_list_prod'),
|
||||
path('get_team_photos', get_team_photos, name='get_team_photos'),
|
||||
path('getCheckpointList', get_checkpoint_list, name='get_checkpoint_list'),
|
||||
path('makeCpListSheet', make_cp_list_sheet, name='make_cp_list_sheet'),
|
||||
|
||||
@ -218,6 +222,19 @@ urlpatterns += [
|
||||
path('test_gifuroge', test_gifuroge, name='test_gifuroge'),
|
||||
path('practice', practice, name='practice'),
|
||||
|
||||
## S3 Image Management
|
||||
path('upload-checkin-image/', upload_checkin_image, name='upload_checkin_image'),
|
||||
path('upload-standard-image/', upload_standard_image, name='upload_standard_image'),
|
||||
path('get-standard-image/', get_standard_image, name='get_standard_image'),
|
||||
path('list-event-images/', list_event_images, name='list_event_images'),
|
||||
path('delete-image/', delete_image, name='delete_image'),
|
||||
|
||||
## Bulk Upload and Validation Management
|
||||
path('bulk-upload-photos/', bulk_upload_photos, name='bulk_upload_photos'),
|
||||
path('confirm-checkin-validation/', confirm_checkin_validation, name='confirm_checkin_validation'),
|
||||
path('event-participants-ranking/', get_event_participants_ranking, name='get_event_participants_ranking'),
|
||||
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'),
|
||||
|
||||
]
|
||||
|
||||
|
||||
179
rog/views.py
179
rog/views.py
@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
import traceback
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from urllib.parse import unquote # URLデコード用
|
||||
|
||||
import subprocess # subprocessモジュールを追加
|
||||
import tempfile # tempfileモジュールを追加
|
||||
@ -242,37 +243,19 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Location.objects.all()
|
||||
logger.info("=== Location API Called ===")
|
||||
|
||||
# リクエストパラメータの確認
|
||||
group_filter = self.request.query_params.get('group__contains')
|
||||
logger.info(f"Request params: {dict(self.request.query_params)}")
|
||||
logger.info(f"Group filter: {group_filter}")
|
||||
|
||||
if group_filter:
|
||||
# フィルタ適用前のデータ数
|
||||
total_count = queryset.count()
|
||||
logger.info(f"Total locations before filter: {total_count}")
|
||||
|
||||
# フィルタの適用
|
||||
queryset = queryset.filter(group__contains=group_filter)
|
||||
|
||||
# フィルタ適用後のデータ数
|
||||
filtered_count = queryset.count()
|
||||
logger.info(f"Filtered locations count: {filtered_count}")
|
||||
|
||||
# フィルタされたデータのサンプル(最初の5件)
|
||||
sample_data = queryset[:5]
|
||||
logger.info("Sample of filtered data:")
|
||||
for loc in sample_data:
|
||||
logger.info(f"ID: {loc.id}, Name: {loc.location_name}, Group: {loc.group}")
|
||||
|
||||
return queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
try:
|
||||
response = super().list(request, *args, **kwargs)
|
||||
logger.info(f"Response data count: {len(response.data['features'])}")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error in list method: {str(e)}", exc_info=True)
|
||||
@ -862,7 +845,12 @@ class LoginAPI(generics.GenericAPIView):
|
||||
logger.info(f"Login attempt for identifier: {request.data.get('identifier', 'identifier not provided')}")
|
||||
logger.debug(f"Request data: {request.data}")
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
# フロントエンドの 'identifier' フィールドを 'email' にマッピング
|
||||
data = request.data.copy()
|
||||
if 'identifier' in data and 'email' not in data:
|
||||
data['email'] = data['identifier']
|
||||
|
||||
serializer = self.get_serializer(data=data)
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.validated_data
|
||||
@ -2491,11 +2479,59 @@ def get_events(request):
|
||||
)
|
||||
@api_view(['GET'])
|
||||
def get_zekken_numbers(request, event_code):
|
||||
entries = Entry.objects.filter(
|
||||
event__event_name=event_code,
|
||||
is_active=True
|
||||
).order_by('zekken_number')
|
||||
return Response([entry.zekken_number for entry in entries])
|
||||
# 通過審査画面用: GpsCheckinテーブルから過去の移行データと新規Entryテーブルの両方をサポート
|
||||
|
||||
try:
|
||||
print(f"=== get_zekken_numbers called with event_code: {event_code} ===")
|
||||
|
||||
# event_codeからNewEvent2のIDを取得
|
||||
try:
|
||||
event_obj = NewEvent2.objects.get(event_code=event_code)
|
||||
event_id = event_obj.id
|
||||
print(f"Found event: {event_obj.event_name} (ID: {event_id})")
|
||||
except NewEvent2.DoesNotExist:
|
||||
print(f"No event found with event_code: {event_code}, trying legacy data only")
|
||||
event_id = None
|
||||
|
||||
entry_list = []
|
||||
# 新規のEntryテーブルから取得(event_idが見つかった場合のみ)
|
||||
if event_id:
|
||||
entries = Entry.objects.filter(
|
||||
event_id=event_id,
|
||||
zekken_number__gt=0
|
||||
).values_list('zekken_number', flat=True).order_by('zekken_number')
|
||||
|
||||
if event_id:
|
||||
entries = Entry.objects.filter(
|
||||
event_id=event_id,
|
||||
zekken_number__gt=0
|
||||
).values_list('zekken_number', flat=True).order_by('zekken_number')
|
||||
|
||||
entry_list = list(entries)
|
||||
print(f"Entry table found {len(entry_list)} records: {entry_list[:10]}")
|
||||
|
||||
# GpsCheckinテーブルからも検索(過去の移行データ)
|
||||
gps_checkins = GpsCheckin.objects.filter(
|
||||
event_code=event_code,
|
||||
zekken_number__gt=0
|
||||
).values_list('zekken_number', flat=True).distinct().order_by('zekken_number')
|
||||
|
||||
gps_list = list(gps_checkins)
|
||||
print(f"GpsCheckin table found {len(gps_list)} records: {gps_list[:10]}")
|
||||
|
||||
# 両方の結果をマージして重複を除去
|
||||
all_zekken_numbers = entry_list + gps_list
|
||||
unique_zekken_numbers = sorted(set(all_zekken_numbers))
|
||||
|
||||
print(f"Final result: {unique_zekken_numbers[:10]}")
|
||||
|
||||
return Response(unique_zekken_numbers)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in get_zekken_numbers: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return Response({"error": str(e)}, status=500)
|
||||
|
||||
@api_view(['GET'])
|
||||
def get_team_info(request, zekken_number):
|
||||
@ -2550,16 +2586,61 @@ def get_team_info(request, zekken_number):
|
||||
team = Team.objects.get(id=self.kwargs['team_id'])
|
||||
|
||||
def get_image_url(image_path):
|
||||
"""画像URLを生成する補助関数"""
|
||||
"""画像URLを生成する補助関数 - S3とローカルメディアの両方に対応"""
|
||||
if not image_path:
|
||||
return None
|
||||
|
||||
# 開発環境用のパス生成
|
||||
if settings.DEBUG:
|
||||
return os.path.join(settings.MEDIA_URL, str(image_path))
|
||||
image_path_str = str(image_path)
|
||||
|
||||
# 本番環境用のパス生成
|
||||
return f"{settings.MEDIA_URL}{image_path}"
|
||||
# シミュレーション画像の場合はローカルメディアパスを返す
|
||||
if image_path_str in ['simulation_image.jpg', '/media/simulation_image.jpg']:
|
||||
return f"{settings.MEDIA_URL}simulation_image.jpg"
|
||||
|
||||
# ローカルメディアのフルパス(/media/で始まる)の場合は、S3パスを抽出
|
||||
if image_path_str.startswith('/media/'):
|
||||
# /media/を削除してS3パスを取得
|
||||
s3_path = image_path_str[7:] # "/media/"を除去
|
||||
if hasattr(settings, 'AWS_STORAGE_BUCKET_NAME') and settings.AWS_STORAGE_BUCKET_NAME:
|
||||
# S3 URLを直接生成
|
||||
return f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/{s3_path}"
|
||||
|
||||
# S3のファイルパス(checkin/で始まる、画像拡張子を含む)かどうかを判定
|
||||
if (any(keyword in image_path_str.lower() for keyword in ['jpg', 'jpeg', 'png', 'gif', 'webp']) and
|
||||
('checkin/' in image_path_str or not image_path_str.startswith('/'))):
|
||||
# S3 URLを生成
|
||||
if hasattr(settings, 'AWS_STORAGE_BUCKET_NAME') and settings.AWS_STORAGE_BUCKET_NAME:
|
||||
return f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/{image_path_str}"
|
||||
|
||||
# その他の場合はローカルメディアURL
|
||||
return f"{settings.MEDIA_URL}{image_path_str}"
|
||||
|
||||
|
||||
def get_standard_image_url(event_code, image_type):
|
||||
"""規定画像URLを取得(S3優先、フォールバック対応)"""
|
||||
try:
|
||||
# S3から規定画像を取得
|
||||
from .services.s3_service import S3Service
|
||||
s3_service = S3Service()
|
||||
s3_url = s3_service.get_standard_image_url(event_code, image_type)
|
||||
|
||||
if s3_url:
|
||||
return s3_url
|
||||
|
||||
# S3に規定画像がない場合はデフォルト画像を返す
|
||||
default_images = {
|
||||
'goal': 'https://rogaining.sumasen.net/images/gifuRoge/asset/goal.png',
|
||||
'start': 'https://rogaining.sumasen.net/images/gifuRoge/asset/start.png',
|
||||
'checkpoint': 'https://rogaining.sumasen.net/images/gifuRoge/asset/checkpoint.png',
|
||||
'finish': 'https://rogaining.sumasen.net/images/gifuRoge/asset/finish.png',
|
||||
'photo_none': 'https://rogaining.sumasen.net/images/gifuRoge/asset/photo_none.png'
|
||||
}
|
||||
|
||||
return default_images.get(image_type, default_images['photo_none'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting standard image: {e}")
|
||||
# エラー時はデフォルト画像を返す
|
||||
return 'https://rogaining.sumasen.net/images/gifuRoge/asset/photo_none.png'
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@ -2584,9 +2665,6 @@ def get_checkins(request, *args, **kwargs):
|
||||
event_code=event_code
|
||||
).order_by('path_order')
|
||||
|
||||
# すべてのフィールドを確実に取得できるようにデバッグログを追加
|
||||
logger.debug(f"Found {checkins.count()} checkins for zekken_number {zekken_number} and event_code {event_code}")
|
||||
|
||||
data = []
|
||||
for c in checkins:
|
||||
location = Location.objects.filter(cp=c.cp_number,group=event_code).first()
|
||||
@ -3111,11 +3189,21 @@ def export_excel(request, zekken_number, event_code):
|
||||
s3.upload_file(pdf_path, f'{event_code}/scoreboard/certificate_{zekken_number}.pdf')
|
||||
s3.upload_file(excel_path, f'{event_code}/scoreboard_excel/certificate_{zekken_number}.xlsx')
|
||||
|
||||
# PDFファイルを読み込んでレスポンスとして返す
|
||||
with open(pdf_path, 'rb') as pdf_file:
|
||||
pdf_content = pdf_file.read()
|
||||
|
||||
os.remove(temp_excel_path)
|
||||
os.remove(excel_path)
|
||||
os.remove(pdf_path)
|
||||
|
||||
return Response( status=status.HTTP_200_OK )
|
||||
# PDFファイルをレスポンスとして返す
|
||||
response = HttpResponse(
|
||||
pdf_content,
|
||||
content_type='application/pdf'
|
||||
)
|
||||
response['Content-Disposition'] = f'inline; filename="certificate_{zekken_number}_{event_code}.pdf"'
|
||||
return response
|
||||
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
@ -3781,3 +3869,26 @@ def all_ranking_top3(request, event_code):
|
||||
{"error": str(e)},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# ルートページ用のビュー
|
||||
def index_view(request):
|
||||
"""ルートページをスーパーバイザー画面にリダイレクト"""
|
||||
from django.shortcuts import render
|
||||
from django.http import HttpResponse
|
||||
|
||||
# supervisor/html/index.htmlを読み込んで返す
|
||||
try:
|
||||
import os
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
supervisor_path = os.path.join(base_dir, 'supervisor', 'html', 'index.html')
|
||||
|
||||
with open(supervisor_path, 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
|
||||
return HttpResponse(content, content_type='text/html')
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading supervisor page: {str(e)}")
|
||||
return HttpResponse(
|
||||
"<h1>System Error</h1><p>Failed to load supervisor interface</p>",
|
||||
status=500
|
||||
)
|
||||
|
||||
352
rog/views_apis/api_admin_validation.py
Normal file
352
rog/views_apis/api_admin_validation.py
Normal file
@ -0,0 +1,352 @@
|
||||
"""
|
||||
通過審査管理画面用API
|
||||
参加者全体の得点とクラス別ランキング表示機能
|
||||
"""
|
||||
|
||||
import logging
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.db.models import Sum, Q, Count
|
||||
from django.db import models
|
||||
|
||||
from rog.models import NewEvent2, Entry, GpsCheckin, NewCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_event_participants_ranking(request):
|
||||
"""
|
||||
イベント参加者全体のクラス別得点ランキング取得
|
||||
|
||||
GET /api/event-participants-ranking/?event_code=FC岐阜
|
||||
"""
|
||||
try:
|
||||
event_code = request.GET.get('event_code')
|
||||
|
||||
if not event_code:
|
||||
return Response({
|
||||
'error': 'event_code parameter is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# イベントの検索(完全一致を優先)
|
||||
event = None
|
||||
if event_code:
|
||||
# まず完全一致でイベント名検索
|
||||
event = NewEvent2.objects.filter(event_name=event_code).first()
|
||||
if not event:
|
||||
# 次にイベントコードで検索
|
||||
event = NewEvent2.objects.filter(event_code=event_code).first()
|
||||
|
||||
if not event:
|
||||
return Response({
|
||||
'error': 'Event not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# イベント参加者の取得と得点計算
|
||||
entries = Entry.objects.filter(
|
||||
event=event,
|
||||
is_active=True
|
||||
).select_related('category', 'team').prefetch_related('team__members')
|
||||
|
||||
ranking_data = []
|
||||
|
||||
for entry in entries:
|
||||
# このエントリーのチェックイン記録を取得
|
||||
checkins = GpsCheckin.objects.filter(
|
||||
zekken_number=str(entry.zekken_number),
|
||||
event_code=event_code
|
||||
)
|
||||
|
||||
# 得点計算
|
||||
total_points = 0
|
||||
cp_points = 0
|
||||
buy_points = 0
|
||||
confirmed_points = 0 # 確定済み得点
|
||||
unconfirmed_points = 0 # 未確定得点
|
||||
late_penalty = 0
|
||||
|
||||
for checkin in checkins:
|
||||
if checkin.points:
|
||||
if checkin.validate_location: # 確定済み
|
||||
confirmed_points += checkin.points
|
||||
if checkin.buy_flag:
|
||||
buy_points += checkin.points
|
||||
else:
|
||||
cp_points += checkin.points
|
||||
else: # 未確定
|
||||
unconfirmed_points += checkin.points
|
||||
|
||||
if checkin.late_point:
|
||||
late_penalty += checkin.late_point
|
||||
|
||||
total_points = confirmed_points - late_penalty
|
||||
|
||||
# チェックイン確定状況
|
||||
total_checkins = checkins.count()
|
||||
confirmed_checkins = checkins.filter(validate_location=True).count()
|
||||
confirmation_rate = (confirmed_checkins / total_checkins * 100) if total_checkins > 0 else 0
|
||||
|
||||
# チームメンバー情報
|
||||
team_members = []
|
||||
if entry.team and entry.team.members.exists():
|
||||
team_members = [
|
||||
{
|
||||
'name': f"{member.user.firstname} {member.user.lastname}" if member.user else member.name,
|
||||
'age': member.age if hasattr(member, 'age') else None
|
||||
}
|
||||
for member in entry.team.members.all()
|
||||
]
|
||||
|
||||
ranking_data.append({
|
||||
'rank': 0, # 後で設定
|
||||
'zekken_number': entry.zekken_number,
|
||||
'zekken_label': entry.zekken_label or f"{entry.zekken_number}",
|
||||
'team_name': entry.team.team_name if entry.team else "チーム名不明",
|
||||
'category': {
|
||||
'name': entry.category.category_name,
|
||||
'class_name': entry.category.category_name # class_nameプロパティがない場合はcategory_nameを使用
|
||||
},
|
||||
'members': team_members,
|
||||
'points': {
|
||||
'total': total_points,
|
||||
'cp_points': cp_points,
|
||||
'buy_points': buy_points,
|
||||
'confirmed_points': confirmed_points,
|
||||
'unconfirmed_points': unconfirmed_points,
|
||||
'late_penalty': late_penalty
|
||||
},
|
||||
'checkin_status': {
|
||||
'total_checkins': total_checkins,
|
||||
'confirmed_checkins': confirmed_checkins,
|
||||
'unconfirmed_checkins': total_checkins - confirmed_checkins,
|
||||
'confirmation_rate': round(confirmation_rate, 1)
|
||||
},
|
||||
'entry_status': {
|
||||
'has_participated': entry.hasParticipated,
|
||||
'has_goaled': entry.hasGoaled
|
||||
}
|
||||
})
|
||||
|
||||
# クラス別にソートしてランキング設定
|
||||
ranking_data.sort(key=lambda x: (x['category']['class_name'], -x['points']['total']))
|
||||
|
||||
# クラス別ランキングの設定
|
||||
current_class = None
|
||||
current_rank = 0
|
||||
for i, item in enumerate(ranking_data):
|
||||
if item['category']['class_name'] != current_class:
|
||||
current_class = item['category']['class_name']
|
||||
current_rank = 1
|
||||
else:
|
||||
current_rank += 1
|
||||
item['rank'] = current_rank
|
||||
item['class_rank'] = current_rank
|
||||
|
||||
# クラス別にグループ化
|
||||
classes_ranking = {}
|
||||
for item in ranking_data:
|
||||
class_name = item['category']['class_name']
|
||||
if class_name not in classes_ranking:
|
||||
classes_ranking[class_name] = []
|
||||
classes_ranking[class_name].append(item)
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'event': {
|
||||
'event_code': event_code,
|
||||
'event_name': event.event_name,
|
||||
'total_participants': len(ranking_data)
|
||||
},
|
||||
'classes_ranking': classes_ranking,
|
||||
'all_participants': ranking_data,
|
||||
'participants': ranking_data # JavaScript互換性のため
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_event_participants_ranking: {str(e)}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_participant_validation_details(request):
|
||||
"""
|
||||
参加者の通過情報詳細と確定状況の取得
|
||||
|
||||
GET /api/participant-validation-details/?event_code=FC岐阜&zekken_number=123
|
||||
"""
|
||||
try:
|
||||
event_code = request.GET.get('event_code')
|
||||
zekken_number = request.GET.get('zekken_number')
|
||||
|
||||
if not all([event_code, zekken_number]):
|
||||
return Response({
|
||||
'error': 'event_code and zekken_number parameters are required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# イベントの確認
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_code=event_code)
|
||||
except NewEvent2.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Event not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# エントリーの確認
|
||||
try:
|
||||
entry = Entry.objects.get(
|
||||
event=event,
|
||||
zekken_number=zekken_number
|
||||
)
|
||||
except Entry.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Participant not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# チェックイン記録の取得
|
||||
checkins = GpsCheckin.objects.filter(
|
||||
zekken_number=str(zekken_number),
|
||||
event_code=event_code
|
||||
).order_by('path_order')
|
||||
|
||||
checkin_details = []
|
||||
for checkin in checkins:
|
||||
checkin_details.append({
|
||||
'id': checkin.id,
|
||||
'path_order': checkin.path_order,
|
||||
'cp_number': checkin.cp_number,
|
||||
'checkin_time': checkin.create_at.isoformat() if checkin.create_at else None,
|
||||
'image_url': checkin.image_address,
|
||||
'gps_location': {
|
||||
'latitude': checkin.lattitude,
|
||||
'longitude': checkin.longitude
|
||||
} if checkin.lattitude and checkin.longitude else None,
|
||||
'validation': {
|
||||
'is_confirmed': checkin.validate_location,
|
||||
'buy_flag': checkin.buy_flag,
|
||||
'points': checkin.points or 0
|
||||
},
|
||||
'metadata': {
|
||||
'create_user': checkin.create_user,
|
||||
'update_user': checkin.update_user,
|
||||
'update_time': checkin.update_at.isoformat() if checkin.update_at else None
|
||||
}
|
||||
})
|
||||
|
||||
# 統計情報
|
||||
stats = {
|
||||
'total_checkins': len(checkin_details),
|
||||
'confirmed_checkins': sum(1 for c in checkin_details if c['validation']['is_confirmed']),
|
||||
'unconfirmed_checkins': sum(1 for c in checkin_details if not c['validation']['is_confirmed']),
|
||||
'total_points': sum(c['validation']['points'] for c in checkin_details if c['validation']['is_confirmed']),
|
||||
'potential_points': sum(c['validation']['points'] for c in checkin_details)
|
||||
}
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'participant': {
|
||||
'zekken_number': entry.zekken_number,
|
||||
'team_name': entry.team_name,
|
||||
'category': entry.category.name,
|
||||
'class_name': entry.class_name
|
||||
},
|
||||
'statistics': stats,
|
||||
'checkins': checkin_details
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_participant_validation_details: {str(e)}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_event_zekken_list(request):
|
||||
"""
|
||||
イベントのゼッケン番号リスト取得(ALLオプション付き)
|
||||
|
||||
POST /api/event-zekken-list/
|
||||
{
|
||||
"event_code": "FC岐阜"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
data = json.loads(request.body)
|
||||
event_code = data.get('event_code')
|
||||
|
||||
if event_code is None:
|
||||
return Response({
|
||||
'error': 'event_code parameter is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# イベントの確認 - event_code=Noneの場合の処理を追加
|
||||
try:
|
||||
if event_code == '' or event_code is None:
|
||||
# event_code=Noneまたは空文字列の場合
|
||||
event = NewEvent2.objects.filter(event_code=None).first()
|
||||
else:
|
||||
# まずevent_nameで正確な検索を試す
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_name=event_code)
|
||||
except NewEvent2.DoesNotExist:
|
||||
# event_nameで見つからない場合はevent_codeで検索
|
||||
event = NewEvent2.objects.get(event_code=event_code)
|
||||
|
||||
if not event:
|
||||
return Response({
|
||||
'error': 'Event not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
except NewEvent2.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Event not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# 参加エントリーの取得
|
||||
entries = Entry.objects.filter(
|
||||
event=event,
|
||||
is_active=True
|
||||
).order_by('zekken_number')
|
||||
|
||||
zekken_list = []
|
||||
|
||||
# ALLオプションを最初に追加
|
||||
zekken_list.append({
|
||||
'value': 'ALL',
|
||||
'label': 'ALL(全参加者)',
|
||||
'team_name': '全参加者表示',
|
||||
'category': '全クラス'
|
||||
})
|
||||
|
||||
# 各参加者のゼッケン番号を追加
|
||||
for entry in entries:
|
||||
team_name = entry.team.team_name if entry.team else 'チーム名未設定'
|
||||
category_name = entry.category.category_name if entry.category else 'クラス未設定'
|
||||
|
||||
zekken_list.append({
|
||||
'value': str(entry.zekken_number),
|
||||
'label': f"{entry.zekken_number} - {team_name}",
|
||||
'team_name': team_name,
|
||||
'category': category_name
|
||||
})
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'event_code': event_code,
|
||||
'zekken_options': zekken_list
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_event_zekken_list: {str(e)}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
304
rog/views_apis/api_bulk_upload.py
Normal file
304
rog/views_apis/api_bulk_upload.py
Normal file
@ -0,0 +1,304 @@
|
||||
"""
|
||||
写真一括アップロード機能
|
||||
写真の位置情報と撮影時刻を使用してチェックイン処理を行う
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from django.conf import settings
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS
|
||||
import piexif
|
||||
|
||||
from rog.models import NewEvent2, Entry, GpsCheckin, Location2025, Checkpoint
|
||||
from rog.services.s3_service import S3Service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_gps_from_image(image_file):
|
||||
"""
|
||||
画像からGPS情報と撮影時刻を抽出
|
||||
"""
|
||||
try:
|
||||
image = Image.open(image_file)
|
||||
exif_data = piexif.load(image.info.get('exif', b''))
|
||||
|
||||
gps_info = {}
|
||||
datetime_info = None
|
||||
|
||||
# GPS情報の抽出
|
||||
if 'GPS' in exif_data:
|
||||
gps_data = exif_data['GPS']
|
||||
|
||||
# 緯度の取得
|
||||
if piexif.GPSIFD.GPSLatitude in gps_data and piexif.GPSIFD.GPSLatitudeRef in gps_data:
|
||||
lat = gps_data[piexif.GPSIFD.GPSLatitude]
|
||||
lat_ref = gps_data[piexif.GPSIFD.GPSLatitudeRef].decode('utf-8')
|
||||
latitude = lat[0][0]/lat[0][1] + lat[1][0]/lat[1][1]/60 + lat[2][0]/lat[2][1]/3600
|
||||
if lat_ref == 'S':
|
||||
latitude = -latitude
|
||||
gps_info['latitude'] = latitude
|
||||
|
||||
# 経度の取得
|
||||
if piexif.GPSIFD.GPSLongitude in gps_data and piexif.GPSIFD.GPSLongitudeRef in gps_data:
|
||||
lon = gps_data[piexif.GPSIFD.GPSLongitude]
|
||||
lon_ref = gps_data[piexif.GPSIFD.GPSLongitudeRef].decode('utf-8')
|
||||
longitude = lon[0][0]/lon[0][1] + lon[1][0]/lon[1][1]/60 + lon[2][0]/lon[2][1]/3600
|
||||
if lon_ref == 'W':
|
||||
longitude = -longitude
|
||||
gps_info['longitude'] = longitude
|
||||
|
||||
# 撮影時刻の抽出
|
||||
if 'Exif' in exif_data:
|
||||
exif_ifd = exif_data['Exif']
|
||||
if piexif.ExifIFD.DateTimeOriginal in exif_ifd:
|
||||
datetime_str = exif_ifd[piexif.ExifIFD.DateTimeOriginal].decode('utf-8')
|
||||
datetime_info = datetime.strptime(datetime_str, '%Y:%m:%d %H:%M:%S')
|
||||
|
||||
return gps_info, datetime_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting GPS/datetime from image: {str(e)}")
|
||||
return {}, None
|
||||
|
||||
|
||||
def find_nearby_checkpoint(latitude, longitude, event, radius_meters=50):
|
||||
"""
|
||||
位置情報から近くのチェックポイントを検索
|
||||
"""
|
||||
try:
|
||||
point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
# Location2025モデルからチェックポイントを検索
|
||||
nearby_checkpoints = Location2025.objects.filter(
|
||||
event=event,
|
||||
location__distance_lte=(point, Distance(m=radius_meters))
|
||||
).order_by('location__distance')
|
||||
|
||||
if nearby_checkpoints.exists():
|
||||
return nearby_checkpoints.first()
|
||||
|
||||
# 従来のCheckpointモデルからも検索
|
||||
nearby_legacy_checkpoints = Checkpoint.objects.filter(
|
||||
event=event,
|
||||
location__distance_lte=(point, Distance(m=radius_meters))
|
||||
).order_by('location__distance')
|
||||
|
||||
if nearby_legacy_checkpoints.exists():
|
||||
return nearby_legacy_checkpoints.first()
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding nearby checkpoint: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def bulk_upload_photos(request):
|
||||
"""
|
||||
写真一括アップロード処理
|
||||
|
||||
POST /api/bulk-upload-photos/
|
||||
{
|
||||
"event_code": "FC岐阜",
|
||||
"zekken_number": "123",
|
||||
"images": [<files>]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
event_code = request.data.get('event_code')
|
||||
zekken_number = request.data.get('zekken_number')
|
||||
images = request.FILES.getlist('images')
|
||||
|
||||
if not all([event_code, zekken_number, images]):
|
||||
return Response({
|
||||
'error': 'event_code, zekken_number, and images are required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# イベントの確認
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_code=event_code)
|
||||
except NewEvent2.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Event not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# エントリーの確認
|
||||
try:
|
||||
entry = Entry.objects.get(event=event, zekken_number=zekken_number)
|
||||
except Entry.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Team entry not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
results = {
|
||||
'successful_uploads': [],
|
||||
'failed_uploads': [],
|
||||
'created_checkins': [],
|
||||
'failed_checkins': []
|
||||
}
|
||||
|
||||
s3_service = S3Service()
|
||||
|
||||
with transaction.atomic():
|
||||
for image in images:
|
||||
image_result = {
|
||||
'filename': image.name,
|
||||
'status': 'processing'
|
||||
}
|
||||
|
||||
try:
|
||||
# GPS情報と撮影時刻を抽出
|
||||
gps_info, capture_time = extract_gps_from_image(image)
|
||||
|
||||
# S3にアップロード
|
||||
s3_url = s3_service.upload_checkin_image(
|
||||
image_file=image,
|
||||
event_code=event_code,
|
||||
team_code=str(zekken_number),
|
||||
cp_number=0 # 後で更新
|
||||
)
|
||||
|
||||
image_result['s3_url'] = s3_url
|
||||
image_result['gps_info'] = gps_info
|
||||
image_result['capture_time'] = capture_time.isoformat() if capture_time else None
|
||||
|
||||
# GPS情報がある場合、近くのチェックポイントを検索
|
||||
if gps_info.get('latitude') and gps_info.get('longitude'):
|
||||
checkpoint = find_nearby_checkpoint(
|
||||
gps_info['latitude'],
|
||||
gps_info['longitude'],
|
||||
event
|
||||
)
|
||||
|
||||
if checkpoint:
|
||||
# チェックイン記録の作成
|
||||
# 既存のチェックイン記録数を取得して順序を決定
|
||||
existing_count = GpsCheckin.objects.filter(
|
||||
zekken_number=str(zekken_number),
|
||||
event_code=event_code
|
||||
).count()
|
||||
|
||||
checkin = GpsCheckin.objects.create(
|
||||
zekken_number=str(zekken_number),
|
||||
event_code=event_code,
|
||||
cp_number=checkpoint.cp_number,
|
||||
path_order=existing_count + 1,
|
||||
lattitude=gps_info['latitude'],
|
||||
longitude=gps_info['longitude'],
|
||||
image_address=s3_url,
|
||||
create_at=capture_time or timezone.now(),
|
||||
validate_location=False, # 初期状態では未確定
|
||||
buy_flag=False,
|
||||
points=checkpoint.cp_point if hasattr(checkpoint, 'cp_point') else 0,
|
||||
create_user=request.user.email if request.user.is_authenticated else None
|
||||
)
|
||||
|
||||
image_result['checkpoint'] = {
|
||||
'cp_number': checkpoint.cp_number,
|
||||
'cp_name': checkpoint.cp_name,
|
||||
'points': checkin.points
|
||||
}
|
||||
image_result['checkin_id'] = checkin.id
|
||||
results['created_checkins'].append(image_result)
|
||||
else:
|
||||
image_result['error'] = 'No nearby checkpoint found'
|
||||
results['failed_checkins'].append(image_result)
|
||||
else:
|
||||
image_result['error'] = 'No GPS information in image'
|
||||
results['failed_checkins'].append(image_result)
|
||||
|
||||
image_result['status'] = 'success'
|
||||
results['successful_uploads'].append(image_result)
|
||||
|
||||
except Exception as e:
|
||||
image_result['status'] = 'failed'
|
||||
image_result['error'] = str(e)
|
||||
results['failed_uploads'].append(image_result)
|
||||
logger.error(f"Error processing image {image.name}: {str(e)}")
|
||||
|
||||
return Response({
|
||||
'status': 'completed',
|
||||
'summary': {
|
||||
'total_images': len(images),
|
||||
'successful_uploads': len(results['successful_uploads']),
|
||||
'failed_uploads': len(results['failed_uploads']),
|
||||
'created_checkins': len(results['created_checkins']),
|
||||
'failed_checkins': len(results['failed_checkins'])
|
||||
},
|
||||
'results': results
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk_upload_photos: {str(e)}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def confirm_checkin_validation(request):
|
||||
"""
|
||||
通過情報の確定・否確定処理
|
||||
|
||||
POST /api/confirm-checkin-validation/
|
||||
{
|
||||
"checkin_ids": [1, 2, 3],
|
||||
"validation_status": true/false
|
||||
}
|
||||
"""
|
||||
try:
|
||||
checkin_ids = request.data.get('checkin_ids', [])
|
||||
validation_status = request.data.get('validation_status')
|
||||
|
||||
if not checkin_ids or validation_status is None:
|
||||
return Response({
|
||||
'error': 'checkin_ids and validation_status are required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
updated_checkins = []
|
||||
|
||||
with transaction.atomic():
|
||||
for checkin_id in checkin_ids:
|
||||
try:
|
||||
checkin = GpsCheckin.objects.get(id=checkin_id)
|
||||
checkin.validate_location = validation_status
|
||||
checkin.update_user = request.user.email if request.user.is_authenticated else None
|
||||
checkin.update_at = timezone.now()
|
||||
checkin.save()
|
||||
|
||||
updated_checkins.append({
|
||||
'id': checkin.id,
|
||||
'cp_number': checkin.cp_number,
|
||||
'validation_status': checkin.validate_location
|
||||
})
|
||||
|
||||
except GpsCheckin.DoesNotExist:
|
||||
logger.warning(f"Checkin with ID {checkin_id} not found")
|
||||
continue
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'message': f'{len(updated_checkins)} checkins updated',
|
||||
'updated_checkins': updated_checkins
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in confirm_checkin_validation: {str(e)}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
@ -1,12 +1,15 @@
|
||||
# 既存のインポート部分に追加
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Transaction
|
||||
# from sqlalchemy import Transaction # 削除 - SQLAlchemy 2.0では利用不可
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rog.models import Location, NewEvent2, Entry, GpsLog
|
||||
import logging
|
||||
import uuid
|
||||
import os
|
||||
from django.db.models import F, Q
|
||||
from django.db import transaction
|
||||
from django.conf import settings
|
||||
import os
|
||||
from urllib.parse import urljoin
|
||||
@ -755,7 +758,7 @@ def goal_checkin(request):
|
||||
goal_time = timezone.now()
|
||||
|
||||
# トランザクション開始
|
||||
with Transaction.atomic():
|
||||
with transaction.atomic():
|
||||
# スコアの計算
|
||||
score = calculate_team_score(entry)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rog.models import NewEvent2, Entry,Location, GpsLog
|
||||
from rog.models import NewEvent2, Entry, Location2025, GpsLog
|
||||
import logging
|
||||
from django.db.models import F, Q
|
||||
from django.conf import settings
|
||||
@ -332,7 +332,7 @@ def analyze_point(request):
|
||||
try:
|
||||
|
||||
# イベントのチェックポイント定義を取得
|
||||
event_cps = Location.objects.filter(event=event)
|
||||
event_cps = Location2025.objects.filter(event=event)
|
||||
|
||||
# チームが通過したチェックポイントを取得
|
||||
team_cps = GpsLog.objects.filter(entry=entry)
|
||||
|
||||
@ -1,18 +1,49 @@
|
||||
|
||||
|
||||
# 既存のインポート部分に追加
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rog.models import NewEvent2, Entry, GpsLog
|
||||
from rog.models import NewEvent2, Entry, GpsCheckin, Team
|
||||
import logging
|
||||
from django.db.models import F, Q
|
||||
from django.conf import settings
|
||||
import os
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin, quote
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_image_url(image_address, event_code, zekken_number):
|
||||
"""
|
||||
画像アドレスからS3 URLまたは適切なURLを生成
|
||||
"""
|
||||
if not image_address:
|
||||
return None
|
||||
|
||||
# 既にHTTP URLの場合はそのまま返す
|
||||
if image_address.startswith('http'):
|
||||
return image_address
|
||||
|
||||
# simulation_image.jpgなどのテスト画像の場合はS3にないのでスキップ
|
||||
if image_address in ['simulation_image.jpg', 'test_image']:
|
||||
return f"/media/{image_address}"
|
||||
|
||||
# S3パスを構築してURLを生成
|
||||
s3_key = f"{event_code}/{zekken_number}/{image_address}"
|
||||
|
||||
try:
|
||||
# S3 URLを生成
|
||||
s3_url = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_S3_REGION_NAME}.amazonaws.com/{quote(s3_key)}"
|
||||
return s3_url
|
||||
except Exception as e:
|
||||
# S3設定に問題がある場合はmediaパスを返す
|
||||
return f"/media/{image_address}"
|
||||
|
||||
|
||||
"""
|
||||
解説
|
||||
この実装では以下の処理を行っています:
|
||||
@ -113,7 +144,7 @@ def get_photo_list_prod(request):
|
||||
|
||||
# パスワード検証
|
||||
if not hasattr(entry, 'password') or entry.password != password:
|
||||
logger.warning(f"Invalid password for team: {entry.team_name}")
|
||||
logger.warning(f"Invalid password for team: {entry.team.team_name if entry.team else 'Unknown'}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "パスワードが一致しません"
|
||||
@ -128,154 +159,49 @@ def get_photo_list_prod(request):
|
||||
"message": "サーバーエラーが発生しました"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def get_team_photos(zekken_number, event_code):
|
||||
def get_team_photos(request):
|
||||
"""
|
||||
チームの写真とレポートURLを取得する共通関数
|
||||
チーム別の写真データを取得するAPI
|
||||
"""
|
||||
try:
|
||||
# イベントの存在確認
|
||||
event = NewEvent2.objects.filter(event_name=event_code).first()
|
||||
if not event:
|
||||
logger.warning(f"Event not found: {event_code}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "指定されたイベントが見つかりません"
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# チームの存在確認
|
||||
entry = Entry.objects.filter(
|
||||
event=event,
|
||||
zekken_number=zekken_number
|
||||
).first()
|
||||
|
||||
if not entry:
|
||||
logger.warning(f"Team with zekken number {zekken_number} not found in event: {event_code}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "指定されたゼッケン番号のチームが見つかりません"
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# チームの基本情報を取得
|
||||
team_info = {
|
||||
"team_name": entry.team_name,
|
||||
"zekken_number": entry.zekken_number,
|
||||
"class_name": entry.class_name,
|
||||
"event_name": event.event_name
|
||||
}
|
||||
|
||||
# チェックポイント通過情報(写真を含む)を取得
|
||||
checkpoints = GpsLog.objects.filter(
|
||||
entry=entry
|
||||
).order_by('checkin_time')
|
||||
|
||||
# 写真リストを作成
|
||||
photos = []
|
||||
|
||||
for cp in checkpoints:
|
||||
# 写真URLがある場合のみ追加
|
||||
if hasattr(cp, 'image') and cp.image:
|
||||
photo_data = {
|
||||
"cp_number": cp.cp_number,
|
||||
"checkin_time": cp.checkin_time.strftime("%Y-%m-%d %H:%M:%S") if cp.checkin_time else None,
|
||||
"image_url": request.build_absolute_uri(cp.image.url) if hasattr(request, 'build_absolute_uri') else cp.image.url
|
||||
}
|
||||
|
||||
# サービスチェックの情報があれば追加
|
||||
if hasattr(cp, 'is_service_checked'):
|
||||
photo_data["is_service_checked"] = cp.is_service_checked
|
||||
|
||||
photos.append(photo_data)
|
||||
|
||||
# スタート写真があれば追加
|
||||
if hasattr(entry, 'start_info') and hasattr(entry.start_info, 'start_image') and entry.start_info.start_image:
|
||||
start_image = {
|
||||
"cp_number": "START",
|
||||
"checkin_time": entry.start_info.start_time.strftime("%Y-%m-%d %H:%M:%S") if entry.start_info.start_time else None,
|
||||
"image_url": request.build_absolute_uri(entry.start_info.start_image.url) if hasattr(request, 'build_absolute_uri') else entry.start_info.start_image.url
|
||||
}
|
||||
photos.insert(0, start_image) # リストの先頭に追加
|
||||
|
||||
# ゴール写真があれば追加
|
||||
if hasattr(entry, 'goal_info') and hasattr(entry.goal_info, 'goal_image') and entry.goal_info.goal_image:
|
||||
goal_image = {
|
||||
"cp_number": "GOAL",
|
||||
"checkin_time": entry.goal_info.goal_time.strftime("%Y-%m-%d %H:%M:%S") if entry.goal_info.goal_time else None,
|
||||
"image_url": request.build_absolute_uri(entry.goal_info.goal_image.url) if hasattr(request, 'build_absolute_uri') else entry.goal_info.goal_image.url
|
||||
}
|
||||
photos.append(goal_image) # リストの末尾に追加
|
||||
|
||||
# チームレポートURLを生成
|
||||
# レポートURLは「/レポートディレクトリ/イベント名/ゼッケン番号.pdf」のパターンを想定
|
||||
report_directory = getattr(settings, 'REPORT_DIRECTORY', 'reports')
|
||||
report_base_url = getattr(settings, 'REPORT_BASE_URL', '/media/reports/')
|
||||
|
||||
# レポートファイルの物理パスをチェック
|
||||
report_path = os.path.join(
|
||||
settings.MEDIA_ROOT,
|
||||
report_directory,
|
||||
event_code,
|
||||
f"{zekken_number}.pdf"
|
||||
)
|
||||
|
||||
# レポートURLを生成
|
||||
has_report = os.path.exists(report_path)
|
||||
report_url = None
|
||||
|
||||
if has_report:
|
||||
report_url = urljoin(
|
||||
report_base_url,
|
||||
f"{event_code}/{zekken_number}.pdf"
|
||||
)
|
||||
|
||||
# 絶対URLに変換
|
||||
if hasattr(request, 'build_absolute_uri'):
|
||||
report_url = request.build_absolute_uri(report_url)
|
||||
|
||||
# スコアボードURLを生成
|
||||
scoreboard_path = os.path.join(
|
||||
settings.MEDIA_ROOT,
|
||||
'scoreboards',
|
||||
event_code,
|
||||
f"scoreboard_{zekken_number}.pdf"
|
||||
)
|
||||
|
||||
has_scoreboard = os.path.exists(scoreboard_path)
|
||||
scoreboard_url = None
|
||||
|
||||
if has_scoreboard:
|
||||
scoreboard_url = urljoin(
|
||||
'/media/scoreboards/',
|
||||
f"{event_code}/scoreboard_{zekken_number}.pdf"
|
||||
)
|
||||
|
||||
# 絶対URLに変換
|
||||
if hasattr(request, 'build_absolute_uri'):
|
||||
scoreboard_url = request.build_absolute_uri(scoreboard_url)
|
||||
|
||||
# チームのスコア情報
|
||||
score = None
|
||||
if hasattr(entry, 'goal_info') and hasattr(entry.goal_info, 'score'):
|
||||
score = entry.goal_info.score
|
||||
|
||||
# レスポンスデータ
|
||||
response_data = {
|
||||
"status": "OK",
|
||||
"team": team_info,
|
||||
"photos": photos,
|
||||
"photo_count": len(photos),
|
||||
"has_report": has_report,
|
||||
"report_url": report_url,
|
||||
"has_scoreboard": has_scoreboard,
|
||||
"scoreboard_url": scoreboard_url,
|
||||
"score": score
|
||||
}
|
||||
|
||||
return Response(response_data)
|
||||
zekken = request.GET.get('zekken')
|
||||
event = request.GET.get('event')
|
||||
|
||||
if not zekken or not event:
|
||||
return JsonResponse({
|
||||
'error': 'zekken and event parameters are required'
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
# GpsCheckinからチームの画像データを取得
|
||||
gps_checkins = GpsCheckin.objects.filter(
|
||||
zekken_number=zekken,
|
||||
event_code=event
|
||||
).exclude(
|
||||
image_address__isnull=True
|
||||
).exclude(
|
||||
image_address=''
|
||||
).order_by('create_at')
|
||||
|
||||
photos = []
|
||||
for gps in gps_checkins:
|
||||
# image_addressを処理してS3 URLまたは既存URLを生成
|
||||
image_url = generate_image_url(gps.image_address, event, zekken)
|
||||
|
||||
photos.append({
|
||||
'id': gps.id,
|
||||
'image_url': image_url,
|
||||
'created_at': gps.create_at.strftime('%Y-%m-%d %H:%M:%S') if gps.create_at else None,
|
||||
'point_name': gps.checkpoint_id,
|
||||
'latitude': float(gps.lattitude) if gps.lattitude else None,
|
||||
'longitude': float(gps.longitude) if gps.longitude else None,
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'photos': photos,
|
||||
'count': len(photos)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_team_photos: {str(e)}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "サーバーエラーが発生しました"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return JsonResponse({
|
||||
'error': f'Error retrieving photos: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
239
rog/views_apis/api_photos_fixed.py
Normal file
239
rog/views_apis/api_photos_fixed.py
Normal file
@ -0,0 +1,239 @@
|
||||
# 既存のインポート部分に追加
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rog.models import NewEvent2, Entry, GpsCheckin, Team
|
||||
import logging
|
||||
from django.db.models import F, Q
|
||||
from django.conf import settings
|
||||
import os
|
||||
from urllib.parse import urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
解説
|
||||
この実装では以下の処理を行っています:
|
||||
|
||||
1.2つのエンドポイントを提供しています:
|
||||
- /get_photo_list - 認証なしで写真とレポートURLを取得
|
||||
- /get_photo_list_prod - パスワード認証付きで同じ情報を取得
|
||||
2.共通のロジックは get_team_photos 関数に集約し、以下の情報を取得します:
|
||||
- チームの基本情報(名前、ゼッケン番号、クラス名)
|
||||
- チェックポイント通過時の写真(時間順、サービスチェック情報含む)
|
||||
- スタート写真とゴール写真(あれば)
|
||||
- チームレポートのURL(存在する場合)
|
||||
- スコアボードのURL(存在する場合)
|
||||
- チームのスコア(ゴール済みの場合)
|
||||
3.レポートとスコアボードのファイルパスを実際に確認し、存在する場合のみURLを提供します
|
||||
4.写真の表示順はスタート→チェックポイント(時間順)→ゴールとなっており、チェックポイントについてはそれぞれ番号、撮影時間、サービスチェック状態などの情報も含めています
|
||||
|
||||
この実装により、チームは自分たちの競技中の写真やレポートを簡単に確認できます。
|
||||
本番環境(_prod版)ではパスワード認証によりセキュリティを確保しています。
|
||||
"""
|
||||
|
||||
@api_view(['GET'])
|
||||
def get_photo_list(request):
|
||||
"""
|
||||
チームの写真とレポートURLを取得(認証なし版)
|
||||
|
||||
パラメータ:
|
||||
- zekken: ゼッケン番号
|
||||
- event: イベントコード
|
||||
"""
|
||||
logger.info("get_photo_list called")
|
||||
|
||||
# リクエストからパラメータを取得
|
||||
zekken_number = request.query_params.get('zekken')
|
||||
event_code = request.query_params.get('event')
|
||||
|
||||
logger.debug(f"Parameters: zekken={zekken_number}, event={event_code}")
|
||||
|
||||
# パラメータ検証
|
||||
if not all([zekken_number, event_code]):
|
||||
logger.warning("Missing required parameters")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "ゼッケン番号とイベントコードが必要です"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return get_team_photos(zekken_number, event_code)
|
||||
|
||||
@api_view(['GET'])
|
||||
def get_photo_list_prod(request):
|
||||
"""
|
||||
チームの写真とレポートURLを取得(認証あり版)
|
||||
|
||||
パラメータ:
|
||||
- zekken: ゼッケン番号
|
||||
- pw: パスワード
|
||||
- event: イベントコード
|
||||
"""
|
||||
logger.info("get_photo_list_prod called")
|
||||
|
||||
# リクエストからパラメータを取得
|
||||
zekken_number = request.query_params.get('zekken')
|
||||
password = request.query_params.get('pw')
|
||||
event_code = request.query_params.get('event')
|
||||
|
||||
logger.debug(f"Parameters: zekken={zekken_number}, event={event_code}, has_password={bool(password)}")
|
||||
|
||||
# パラメータ検証
|
||||
if not all([zekken_number, password, event_code]):
|
||||
logger.warning("Missing required parameters")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "ゼッケン番号、パスワード、イベントコードが必要です"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
# イベントの存在確認
|
||||
event = NewEvent2.objects.filter(event_name=event_code).first()
|
||||
if not event:
|
||||
logger.warning(f"Event not found: {event_code}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "指定されたイベントが見つかりません"
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# チームの存在確認とパスワード検証
|
||||
entry = Entry.objects.filter(
|
||||
event=event,
|
||||
zekken_number=zekken_number
|
||||
).first()
|
||||
|
||||
if not entry:
|
||||
logger.warning(f"Team with zekken number {zekken_number} not found in event: {event_code}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "指定されたゼッケン番号のチームが見つかりません"
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# パスワード検証
|
||||
if not hasattr(entry, 'password') or entry.password != password:
|
||||
logger.warning(f"Invalid password for team: {entry.team.team_name if entry.team else 'Unknown'}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "パスワードが一致しません"
|
||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
return get_team_photos(zekken_number, event_code)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_photo_list_prod: {str(e)}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "サーバーエラーが発生しました"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def get_team_photos(zekken_number, event_code):
|
||||
"""
|
||||
チームの写真とレポートURLを取得する共通関数
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Getting photos for zekken: {zekken_number}, event: {event_code}")
|
||||
|
||||
# イベントの存在確認(event_codeで検索)
|
||||
event = NewEvent2.objects.filter(event_code=event_code).first()
|
||||
if not event:
|
||||
logger.warning(f"Event not found with event_code: {event_code}")
|
||||
# event_nameでも試してみる
|
||||
event = NewEvent2.objects.filter(event_name=event_code).first()
|
||||
if not event:
|
||||
logger.warning(f"Event not found with event_name: {event_code}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "指定されたイベントが見つかりません"
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
logger.info(f"Found event: {event.event_name} (ID: {event.id})")
|
||||
|
||||
# まずEntryテーブルを確認
|
||||
entry = Entry.objects.filter(
|
||||
event=event,
|
||||
zekken_number=zekken_number
|
||||
).first()
|
||||
|
||||
team_name = "Unknown Team"
|
||||
if entry and entry.team:
|
||||
team_name = entry.team.team_name
|
||||
logger.info(f"Found team in Entry: {team_name}")
|
||||
else:
|
||||
logger.info(f"No Entry found for zekken {zekken_number}, checking GpsCheckin for legacy data")
|
||||
|
||||
# GpsCheckinテーブルからチーム情報を取得(レガシーデータ対応)
|
||||
gps_checkin_sample = GpsCheckin.objects.filter(
|
||||
event_code=event_code,
|
||||
zekken_number=str(zekken_number)
|
||||
).first()
|
||||
|
||||
if gps_checkin_sample and gps_checkin_sample.team:
|
||||
team_name = gps_checkin_sample.team.team_name
|
||||
logger.info(f"Found team in GpsCheckin: {team_name}")
|
||||
else:
|
||||
team_name = f"Team {zekken_number}"
|
||||
logger.info(f"No team found, using default: {team_name}")
|
||||
|
||||
# GpsCheckinテーブルから写真データを取得
|
||||
gps_checkins = GpsCheckin.objects.filter(
|
||||
event_code=event_code,
|
||||
zekken_number=str(zekken_number),
|
||||
image_address__isnull=False
|
||||
).exclude(
|
||||
image_address=''
|
||||
).order_by('path_order', 'create_at')
|
||||
|
||||
logger.info(f"Found {gps_checkins.count()} GPS checkins with images")
|
||||
|
||||
# 写真リストを作成
|
||||
photos = []
|
||||
|
||||
for gps in gps_checkins:
|
||||
if gps.image_address:
|
||||
# 画像URLを構築
|
||||
if gps.image_address.startswith('http'):
|
||||
# 絶対URLの場合はそのまま使用
|
||||
image_url = gps.image_address
|
||||
else:
|
||||
# 相対パスの場合はベースURLと結合
|
||||
# settings.MEDIA_URLやstatic fileの設定に基づいて調整
|
||||
image_url = f"/media/{gps.image_address}" if not gps.image_address.startswith('/') else gps.image_address
|
||||
|
||||
photo_data = {
|
||||
"cp_number": gps.cp_number if gps.cp_number is not None else 0,
|
||||
"photo_url": image_url,
|
||||
"checkin_time": gps.create_at.strftime("%Y-%m-%d %H:%M:%S") if gps.create_at else None,
|
||||
"path_order": gps.path_order,
|
||||
"buy_flag": gps.buy_flag,
|
||||
"validate_location": gps.validate_location,
|
||||
"points": gps.points
|
||||
}
|
||||
|
||||
photos.append(photo_data)
|
||||
logger.debug(f"Added photo: CP {gps.cp_number}, URL: {image_url}")
|
||||
|
||||
# チームの基本情報
|
||||
team_info = {
|
||||
"team_name": team_name,
|
||||
"zekken_number": zekken_number,
|
||||
"event_name": event.event_name,
|
||||
"photo_count": len(photos)
|
||||
}
|
||||
|
||||
# レスポンス構築
|
||||
response_data = {
|
||||
"status": "SUCCESS",
|
||||
"message": f"写真を{len(photos)}枚取得しました",
|
||||
"team_info": team_info,
|
||||
"photo_list": photos
|
||||
}
|
||||
|
||||
logger.info(f"Successfully retrieved {len(photos)} photos for team {team_name}")
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_team_photos: {str(e)}")
|
||||
return Response({
|
||||
"status": "ERROR",
|
||||
"message": "サーバーエラーが発生しました"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
@ -2,7 +2,7 @@
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rog.models import NewEvent2, Entry, Location
|
||||
from rog.models import NewEvent2, Entry, Location2025
|
||||
from rog.models import GpsLog
|
||||
import logging
|
||||
from django.db.models import F, Q
|
||||
@ -172,7 +172,7 @@ def get_checkpoint_list(request):
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# イベントのチェックポイント情報を取得
|
||||
checkpoints = Location.objects.filter(event=event).order_by('cp_number')
|
||||
checkpoints = Location2025.objects.filter(event=event).order_by('cp_number')
|
||||
|
||||
checkpoint_list = []
|
||||
for cp in checkpoints:
|
||||
@ -398,12 +398,12 @@ def checkin_from_rogapp(request):
|
||||
# イベントのチェックポイント定義を確認(存在する場合)
|
||||
event_cp = None
|
||||
try:
|
||||
event_cp = Location.objects.filter(
|
||||
event_cp = Location2025.objects.filter(
|
||||
event=event,
|
||||
cp_number=cp_number
|
||||
).first()
|
||||
except:
|
||||
logger.info(f"Location model not available or CP {cp_number} not defined for event")
|
||||
logger.info(f"Location2025 model not available or CP {cp_number} not defined for event")
|
||||
|
||||
# トランザクション開始
|
||||
with transaction.atomic():
|
||||
@ -595,8 +595,8 @@ def calculate_team_score(entry):
|
||||
# チェックポイントの得点を取得
|
||||
cp_point = 0
|
||||
try:
|
||||
# Location
|
||||
event_cp = Location.objects.filter(
|
||||
# Location2025
|
||||
event_cp = Location2025.objects.filter(
|
||||
event=entry.event,
|
||||
cp_number=cp.cp_number
|
||||
).first()
|
||||
|
||||
235
rog/views_apis/s3_views.py
Normal file
235
rog/views_apis/s3_views.py
Normal file
@ -0,0 +1,235 @@
|
||||
"""
|
||||
API views for S3 image management
|
||||
"""
|
||||
import json
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from ..services.s3_service import S3Service
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@csrf_exempt
|
||||
def upload_checkin_image(request):
|
||||
"""
|
||||
Upload checkin image to S3
|
||||
|
||||
POST /api/upload-checkin-image/
|
||||
{
|
||||
"event_code": "FC岐阜",
|
||||
"team_code": "3432",
|
||||
"cp_number": 10,
|
||||
"image": <file>
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Validate required fields
|
||||
event_code = request.data.get('event_code')
|
||||
team_code = request.data.get('team_code')
|
||||
image_file = request.FILES.get('image')
|
||||
cp_number = request.data.get('cp_number')
|
||||
|
||||
if not all([event_code, team_code, image_file]):
|
||||
return Response({
|
||||
'error': 'event_code, team_code, and image are required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Initialize S3 service
|
||||
s3_service = S3Service()
|
||||
|
||||
# Upload image
|
||||
s3_url = s3_service.upload_checkin_image(
|
||||
image_file=image_file,
|
||||
event_code=event_code,
|
||||
team_code=team_code,
|
||||
cp_number=cp_number
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'image_url': s3_url,
|
||||
'message': 'Image uploaded successfully'
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading checkin image: {e}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@csrf_exempt
|
||||
def upload_standard_image(request):
|
||||
"""
|
||||
Upload standard image (goal, start, etc.) to S3
|
||||
|
||||
POST /api/upload-standard-image/
|
||||
{
|
||||
"event_code": "FC岐阜",
|
||||
"image_type": "goal", # goal, start, checkpoint, etc.
|
||||
"image": <file>
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Validate required fields
|
||||
event_code = request.data.get('event_code')
|
||||
image_type = request.data.get('image_type')
|
||||
image_file = request.FILES.get('image')
|
||||
|
||||
if not all([event_code, image_type, image_file]):
|
||||
return Response({
|
||||
'error': 'event_code, image_type, and image are required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate image_type
|
||||
valid_types = ['goal', 'start', 'checkpoint', 'finish']
|
||||
if image_type not in valid_types:
|
||||
return Response({
|
||||
'error': f'image_type must be one of: {valid_types}'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Initialize S3 service
|
||||
s3_service = S3Service()
|
||||
|
||||
# Upload standard image
|
||||
s3_url = s3_service.upload_standard_image(
|
||||
image_file=image_file,
|
||||
event_code=event_code,
|
||||
image_type=image_type
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'image_url': s3_url,
|
||||
'message': 'Standard image uploaded successfully'
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading standard image: {e}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@api_view(['GET'])
|
||||
def get_standard_image(request):
|
||||
"""
|
||||
Get standard image URL
|
||||
|
||||
GET /api/get-standard-image/?event_code=FC岐阜&image_type=goal
|
||||
"""
|
||||
try:
|
||||
event_code = request.GET.get('event_code')
|
||||
image_type = request.GET.get('image_type')
|
||||
|
||||
if not all([event_code, image_type]):
|
||||
return Response({
|
||||
'error': 'event_code and image_type are required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Initialize S3 service
|
||||
s3_service = S3Service()
|
||||
|
||||
# Get standard image URL
|
||||
image_url = s3_service.get_standard_image_url(event_code, image_type)
|
||||
|
||||
if image_url:
|
||||
return Response({
|
||||
'success': True,
|
||||
'image_url': image_url
|
||||
}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Standard image not found',
|
||||
'image_url': None
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting standard image: {e}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@api_view(['GET'])
|
||||
def list_event_images(request):
|
||||
"""
|
||||
List all images for an event
|
||||
|
||||
GET /api/list-event-images/?event_code=FC岐阜&limit=50
|
||||
"""
|
||||
try:
|
||||
event_code = request.GET.get('event_code')
|
||||
limit = int(request.GET.get('limit', 100))
|
||||
|
||||
if not event_code:
|
||||
return Response({
|
||||
'error': 'event_code is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Initialize S3 service
|
||||
s3_service = S3Service()
|
||||
|
||||
# List event images
|
||||
image_urls = s3_service.list_event_images(event_code, limit)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'event_code': event_code,
|
||||
'image_count': len(image_urls),
|
||||
'images': image_urls
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing event images: {e}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@api_view(['DELETE'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@csrf_exempt
|
||||
def delete_image(request):
|
||||
"""
|
||||
Delete image from S3
|
||||
|
||||
DELETE /api/delete-image/
|
||||
{
|
||||
"image_url": "https://sumasenrogaining.s3.us-west-2.amazonaws.com/FC岐阜/3432/image.jpg"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
image_url = request.data.get('image_url')
|
||||
|
||||
if not image_url:
|
||||
return Response({
|
||||
'error': 'image_url is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Initialize S3 service
|
||||
s3_service = S3Service()
|
||||
|
||||
# Delete image
|
||||
success = s3_service.delete_image(image_url)
|
||||
|
||||
if success:
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Image deleted successfully'
|
||||
}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Failed to delete image'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting image: {e}")
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
Reference in New Issue
Block a user