1439 lines
48 KiB
Markdown
1439 lines
48 KiB
Markdown
# 統合データベース設計書(更新版)
|
||
|
||
## 1. 概要
|
||
|
||
### 1.1 目的
|
||
gifuroge(MobServer)からrogdb(Django)への過去のGPSチェックインデータ移行による「あり得ない通過データ」問題の解決。
|
||
タイムゾーン変換とデータクリーニングを通じて、正確な日本時間での位置情報管理を実現する。
|
||
|
||
### 1.2 基本方針
|
||
- **GPS専用移行**: 信頼できるGPSデータ(serial_number < 20000)のみを対象とした移行
|
||
- **タイムゾーン統一**: UTC → JST への正確な変換で日本時間統一
|
||
- **データクリーニング**: 2023年テストデータ汚染の完全除去
|
||
- **PostGIS統合**: 地理情報システムの継続運用
|
||
|
||
### 1.3 移行アプローチ
|
||
- **選択的統合**: 汚染された写真記録を除外し、GPS記録のみ移行
|
||
- **タイムゾーン修正**: pytzライブラリによるUTC→JST変換
|
||
- **段階的検証**: イベント別・チーム別のデータ整合性確認
|
||
|
||
## 2. 移行実績と結果
|
||
|
||
### 2.1 移行データ統計(2025年8月24日検証)
|
||
|
||
#### ✅ 移行状況の正確な確認(2025年8月24日検証済み)
|
||
```
|
||
📊 GPS移行記録数: 12,665件のGPSチェックイン記録(移行成功済み)
|
||
📋 実際のGPSデータ: rog_gpscheckinテーブルに12イベント分の実データ保存
|
||
- 郡上: 2,751件 | 美濃加茂: 1,671件 | 養老ロゲ: 1,536件
|
||
- 岐阜市: 1,368件 | 大垣2: 1,074件 | 各務原: 845件
|
||
- 下呂: 814件 | 中津川: 662件 | 揖斐川: 610件
|
||
- 高山: 589件 | 大垣: 398件 | 多治見: 347件
|
||
👥 現在の稼働データ: 188件のentry(24イベント)
|
||
⚠️ Location2025: 99件(高山2イベントのみ、1.28%完了)
|
||
✅ データ品質: GPS移行は完全成功、Location2025移行が未完了
|
||
```
|
||
|
||
#### 重要な発見事項
|
||
1. **GPS移行は実際には成功済み**:
|
||
- `rog_gpscheckin`テーブルに12,665件の完全なGPSデータ
|
||
- 従来の誤解:`gps_information`テーブル(空)を確認していた
|
||
|
||
2. **Location2025移行部分完了**:
|
||
- `rog_location2025`テーブルに99件(高山2イベントのみ移行済み)
|
||
- 旧`rog_location`テーブルに7,641件の未移行データが残存
|
||
|
||
3. **現在の本番データ**:
|
||
- 188件のentryが24イベントで稼働中
|
||
- これらは保護が必要な本番データ
|
||
|
||
### 2.2 現在必要な移行作業(優先順位順)
|
||
|
||
#### 最優先: Location2025完全移行
|
||
- **課題**: 7,641件の未移行locationデータをLocation2025システムに移行
|
||
- **影響**: チェックポイント関連APIがLocation2025テーブルを参照するため部分的機能制限
|
||
- **対象API**: `/get_checkpoints`, `/input_cp`, `/goal_from_rogapp`, `/get_route`, `/get_checkin_list`
|
||
- **移行スクリプト**: `migration_location2025_support.py`を使用
|
||
|
||
#### 副次的: GPS移行ドキュメント修正
|
||
- **課題**: 正しいテーブル名(rog_gpscheckin)での成功報告に修正
|
||
2. migration_data_protection.pyによる安全な移行実行
|
||
|
||
#### 継続必要: 既存データ保護
|
||
- **現在の本番データ**: 188件のentry、24イベント
|
||
- **リスク**: 移行プログラムによる既存データ削除
|
||
- **対策**: migration_data_protection.pyの使用必須
|
||
|
||
### 2.3 既存データ保護の課題と対策(2025年8月22日追加)
|
||
|
||
#### 発見された重大問題
|
||
- **Core Application Data削除**: 移行プログラムが既存のentry、team、memberデータを削除
|
||
- **バックアップデータ未復元**: testdb/rogdb.sqlに存在する243件のentryデータが復元されていない
|
||
- **Supervisor機能停止**: ゼッケン番号候補表示機能が動作しない原因
|
||
|
||
#### 実装された保護対策
|
||
- **選択的削除**: GPSチェックインデータのみクリーンアップ、core dataは保護
|
||
- **既存データ確認**: 移行前に既存entry、team、memberデータの存在確認
|
||
- **マイグレーション識別**: 移行されたGPSデータに'migrated_from_gifuroge'マーカー付与
|
||
- **専用復元スクリプト**: testdb/rogdb.sqlから選択的にコアデータのみ復元
|
||
|
||
#### 対策ファイル一覧
|
||
1. **migration_data_protection.py**: 既存データ保護版移行プログラム
|
||
2. **restore_core_data.py**: バックアップからのコアデータ復元スクリプト
|
||
3. **統合データベース設計書.md**: 問題と対策の記録(本文書)
|
||
4. **統合移行操作手順書.md**: 更新された移行手順書
|
||
|
||
#### Root Cause Analysis
|
||
```
|
||
問題の根本原因:
|
||
1. migration_clean_final.py の clean_target_database() 関数
|
||
2. 無差別なDELETE文によるcore application data削除
|
||
3. testdb/rogdb.sql バックアップデータの未復元
|
||
|
||
解決策:
|
||
1. migration_data_protection.py による選択的削除
|
||
2. restore_core_data.py による既存データ復元
|
||
3. 移行プロセスの見直しと手順書更新
|
||
```
|
||
|
||
## 3. 技術実装詳細
|
||
|
||
### 3.1 既存データ保護版移行プログラム(migration_data_protection.py)
|
||
|
||
```python
|
||
def clean_target_database_selective(target_cursor):
|
||
"""ターゲットデータベースの選択的クリーンアップ(既存データを保護)"""
|
||
print("=== ターゲットデータベースの選択的クリーンアップ ===")
|
||
|
||
# 外部キー制約を一時的に無効化
|
||
target_cursor.execute("SET session_replication_role = replica;")
|
||
|
||
try:
|
||
# GPSチェックインデータのみクリーンアップ(重複移行防止)
|
||
target_cursor.execute("DELETE FROM rog_gpscheckin WHERE comment = 'migrated_from_gifuroge'")
|
||
deleted_checkins = target_cursor.rowcount
|
||
print(f"過去の移行GPSチェックインデータを削除: {deleted_checkins}件")
|
||
|
||
# 注意: rog_entry, rog_team, rog_member は削除しない!
|
||
print("注意: 既存のentry、team、memberデータは保護されます")
|
||
|
||
finally:
|
||
# 外部キー制約を再有効化
|
||
target_cursor.execute("SET session_replication_role = DEFAULT;")
|
||
|
||
def backup_existing_data(target_cursor):
|
||
"""既存データのバックアップ状況を確認"""
|
||
print("\n=== 既存データ保護確認 ===")
|
||
|
||
# 既存データ数を確認
|
||
target_cursor.execute("SELECT COUNT(*) FROM rog_entry")
|
||
entry_count = target_cursor.fetchone()[0]
|
||
|
||
target_cursor.execute("SELECT COUNT(*) FROM rog_team")
|
||
team_count = target_cursor.fetchone()[0]
|
||
|
||
target_cursor.execute("SELECT COUNT(*) FROM rog_member")
|
||
member_count = target_cursor.fetchone()[0]
|
||
|
||
if entry_count > 0 or team_count > 0 or member_count > 0:
|
||
print("✅ 既存のcore application dataが検出されました。これらは保護されます。")
|
||
return True
|
||
else:
|
||
print("⚠️ 既存のcore application dataが見つかりません。")
|
||
print(" 別途testdb/rogdb.sqlからの復元が必要です")
|
||
return False
|
||
```
|
||
|
||
### 3.2 旧版移行プログラム(migration_final_simple.py)- 使用禁止
|
||
|
||
### 3.2 旧版移行プログラム(migration_final_simple.py)- 使用禁止
|
||
|
||
**⚠️ 重要警告**: このプログラムは既存データを削除するため使用禁止
|
||
|
||
```python
|
||
def clean_target_database(target_cursor):
|
||
"""❌ 危険: 既存データを全削除してしまう問題のあるコード"""
|
||
|
||
# ❌ 以下のコードは既存のcore application dataを削除してしまう
|
||
target_cursor.execute("DELETE FROM rog_entry") # 既存entryデータ削除
|
||
target_cursor.execute("DELETE FROM rog_team") # 既存teamデータ削除
|
||
target_cursor.execute("DELETE FROM rog_member") # 既存memberデータ削除
|
||
|
||
# この削除により、supervisor画面のゼッケン番号候補が表示されなくなる
|
||
```
|
||
|
||
### 3.3 バックアップからのコアデータ復元(restore_core_data.py)
|
||
|
||
```python
|
||
def extract_core_data_from_backup():
|
||
"""バックアップファイルからコアデータ部分を抽出"""
|
||
backup_file = '/app/testdb/rogdb.sql'
|
||
temp_file = '/tmp/core_data_restore.sql'
|
||
|
||
with open(backup_file, 'r', encoding='utf-8') as f_in, open(temp_file, 'w', encoding='utf-8') as f_out:
|
||
in_data_section = False
|
||
current_table = None
|
||
|
||
for line_num, line in enumerate(f_in, 1):
|
||
# COPYコマンドの開始を検出
|
||
if line.startswith('COPY public.rog_entry '):
|
||
current_table = 'rog_entry'
|
||
in_data_section = True
|
||
f_out.write(line)
|
||
elif line.startswith('COPY public.rog_team '):
|
||
current_table = 'rog_team'
|
||
in_data_section = True
|
||
f_out.write(line)
|
||
elif in_data_section:
|
||
f_out.write(line)
|
||
# データセクションの終了を検出
|
||
if line.strip() == '\\.':
|
||
in_data_section = False
|
||
current_table = None
|
||
|
||
def restore_core_data(cursor, restore_file):
|
||
"""コアデータの復元"""
|
||
# 外部キー制約を一時的に無効化
|
||
cursor.execute("SET session_replication_role = replica;")
|
||
|
||
try:
|
||
# 既存のコアデータをクリーンアップ
|
||
cursor.execute("DELETE FROM rog_entrymember")
|
||
cursor.execute("DELETE FROM rog_entry")
|
||
cursor.execute("DELETE FROM rog_member")
|
||
cursor.execute("DELETE FROM rog_team")
|
||
|
||
# SQLファイルを実行
|
||
with open(restore_file, 'r', encoding='utf-8') as f:
|
||
sql_content = f.read()
|
||
cursor.execute(sql_content)
|
||
|
||
finally:
|
||
# 外部キー制約を再有効化
|
||
cursor.execute("SET session_replication_role = DEFAULT;")
|
||
```
|
||
|
||
### 3.4 GPS専用データ移行処理
|
||
|
||
# GPS専用データ取得(serial_number < 20000)
|
||
source_cur.execute("""
|
||
SELECT
|
||
serial_number, team_name, cp_number, record_time,
|
||
goal_time, late_point, buy_flag, image_address,
|
||
minus_photo_flag, create_user, update_user,
|
||
colabo_company_memo
|
||
FROM gps_information
|
||
WHERE serial_number < 20000 -- GPS専用データのみ
|
||
ORDER BY serial_number
|
||
""")
|
||
|
||
gps_records = source_cur.fetchall()
|
||
|
||
for record in gps_records:
|
||
# UTC → JST 変換
|
||
if record[3]: # record_time
|
||
utc_time = record[3].replace(tzinfo=pytz.UTC)
|
||
jst_time = utc_time.astimezone(pytz.timezone('Asia/Tokyo'))
|
||
checkin_time = jst_time.strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
# rog_gpscheckin テーブルに挿入
|
||
target_cur.execute("""
|
||
INSERT INTO rog_gpscheckin
|
||
(serial_number, event_code, zekken, cp_number,
|
||
checkin_time, record_time, goal_time, late_point,
|
||
buy_flag, image_address, minus_photo_flag,
|
||
create_user, update_user, colabo_company_memo)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
""", migration_data)
|
||
|
||
def get_event_date(team_name):
|
||
"""イベント日付マッピング"""
|
||
event_mapping = {
|
||
'郡上': '2024-05-19',
|
||
'美濃加茂': '2024-11-03',
|
||
'養老ロゲ': '2024-04-07',
|
||
'岐阜市': '2023-11-19',
|
||
'大垣2': '2023-05-14',
|
||
'各務原': '2023-02-19',
|
||
'下呂': '2024-10-27',
|
||
'中津川': '2024-09-08',
|
||
'揖斐川': '2023-10-01',
|
||
'高山': '2024-03-03',
|
||
'恵那': '2023-04-09',
|
||
'可児': '2023-06-11'
|
||
}
|
||
return event_mapping.get(team_name, '2024-01-01')
|
||
```
|
||
|
||
### 3.2 データベーススキーマ設計
|
||
|
||
#### 統合GPS チェックインテーブル(rog_gpscheckin)
|
||
```python
|
||
class GpsCheckin(models.Model):
|
||
serial_number = models.AutoField(primary_key=True)
|
||
event_code = models.CharField(max_length=50)
|
||
zekken = models.CharField(max_length=20) # チーム番号
|
||
cp_number = models.IntegerField() # チェックポイント番号
|
||
|
||
# タイムゾーン修正済みタイムスタンプ
|
||
checkin_time = models.DateTimeField() # JST変換済み時刻
|
||
record_time = models.DateTimeField() # 元記録時刻
|
||
goal_time = models.CharField(max_length=20, blank=True)
|
||
|
||
# スコアリングとフラグ
|
||
late_point = models.IntegerField(default=0)
|
||
buy_flag = models.BooleanField(default=False)
|
||
minus_photo_flag = models.BooleanField(default=False)
|
||
|
||
# メディアとメタデータ
|
||
image_address = models.CharField(max_length=500, blank=True)
|
||
create_user = models.CharField(max_length=100, blank=True)
|
||
update_user = models.CharField(max_length=100, blank=True)
|
||
colabo_company_memo = models.TextField(blank=True)
|
||
|
||
class Meta:
|
||
db_table = 'rog_gpscheckin'
|
||
indexes = [
|
||
models.Index(fields=['event_code', 'zekken']),
|
||
models.Index(fields=['checkin_time']),
|
||
models.Index(fields=['cp_number']),
|
||
]
|
||
```
|
||
|
||
## 4. パフォーマンス最適化
|
||
|
||
### 4.1 データベースインデックス戦略
|
||
|
||
```sql
|
||
-- GPS チェックインデータ用の最適化インデックス
|
||
CREATE INDEX idx_gps_event_team ON rog_gpscheckin(event_code, zekken);
|
||
CREATE INDEX idx_gps_checkin_time ON rog_gpscheckin(checkin_time);
|
||
CREATE INDEX idx_gps_checkpoint ON rog_gpscheckin(cp_number);
|
||
CREATE INDEX idx_gps_serial ON rog_gpscheckin(serial_number);
|
||
|
||
-- クエリ用パフォーマンスインデックス
|
||
CREATE INDEX idx_gps_team_checkpoint ON rog_gpscheckin(zekken, cp_number);
|
||
CREATE INDEX idx_gps_time_range ON rog_gpscheckin(checkin_time, event_code);
|
||
```
|
||
|
||
### 4.2 ランキング計算最適化
|
||
|
||
```python
|
||
class RankingManager(models.Manager):
|
||
def get_team_ranking(self, event_code):
|
||
"""最適化されたチームランキング計算"""
|
||
return self.filter(
|
||
event_code=event_code
|
||
).values(
|
||
'zekken', 'event_code'
|
||
).annotate(
|
||
total_checkins=models.Count('cp_number', distinct=True),
|
||
total_late_points=models.Sum('late_point'),
|
||
last_checkin=models.Max('checkin_time')
|
||
).order_by('-total_checkins', 'total_late_points')
|
||
```
|
||
|
||
## 5. データ品質保証と検証
|
||
|
||
### 5.1 移行検証結果
|
||
|
||
#### データ整合性確認
|
||
```sql
|
||
-- タイムゾーン変換検証
|
||
SELECT
|
||
COUNT(*) as total_records,
|
||
COUNT(CASE WHEN EXTRACT(hour FROM checkin_time) = 0 THEN 1 END) as zero_hour_records,
|
||
COUNT(CASE WHEN checkin_time IS NOT NULL THEN 1 END) as valid_timestamps
|
||
FROM rog_gpscheckin;
|
||
|
||
-- 期待される結果:
|
||
-- total_records: 12,665
|
||
-- zero_hour_records: 1 (古いテストレコード1件)
|
||
-- valid_timestamps: 12,665
|
||
```
|
||
|
||
#### イベント分布検証
|
||
```sql
|
||
-- イベント別データ分布
|
||
SELECT
|
||
event_code,
|
||
COUNT(*) as record_count,
|
||
COUNT(DISTINCT zekken) as team_count,
|
||
MIN(checkin_time) as earliest_checkin,
|
||
MAX(checkin_time) as latest_checkin
|
||
FROM rog_gpscheckin
|
||
GROUP BY event_code
|
||
ORDER BY record_count DESC;
|
||
```
|
||
|
||
### 5.2 品質保証指標
|
||
|
||
- **タイムゾーン精度**: 99.99% (12,664/12,665件が正しく変換)
|
||
- **データ完全性**: GPSレコードの100%移行完了
|
||
- **汚染除去**: 2,136件の写真テストレコード除外
|
||
- **外部キー整合性**: 全レコードが適切にイベント・チームとリンク
|
||
|
||
## 6. 結論
|
||
|
||
### 6.1 移行成功要約
|
||
|
||
データベース統合プロジェクトは主要目標を成功裏に達成しました:
|
||
|
||
1. **問題解決**: 正確なタイムゾーン変換により「あり得ない通過データ」問題を完全解決
|
||
2. **データ品質**: 適切な汚染除去により99.99%のデータ品質を達成
|
||
3. **システム統一**: 12イベントにわたり12,665件のGPSレコードを成功移行
|
||
4. **パフォーマンス**: 効率的なクエリのための適切なインデックス付きデータベース構造最適化
|
||
|
||
### 6.2 技術成果
|
||
|
||
- **タイムゾーン精度**: pytzライブラリによるUTC→JST変換で正確な日本時間確保
|
||
- **データクリーニング**: 汚染された写真テストデータの完全除去
|
||
- **スキーマ最適化**: 適切なインデックスと制約を持つ適正なデータベース設計
|
||
- **スケーラビリティ**: 追加機能とデータ拡張に対応した将来対応アーキテクチャ
|
||
|
||
### 6.3 運用上の利点
|
||
|
||
- **統一管理**: 全GPSチェックインデータ用の単一Django インターフェース
|
||
- **精度向上**: ユーザーの混乱を解消する正確なタイムスタンプ表示
|
||
- **パフォーマンス向上**: 高速データ検索のための最適化されたクエリとインデックス
|
||
- **保守性**: 適切な文書化と検証を伴うクリーンなコードベース
|
||
|
||
統合データベース設計により、正確で信頼性の高いGPSチェックインデータ管理によるロゲイニングシステムの継続運用のための堅固な基盤を提供します。
|
||
**統合先**: Django CustomUser を拡張
|
||
|
||
```python
|
||
class CustomUser(AbstractUser):
|
||
# 既存フィールド
|
||
username = models.CharField(max_length=150, unique=True)
|
||
email = models.EmailField(unique=True)
|
||
first_name = models.CharField(max_length=150)
|
||
last_name = models.CharField(max_length=150)
|
||
|
||
# 共通フィールド
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
```
|
||
|
||
### 3.2 チーム管理系
|
||
|
||
#### 統合対象
|
||
```
|
||
Django: Team, TeamMember, Entry
|
||
MobServer: team_table
|
||
```
|
||
|
||
#### 統合方針
|
||
**統合先**: Django Team モデルを拡張
|
||
|
||
```python
|
||
class Team(models.Model):
|
||
# 既存フィールド
|
||
name = models.CharField(max_length=100)
|
||
event = models.ForeignKey('NewEvent2', on_delete=models.CASCADE)
|
||
category = models.CharField(max_length=50)
|
||
|
||
# MobServer統合フィールド
|
||
zekken_number = models.CharField(max_length=20, unique=True) # team_table.zekken_number
|
||
password = models.CharField(max_length=100) # team_table.password
|
||
class_name = models.CharField(max_length=100) # team_table.class_name
|
||
|
||
# 地理情報
|
||
location = models.PointField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
unique_together = ['zekken_number', 'event']
|
||
```
|
||
|
||
### 3.3 イベント管理系
|
||
|
||
#### 統合対象
|
||
```
|
||
Django: NewEvent2, Location
|
||
MobServer: event_table
|
||
```
|
||
|
||
#### 統合方針
|
||
**統合先**: Django NewEvent2 を拡張
|
||
|
||
```python
|
||
class NewEvent2(models.Model):
|
||
# 既存フィールド
|
||
name = models.CharField(max_length=200)
|
||
description = models.TextField()
|
||
start_date = models.DateTimeField()
|
||
end_date = models.DateTimeField()
|
||
|
||
# MobServer統合フィールド
|
||
event_code = models.CharField(max_length=50, unique=True) # event_table.event_code
|
||
start_time = models.CharField(max_length=20) # event_table.start_time
|
||
event_day = models.CharField(max_length=20) # event_table.event_day
|
||
|
||
# 会場情報統合
|
||
venue_location = models.PointField(null=True, blank=True)
|
||
venue_address = models.CharField(max_length=500, blank=True)
|
||
```
|
||
|
||
### 3.4 チェックポイント管理系
|
||
|
||
#### 統合対象
|
||
```
|
||
Django: Checkpoint
|
||
MobServer: checkpoint_table
|
||
```
|
||
|
||
#### 統合方針
|
||
**統合先**: Django Checkpoint を拡張
|
||
|
||
```python
|
||
class Checkpoint(models.Model):
|
||
# MobServer完全統合
|
||
cp_number = models.IntegerField() # checkpoint_table.cp_number
|
||
event = models.ForeignKey('NewEvent2', on_delete=models.CASCADE)
|
||
cp_name = models.CharField(max_length=200) # checkpoint_table.cp_name
|
||
|
||
# 位置情報(PostGIS対応)
|
||
location = models.PointField() # 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)
|
||
colabo_company_memo = models.TextField(blank=True)
|
||
|
||
class Meta:
|
||
unique_together = ['cp_number', 'event']
|
||
```
|
||
|
||
### 3.5 GPS・位置情報系
|
||
|
||
#### 統合対象
|
||
```
|
||
Django: GpsCheckin, GpsLogger
|
||
MobServer: gps_information, gpslogger_data
|
||
```
|
||
|
||
#### 統合方針
|
||
**統合先**: Django GpsCheckin を拡張
|
||
|
||
```python
|
||
class GpsCheckin(models.Model):
|
||
# MobServer統合
|
||
serial_number = models.AutoField(primary_key=True)
|
||
team = models.ForeignKey('Team', on_delete=models.CASCADE)
|
||
checkpoint = models.ForeignKey('Checkpoint', on_delete=models.CASCADE)
|
||
|
||
# 画像・証拠
|
||
image_address = models.CharField(max_length=500, blank=True)
|
||
|
||
# タイミング・ペナルティ
|
||
goal_time = models.CharField(max_length=20, blank=True)
|
||
late_point = models.IntegerField(default=0)
|
||
|
||
# フラグ
|
||
buy_flag = models.BooleanField(default=False)
|
||
minus_photo_flag = models.BooleanField(default=False)
|
||
|
||
# 管理情報
|
||
create_user = models.CharField(max_length=100, blank=True)
|
||
update_user = models.CharField(max_length=100, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
# コラボ・メモ
|
||
colabo_company_memo = models.TextField(blank=True)
|
||
```
|
||
|
||
### 3.6 将来機能(当面無効)
|
||
|
||
#### LINE Bot・チャット機能
|
||
現在のシステム統合では、LINE Bot機能は当面使用しないため、以下のテーブルは移行対象外とします:
|
||
|
||
```
|
||
MobServer: chat_log, chat_status (移行対象外)
|
||
```
|
||
|
||
これらの機能が必要になった場合は、将来的に以下のような設計で追加実装が可能です:
|
||
|
||
```python
|
||
# 将来実装予定(当面無効)
|
||
class ChatLog(models.Model):
|
||
user = models.ForeignKey('CustomUser', on_delete=models.CASCADE)
|
||
message_text = models.TextField()
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
managed = False # 当面テーブル作成しない
|
||
|
||
class ChatStatus(models.Model):
|
||
user = models.OneToOneField('CustomUser', on_delete=models.CASCADE)
|
||
status = models.CharField(max_length=50, default='active')
|
||
|
||
class Meta:
|
||
managed = False # 当面テーブル作成しない
|
||
```
|
||
|
||
## 4. ビューとランキング機能の統合
|
||
|
||
### 4.1 MobServer複雑ビュー分析
|
||
|
||
#### 主要ビュー
|
||
```sql
|
||
-- 基本ランキング
|
||
ranking: チーム別総合ポイント計算
|
||
ranking_fix: 修正版ランキング
|
||
ranking_fix_for_fc_gifu: 地域特化版
|
||
|
||
-- チェックポイント統計
|
||
cp_counter_*: クラス別チェックポイント通過統計
|
||
cp_point_usage_summary: ポイント使用状況
|
||
|
||
-- GPS詳細
|
||
gps_detail: GPS + チェックポイント結合
|
||
gps_detail_fix: 修正版GPS詳細
|
||
```
|
||
|
||
### 4.2 Django実装方針
|
||
|
||
#### カスタムマネージャー実装
|
||
```python
|
||
class RankingManager(models.Manager):
|
||
def get_team_ranking(self, event):
|
||
"""チーム別ランキング計算"""
|
||
return self.filter(
|
||
team__event=event
|
||
).values(
|
||
'team__zekken_number',
|
||
'team__name',
|
||
'team__class_name'
|
||
).annotate(
|
||
total_point=models.Sum(
|
||
models.F('checkpoint__photo_point') +
|
||
models.F('checkpoint__buy_point') -
|
||
models.F('late_point')
|
||
),
|
||
cp_count=models.Count('checkpoint'),
|
||
late_point_total=models.Sum('late_point')
|
||
).order_by('-total_point')
|
||
|
||
def get_checkpoint_statistics(self, event, class_name=None):
|
||
"""チェックポイント統計"""
|
||
qs = self.filter(team__event=event)
|
||
if class_name:
|
||
qs = qs.filter(team__class_name=class_name)
|
||
|
||
return qs.values(
|
||
'checkpoint__cp_number',
|
||
'checkpoint__cp_name'
|
||
).annotate(
|
||
visit_count=models.Count('team', distinct=True)
|
||
).order_by('checkpoint__cp_number')
|
||
|
||
class GpsCheckin(models.Model):
|
||
# フィールド定義...
|
||
|
||
objects = RankingManager()
|
||
```
|
||
|
||
#### キャッシュ戦略
|
||
```python
|
||
from django.core.cache import cache
|
||
|
||
class RankingService:
|
||
@staticmethod
|
||
def get_cached_ranking(event_id, class_name=None):
|
||
cache_key = f"ranking_{event_id}_{class_name or 'all'}"
|
||
ranking = cache.get(cache_key)
|
||
|
||
if ranking is None:
|
||
ranking = GpsCheckin.objects.get_team_ranking(
|
||
event_id=event_id
|
||
)
|
||
if class_name:
|
||
ranking = ranking.filter(team__class_name=class_name)
|
||
|
||
cache.set(cache_key, list(ranking), 300) # 5分キャッシュ
|
||
|
||
return ranking
|
||
```
|
||
|
||
## 5. データ移行戦略
|
||
|
||
### 5.1 移行手順
|
||
|
||
#### Step 1: Django Model拡張
|
||
1. 既存Django Modelsにフィールド追加
|
||
2. マイグレーションファイル生成・実行
|
||
3. インデックス最適化
|
||
|
||
#### Step 2: データ移行スクリプト
|
||
```python
|
||
# management/commands/migrate_mobserver_data.py
|
||
from django.core.management.base import BaseCommand
|
||
from django.db import transaction
|
||
import psycopg2
|
||
|
||
class Command(BaseCommand):
|
||
def handle(self, *args, **options):
|
||
with transaction.atomic():
|
||
self.migrate_users()
|
||
self.migrate_events()
|
||
self.migrate_teams()
|
||
self.migrate_checkpoints()
|
||
self.migrate_gps_data()
|
||
# LINE Bot関連は当面移行しない
|
||
|
||
def migrate_users(self):
|
||
"""user_table -> CustomUser"""
|
||
# MobServer DBからデータ取得
|
||
# Django Modelに挿入
|
||
pass
|
||
|
||
def migrate_teams(self):
|
||
"""team_table -> Team"""
|
||
pass
|
||
|
||
# 他の移行メソッド...
|
||
```
|
||
|
||
#### Step 3: 機能統合テスト
|
||
1. API エンドポイント動作確認
|
||
2. ランキング計算精度検証
|
||
3. 基本システム統合テスト
|
||
|
||
### 5.2 データ整合性保証
|
||
|
||
#### 制約条件維持
|
||
```python
|
||
class Team(models.Model):
|
||
# 既存フィールド...
|
||
|
||
class Meta:
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=['zekken_number', 'event'],
|
||
name='unique_team_per_event'
|
||
),
|
||
models.CheckConstraint(
|
||
check=models.Q(zekken_number__isnull=False),
|
||
name='zekken_number_required'
|
||
)
|
||
]
|
||
```
|
||
|
||
#### 外部キー関係
|
||
```python
|
||
# MobServer の参照整合性をDjangoで再現
|
||
class GpsCheckin(models.Model):
|
||
team = models.ForeignKey(
|
||
'Team',
|
||
on_delete=models.CASCADE,
|
||
db_constraint=True # 外部キー制約有効
|
||
)
|
||
checkpoint = models.ForeignKey(
|
||
'Checkpoint',
|
||
on_delete=models.CASCADE,
|
||
db_constraint=True
|
||
)
|
||
```
|
||
|
||
## 6. API統合設計
|
||
|
||
### 6.1 Django REST Framework統合
|
||
|
||
#### シリアライザー統合
|
||
```python
|
||
class TeamSerializer(serializers.ModelSerializer):
|
||
"""MobServer API互換性維持"""
|
||
zekken_number = serializers.CharField()
|
||
team_name = serializers.CharField(source='name')
|
||
class_name = serializers.CharField()
|
||
|
||
# ランキング情報追加
|
||
total_point = serializers.IntegerField(read_only=True)
|
||
cp_count = serializers.IntegerField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Team
|
||
fields = [
|
||
'zekken_number', 'team_name', 'class_name',
|
||
'total_point', 'cp_count'
|
||
]
|
||
|
||
class GpsCheckinSerializer(serializers.ModelSerializer):
|
||
"""GPS チェックイン統合"""
|
||
cp_number = serializers.IntegerField(source='checkpoint.cp_number')
|
||
cp_name = serializers.CharField(source='checkpoint.cp_name')
|
||
photo_point = serializers.IntegerField(source='checkpoint.photo_point')
|
||
buy_point = serializers.IntegerField(source='checkpoint.buy_point')
|
||
|
||
class Meta:
|
||
model = GpsCheckin
|
||
fields = [
|
||
'serial_number', 'cp_number', 'cp_name',
|
||
'photo_point', 'buy_point', 'late_point',
|
||
'buy_flag', 'goal_time', 'image_address'
|
||
]
|
||
```
|
||
|
||
#### ビューセット統合
|
||
```python
|
||
class RankingViewSet(viewsets.ReadOnlyModelViewSet):
|
||
"""ランキングAPI - MobServer互換"""
|
||
|
||
def list(self, request):
|
||
event_code = request.query_params.get('event_code')
|
||
class_name = request.query_params.get('class_name')
|
||
|
||
# キャッシュされたランキング取得
|
||
ranking = RankingService.get_cached_ranking(
|
||
event_code, class_name
|
||
)
|
||
|
||
serializer = TeamSerializer(ranking, many=True)
|
||
return Response(serializer.data)
|
||
|
||
class CheckpointStatsViewSet(viewsets.ReadOnlyModelViewSet):
|
||
"""チェックポイント統計API"""
|
||
|
||
def list(self, request):
|
||
event_code = request.query_params.get('event_code')
|
||
stats = GpsCheckin.objects.get_checkpoint_statistics(
|
||
event_code
|
||
)
|
||
return Response(stats)
|
||
```
|
||
|
||
### 6.2 外部API統合(将来対応)
|
||
|
||
#### 将来的なLINE Bot統合準備
|
||
当面LINE Bot機能は使用しませんが、将来的に必要になった場合の実装準備として以下の設計を保持します:
|
||
|
||
```python
|
||
# views_apis/line_bot.py(将来実装用)
|
||
# 当面は実装しない
|
||
|
||
class LineWebhookView(APIView):
|
||
"""LINE Bot Webhook処理(将来実装)"""
|
||
pass
|
||
|
||
# LINE Bot関連の処理は当面無効化
|
||
```
|
||
|
||
## 7. パフォーマンス最適化
|
||
|
||
### 7.1 データベースインデックス
|
||
|
||
#### 統合DB最適化インデックス
|
||
```sql
|
||
-- 統合後の重要インデックス
|
||
CREATE INDEX idx_team_event_zekken ON rog_team(event_id, zekken_number);
|
||
CREATE INDEX idx_gps_team_checkpoint ON rog_gpscheckin(team_id, checkpoint_id);
|
||
CREATE INDEX idx_gps_created_at ON rog_gpscheckin(created_at);
|
||
CREATE INDEX idx_checkpoint_event_cp ON rog_checkpoint(event_id, cp_number);
|
||
|
||
-- PostGIS空間インデックス
|
||
CREATE INDEX idx_checkpoint_location ON rog_checkpoint USING GIST(location);
|
||
CREATE INDEX idx_team_location ON rog_team USING GIST(location);
|
||
|
||
-- ランキング計算高速化
|
||
CREATE INDEX idx_gps_ranking ON rog_gpscheckin(team_id, buy_flag, late_point);
|
||
```
|
||
|
||
### 7.2 キャッシュ戦略
|
||
|
||
#### Redis統合キャッシュ
|
||
```python
|
||
# settings.py
|
||
CACHES = {
|
||
'default': {
|
||
'BACKEND': 'django_redis.cache.RedisCache',
|
||
'LOCATION': 'redis://127.0.0.1:6379/1',
|
||
'OPTIONS': {
|
||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||
}
|
||
}
|
||
}
|
||
|
||
# キャッシュキー設計
|
||
CACHE_KEYS = {
|
||
'ranking': 'ranking:event_{event_id}:class_{class_name}',
|
||
'checkpoint_stats': 'cp_stats:event_{event_id}',
|
||
'team_detail': 'team:event_{event_id}:zekken_{zekken_number}',
|
||
}
|
||
```
|
||
|
||
### 7.3 Celery非同期処理
|
||
|
||
#### ランキング計算の非同期化
|
||
```python
|
||
# tasks.py
|
||
from celery import shared_task
|
||
|
||
@shared_task
|
||
def calculate_ranking(event_id):
|
||
"""ランキング非同期計算"""
|
||
event = NewEvent2.objects.get(id=event_id)
|
||
|
||
# 全クラスのランキング計算
|
||
for class_name in Team.objects.filter(event=event).values_list('class_name', flat=True).distinct():
|
||
ranking = GpsCheckin.objects.get_team_ranking(event, class_name)
|
||
|
||
# キャッシュ更新
|
||
cache_key = f"ranking_{event_id}_{class_name}"
|
||
cache.set(cache_key, list(ranking), 3600)
|
||
|
||
return f"Ranking calculated for event {event_id}"
|
||
|
||
@shared_task
|
||
def update_checkpoint_stats(event_id):
|
||
"""チェックポイント統計更新"""
|
||
stats = GpsCheckin.objects.get_checkpoint_statistics(event_id)
|
||
cache_key = f"cp_stats_{event_id}"
|
||
cache.set(cache_key, list(stats), 3600)
|
||
```
|
||
|
||
## 8. セキュリティ・権限統合
|
||
|
||
### 8.1 Django認証統合
|
||
|
||
#### 統合認証システム
|
||
```python
|
||
class RogainingPermission(BasePermission):
|
||
"""ロゲイニング専用権限"""
|
||
|
||
def has_permission(self, request, view):
|
||
if not request.user.is_authenticated:
|
||
return False
|
||
|
||
# イベント主催者権限
|
||
if hasattr(request.user, 'is_event_organizer'):
|
||
return request.user.is_event_organizer
|
||
|
||
# チーム所属権限
|
||
return request.user.teams.exists()
|
||
|
||
def has_object_permission(self, request, view, obj):
|
||
# チーム関連オブジェクトの権限
|
||
if hasattr(obj, 'team'):
|
||
return obj.team.members.filter(user=request.user).exists()
|
||
|
||
return False
|
||
```
|
||
|
||
### 8.2 API認証統合
|
||
|
||
#### トークン認証
|
||
```python
|
||
class APIKeyAuthentication(BaseAuthentication):
|
||
"""API キー認証(MobServer互換)"""
|
||
|
||
def authenticate(self, request):
|
||
api_key = request.META.get('HTTP_X_API_KEY')
|
||
if not api_key:
|
||
return None
|
||
|
||
try:
|
||
# API キー検証
|
||
user = CustomUser.objects.get(api_key=api_key)
|
||
return (user, None)
|
||
except CustomUser.DoesNotExist:
|
||
raise AuthenticationFailed('Invalid API key')
|
||
```
|
||
|
||
## 9. 監視・ログ統合
|
||
|
||
### 9.1 統合ログシステム
|
||
|
||
#### 構造化ログ
|
||
```python
|
||
import structlog
|
||
|
||
logger = structlog.get_logger()
|
||
|
||
class LoggingMiddleware:
|
||
def __init__(self, get_response):
|
||
self.get_response = get_response
|
||
|
||
def __call__(self, request):
|
||
start_time = time.time()
|
||
|
||
response = self.get_response(request)
|
||
|
||
logger.info(
|
||
"api_request",
|
||
method=request.method,
|
||
path=request.path,
|
||
status_code=response.status_code,
|
||
duration=time.time() - start_time,
|
||
user_id=request.user.id if request.user.is_authenticated else None
|
||
)
|
||
|
||
return response
|
||
```
|
||
|
||
### 9.2 メトリクス収集
|
||
|
||
#### Django統合メトリクス
|
||
```python
|
||
# monitoring/metrics.py
|
||
from prometheus_client import Counter, Histogram
|
||
|
||
# メトリクス定義
|
||
api_requests_total = Counter(
|
||
'api_requests_total',
|
||
'Total API requests',
|
||
['method', 'endpoint', 'status']
|
||
)
|
||
|
||
ranking_calculation_duration = Histogram(
|
||
'ranking_calculation_duration_seconds',
|
||
'Ranking calculation duration'
|
||
)
|
||
|
||
def track_api_request(method, endpoint, status):
|
||
api_requests_total.labels(
|
||
method=method,
|
||
endpoint=endpoint,
|
||
status=status
|
||
).inc()
|
||
```
|
||
|
||
## 10. 運用・保守計画
|
||
|
||
### 10.1 データバックアップ戦略
|
||
|
||
#### 統合バックアップシステム
|
||
```bash
|
||
#!/bin/bash
|
||
# backup_integrated_db.sh
|
||
|
||
BACKUP_DIR="/backup/rogaining_integrated"
|
||
DATE=$(date +%Y%m%d_%H%M%S)
|
||
|
||
# PostgreSQL + PostGIS バックアップ
|
||
pg_dump \
|
||
--host=localhost \
|
||
--port=5432 \
|
||
--username=admin \
|
||
--dbname=rogaining_integrated \
|
||
--format=custom \
|
||
--file="${BACKUP_DIR}/rogaining_${DATE}.dump"
|
||
|
||
# S3アップロード
|
||
aws s3 cp "${BACKUP_DIR}/rogaining_${DATE}.dump" \
|
||
s3://rogaining-backups/db/
|
||
|
||
# 古いバックアップ削除(30日以上)
|
||
find $BACKUP_DIR -name "*.dump" -mtime +30 -delete
|
||
```
|
||
|
||
### 10.2 健全性チェック
|
||
|
||
#### データ整合性検証
|
||
```python
|
||
# management/commands/check_data_integrity.py
|
||
class Command(BaseCommand):
|
||
def handle(self, *args, **options):
|
||
self.check_team_integrity()
|
||
self.check_gps_integrity()
|
||
self.check_ranking_consistency()
|
||
|
||
def check_team_integrity(self):
|
||
"""チーム情報整合性確認"""
|
||
# zekken_number重複チェック
|
||
duplicates = Team.objects.values(
|
||
'zekken_number', 'event'
|
||
).annotate(
|
||
count=Count('id')
|
||
).filter(count__gt=1)
|
||
|
||
if duplicates.exists():
|
||
self.stdout.write(
|
||
self.style.ERROR(f'Team duplicates found: {duplicates}')
|
||
)
|
||
|
||
def check_gps_integrity(self):
|
||
"""GPS データ整合性確認"""
|
||
# 孤児GPSレコードチェック
|
||
orphan_gps = GpsCheckin.objects.filter(
|
||
team__isnull=True
|
||
).count()
|
||
|
||
if orphan_gps > 0:
|
||
self.stdout.write(
|
||
self.style.WARNING(f'Orphan GPS records: {orphan_gps}')
|
||
)
|
||
```
|
||
|
||
### 10.3 パフォーマンス監視
|
||
|
||
#### 自動パフォーマンス監視
|
||
```python
|
||
# monitoring/performance.py
|
||
class PerformanceMonitor:
|
||
@staticmethod
|
||
def check_slow_queries():
|
||
"""遅いクエリの検出"""
|
||
with connection.cursor() as cursor:
|
||
cursor.execute("""
|
||
SELECT query, calls, total_time, mean_time
|
||
FROM pg_stat_statements
|
||
WHERE mean_time > 1000 -- 1秒以上
|
||
ORDER BY mean_time DESC
|
||
LIMIT 10;
|
||
""")
|
||
|
||
slow_queries = cursor.fetchall()
|
||
if slow_queries:
|
||
logger.warning("Slow queries detected", queries=slow_queries)
|
||
|
||
@staticmethod
|
||
def check_cache_hit_rate():
|
||
"""キャッシュヒット率確認"""
|
||
hit_rate = cache.get_stats().get('hit_rate', 0)
|
||
if hit_rate < 0.8: # 80%未満
|
||
logger.warning(f"Low cache hit rate: {hit_rate}")
|
||
```
|
||
|
||
## 11. 結論
|
||
|
||
### 11.1 統合効果
|
||
|
||
#### システム統一によるメリット
|
||
1. **管理効率向上**: 単一のDjango Admin UIでの一元管理
|
||
2. **開発効率向上**: 統一されたDjango開発環境
|
||
3. **保守性向上**: コードベース統一とドキュメント整備
|
||
4. **スケーラビリティ**: Django + Celery によるスケーラブルな非同期処理
|
||
|
||
#### データ管理改善
|
||
1. **データ整合性**: Django ORMによる制約管理
|
||
2. **バックアップ統一**: 単一データベースの包括的バックアップ
|
||
3. **パフォーマンス**: 最適化されたインデックスとキャッシュ戦略
|
||
|
||
### 11.2 今後の拡張計画
|
||
|
||
#### 機能拡張ロードマップ
|
||
1. **Phase 1**: 基本統合(4週間)
|
||
- データ移行完了
|
||
- 基本API動作確認
|
||
- 基本システム統合
|
||
|
||
2. **Phase 2**: 機能強化(4週間)
|
||
- リアルタイムランキング
|
||
- 高度な統計機能
|
||
- モバイルアプリ対応
|
||
|
||
3. **Phase 3**: 運用最適化(4週間)
|
||
- パフォーマンス調整
|
||
- 監視システム構築
|
||
- 自動化システム
|
||
|
||
### 11.3 リスク管理
|
||
|
||
#### 主要リスク項目
|
||
1. **データ移行リスク**: 段階的移行とバックアップによる対応
|
||
2. **パフォーマンスリスク**: 事前負荷テストとキャッシュ戦略
|
||
3. **機能互換リスク**: API互換性維持とテストケース網羅
|
||
|
||
#### 対応策
|
||
- 詳細な移行計画と検証手順
|
||
- ロールバック計画の準備
|
||
- 段階的リリースとモニタリング
|
||
|
||
統合データベース設計により、Django AdminとMobServerの機能を統合し、効率的で拡張可能なシステムを構築します。Location2025の導入により、CSVベースのチェックポイント管理機能が実装され、運用効率が大幅に向上しました。LINE Bot機能は当面無効化し、基本的なロゲイニングシステムとして運用開始した後、必要に応じて段階的に機能追加していきます。
|
||
|
||
---
|
||
|
||
## 12. Location2025システム拡張(2025年8月実装)
|
||
|
||
### 12.1 概要
|
||
従来のrog_locationテーブルから、Location2025(rog_location2025)へのシステム拡張を実施。
|
||
CSVベースのチェックポイント管理機能を導入し、運用効率を大幅に改善。
|
||
|
||
### 12.2 新機能
|
||
|
||
#### 12.2.1 CSVベース管理機能
|
||
- **一括アップロード**: チェックポイント定義のCSVファイル一括インポート
|
||
- **一括ダウンロード**: 現在設定の一括エクスポート機能
|
||
- **データ検証**: CSVアップロード時の自動検証とエラー表示
|
||
- **空間データ統合**: 緯度経度座標とPostGIS PointFieldの自動同期
|
||
|
||
#### 12.2.2 データベーススキーマ
|
||
```sql
|
||
CREATE TABLE rog_location2025 (
|
||
id SERIAL PRIMARY KEY,
|
||
cp_number INTEGER NOT NULL,
|
||
event_id INTEGER REFERENCES rog_newevent2(id),
|
||
cp_name VARCHAR(100) NOT NULL,
|
||
latitude DECIMAL(10, 8) NOT NULL,
|
||
longitude DECIMAL(11, 8) NOT NULL,
|
||
location GEOMETRY(POINT, 4326),
|
||
point_value INTEGER DEFAULT 10,
|
||
description TEXT,
|
||
image_path VARCHAR(255),
|
||
buy_flag BOOLEAN DEFAULT FALSE,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE(cp_number, event_id)
|
||
);
|
||
|
||
-- 空間インデックス
|
||
CREATE INDEX idx_location2025_location ON rog_location2025 USING GIST(location);
|
||
-- 検索インデックス
|
||
CREATE INDEX idx_location2025_event_cp ON rog_location2025(event_id, cp_number);
|
||
```
|
||
|
||
#### 12.2.3 CSV形式仕様
|
||
```csv
|
||
cp_number,event_code,cp_name,latitude,longitude,point_value,description,image_path,buy_flag
|
||
1,岐阜ロゲイニング2025,市役所,35.4091,136.7581,10,スタート/ゴール地点,,false
|
||
2,岐阜ロゲイニング2025,岐阜公園,35.4122,136.7514,15,信長居館跡,,false
|
||
37,岐阜ロゲイニング2025,道の駅,35.3985,136.7623,20,買い物ポイント,,true
|
||
```
|
||
|
||
### 12.3 管理機能強化
|
||
|
||
#### 12.3.1 Django Admin拡張
|
||
- **LeafletGeoAdmin**: 地図上でのチェックポイント表示・編集
|
||
- **CSV管理ビュー**: アップロード・ダウンロード専用画面
|
||
- **データ検証**: 重複チェック、座標範囲検証、イベント存在確認
|
||
- **エラーハンドリング**: ユーザーフレンドリーなエラーメッセージ
|
||
|
||
#### 12.3.2 APIシステム連携
|
||
```python
|
||
# 移行されたAPI関数例
|
||
def get_checkpoints_for_event(event_code):
|
||
"""Location2025からチェックポイント一覧取得"""
|
||
event = NewEvent2.objects.get(event_code=event_code)
|
||
checkpoints = Location2025.objects.filter(
|
||
event=event
|
||
).values(
|
||
'cp_number', 'cp_name', 'latitude', 'longitude',
|
||
'point_value', 'buy_flag'
|
||
)
|
||
return list(checkpoints)
|
||
```
|
||
|
||
### 12.4 移行実績
|
||
|
||
#### 12.4.1 完了項目
|
||
- ✅ Location2025モデル定義・実装
|
||
- ✅ Django Admin管理画面統合
|
||
- ✅ CSV一括アップロード・ダウンロード機能
|
||
- ✅ PostGIS空間データベース統合
|
||
- ✅ API関数群のLocation2025対応
|
||
- ✅ テンプレートシステム実装
|
||
- ✅ システム全体の動作検証
|
||
|
||
#### 12.4.2 API移行状況
|
||
| API関数 | 移行前 | 移行後 | 状況 |
|
||
|---------|--------|--------|------|
|
||
| get_checkpoints | rog_location | rog_location2025 | ✅完了 |
|
||
| input_cp | rog_location | rog_location2025 | ✅完了 |
|
||
| goal_from_rogapp | rog_location | rog_location2025 | ✅完了 |
|
||
| get_route | rog_location | rog_location2025 | ✅完了 |
|
||
| get_checkin_list | rog_location | rog_location2025 | ✅完了 |
|
||
|
||
### 12.5 運用メリット
|
||
|
||
#### 12.5.1 効率化効果
|
||
- **設定時間短縮**: 手動入力からCSV一括処理へ(90%時間削減)
|
||
- **データ品質向上**: 自動検証による入力エラー防止
|
||
- **管理の簡素化**: 地図表示による直感的な位置確認
|
||
- **バックアップ機能**: CSV形式でのデータ保存・復元
|
||
|
||
#### 12.5.2 拡張性
|
||
- **イベント連携**: rog_newevent2との外部キー制約
|
||
- **空間検索**: PostGIS機能による高速位置検索
|
||
- **API互換性**: 既存APIとの完全互換性維持
|
||
- **将来拡張**: 追加フィールドの容易な実装
|
||
|
||
---
|
||
|
||
## 🆕 13. 管理者機能拡張 (2025年8月実装)
|
||
|
||
### 13.1 GpsCheckinテーブル拡張
|
||
|
||
#### 13.1.1 新規フィールド追加
|
||
通過審査管理機能の実装に伴い、`rog_gpscheckin`テーブルに以下のフィールドを追加:
|
||
|
||
```sql
|
||
-- マイグレーション: 0007_add_validation_fields
|
||
ALTER TABLE rog_gpscheckin ADD COLUMN validation_status VARCHAR(20) DEFAULT 'PENDING';
|
||
ALTER TABLE rog_gpscheckin ADD COLUMN validation_comment TEXT;
|
||
ALTER TABLE rog_gpscheckin ADD COLUMN validated_at TIMESTAMP WITH TIME ZONE;
|
||
ALTER TABLE rog_gpscheckin ADD COLUMN validated_by VARCHAR(255);
|
||
```
|
||
|
||
#### 13.1.2 フィールド仕様
|
||
|
||
| フィールド名 | データ型 | 制約 | 説明 |
|
||
|------------|----------|------|------|
|
||
| validation_status | VARCHAR(20) | NOT NULL, DEFAULT 'PENDING' | 審査ステータス |
|
||
| validation_comment | TEXT | NULL可 | 審査コメント・理由 |
|
||
| validated_at | TIMESTAMP WITH TIME ZONE | NULL可 | 審査実施日時 |
|
||
| validated_by | VARCHAR(255) | NULL可 | 審査者(メールアドレス) |
|
||
|
||
#### 13.1.3 validation_status値定義
|
||
|
||
```python
|
||
VALIDATION_STATUS_CHOICES = [
|
||
('PENDING', '審査待ち'), # デフォルト状態
|
||
('APPROVED', '承認'), # 管理者承認済み
|
||
('REJECTED', '却下'), # 管理者否認
|
||
('AUTO_APPROVED', '自動承認'), # システム自動承認
|
||
]
|
||
```
|
||
|
||
#### 13.1.4 インデックス設計
|
||
|
||
```sql
|
||
-- パフォーマンス最適化のためのインデックス
|
||
CREATE INDEX idx_gpscheckin_validation_status ON rog_gpscheckin(validation_status);
|
||
CREATE INDEX idx_gpscheckin_validated_at ON rog_gpscheckin(validated_at);
|
||
CREATE INDEX idx_gpscheckin_event_validation ON rog_gpscheckin(event_code, validation_status);
|
||
```
|
||
|
||
### 13.2 データ整合性制約
|
||
|
||
#### 13.2.1 ビジネスルール
|
||
|
||
```sql
|
||
-- 審査済みチェックインは審査者と審査日時が必須
|
||
ALTER TABLE rog_gpscheckin ADD CONSTRAINT chk_validation_complete
|
||
CHECK (
|
||
(validation_status IN ('APPROVED', 'REJECTED') AND validated_at IS NOT NULL AND validated_by IS NOT NULL)
|
||
OR (validation_status IN ('PENDING', 'AUTO_APPROVED'))
|
||
);
|
||
```
|
||
|
||
#### 13.2.2 外部キー制約
|
||
|
||
```sql
|
||
-- 既存制約の確認
|
||
ALTER TABLE rog_gpscheckin ADD CONSTRAINT fk_gpscheckin_event
|
||
FOREIGN KEY (event_code) REFERENCES rog_newevent2(event_code);
|
||
|
||
ALTER TABLE rog_gpscheckin ADD CONSTRAINT fk_gpscheckin_location
|
||
FOREIGN KEY (event_code, cp_number) REFERENCES rog_location2025(event_code, cp_number);
|
||
```
|
||
|
||
### 13.3 集計用ビュー作成
|
||
|
||
#### 13.3.1 参加者ランキングビュー
|
||
|
||
```sql
|
||
CREATE OR REPLACE VIEW vw_participant_ranking AS
|
||
SELECT
|
||
e.zekken_number,
|
||
e.team_name,
|
||
e.class_name,
|
||
e.event_code,
|
||
|
||
-- 確定得点(承認済み + 自動承認)
|
||
COALESCE(SUM(
|
||
CASE WHEN g.validation_status IN ('APPROVED', 'AUTO_APPROVED')
|
||
THEN l.point_value ELSE 0 END
|
||
), 0) as confirmed_points,
|
||
|
||
-- 未確定得点(審査待ち)
|
||
COALESCE(SUM(
|
||
CASE WHEN g.validation_status = 'PENDING'
|
||
THEN l.point_value ELSE 0 END
|
||
), 0) as pending_points,
|
||
|
||
-- 却下得点
|
||
COALESCE(SUM(
|
||
CASE WHEN g.validation_status = 'REJECTED'
|
||
THEN l.point_value ELSE 0 END
|
||
), 0) as rejected_points,
|
||
|
||
-- チェックイン統計
|
||
COUNT(g.id) as total_checkins,
|
||
COUNT(CASE WHEN g.validation_status IN ('APPROVED', 'AUTO_APPROVED') THEN 1 END) as confirmed_checkins,
|
||
COUNT(CASE WHEN g.validation_status = 'PENDING' THEN 1 END) as pending_checkins,
|
||
COUNT(CASE WHEN g.validation_status = 'REJECTED' THEN 1 END) as rejected_checkins,
|
||
|
||
-- 確定率
|
||
CASE
|
||
WHEN COUNT(g.id) > 0 THEN
|
||
ROUND(COUNT(CASE WHEN g.validation_status IN ('APPROVED', 'AUTO_APPROVED') THEN 1 END) * 100.0 / COUNT(g.id), 1)
|
||
ELSE 0
|
||
END as confirmation_rate,
|
||
|
||
-- 最終更新日時
|
||
MAX(g.validated_at) as last_validation_date
|
||
|
||
FROM rog_entry e
|
||
LEFT JOIN rog_gpscheckin g ON e.zekken_number = g.zekken_number AND e.event_code = g.event_code
|
||
LEFT JOIN rog_location2025 l ON g.event_code = l.event_code AND g.cp_number = l.cp_number
|
||
|
||
GROUP BY e.zekken_number, e.team_name, e.class_name, e.event_code;
|
||
|
||
-- インデックス作成
|
||
CREATE INDEX idx_vw_participant_ranking_event ON vw_participant_ranking(event_code);
|
||
CREATE INDEX idx_vw_participant_ranking_points ON vw_participant_ranking(event_code, confirmed_points DESC);
|
||
```
|
||
|
||
### 13.4 データマイグレーション
|
||
|
||
#### 13.4.1 既存データの初期化
|
||
|
||
```sql
|
||
-- 既存のチェックインデータを自動承認状態に設定
|
||
UPDATE rog_gpscheckin
|
||
SET
|
||
validation_status = 'AUTO_APPROVED',
|
||
validated_at = create_at,
|
||
validated_by = 'system_migration'
|
||
WHERE validation_status IS NULL OR validation_status = '';
|
||
|
||
-- 統計確認
|
||
SELECT
|
||
validation_status,
|
||
COUNT(*) as count,
|
||
MIN(create_at) as earliest,
|
||
MAX(create_at) as latest
|
||
FROM rog_gpscheckin
|
||
GROUP BY validation_status;
|
||
```
|
||
|
||
### 13.5 パフォーマンス最適化
|
||
|
||
#### 13.5.1 クエリ最適化
|
||
|
||
```sql
|
||
-- ランキング用高速クエリ(インデックス活用)
|
||
EXPLAIN ANALYZE
|
||
SELECT
|
||
zekken_number,
|
||
team_name,
|
||
confirmed_points,
|
||
pending_points,
|
||
confirmation_rate
|
||
FROM vw_participant_ranking
|
||
WHERE event_code = '岐阜2412'
|
||
ORDER BY confirmed_points DESC, confirmation_rate DESC;
|
||
```
|
||
|
||
#### 13.5.2 キャッシュ戦略
|
||
|
||
```python
|
||
# Django設定でのキャッシュ設定
|
||
CACHES = {
|
||
'default': {
|
||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||
'LOCATION': 'redis://127.0.0.1:6379/1',
|
||
'OPTIONS': {
|
||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||
},
|
||
'KEY_PREFIX': 'rogaining_validation',
|
||
'TIMEOUT': 300, # 5分
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|