Finish basic API implementation
This commit is contained in:
264
API_IMPLEMENTATION_REPORT.md
Normal file
264
API_IMPLEMENTATION_REPORT.md
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
# サーバーAPI変更要求書 実装報告書
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
2025年8月27日のサーバーAPI変更要求書に基づき、最高優先度および高優先度項目の実装を完了しました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 実装完了項目
|
||||||
|
|
||||||
|
### 🔴 最高優先度(完了)
|
||||||
|
|
||||||
|
#### 1. アプリバージョンチェックAPI
|
||||||
|
**エンドポイント**: `POST /api/app/version-check`
|
||||||
|
|
||||||
|
**実装ファイル**:
|
||||||
|
- `rog/models.py`: `AppVersion`モデル追加
|
||||||
|
- `rog/serializers.py`: `AppVersionSerializer`, `AppVersionCheckSerializer`, `AppVersionResponseSerializer`
|
||||||
|
- `rog/app_version_views.py`: バージョンチェックAPI実装
|
||||||
|
- `rog/urls.py`: URLパターン追加
|
||||||
|
- `create_app_versions_table.sql`: データベーステーブル作成
|
||||||
|
|
||||||
|
**機能**:
|
||||||
|
- セマンティックバージョニング対応
|
||||||
|
- プラットフォーム別管理(Android/iOS)
|
||||||
|
- 強制更新フラグ制御
|
||||||
|
- カスタムメッセージ設定
|
||||||
|
- 管理者向けバージョン管理API
|
||||||
|
|
||||||
|
**使用例**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/app/version-check/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"current_version": "1.2.3",
|
||||||
|
"platform": "android",
|
||||||
|
"build_number": "123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. イベントステータス管理拡張
|
||||||
|
**エンドポイント**: `GET /newevent2-list/` (既存API拡張)
|
||||||
|
|
||||||
|
**実装ファイル**:
|
||||||
|
- `rog/models.py`: `NewEvent2`モデルに`status`フィールド追加
|
||||||
|
- `rog/serializers.py`: `NewEvent2Serializer`拡張
|
||||||
|
- `api_requirements_migration.sql`: データベース移行スクリプト
|
||||||
|
|
||||||
|
**機能**:
|
||||||
|
- ステータス管理: `public`, `private`, `draft`, `closed`
|
||||||
|
- `deadline_datetime`フィールド追加(API応答統一)
|
||||||
|
- 既存`public`フィールドからの自動移行
|
||||||
|
- ユーザーアクセス権限チェック機能
|
||||||
|
|
||||||
|
**レスポンス例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"event_name": "岐阜ロゲイニング2025",
|
||||||
|
"start_datetime": "2025-09-15T10:00:00Z",
|
||||||
|
"end_datetime": "2025-09-15T16:00:00Z",
|
||||||
|
"deadline_datetime": "2025-09-10T23:59:59Z",
|
||||||
|
"status": "public"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🟡 高優先度(完了)
|
||||||
|
|
||||||
|
#### 3. エントリー情報API拡張
|
||||||
|
**エンドポイント**: `GET /entry/` (既存API拡張)
|
||||||
|
|
||||||
|
**実装ファイル**:
|
||||||
|
- `rog/models.py`: `Entry`モデルにスタッフ権限フィールド追加
|
||||||
|
- `rog/serializers.py`: `EntrySerializer`拡張
|
||||||
|
|
||||||
|
**追加フィールド**:
|
||||||
|
- `staff_privileges`: スタッフ権限フラグ
|
||||||
|
- `can_access_private_events`: 非公開イベント参加権限
|
||||||
|
- `team_validation_status`: チーム承認状況
|
||||||
|
|
||||||
|
#### 4. チェックイン拡張情報システム
|
||||||
|
**実装ファイル**:
|
||||||
|
- `rog/models.py`: `CheckinExtended`モデル追加
|
||||||
|
- `create_checkin_extended_table.sql`: データベーステーブル作成
|
||||||
|
- `rog/views_apis/api_play.py`: `checkin_from_rogapp`API拡張
|
||||||
|
|
||||||
|
**機能**:
|
||||||
|
- GPS精度・座標情報の詳細記録
|
||||||
|
- カメラメタデータ保存
|
||||||
|
- 審査・検証システム
|
||||||
|
- 詳細スコアリング機能
|
||||||
|
- 自動審査フラグ
|
||||||
|
|
||||||
|
**拡張レスポンス例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"message": "チェックポイントが正常に登録されました",
|
||||||
|
"team_name": "チーム名",
|
||||||
|
"cp_number": 1,
|
||||||
|
"checkpoint_id": 123,
|
||||||
|
"checkin_time": "2025-09-15 11:30:00",
|
||||||
|
"point_value": 10,
|
||||||
|
"bonus_points": 5,
|
||||||
|
"scoring_breakdown": {
|
||||||
|
"base_points": 10,
|
||||||
|
"camera_bonus": 5,
|
||||||
|
"total_points": 15
|
||||||
|
},
|
||||||
|
"validation_status": "pending",
|
||||||
|
"requires_manual_review": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 データベース変更
|
||||||
|
|
||||||
|
### 新規テーブル
|
||||||
|
1. **app_versions**: アプリバージョン管理
|
||||||
|
2. **rog_checkin_extended**: チェックイン拡張情報
|
||||||
|
|
||||||
|
### 既存テーブル拡張
|
||||||
|
1. **rog_newevent2**:
|
||||||
|
- `status` VARCHAR(20): イベントステータス
|
||||||
|
|
||||||
|
2. **rog_entry**:
|
||||||
|
- `staff_privileges` BOOLEAN: スタッフ権限
|
||||||
|
- `can_access_private_events` BOOLEAN: 非公開イベント参加権限
|
||||||
|
- `team_validation_status` VARCHAR(20): チーム承認状況
|
||||||
|
|
||||||
|
### インデックス追加
|
||||||
|
- `idx_app_versions_platform`
|
||||||
|
- `idx_app_versions_latest`
|
||||||
|
- `idx_newevent2_status`
|
||||||
|
- `idx_entry_staff_privileges`
|
||||||
|
- `idx_checkin_extended_gpslog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 技術的実装詳細
|
||||||
|
|
||||||
|
### セキュリティ機能
|
||||||
|
- アプリバージョンチェックは認証不要(AllowAny)
|
||||||
|
- イベントアクセス権限チェック機能
|
||||||
|
- スタッフ権限による非公開イベント制御
|
||||||
|
|
||||||
|
### パフォーマンス最適化
|
||||||
|
- 適切なデータベースインデックス追加
|
||||||
|
- JSON形式でのスコアリング詳細保存
|
||||||
|
- 最新バージョンフラグによる高速検索
|
||||||
|
|
||||||
|
### エラーハンドリング
|
||||||
|
- 包括的なバリデーション
|
||||||
|
- 詳細なログ出力
|
||||||
|
- ユーザーフレンドリーなエラーメッセージ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 実装ファイル一覧
|
||||||
|
|
||||||
|
### Core Files
|
||||||
|
- `rog/models.py` - モデル定義
|
||||||
|
- `rog/serializers.py` - シリアライザー
|
||||||
|
- `rog/urls.py` - URLパターン
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
- `rog/app_version_views.py` - バージョンチェックAPI
|
||||||
|
- `create_app_versions_table.sql` - アプリバージョンテーブル
|
||||||
|
- `create_checkin_extended_table.sql` - チェックイン拡張テーブル
|
||||||
|
- `api_requirements_migration.sql` - 全体マイグレーション
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
- `rog/views_apis/api_play.py` - チェックインAPI拡張
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 デプロイ手順
|
||||||
|
|
||||||
|
### 1. データベース移行
|
||||||
|
```bash
|
||||||
|
# PostgreSQLに接続
|
||||||
|
psql -h localhost -U postgres -d rogdb
|
||||||
|
|
||||||
|
# マイグレーションスクリプト実行
|
||||||
|
\i api_requirements_migration.sql
|
||||||
|
\i create_app_versions_table.sql
|
||||||
|
\i create_checkin_extended_table.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Django設定
|
||||||
|
```bash
|
||||||
|
# モデル変更検出
|
||||||
|
python manage.py makemigrations
|
||||||
|
|
||||||
|
# マイグレーション実行
|
||||||
|
python manage.py migrate
|
||||||
|
|
||||||
|
# サーバー再起動
|
||||||
|
sudo systemctl restart rogaining_srv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 動作確認
|
||||||
|
```bash
|
||||||
|
# アプリバージョンチェックテスト
|
||||||
|
curl -X POST http://localhost:8000/api/app/version-check/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"current_version": "1.0.0", "platform": "android"}'
|
||||||
|
|
||||||
|
# イベント一覧確認
|
||||||
|
curl http://localhost:8000/api/newevent2-list/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 パフォーマンス影響
|
||||||
|
|
||||||
|
### 予想される影響
|
||||||
|
- **データベース容量**: 約5-10%増加(新テーブル・フィールド)
|
||||||
|
- **API応答時間**: ほぼ影響なし(適切なインデックス配置)
|
||||||
|
- **メモリ使用量**: 軽微な増加(新モデル定義)
|
||||||
|
|
||||||
|
### 監視項目
|
||||||
|
- アプリバージョンチェックAPI応答時間
|
||||||
|
- チェックイン拡張情報保存成功率
|
||||||
|
- データベース接続プール使用率
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事項
|
||||||
|
|
||||||
|
### 後方互換性
|
||||||
|
- 既存API仕様は維持
|
||||||
|
- 新フィールドは全てオプショナル
|
||||||
|
- 段階的移行が可能
|
||||||
|
|
||||||
|
### データ整合性
|
||||||
|
- `public`フィールドと`status`フィールドの整合性チェック実装
|
||||||
|
- トランザクション処理による原子性保証
|
||||||
|
|
||||||
|
### 今後の課題
|
||||||
|
- Location2025テーブルとの完全連携
|
||||||
|
- リアルタイム通知システムの実装
|
||||||
|
- 管理者向けダッシュボード強化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 次のアクション
|
||||||
|
|
||||||
|
### 🟢 中優先度項目(残り)
|
||||||
|
1. **チェックポイント詳細情報API**: Location2025対応
|
||||||
|
2. **管理者向け機能拡張**: 一括操作・リアルタイム監視
|
||||||
|
3. **プッシュ通知システム**: FCM連携
|
||||||
|
|
||||||
|
### 実装予定
|
||||||
|
- **9月3日まで**: 中優先度項目の実装
|
||||||
|
- **9月10日まで**: テスト・検証完了
|
||||||
|
- **9月15日**: 本番リリース
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**実装完了日**: 2025年8月27日
|
||||||
|
**実装者**: サーバー開発チーム
|
||||||
|
**レビュー**: 技術リード
|
||||||
|
**次回進捗確認**: 2025年9月3日
|
||||||
202
LOCATION_INTERACTION_SYSTEM_README.md
Normal file
202
LOCATION_INTERACTION_SYSTEM_README.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# Location Interaction System - evaluation_value Based Implementation
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
LocationモデルのDestinationにuse_qr_codeフラグとevaluation_valueフィールドを使用した、拡張されたロケーションインタラクションシステムを実装しました。
|
||||||
|
|
||||||
|
## システム構成
|
||||||
|
|
||||||
|
### 1. Locationモデル拡張
|
||||||
|
|
||||||
|
**ファイル**: `rog/models.py`
|
||||||
|
|
||||||
|
- `evaluation_value` フィールドを使用してインタラクションタイプを決定
|
||||||
|
- 値の意味:
|
||||||
|
- `"0"` または `null`: 通常ポイント
|
||||||
|
- `"1"`: 写真撮影 + 買い物ポイント
|
||||||
|
- `"2"`: QRコードスキャン + クイズ回答
|
||||||
|
|
||||||
|
### 2. ビジネスロジック
|
||||||
|
|
||||||
|
**ファイル**: `rog/location_interaction.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# インタラクションタイプ定数
|
||||||
|
INTERACTION_TYPE_NORMAL = "0" # 通常ポイント
|
||||||
|
INTERACTION_TYPE_PHOTO = "1" # 写真撮影ポイント
|
||||||
|
INTERACTION_TYPE_QR_QUIZ = "2" # QRコード + クイズポイント
|
||||||
|
|
||||||
|
# 主要関数
|
||||||
|
- get_interaction_type(location): ロケーションのインタラクションタイプを判定
|
||||||
|
- validate_interaction_requirements(location, request_data): 必要なデータの検証
|
||||||
|
- get_point_calculation(location, interaction_result): ポイント計算
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. チェックインAPI
|
||||||
|
|
||||||
|
**ファイル**: `rog/location_checkin_view.py`
|
||||||
|
|
||||||
|
**エンドポイント**: `POST /api/location-checkin/`
|
||||||
|
|
||||||
|
**リクエスト形式**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"location_id": 123,
|
||||||
|
"latitude": 35.1234,
|
||||||
|
"longitude": 136.5678,
|
||||||
|
"photo": "base64_encoded_image_data", // evaluation_value="1"の場合必須
|
||||||
|
"qr_code_data": "{\"quiz_id\": 1, \"correct_answer\": \"答え\"}", // evaluation_value="2"の場合必須
|
||||||
|
"quiz_answer": "ユーザーの回答" // evaluation_value="2"の場合必須
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**レスポンス形式**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"checkin_id": 456,
|
||||||
|
"points_awarded": 10,
|
||||||
|
"point_type": "photo_shopping",
|
||||||
|
"message": "写真撮影が完了しました。買い物ポイントを獲得!",
|
||||||
|
"location_name": "ロケーション名",
|
||||||
|
"interaction_type": "1",
|
||||||
|
"interaction_result": {
|
||||||
|
"photo_saved": true,
|
||||||
|
"photo_filename": "checkin_123_20250103_143022.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. APIデータ拡張
|
||||||
|
|
||||||
|
**ファイル**: `rog/serializers.py`
|
||||||
|
|
||||||
|
LocationSerializerを拡張して、以下の情報を追加:
|
||||||
|
- `interaction_type`: インタラクションタイプ ("0", "1", "2")
|
||||||
|
- `requires_photo`: 写真撮影が必要かどうか
|
||||||
|
- `requires_qr_code`: QRコードスキャンが必要かどうか
|
||||||
|
- `interaction_instructions`: ユーザー向け指示メッセージ
|
||||||
|
|
||||||
|
### 5. テスト用Webインターフェース
|
||||||
|
|
||||||
|
**ファイル**: `templates/location_checkin_test.html`
|
||||||
|
|
||||||
|
**アクセス**: `/api/location-checkin-test/`
|
||||||
|
|
||||||
|
機能:
|
||||||
|
- ロケーション一覧の表示
|
||||||
|
- evaluation_valueに基づく要件の表示
|
||||||
|
- 写真アップロード (evaluation_value="1")
|
||||||
|
- QRデータ・クイズ入力 (evaluation_value="2")
|
||||||
|
- チェックイン実行とテスト
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 1. 通常ポイント (evaluation_value="0")
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const data = {
|
||||||
|
location_id: 123,
|
||||||
|
latitude: 35.1234,
|
||||||
|
longitude: 136.5678
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/location-checkin/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 写真撮影ポイント (evaluation_value="1")
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const data = {
|
||||||
|
location_id: 123,
|
||||||
|
latitude: 35.1234,
|
||||||
|
longitude: 136.5678,
|
||||||
|
photo: "base64_encoded_image_data" // 写真必須
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. QRコード + クイズポイント (evaluation_value="2")
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const data = {
|
||||||
|
location_id: 123,
|
||||||
|
latitude: 35.1234,
|
||||||
|
longitude: 136.5678,
|
||||||
|
qr_code_data: '{"quiz_id": 1, "correct_answer": "岐阜城"}', // QRコードデータ
|
||||||
|
quiz_answer: "岐阜城" // ユーザーの回答
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## ポイント計算システム
|
||||||
|
|
||||||
|
### 基本ポイント
|
||||||
|
- 通常ポイント: 10ポイント
|
||||||
|
- 写真撮影ポイント: 15ポイント
|
||||||
|
- QRコード + クイズポイント: 20ポイント (正解時)
|
||||||
|
|
||||||
|
### ボーナスポイント
|
||||||
|
- クイズ正解ボーナス: +5ポイント
|
||||||
|
- 写真保存成功ボーナス: +2ポイント
|
||||||
|
|
||||||
|
## エラーハンドリング
|
||||||
|
|
||||||
|
### 検証エラー
|
||||||
|
- 必須フィールド不足
|
||||||
|
- 距離制限外
|
||||||
|
- 写真データ不正
|
||||||
|
- QRコードデータ不正
|
||||||
|
|
||||||
|
### 処理エラー
|
||||||
|
- 写真保存失敗
|
||||||
|
- データベースエラー
|
||||||
|
- ネットワークエラー
|
||||||
|
|
||||||
|
## セキュリティ考慮事項
|
||||||
|
|
||||||
|
1. **認証**: `@login_required`デコレータでユーザー認証必須
|
||||||
|
2. **CSRF**: `@csrf_exempt`だが、トークン検証推奨
|
||||||
|
3. **距離検証**: Haversine公式による正確な距離計算
|
||||||
|
4. **データ検証**: 入力データの厳密な検証
|
||||||
|
|
||||||
|
## データベース影響
|
||||||
|
|
||||||
|
### 新規追加なし
|
||||||
|
- 既存の`evaluation_value`フィールドを活用
|
||||||
|
- `Useractions`テーブルでチェックイン記録
|
||||||
|
|
||||||
|
### 推奨される追加フィールド (今後の拡張)
|
||||||
|
- `Location.checkin_radius`: チェックイン許可範囲
|
||||||
|
- `Location.use_qr_code`: QRコード使用フラグ
|
||||||
|
- `Location.quiz_data`: クイズデータ
|
||||||
|
|
||||||
|
## 今後の拡張予定
|
||||||
|
|
||||||
|
1. **写真検証**: AI による撮影内容検証
|
||||||
|
2. **QRコード生成**: 動的QRコード生成システム
|
||||||
|
3. **ゲーミフィケーション**: バッジ・称号システム
|
||||||
|
4. **リアルタイム**: WebSocket による即座反映
|
||||||
|
5. **統計**: インタラクション統計・分析
|
||||||
|
|
||||||
|
## テスト手順
|
||||||
|
|
||||||
|
1. テストページにアクセス: `/api/location-checkin-test/`
|
||||||
|
2. evaluation_valueが異なるロケーションを選択
|
||||||
|
3. 各インタラクションタイプでチェックイン実行
|
||||||
|
4. レスポンスの確認
|
||||||
|
|
||||||
|
## 関連ファイル
|
||||||
|
|
||||||
|
- `rog/models.py`: Locationモデル定義
|
||||||
|
- `rog/serializers.py`: LocationSerializer拡張
|
||||||
|
- `rog/location_interaction.py`: ビジネスロジック
|
||||||
|
- `rog/location_checkin_view.py`: チェックインAPI
|
||||||
|
- `rog/urls.py`: URL設定
|
||||||
|
- `templates/location_checkin_test.html`: テストインターフェース
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
この実装により、evaluation_valueに基づく柔軟なロケーションインタラクションシステムが完成しました。各ロケーションで異なるユーザー体験を提供し、ゲーミフィケーション要素を追加することで、より魅力的なロゲイニング体験を実現します。
|
||||||
22
add_use_qr_code_migration.py
Normal file
22
add_use_qr_code_migration.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Generated migration for adding use_qr_code field to Location model
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rog', '0001_initial'), # 最新のマイグレーションファイル名に合わせてください
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='location',
|
||||||
|
name='use_qr_code',
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='QRコードを使用したインタラクションを有効にする',
|
||||||
|
verbose_name='Use QR Code for interaction'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
117
api_requirements_migration.sql
Normal file
117
api_requirements_migration.sql
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
-- サーバーAPI変更要求書対応データベース移行スクリプト
|
||||||
|
-- 2025年8月27日
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. NewEvent2テーブルにstatusフィールド追加
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'rog_newevent2' AND column_name = 'status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE rog_newevent2 ADD COLUMN status VARCHAR(20) DEFAULT 'draft'
|
||||||
|
CHECK (status IN ('public', 'private', 'draft', 'closed'));
|
||||||
|
|
||||||
|
-- 既存のpublicフィールドからstatusフィールドへの移行
|
||||||
|
UPDATE rog_newevent2 SET status = CASE
|
||||||
|
WHEN public = true THEN 'public'
|
||||||
|
ELSE 'draft'
|
||||||
|
END;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN rog_newevent2.status IS 'イベントステータス (public/private/draft/closed)';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Entryテーブルにスタッフ権限フィールド追加
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'rog_entry' AND column_name = 'staff_privileges'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE rog_entry ADD COLUMN staff_privileges BOOLEAN DEFAULT FALSE;
|
||||||
|
COMMENT ON COLUMN rog_entry.staff_privileges IS 'スタッフ権限フラグ';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'rog_entry' AND column_name = 'can_access_private_events'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE rog_entry ADD COLUMN can_access_private_events BOOLEAN DEFAULT FALSE;
|
||||||
|
COMMENT ON COLUMN rog_entry.can_access_private_events IS '非公開イベント参加権限';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'rog_entry' AND column_name = 'team_validation_status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE rog_entry ADD COLUMN team_validation_status VARCHAR(20) DEFAULT 'approved'
|
||||||
|
CHECK (team_validation_status IN ('approved', 'pending', 'rejected'));
|
||||||
|
COMMENT ON COLUMN rog_entry.team_validation_status IS 'チーム承認状況';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. インデックス追加
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_newevent2_status ON rog_newevent2(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_staff_privileges ON rog_entry(staff_privileges) WHERE staff_privileges = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_validation_status ON rog_entry(team_validation_status);
|
||||||
|
|
||||||
|
-- 4. データ整合性チェック
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec RECORD;
|
||||||
|
inconsistent_count INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
-- publicフィールドとstatusフィールドの整合性チェック
|
||||||
|
FOR rec IN (
|
||||||
|
SELECT id, event_name, public, status
|
||||||
|
FROM rog_newevent2
|
||||||
|
WHERE (public = TRUE AND status != 'public')
|
||||||
|
OR (public = FALSE AND status = 'public')
|
||||||
|
) LOOP
|
||||||
|
RAISE NOTICE 'Inconsistent status for event %: public=%, status=%',
|
||||||
|
rec.event_name, rec.public, rec.status;
|
||||||
|
inconsistent_count := inconsistent_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF inconsistent_count > 0 THEN
|
||||||
|
RAISE NOTICE 'Found % events with inconsistent public/status values', inconsistent_count;
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'All events have consistent public/status values';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 5. 統計情報更新
|
||||||
|
ANALYZE rog_newevent2;
|
||||||
|
ANALYZE rog_entry;
|
||||||
|
|
||||||
|
-- 6. 移行結果サマリー
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
event_count INTEGER;
|
||||||
|
entry_count INTEGER;
|
||||||
|
public_events INTEGER;
|
||||||
|
private_events INTEGER;
|
||||||
|
draft_events INTEGER;
|
||||||
|
staff_entries INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO event_count FROM rog_newevent2;
|
||||||
|
SELECT COUNT(*) INTO entry_count FROM rog_entry;
|
||||||
|
SELECT COUNT(*) INTO public_events FROM rog_newevent2 WHERE status = 'public';
|
||||||
|
SELECT COUNT(*) INTO private_events FROM rog_newevent2 WHERE status = 'private';
|
||||||
|
SELECT COUNT(*) INTO draft_events FROM rog_newevent2 WHERE status = 'draft';
|
||||||
|
SELECT COUNT(*) INTO staff_entries FROM rog_entry WHERE staff_privileges = TRUE;
|
||||||
|
|
||||||
|
RAISE NOTICE '';
|
||||||
|
RAISE NOTICE '=== 移行完了サマリー ===';
|
||||||
|
RAISE NOTICE 'イベント総数: %', event_count;
|
||||||
|
RAISE NOTICE ' - Public: %', public_events;
|
||||||
|
RAISE NOTICE ' - Private: %', private_events;
|
||||||
|
RAISE NOTICE ' - Draft: %', draft_events;
|
||||||
|
RAISE NOTICE 'エントリー総数: %', entry_count;
|
||||||
|
RAISE NOTICE ' - スタッフ権限付与: %', staff_entries;
|
||||||
|
RAISE NOTICE '';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
37
create_app_versions_table.sql
Normal file
37
create_app_versions_table.sql
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
-- アプリバージョン管理テーブル作成
|
||||||
|
-- 2025年8月27日 - サーバーAPI変更要求書対応
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_versions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
platform VARCHAR(10) NOT NULL CHECK (platform IN ('android', 'ios')),
|
||||||
|
build_number VARCHAR(20),
|
||||||
|
is_latest BOOLEAN DEFAULT FALSE,
|
||||||
|
is_required BOOLEAN DEFAULT FALSE,
|
||||||
|
update_message TEXT,
|
||||||
|
download_url TEXT,
|
||||||
|
release_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(version, platform)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- インデックス作成
|
||||||
|
CREATE INDEX idx_app_versions_platform ON app_versions(platform);
|
||||||
|
CREATE INDEX idx_app_versions_latest ON app_versions(is_latest) WHERE is_latest = TRUE;
|
||||||
|
|
||||||
|
-- 初期データ挿入(例)
|
||||||
|
INSERT INTO app_versions (version, platform, build_number, is_latest, is_required, update_message, download_url)
|
||||||
|
VALUES
|
||||||
|
('1.3.0', 'android', '130', TRUE, FALSE, '新機能が追加されました。更新を必ずしてください。', 'https://play.google.com/store/apps/details?id=com.gifurogeining.app'),
|
||||||
|
('1.3.0', 'ios', '130', TRUE, FALSE, '新機能が追加されました。更新を必ずしてください。', 'https://apps.apple.com/jp/app/id123456789'),
|
||||||
|
('1.2.0', 'android', '120', FALSE, FALSE, '前バージョン', 'https://play.google.com/store/apps/details?id=com.gifurogeining.app'),
|
||||||
|
('1.2.0', 'ios', '120', FALSE, FALSE, '前バージョン', 'https://apps.apple.com/jp/app/id123456789');
|
||||||
|
|
||||||
|
COMMENT ON TABLE app_versions IS 'アプリバージョン管理テーブル';
|
||||||
|
COMMENT ON COLUMN app_versions.version IS 'セマンティックバージョン (1.2.3)';
|
||||||
|
COMMENT ON COLUMN app_versions.platform IS 'プラットフォーム (android/ios)';
|
||||||
|
COMMENT ON COLUMN app_versions.build_number IS 'ビルド番号';
|
||||||
|
COMMENT ON COLUMN app_versions.is_latest IS '最新版フラグ';
|
||||||
|
COMMENT ON COLUMN app_versions.is_required IS '強制更新フラグ';
|
||||||
|
COMMENT ON COLUMN app_versions.update_message IS 'ユーザー向け更新メッセージ';
|
||||||
|
COMMENT ON COLUMN app_versions.download_url IS 'アプリストアURL';
|
||||||
80
create_checkin_extended_table.sql
Normal file
80
create_checkin_extended_table.sql
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
-- チェックイン拡張情報テーブル作成
|
||||||
|
-- 2025年8月27日 - サーバーAPI変更要求書対応
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rog_checkin_extended (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
gpslog_id INTEGER REFERENCES rog_gpslog(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- GPS拡張情報
|
||||||
|
gps_latitude DECIMAL(10, 8),
|
||||||
|
gps_longitude DECIMAL(11, 8),
|
||||||
|
gps_accuracy DECIMAL(6, 2),
|
||||||
|
gps_timestamp TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- カメラメタデータ
|
||||||
|
camera_capture_time TIMESTAMP WITH TIME ZONE,
|
||||||
|
device_info TEXT,
|
||||||
|
|
||||||
|
-- 審査・検証情報
|
||||||
|
validation_status VARCHAR(20) DEFAULT 'pending'
|
||||||
|
CHECK (validation_status IN ('pending', 'approved', 'rejected', 'requires_review')),
|
||||||
|
validation_comment TEXT,
|
||||||
|
validated_by INTEGER REFERENCES rog_customuser(id),
|
||||||
|
validated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- スコア情報
|
||||||
|
bonus_points INTEGER DEFAULT 0,
|
||||||
|
scoring_breakdown JSONB,
|
||||||
|
|
||||||
|
-- システム情報
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- インデックス作成
|
||||||
|
CREATE INDEX idx_checkin_extended_gpslog ON rog_checkin_extended(gpslog_id);
|
||||||
|
CREATE INDEX idx_checkin_extended_validation_status ON rog_checkin_extended(validation_status);
|
||||||
|
CREATE INDEX idx_checkin_extended_validated_by ON rog_checkin_extended(validated_by);
|
||||||
|
CREATE INDEX idx_checkin_extended_created_at ON rog_checkin_extended(created_at);
|
||||||
|
|
||||||
|
-- トリガー関数:updated_at自動更新
|
||||||
|
CREATE OR REPLACE FUNCTION update_checkin_extended_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- トリガー作成
|
||||||
|
CREATE TRIGGER trigger_update_checkin_extended_updated_at
|
||||||
|
BEFORE UPDATE ON rog_checkin_extended
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_checkin_extended_updated_at();
|
||||||
|
|
||||||
|
-- コメント追加
|
||||||
|
COMMENT ON TABLE rog_checkin_extended IS 'チェックイン拡張情報テーブル - GPS精度、カメラメタデータ、審査情報';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.gpslog_id IS '関連するGPSログID';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.gps_latitude IS 'GPS緯度';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.gps_longitude IS 'GPS経度';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.gps_accuracy IS 'GPS精度(メートル)';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.gps_timestamp IS 'GPS取得時刻';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.camera_capture_time IS 'カメラ撮影時刻';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.device_info IS 'デバイス情報';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.validation_status IS '審査ステータス';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.validation_comment IS '審査コメント';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.validated_by IS '審査者ID';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.validated_at IS '審査日時';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.bonus_points IS 'ボーナスポイント';
|
||||||
|
COMMENT ON COLUMN rog_checkin_extended.scoring_breakdown IS 'スコア詳細(JSON)';
|
||||||
|
|
||||||
|
-- 初期データ例
|
||||||
|
INSERT INTO rog_checkin_extended (
|
||||||
|
gpslog_id, gps_latitude, gps_longitude, gps_accuracy, gps_timestamp,
|
||||||
|
camera_capture_time, device_info, validation_status, bonus_points,
|
||||||
|
scoring_breakdown
|
||||||
|
) VALUES
|
||||||
|
(1, 35.4091, 136.7581, 5.2, '2025-09-15 11:30:00+09:00',
|
||||||
|
'2025-09-15 11:30:00+09:00', 'iPhone 12', 'pending', 5,
|
||||||
|
'{"base_points": 10, "camera_bonus": 5, "total_points": 15}'::jsonb)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
87
create_uploaded_images_table.sql
Normal file
87
create_uploaded_images_table.sql
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
-- 画像管理テーブル作成
|
||||||
|
-- サーバーAPI変更要求書対応 - 最優先項目
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rog_uploaded_images (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- 基本情報
|
||||||
|
original_filename VARCHAR(255) NOT NULL,
|
||||||
|
server_filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
file_url TEXT NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
mime_type VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
|
-- 関連情報
|
||||||
|
event_code VARCHAR(50),
|
||||||
|
team_name VARCHAR(255),
|
||||||
|
cp_number INTEGER,
|
||||||
|
|
||||||
|
-- アップロード情報
|
||||||
|
upload_source VARCHAR(50) DEFAULT 'direct', -- 'direct', 'sharing_intent', 'bulk_upload'
|
||||||
|
device_platform VARCHAR(20), -- 'ios', 'android'
|
||||||
|
|
||||||
|
-- メタデータ
|
||||||
|
capture_timestamp TIMESTAMP WITH TIME ZONE,
|
||||||
|
upload_timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
device_info TEXT,
|
||||||
|
|
||||||
|
-- 処理状況
|
||||||
|
processing_status VARCHAR(20) DEFAULT 'uploaded', -- 'uploaded', 'processing', 'processed', 'failed'
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
|
||||||
|
-- 外部キー
|
||||||
|
gpslog_id INTEGER REFERENCES rog_gpslog(id) ON DELETE SET NULL,
|
||||||
|
entry_id INTEGER REFERENCES rog_entry(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- システム情報
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- インデックス作成
|
||||||
|
CREATE INDEX idx_uploaded_images_event_team ON rog_uploaded_images(event_code, team_name);
|
||||||
|
CREATE INDEX idx_uploaded_images_cp_number ON rog_uploaded_images(cp_number);
|
||||||
|
CREATE INDEX idx_uploaded_images_upload_timestamp ON rog_uploaded_images(upload_timestamp);
|
||||||
|
CREATE INDEX idx_uploaded_images_processing_status ON rog_uploaded_images(processing_status);
|
||||||
|
CREATE INDEX idx_uploaded_images_gpslog ON rog_uploaded_images(gpslog_id);
|
||||||
|
|
||||||
|
-- コメント追加
|
||||||
|
COMMENT ON TABLE rog_uploaded_images IS '画像アップロード管理テーブル - マルチアップロード対応';
|
||||||
|
COMMENT ON COLUMN rog_uploaded_images.original_filename IS '元のファイル名';
|
||||||
|
COMMENT ON COLUMN rog_uploaded_images.server_filename IS 'サーバー上のファイル名';
|
||||||
|
COMMENT ON COLUMN rog_uploaded_images.file_url IS '画像URL';
|
||||||
|
COMMENT ON COLUMN rog_uploaded_images.file_size IS 'ファイルサイズ(バイト)';
|
||||||
|
COMMENT ON COLUMN rog_uploaded_images.upload_source IS 'アップロード方法';
|
||||||
|
COMMENT ON COLUMN rog_uploaded_images.device_platform IS 'デバイスプラットフォーム';
|
||||||
|
COMMENT ON COLUMN rog_uploaded_images.processing_status IS '処理状況';
|
||||||
|
|
||||||
|
-- 制約追加
|
||||||
|
ALTER TABLE rog_uploaded_images ADD CONSTRAINT chk_file_size
|
||||||
|
CHECK (file_size > 0 AND file_size <= 10485760); -- 最大10MB
|
||||||
|
|
||||||
|
ALTER TABLE rog_uploaded_images ADD CONSTRAINT chk_mime_type
|
||||||
|
CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/heic', 'image/webp'));
|
||||||
|
|
||||||
|
ALTER TABLE rog_uploaded_images ADD CONSTRAINT chk_upload_source
|
||||||
|
CHECK (upload_source IN ('direct', 'sharing_intent', 'bulk_upload'));
|
||||||
|
|
||||||
|
ALTER TABLE rog_uploaded_images ADD CONSTRAINT chk_device_platform
|
||||||
|
CHECK (device_platform IN ('ios', 'android', 'web'));
|
||||||
|
|
||||||
|
ALTER TABLE rog_uploaded_images ADD CONSTRAINT chk_processing_status
|
||||||
|
CHECK (processing_status IN ('uploaded', 'processing', 'processed', 'failed'));
|
||||||
|
|
||||||
|
-- トリガー関数:updated_at自動更新
|
||||||
|
CREATE OR REPLACE FUNCTION update_uploaded_images_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- トリガー作成
|
||||||
|
CREATE TRIGGER trigger_update_uploaded_images_updated_at
|
||||||
|
BEFORE UPDATE ON rog_uploaded_images
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_uploaded_images_updated_at();
|
||||||
242
rog/app_version_views.py
Normal file
242
rog/app_version_views.py
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
App Version Check API Views
|
||||||
|
アプリバージョンチェック機能
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views import View
|
||||||
|
from django.http import JsonResponse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .models import AppVersion
|
||||||
|
from .serializers import AppVersionCheckSerializer, AppVersionResponseSerializer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def app_version_check(request):
|
||||||
|
"""
|
||||||
|
アプリバージョンチェックAPI
|
||||||
|
|
||||||
|
POST /api/app/version-check
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"current_version": "1.2.3",
|
||||||
|
"platform": "android",
|
||||||
|
"build_number": "123"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"latest_version": "1.3.0",
|
||||||
|
"update_required": false,
|
||||||
|
"update_available": true,
|
||||||
|
"update_message": "新機能が追加されました。更新をお勧めします。",
|
||||||
|
"download_url": "https://play.google.com/store/apps/details?id=com.example.app",
|
||||||
|
"release_date": "2025-08-25T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# リクエストデータの検証
|
||||||
|
serializer = AppVersionCheckSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response({
|
||||||
|
'error': 'Invalid request data',
|
||||||
|
'details': serializer.errors
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
current_version = serializer.validated_data['current_version']
|
||||||
|
platform = serializer.validated_data['platform']
|
||||||
|
build_number = serializer.validated_data.get('build_number')
|
||||||
|
|
||||||
|
# 最新バージョン情報を取得
|
||||||
|
latest_version_obj = AppVersion.get_latest_version(platform)
|
||||||
|
|
||||||
|
if not latest_version_obj:
|
||||||
|
return Response({
|
||||||
|
'error': 'No version information available for this platform'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# バージョン比較
|
||||||
|
comparison = AppVersion.compare_versions(current_version, latest_version_obj.version)
|
||||||
|
|
||||||
|
# レスポンスデータ作成
|
||||||
|
response_data = {
|
||||||
|
'latest_version': latest_version_obj.version,
|
||||||
|
'update_required': False,
|
||||||
|
'update_available': False,
|
||||||
|
'update_message': latest_version_obj.update_message or 'アプリは最新版です',
|
||||||
|
'download_url': latest_version_obj.download_url or '',
|
||||||
|
'release_date': latest_version_obj.release_date
|
||||||
|
}
|
||||||
|
|
||||||
|
if comparison < 0: # current_version < latest_version
|
||||||
|
response_data['update_available'] = True
|
||||||
|
|
||||||
|
# 強制更新が必要かチェック
|
||||||
|
if latest_version_obj.is_required:
|
||||||
|
response_data['update_required'] = True
|
||||||
|
response_data['update_message'] = (
|
||||||
|
latest_version_obj.update_message or
|
||||||
|
'このバージョンは古すぎるため、更新が必要です。'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response_data['update_message'] = (
|
||||||
|
latest_version_obj.update_message or
|
||||||
|
'新しいバージョンが利用可能です。更新をお勧めします。'
|
||||||
|
)
|
||||||
|
|
||||||
|
# レスポンス検証
|
||||||
|
response_serializer = AppVersionResponseSerializer(data=response_data)
|
||||||
|
if response_serializer.is_valid():
|
||||||
|
return Response(response_serializer.validated_data, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
logger.error(f"Response serialization error: {response_serializer.errors}")
|
||||||
|
return Response({
|
||||||
|
'error': 'Internal server error'
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"App version check error: {e}")
|
||||||
|
return Response({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': str(e)
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class AppVersionManagementView(View):
|
||||||
|
"""アプリバージョン管理ビュー(管理者用)"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""全バージョン情報取得"""
|
||||||
|
try:
|
||||||
|
platform = request.GET.get('platform')
|
||||||
|
|
||||||
|
queryset = AppVersion.objects.all().order_by('-created_at')
|
||||||
|
if platform:
|
||||||
|
queryset = queryset.filter(platform=platform)
|
||||||
|
|
||||||
|
versions = []
|
||||||
|
for version in queryset:
|
||||||
|
versions.append({
|
||||||
|
'id': version.id,
|
||||||
|
'version': version.version,
|
||||||
|
'platform': version.platform,
|
||||||
|
'build_number': version.build_number,
|
||||||
|
'is_latest': version.is_latest,
|
||||||
|
'is_required': version.is_required,
|
||||||
|
'update_message': version.update_message,
|
||||||
|
'download_url': version.download_url,
|
||||||
|
'release_date': version.release_date.isoformat(),
|
||||||
|
'created_at': version.created_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'versions': versions,
|
||||||
|
'total': len(versions)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Version list error: {e}")
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Failed to fetch versions'
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""新バージョン登録"""
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
|
||||||
|
# 必須フィールドチェック
|
||||||
|
required_fields = ['version', 'platform']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': f'Missing required field: {field}'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# バージョンオブジェクト作成
|
||||||
|
version_obj = AppVersion(
|
||||||
|
version=data['version'],
|
||||||
|
platform=data['platform'],
|
||||||
|
build_number=data.get('build_number'),
|
||||||
|
is_latest=data.get('is_latest', False),
|
||||||
|
is_required=data.get('is_required', False),
|
||||||
|
update_message=data.get('update_message'),
|
||||||
|
download_url=data.get('download_url')
|
||||||
|
)
|
||||||
|
|
||||||
|
version_obj.save()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'message': 'Version created successfully',
|
||||||
|
'id': version_obj.id,
|
||||||
|
'version': version_obj.version,
|
||||||
|
'platform': version_obj.platform
|
||||||
|
}, status=201)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Invalid JSON data'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Version creation error: {e}")
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Failed to create version'
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
def put(self, request):
|
||||||
|
"""バージョン情報更新"""
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
version_id = data.get('id')
|
||||||
|
|
||||||
|
if not version_id:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Version ID is required'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
version_obj = AppVersion.objects.get(id=version_id)
|
||||||
|
except AppVersion.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Version not found'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# フィールド更新
|
||||||
|
updateable_fields = [
|
||||||
|
'build_number', 'is_latest', 'is_required',
|
||||||
|
'update_message', 'download_url'
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in updateable_fields:
|
||||||
|
if field in data:
|
||||||
|
setattr(version_obj, field, data[field])
|
||||||
|
|
||||||
|
version_obj.save()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'message': 'Version updated successfully',
|
||||||
|
'id': version_obj.id,
|
||||||
|
'version': version_obj.version
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Invalid JSON data'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Version update error: {e}")
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Failed to update version'
|
||||||
|
}, status=500)
|
||||||
357
rog/gpx_route_views.py
Normal file
357
rog/gpx_route_views.py
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
"""
|
||||||
|
GPX Test Route API Views
|
||||||
|
GPXシミュレーション用のテストルートデータ取得
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .models import NewEvent2, Location
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def gpx_test_data(request):
|
||||||
|
"""
|
||||||
|
GPXシミュレーション用のテストルートデータ取得
|
||||||
|
|
||||||
|
GET /api/routes/gpx-test-data
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- event_code: イベントコード
|
||||||
|
- route_type: ルートタイプ (sample, short, long)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_code = request.GET.get('event_code')
|
||||||
|
route_type = request.GET.get('route_type', 'sample')
|
||||||
|
|
||||||
|
if not event_code:
|
||||||
|
return Response({
|
||||||
|
'error': 'event_code parameter is required'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# イベントの存在確認
|
||||||
|
try:
|
||||||
|
event = NewEvent2.objects.get(event_name=event_code)
|
||||||
|
except NewEvent2.DoesNotExist:
|
||||||
|
return Response({
|
||||||
|
'error': f'Event "{event_code}" not found'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# ルートタイプに応じたテストデータ生成
|
||||||
|
routes = []
|
||||||
|
|
||||||
|
if route_type == 'sample':
|
||||||
|
routes = _generate_sample_route(event_code)
|
||||||
|
elif route_type == 'short':
|
||||||
|
routes = _generate_short_route(event_code)
|
||||||
|
elif route_type == 'long':
|
||||||
|
routes = _generate_long_route(event_code)
|
||||||
|
else:
|
||||||
|
routes = _generate_sample_route(event_code)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'routes': routes,
|
||||||
|
'event_code': event_code,
|
||||||
|
'route_type': route_type,
|
||||||
|
'generated_at': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GPX test data error: {e}")
|
||||||
|
return Response({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': str(e)
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_sample_route(event_code):
|
||||||
|
"""サンプルルート生成"""
|
||||||
|
|
||||||
|
# 岐阜市内の主要ポイント
|
||||||
|
waypoints = [
|
||||||
|
{
|
||||||
|
"lat": 35.4122,
|
||||||
|
"lng": 136.7514,
|
||||||
|
"timestamp": "2025-09-15T10:00:00Z",
|
||||||
|
"cp_number": 1,
|
||||||
|
"description": "岐阜公園",
|
||||||
|
"elevation": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4089,
|
||||||
|
"lng": 136.7581,
|
||||||
|
"timestamp": "2025-09-15T10:15:00Z",
|
||||||
|
"cp_number": 2,
|
||||||
|
"description": "岐阜城天守閣",
|
||||||
|
"elevation": 329
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4091,
|
||||||
|
"lng": 136.7456,
|
||||||
|
"timestamp": "2025-09-15T10:30:00Z",
|
||||||
|
"cp_number": 3,
|
||||||
|
"description": "長良川うかいミュージアム",
|
||||||
|
"elevation": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4187,
|
||||||
|
"lng": 136.7598,
|
||||||
|
"timestamp": "2025-09-15T10:45:00Z",
|
||||||
|
"cp_number": 4,
|
||||||
|
"description": "岐阜市歴史博物館",
|
||||||
|
"elevation": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4122,
|
||||||
|
"lng": 136.7514,
|
||||||
|
"timestamp": "2025-09-15T11:00:00Z",
|
||||||
|
"cp_number": 0,
|
||||||
|
"description": "岐阜公園(ゴール)",
|
||||||
|
"elevation": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
gpx_data = _generate_gpx_xml(waypoints, "岐阜市内サンプルルート")
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"route_name": "岐阜市内サンプルルート",
|
||||||
|
"description": "チェックポイント1-4を巡回するテストルート",
|
||||||
|
"estimated_time": "60分",
|
||||||
|
"total_distance": "約3.2km",
|
||||||
|
"elevation_gain": "約314m",
|
||||||
|
"difficulty": "中級",
|
||||||
|
"waypoints": waypoints,
|
||||||
|
"gpx_data": gpx_data
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_short_route(event_code):
|
||||||
|
"""短距離ルート生成"""
|
||||||
|
|
||||||
|
waypoints = [
|
||||||
|
{
|
||||||
|
"lat": 35.4122,
|
||||||
|
"lng": 136.7514,
|
||||||
|
"timestamp": "2025-09-15T10:00:00Z",
|
||||||
|
"cp_number": 1,
|
||||||
|
"description": "岐阜公園(スタート)",
|
||||||
|
"elevation": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4150,
|
||||||
|
"lng": 136.7545,
|
||||||
|
"timestamp": "2025-09-15T10:10:00Z",
|
||||||
|
"cp_number": 2,
|
||||||
|
"description": "信長の居館跡",
|
||||||
|
"elevation": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4122,
|
||||||
|
"lng": 136.7514,
|
||||||
|
"timestamp": "2025-09-15T10:20:00Z",
|
||||||
|
"cp_number": 0,
|
||||||
|
"description": "岐阜公園(ゴール)",
|
||||||
|
"elevation": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
gpx_data = _generate_gpx_xml(waypoints, "岐阜公園周辺ショートルート")
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"route_name": "岐阜公園周辺ショートルート",
|
||||||
|
"description": "初心者向けの短距離ルート",
|
||||||
|
"estimated_time": "20分",
|
||||||
|
"total_distance": "約0.8km",
|
||||||
|
"elevation_gain": "約10m",
|
||||||
|
"difficulty": "初級",
|
||||||
|
"waypoints": waypoints,
|
||||||
|
"gpx_data": gpx_data
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_long_route(event_code):
|
||||||
|
"""長距離ルート生成"""
|
||||||
|
|
||||||
|
waypoints = [
|
||||||
|
{
|
||||||
|
"lat": 35.4122,
|
||||||
|
"lng": 136.7514,
|
||||||
|
"timestamp": "2025-09-15T10:00:00Z",
|
||||||
|
"cp_number": 1,
|
||||||
|
"description": "岐阜公園(スタート)",
|
||||||
|
"elevation": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4089,
|
||||||
|
"lng": 136.7581,
|
||||||
|
"timestamp": "2025-09-15T10:20:00Z",
|
||||||
|
"cp_number": 2,
|
||||||
|
"description": "岐阜城天守閣",
|
||||||
|
"elevation": 329
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.3978,
|
||||||
|
"lng": 136.7456,
|
||||||
|
"timestamp": "2025-09-15T10:45:00Z",
|
||||||
|
"cp_number": 3,
|
||||||
|
"description": "長良川河川敷",
|
||||||
|
"elevation": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4234,
|
||||||
|
"lng": 136.7345,
|
||||||
|
"timestamp": "2025-09-15T11:15:00Z",
|
||||||
|
"cp_number": 4,
|
||||||
|
"description": "金華橋",
|
||||||
|
"elevation": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4391,
|
||||||
|
"lng": 136.7598,
|
||||||
|
"timestamp": "2025-09-15T11:45:00Z",
|
||||||
|
"cp_number": 5,
|
||||||
|
"description": "護国神社",
|
||||||
|
"elevation": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4187,
|
||||||
|
"lng": 136.7698,
|
||||||
|
"timestamp": "2025-09-15T12:10:00Z",
|
||||||
|
"cp_number": 6,
|
||||||
|
"description": "岐阜メモリアルセンター",
|
||||||
|
"elevation": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4122,
|
||||||
|
"lng": 136.7514,
|
||||||
|
"timestamp": "2025-09-15T12:30:00Z",
|
||||||
|
"cp_number": 0,
|
||||||
|
"description": "岐阜公園(ゴール)",
|
||||||
|
"elevation": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
gpx_data = _generate_gpx_xml(waypoints, "岐阜市内ロングルート")
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"route_name": "岐阜市内ロングルート",
|
||||||
|
"description": "上級者向けの長距離チャレンジルート",
|
||||||
|
"estimated_time": "150分",
|
||||||
|
"total_distance": "約8.5km",
|
||||||
|
"elevation_gain": "約321m",
|
||||||
|
"difficulty": "上級",
|
||||||
|
"waypoints": waypoints,
|
||||||
|
"gpx_data": gpx_data
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_gpx_xml(waypoints, route_name):
|
||||||
|
"""GPXファイル形式のXMLを生成"""
|
||||||
|
|
||||||
|
gpx_header = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="GifuRogaining" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<metadata>
|
||||||
|
<name>{}</name>
|
||||||
|
<desc>Generated test route for rogaining simulation</desc>
|
||||||
|
<time>{}</time>
|
||||||
|
</metadata>'''.format(route_name, datetime.now().isoformat())
|
||||||
|
|
||||||
|
# トラックセグメント
|
||||||
|
track_points = []
|
||||||
|
for waypoint in waypoints:
|
||||||
|
track_points.append(''' <trkpt lat="{}" lon="{}">
|
||||||
|
<ele>{}</ele>
|
||||||
|
<time>{}</time>
|
||||||
|
<name>CP{} - {}</name>
|
||||||
|
</trkpt>'''.format(
|
||||||
|
waypoint['lat'],
|
||||||
|
waypoint['lng'],
|
||||||
|
waypoint.get('elevation', 0),
|
||||||
|
waypoint['timestamp'],
|
||||||
|
waypoint['cp_number'],
|
||||||
|
waypoint['description']
|
||||||
|
))
|
||||||
|
|
||||||
|
# ウェイポイント
|
||||||
|
waypoint_elements = []
|
||||||
|
for waypoint in waypoints:
|
||||||
|
waypoint_elements.append(''' <wpt lat="{}" lon="{}">
|
||||||
|
<ele>{}</ele>
|
||||||
|
<time>{}</time>
|
||||||
|
<name>CP{}</name>
|
||||||
|
<desc>{}</desc>
|
||||||
|
<sym>Flag, Blue</sym>
|
||||||
|
</wpt>'''.format(
|
||||||
|
waypoint['lat'],
|
||||||
|
waypoint['lng'],
|
||||||
|
waypoint.get('elevation', 0),
|
||||||
|
waypoint['timestamp'],
|
||||||
|
waypoint['cp_number'],
|
||||||
|
waypoint['description']
|
||||||
|
))
|
||||||
|
|
||||||
|
gpx_content = f'''{gpx_header}
|
||||||
|
|
||||||
|
<trk>
|
||||||
|
<name>{route_name}</name>
|
||||||
|
<desc>Test route for rogaining simulation</desc>
|
||||||
|
<trkseg>
|
||||||
|
{chr(10).join(track_points)}
|
||||||
|
</trkseg>
|
||||||
|
</trk>
|
||||||
|
|
||||||
|
{chr(10).join(waypoint_elements)}
|
||||||
|
|
||||||
|
</gpx>'''
|
||||||
|
|
||||||
|
return gpx_content
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def available_routes(request):
|
||||||
|
"""利用可能なテストルート一覧取得"""
|
||||||
|
|
||||||
|
event_code = request.GET.get('event_code')
|
||||||
|
|
||||||
|
routes_info = [
|
||||||
|
{
|
||||||
|
"route_type": "sample",
|
||||||
|
"name": "岐阜市内サンプルルート",
|
||||||
|
"description": "標準的なテストルート",
|
||||||
|
"estimated_time": "60分",
|
||||||
|
"difficulty": "中級",
|
||||||
|
"checkpoint_count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route_type": "short",
|
||||||
|
"name": "岐阜公園周辺ショートルート",
|
||||||
|
"description": "初心者向けの短距離ルート",
|
||||||
|
"estimated_time": "20分",
|
||||||
|
"difficulty": "初級",
|
||||||
|
"checkpoint_count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route_type": "long",
|
||||||
|
"name": "岐阜市内ロングルート",
|
||||||
|
"description": "上級者向けの長距離ルート",
|
||||||
|
"estimated_time": "150分",
|
||||||
|
"difficulty": "上級",
|
||||||
|
"checkpoint_count": 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"available_routes": routes_info,
|
||||||
|
"event_code": event_code,
|
||||||
|
"total_routes": len(routes_info)
|
||||||
|
})
|
||||||
240
rog/location_checkin_view.py
Normal file
240
rog/location_checkin_view.py
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Location checkin view with evaluation_value based interaction logic
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views import View
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
@method_decorator(login_required, name='dispatch')
|
||||||
|
class LocationCheckinView(View):
|
||||||
|
"""
|
||||||
|
evaluation_valueに基づく拡張チェックイン処理
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""
|
||||||
|
ロケーションチェックイン処理
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"location_id": int,
|
||||||
|
"latitude": float,
|
||||||
|
"longitude": float,
|
||||||
|
"photo": str (base64) - evaluation_value=1の場合必須,
|
||||||
|
"qr_code_data": str - evaluation_value=2の場合必須,
|
||||||
|
"quiz_answer": str - evaluation_value=2の場合必須
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
location_id = data.get('location_id')
|
||||||
|
user_lat = data.get('latitude')
|
||||||
|
user_lon = data.get('longitude')
|
||||||
|
|
||||||
|
if not all([location_id, user_lat, user_lon]):
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'location_id, latitude, longitude are required'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# ロケーション取得
|
||||||
|
from .models import Location
|
||||||
|
try:
|
||||||
|
location = Location.objects.get(id=location_id)
|
||||||
|
except Location.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Location not found'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# 距離チェック
|
||||||
|
if not self._is_within_checkin_radius(location, user_lat, user_lon):
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Too far from location',
|
||||||
|
'required_radius': location.checkin_radius or 15.0
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# evaluation_valueに基づく要件検証
|
||||||
|
from .location_interaction import validate_interaction_requirements
|
||||||
|
validation_result = validate_interaction_requirements(location, data)
|
||||||
|
|
||||||
|
if not validation_result['valid']:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Interaction requirements not met',
|
||||||
|
'errors': validation_result['errors']
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# インタラクション処理
|
||||||
|
interaction_result = self._process_interaction(location, data)
|
||||||
|
|
||||||
|
# ポイント計算
|
||||||
|
from .location_interaction import get_point_calculation
|
||||||
|
point_info = get_point_calculation(location, interaction_result)
|
||||||
|
|
||||||
|
# チェックイン記録保存
|
||||||
|
checkin_record = self._save_checkin_record(
|
||||||
|
request.user, location, user_lat, user_lon,
|
||||||
|
interaction_result, point_info
|
||||||
|
)
|
||||||
|
|
||||||
|
# レスポンス
|
||||||
|
response_data = {
|
||||||
|
'success': True,
|
||||||
|
'checkin_id': checkin_record.id,
|
||||||
|
'points_awarded': point_info['points_awarded'],
|
||||||
|
'point_type': point_info['point_type'],
|
||||||
|
'message': point_info['message'],
|
||||||
|
'location_name': location.location_name,
|
||||||
|
'interaction_type': location.evaluation_value or "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
# インタラクション結果の詳細を追加
|
||||||
|
if interaction_result:
|
||||||
|
response_data['interaction_result'] = interaction_result
|
||||||
|
|
||||||
|
return JsonResponse(response_data)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Invalid JSON data'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Checkin error: {e}")
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Internal server error'
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
def _is_within_checkin_radius(self, location, user_lat, user_lon):
|
||||||
|
"""チェックイン範囲内かどうかを判定"""
|
||||||
|
from math import radians, cos, sin, asin, sqrt
|
||||||
|
|
||||||
|
# ロケーションの座標を取得
|
||||||
|
if location.geom and location.geom.coords:
|
||||||
|
loc_lon, loc_lat = location.geom.coords[0][:2]
|
||||||
|
else:
|
||||||
|
loc_lat = location.latitude
|
||||||
|
loc_lon = location.longitude
|
||||||
|
|
||||||
|
if not all([loc_lat, loc_lon]):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Haversine公式で距離計算
|
||||||
|
def haversine(lon1, lat1, lon2, lat2):
|
||||||
|
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
||||||
|
c = 2 * asin(sqrt(a))
|
||||||
|
r = 6371000 # 地球の半径(メートル)
|
||||||
|
return c * r
|
||||||
|
|
||||||
|
distance = haversine(loc_lon, loc_lat, user_lon, user_lat)
|
||||||
|
allowed_radius = location.checkin_radius or 15.0
|
||||||
|
|
||||||
|
return distance <= allowed_radius
|
||||||
|
|
||||||
|
def _process_interaction(self, location, data):
|
||||||
|
"""evaluation_valueに基づくインタラクション処理"""
|
||||||
|
evaluation_value = location.evaluation_value or "0"
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
if evaluation_value == "1":
|
||||||
|
# 写真撮影処理
|
||||||
|
photo_data = data.get('photo')
|
||||||
|
if photo_data:
|
||||||
|
result['photo_saved'] = True
|
||||||
|
result['photo_filename'] = self._save_photo(photo_data, location)
|
||||||
|
|
||||||
|
elif evaluation_value == "2":
|
||||||
|
# QRコード + クイズ処理
|
||||||
|
qr_data = data.get('qr_code_data')
|
||||||
|
quiz_answer = data.get('quiz_answer')
|
||||||
|
|
||||||
|
if qr_data and quiz_answer:
|
||||||
|
result['qr_scanned'] = True
|
||||||
|
result['quiz_answer'] = quiz_answer
|
||||||
|
result['quiz_correct'] = self._check_quiz_answer(qr_data, quiz_answer)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _save_photo(self, photo_data, location):
|
||||||
|
"""写真データを保存(実装は要調整)"""
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from django.conf import settings
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Base64デコード
|
||||||
|
photo_binary = base64.b64decode(photo_data)
|
||||||
|
|
||||||
|
# ファイル名生成
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
filename = f"checkin_{location.id}_{timestamp}.jpg"
|
||||||
|
|
||||||
|
# 保存先ディレクトリ
|
||||||
|
photo_dir = os.path.join(settings.MEDIA_ROOT, 'checkin_photos')
|
||||||
|
os.makedirs(photo_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# ファイル保存
|
||||||
|
file_path = os.path.join(photo_dir, filename)
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(photo_binary)
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Photo save error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_quiz_answer(self, qr_data, quiz_answer):
|
||||||
|
"""クイズ回答の正答チェック(実装は要調整)"""
|
||||||
|
# QRコードデータから正答を取得
|
||||||
|
# 実際の実装では、QRコードに含まれるクイズIDから正答を取得
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
qr_info = json.loads(qr_data)
|
||||||
|
correct_answer = qr_info.get('correct_answer', '').lower()
|
||||||
|
user_answer = quiz_answer.lower().strip()
|
||||||
|
|
||||||
|
return correct_answer == user_answer
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
# QRデータの形式が不正な場合はデフォルトで不正解
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _save_checkin_record(self, user, location, lat, lon, interaction_result, point_info):
|
||||||
|
"""チェックイン記録を保存"""
|
||||||
|
from .models import Useractions
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Useractionsレコード作成/更新
|
||||||
|
checkin_record, created = Useractions.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
location=location,
|
||||||
|
defaults={
|
||||||
|
'checkin': True,
|
||||||
|
'created_at': datetime.now(),
|
||||||
|
'last_updated_at': datetime.now()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
checkin_record.checkin = True
|
||||||
|
checkin_record.last_updated_at = datetime.now()
|
||||||
|
checkin_record.save()
|
||||||
|
|
||||||
|
return checkin_record
|
||||||
192
rog/location_interaction.py
Normal file
192
rog/location_interaction.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Location evaluation_value に基づく処理ロジック
|
||||||
|
|
||||||
|
evaluation_value の値に応じた処理を定義:
|
||||||
|
- 0: 通常ポイント (通常のチェックイン)
|
||||||
|
- 1: 写真撮影 + 買い物ポイント
|
||||||
|
- 2: QRコードスキャン + クイズ回答
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class LocationInteractionType:
|
||||||
|
"""ロケーションインタラクションタイプの定数"""
|
||||||
|
NORMAL_CHECKIN = "0"
|
||||||
|
PHOTO_SHOPPING = "1"
|
||||||
|
QR_QUIZ = "2"
|
||||||
|
|
||||||
|
CHOICES = [
|
||||||
|
(NORMAL_CHECKIN, _("通常ポイント")),
|
||||||
|
(PHOTO_SHOPPING, _("写真撮影 + 買い物ポイント")),
|
||||||
|
(QR_QUIZ, _("QRコードスキャン + クイズ回答")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_interaction_type(location):
|
||||||
|
"""
|
||||||
|
Locationオブジェクトから適切なインタラクションタイプを取得
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: Locationモデルのインスタンス
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: インタラクション情報
|
||||||
|
"""
|
||||||
|
evaluation_value = location.evaluation_value or "0"
|
||||||
|
|
||||||
|
interaction_info = {
|
||||||
|
'type': evaluation_value,
|
||||||
|
'requires_photo': False,
|
||||||
|
'requires_qr_code': False,
|
||||||
|
'point_type': 'checkin',
|
||||||
|
'description': '',
|
||||||
|
'instructions': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation_value == LocationInteractionType.NORMAL_CHECKIN:
|
||||||
|
interaction_info.update({
|
||||||
|
'point_type': 'checkin',
|
||||||
|
'description': '通常のチェックイン',
|
||||||
|
'instructions': 'この場所でチェックインしてポイントを獲得してください',
|
||||||
|
})
|
||||||
|
|
||||||
|
elif evaluation_value == LocationInteractionType.PHOTO_SHOPPING:
|
||||||
|
interaction_info.update({
|
||||||
|
'requires_photo': True,
|
||||||
|
'point_type': 'buy',
|
||||||
|
'description': '写真撮影 + 買い物ポイント',
|
||||||
|
'instructions': '商品の写真を撮影してください。買い物をすることでポイントを獲得できます',
|
||||||
|
})
|
||||||
|
|
||||||
|
elif evaluation_value == LocationInteractionType.QR_QUIZ:
|
||||||
|
interaction_info.update({
|
||||||
|
'requires_qr_code': True,
|
||||||
|
'point_type': 'quiz',
|
||||||
|
'description': 'QRコードスキャン + クイズ回答',
|
||||||
|
'instructions': 'QRコードをスキャンしてクイズに答えてください',
|
||||||
|
})
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 未知の値の場合はデフォルト処理
|
||||||
|
interaction_info.update({
|
||||||
|
'point_type': 'checkin',
|
||||||
|
'description': '通常のチェックイン',
|
||||||
|
'instructions': 'この場所でチェックインしてポイントを獲得してください',
|
||||||
|
})
|
||||||
|
|
||||||
|
return interaction_info
|
||||||
|
|
||||||
|
|
||||||
|
def should_use_qr_code(location):
|
||||||
|
"""
|
||||||
|
ロケーションでQRコードを使用すべきかを判定
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: Locationモデルのインスタンス
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: QRコード使用フラグ
|
||||||
|
"""
|
||||||
|
# use_qr_codeフラグが設定されている場合、またはevaluation_value=2の場合
|
||||||
|
return (getattr(location, 'use_qr_code', False) or
|
||||||
|
location.evaluation_value == LocationInteractionType.QR_QUIZ)
|
||||||
|
|
||||||
|
|
||||||
|
def get_point_calculation(location, interaction_result=None):
|
||||||
|
"""
|
||||||
|
ロケーションでのポイント計算
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: Locationモデルのインスタンス
|
||||||
|
interaction_result: インタラクション結果 (写真、クイズ回答など)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: ポイント情報
|
||||||
|
"""
|
||||||
|
evaluation_value = location.evaluation_value or "0"
|
||||||
|
base_checkin_point = location.checkin_point or 10
|
||||||
|
buy_point = location.buy_point or 0
|
||||||
|
|
||||||
|
point_info = {
|
||||||
|
'points_awarded': 0,
|
||||||
|
'point_type': 'checkin',
|
||||||
|
'bonus_applied': False,
|
||||||
|
'message': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation_value == LocationInteractionType.NORMAL_CHECKIN:
|
||||||
|
# 通常ポイント
|
||||||
|
point_info.update({
|
||||||
|
'points_awarded': base_checkin_point,
|
||||||
|
'point_type': 'checkin',
|
||||||
|
'message': f'チェックインポイント {base_checkin_point}pt を獲得しました!',
|
||||||
|
})
|
||||||
|
|
||||||
|
elif evaluation_value == LocationInteractionType.PHOTO_SHOPPING:
|
||||||
|
# 写真撮影 + 買い物ポイント
|
||||||
|
total_points = base_checkin_point + buy_point
|
||||||
|
point_info.update({
|
||||||
|
'points_awarded': total_points,
|
||||||
|
'point_type': 'buy',
|
||||||
|
'bonus_applied': True,
|
||||||
|
'message': f'写真撮影ボーナス込みで {total_points}pt を獲得しました! (基本: {base_checkin_point}pt + ボーナス: {buy_point}pt)',
|
||||||
|
})
|
||||||
|
|
||||||
|
elif evaluation_value == LocationInteractionType.QR_QUIZ:
|
||||||
|
# QRクイズの場合、正答によってポイントが変わる
|
||||||
|
if interaction_result and interaction_result.get('quiz_correct', False):
|
||||||
|
bonus_points = 20 # クイズ正答ボーナス
|
||||||
|
total_points = base_checkin_point + bonus_points
|
||||||
|
point_info.update({
|
||||||
|
'points_awarded': total_points,
|
||||||
|
'point_type': 'quiz',
|
||||||
|
'bonus_applied': True,
|
||||||
|
'message': f'クイズ正答ボーナス込みで {total_points}pt を獲得しました! (基本: {base_checkin_point}pt + ボーナス: {bonus_points}pt)',
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 不正解またはクイズ未実施
|
||||||
|
point_info.update({
|
||||||
|
'points_awarded': base_checkin_point,
|
||||||
|
'point_type': 'checkin',
|
||||||
|
'message': f'基本ポイント {base_checkin_point}pt を獲得しました',
|
||||||
|
})
|
||||||
|
|
||||||
|
return point_info
|
||||||
|
|
||||||
|
|
||||||
|
def validate_interaction_requirements(location, request_data):
|
||||||
|
"""
|
||||||
|
インタラクション要件の検証
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: Locationモデルのインスタンス
|
||||||
|
request_data: リクエストデータ
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 検証結果
|
||||||
|
"""
|
||||||
|
evaluation_value = location.evaluation_value or "0"
|
||||||
|
validation_result = {
|
||||||
|
'valid': True,
|
||||||
|
'errors': [],
|
||||||
|
'warnings': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation_value == LocationInteractionType.PHOTO_SHOPPING:
|
||||||
|
# 写真が必要
|
||||||
|
if not request_data.get('photo'):
|
||||||
|
validation_result['valid'] = False
|
||||||
|
validation_result['errors'].append('写真の撮影が必要です')
|
||||||
|
|
||||||
|
elif evaluation_value == LocationInteractionType.QR_QUIZ:
|
||||||
|
# QRコードスキャンとクイズ回答が必要
|
||||||
|
if not request_data.get('qr_code_data'):
|
||||||
|
validation_result['valid'] = False
|
||||||
|
validation_result['errors'].append('QRコードのスキャンが必要です')
|
||||||
|
|
||||||
|
if not request_data.get('quiz_answer'):
|
||||||
|
validation_result['valid'] = False
|
||||||
|
validation_result['errors'].append('クイズの回答が必要です')
|
||||||
|
|
||||||
|
return validation_result
|
||||||
265
rog/models.py
265
rog/models.py
@ -6,6 +6,11 @@ from pyexpat import model
|
|||||||
from sre_constants import CH_LOCALE
|
from sre_constants import CH_LOCALE
|
||||||
from typing import ChainMap
|
from typing import ChainMap
|
||||||
from django.contrib.gis.db import models
|
from django.contrib.gis.db import models
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
try:
|
||||||
|
from django.db.models import JSONField
|
||||||
|
except ImportError:
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models.signals import post_save, post_delete, pre_save
|
from django.db.models.signals import post_save, post_delete, pre_save
|
||||||
@ -308,6 +313,210 @@ class TempUser(models.Model):
|
|||||||
def is_valid(self):
|
def is_valid(self):
|
||||||
return timezone.now() <= self.expires_at
|
return timezone.now() <= self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersion(models.Model):
|
||||||
|
"""アプリバージョン管理モデル"""
|
||||||
|
|
||||||
|
PLATFORM_CHOICES = [
|
||||||
|
('android', 'Android'),
|
||||||
|
('ios', 'iOS'),
|
||||||
|
]
|
||||||
|
|
||||||
|
version = models.CharField(max_length=20, help_text="セマンティックバージョン (1.2.3)")
|
||||||
|
platform = models.CharField(max_length=10, choices=PLATFORM_CHOICES)
|
||||||
|
build_number = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
is_latest = models.BooleanField(default=False, help_text="最新版フラグ")
|
||||||
|
is_required = models.BooleanField(default=False, help_text="強制更新フラグ")
|
||||||
|
update_message = models.TextField(blank=True, null=True, help_text="ユーザー向け更新メッセージ")
|
||||||
|
download_url = models.URLField(blank=True, null=True, help_text="アプリストアURL")
|
||||||
|
release_date = models.DateTimeField(default=timezone.now)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'app_versions'
|
||||||
|
unique_together = ['version', 'platform']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['platform'], name='idx_app_versions_platform'),
|
||||||
|
models.Index(
|
||||||
|
fields=['is_latest'],
|
||||||
|
condition=models.Q(is_latest=True),
|
||||||
|
name='idx_app_versions_latest_true'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.platform} {self.version}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""最新版フラグが設定された場合、同一プラットフォームの他のバージョンを非最新にする"""
|
||||||
|
if self.is_latest:
|
||||||
|
AppVersion.objects.filter(
|
||||||
|
platform=self.platform,
|
||||||
|
is_latest=True
|
||||||
|
).exclude(pk=self.pk).update(is_latest=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compare_versions(cls, version1, version2):
|
||||||
|
"""セマンティックバージョンの比較"""
|
||||||
|
def version_tuple(v):
|
||||||
|
return tuple(map(int, v.split('.')))
|
||||||
|
|
||||||
|
v1 = version_tuple(version1)
|
||||||
|
v2 = version_tuple(version2)
|
||||||
|
|
||||||
|
if v1 < v2:
|
||||||
|
return -1
|
||||||
|
elif v1 > v2:
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_latest_version(cls, platform):
|
||||||
|
"""指定プラットフォームの最新バージョンを取得"""
|
||||||
|
try:
|
||||||
|
return cls.objects.filter(platform=platform, is_latest=True).first()
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CheckinExtended(models.Model):
|
||||||
|
"""チェックイン拡張情報モデル"""
|
||||||
|
|
||||||
|
VALIDATION_STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
('requires_review', 'Requires Review'),
|
||||||
|
]
|
||||||
|
|
||||||
|
gpslog = models.ForeignKey('GpsCheckin', on_delete=models.CASCADE, related_name='extended_info')
|
||||||
|
|
||||||
|
# GPS拡張情報
|
||||||
|
gps_latitude = models.DecimalField(max_digits=10, decimal_places=8, null=True, blank=True)
|
||||||
|
gps_longitude = models.DecimalField(max_digits=11, decimal_places=8, null=True, blank=True)
|
||||||
|
gps_accuracy = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True, help_text="GPS精度(メートル)")
|
||||||
|
gps_timestamp = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# カメラメタデータ
|
||||||
|
camera_capture_time = models.DateTimeField(null=True, blank=True)
|
||||||
|
device_info = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# 審査・検証情報
|
||||||
|
validation_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=VALIDATION_STATUS_CHOICES,
|
||||||
|
default='pending'
|
||||||
|
)
|
||||||
|
validation_comment = models.TextField(blank=True, null=True)
|
||||||
|
validated_by = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
validated_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# スコア情報
|
||||||
|
bonus_points = models.IntegerField(default=0)
|
||||||
|
scoring_breakdown = JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
# システム情報
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'rog_checkin_extended'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['validation_status'], name='idx_checkin_ext_valid'),
|
||||||
|
models.Index(fields=['created_at'], name='idx_checkin_ext_created'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"CheckinExtended {self.gpslog_id} - {self.validation_status}"
|
||||||
|
|
||||||
|
|
||||||
|
class UploadedImage(models.Model):
|
||||||
|
"""画像アップロード管理モデル - マルチアップロード対応"""
|
||||||
|
|
||||||
|
UPLOAD_SOURCE_CHOICES = [
|
||||||
|
('direct', 'Direct'),
|
||||||
|
('sharing_intent', 'Sharing Intent'),
|
||||||
|
('bulk_upload', 'Bulk Upload'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PLATFORM_CHOICES = [
|
||||||
|
('ios', 'iOS'),
|
||||||
|
('android', 'Android'),
|
||||||
|
('web', 'Web'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROCESSING_STATUS_CHOICES = [
|
||||||
|
('uploaded', 'Uploaded'),
|
||||||
|
('processing', 'Processing'),
|
||||||
|
('processed', 'Processed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
MIME_TYPE_CHOICES = [
|
||||||
|
('image/jpeg', 'JPEG'),
|
||||||
|
('image/png', 'PNG'),
|
||||||
|
('image/heic', 'HEIC'),
|
||||||
|
('image/webp', 'WebP'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 基本情報
|
||||||
|
original_filename = models.CharField(max_length=255)
|
||||||
|
server_filename = models.CharField(max_length=255, unique=True)
|
||||||
|
file_url = models.URLField()
|
||||||
|
file_size = models.BigIntegerField()
|
||||||
|
mime_type = models.CharField(max_length=50, choices=MIME_TYPE_CHOICES)
|
||||||
|
|
||||||
|
# 関連情報
|
||||||
|
event_code = models.CharField(max_length=50, blank=True, null=True)
|
||||||
|
team_name = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
cp_number = models.IntegerField(blank=True, null=True)
|
||||||
|
|
||||||
|
# アップロード情報
|
||||||
|
upload_source = models.CharField(max_length=50, choices=UPLOAD_SOURCE_CHOICES, default='direct')
|
||||||
|
device_platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES, blank=True, null=True)
|
||||||
|
|
||||||
|
# メタデータ
|
||||||
|
capture_timestamp = models.DateTimeField(blank=True, null=True)
|
||||||
|
upload_timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
device_info = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# 処理状況
|
||||||
|
processing_status = models.CharField(max_length=20, choices=PROCESSING_STATUS_CHOICES, default='uploaded')
|
||||||
|
thumbnail_url = models.URLField(blank=True, null=True)
|
||||||
|
|
||||||
|
# 外部キー
|
||||||
|
gpslog = models.ForeignKey('GpsCheckin', on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
entry = models.ForeignKey('Entry', on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
|
# システム情報
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'rog_uploaded_images'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['event_code', 'team_name'], name='idx_uploaded_event_team'),
|
||||||
|
models.Index(fields=['cp_number'], name='idx_uploaded_cp_number'),
|
||||||
|
models.Index(fields=['upload_timestamp'], name='idx_uploaded_timestamp'),
|
||||||
|
models.Index(fields=['processing_status'], name='idx_uploaded_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.original_filename} - {self.event_code} - CP{self.cp_number}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""バリデーション"""
|
||||||
|
if self.file_size and (self.file_size <= 0 or self.file_size > 10485760): # 10MB
|
||||||
|
raise ValidationError("ファイルサイズは10MB以下である必要があります")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_size_mb(self):
|
||||||
|
"""ファイルサイズをMB単位で取得"""
|
||||||
|
return round(self.file_size / 1024 / 1024, 2) if self.file_size else 0
|
||||||
|
|
||||||
|
|
||||||
class NewEvent2(models.Model):
|
class NewEvent2(models.Model):
|
||||||
# 既存フィールド
|
# 既存フィールド
|
||||||
event_name = models.CharField(max_length=255, unique=True)
|
event_name = models.CharField(max_length=255, unique=True)
|
||||||
@ -318,6 +527,21 @@ class NewEvent2(models.Model):
|
|||||||
|
|
||||||
#// Added @2024-10-21
|
#// Added @2024-10-21
|
||||||
public = models.BooleanField(default=False)
|
public = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Status field for enhanced event management (2025-08-27)
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('public', 'Public'),
|
||||||
|
('private', 'Private'),
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
]
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='draft',
|
||||||
|
help_text="イベントステータス"
|
||||||
|
)
|
||||||
|
|
||||||
hour_3 = models.BooleanField(default=False)
|
hour_3 = models.BooleanField(default=False)
|
||||||
hour_5 = models.BooleanField(default=True)
|
hour_5 = models.BooleanField(default=True)
|
||||||
class_general = models.BooleanField(default=True)
|
class_general = models.BooleanField(default=True)
|
||||||
@ -344,8 +568,33 @@ class NewEvent2(models.Model):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.deadlineDateTime:
|
if not self.deadlineDateTime:
|
||||||
self.deadlineDateTime = self.end_datetime #- timedelta(days=7)
|
self.deadlineDateTime = self.end_datetime #- timedelta(days=7)
|
||||||
|
|
||||||
|
# publicフィールドからstatusフィールドへの自動移行
|
||||||
|
if self.pk is None and self.status == 'draft': # 新規作成時
|
||||||
|
if self.public:
|
||||||
|
self.status = 'public'
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deadline_datetime(self):
|
||||||
|
"""API応答用のフィールド名統一"""
|
||||||
|
return self.deadlineDateTime
|
||||||
|
|
||||||
|
def is_accessible_by_user(self, user):
|
||||||
|
"""ユーザーがこのイベントにアクセス可能かチェック"""
|
||||||
|
if self.status == 'public':
|
||||||
|
return True
|
||||||
|
elif self.status == 'private':
|
||||||
|
# スタッフ権限チェック(後で実装)
|
||||||
|
return hasattr(user, 'staff_privileges') and user.staff_privileges
|
||||||
|
elif self.status == 'draft':
|
||||||
|
# ドラフトは管理者のみ
|
||||||
|
return user.is_staff or user.is_superuser
|
||||||
|
elif self.status == 'closed':
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
class NewEvent(models.Model):
|
class NewEvent(models.Model):
|
||||||
event_name = models.CharField(max_length=255, primary_key=True)
|
event_name = models.CharField(max_length=255, primary_key=True)
|
||||||
start_datetime = models.DateTimeField(default=timezone.now)
|
start_datetime = models.DateTimeField(default=timezone.now)
|
||||||
@ -461,6 +710,22 @@ class Entry(models.Model):
|
|||||||
hasParticipated = models.BooleanField(default=False) # 新しく追加
|
hasParticipated = models.BooleanField(default=False) # 新しく追加
|
||||||
hasGoaled = models.BooleanField(default=False) # 新しく追加
|
hasGoaled = models.BooleanField(default=False) # 新しく追加
|
||||||
|
|
||||||
|
# API変更要求書対応: スタッフ権限管理 (2025-08-27)
|
||||||
|
staff_privileges = models.BooleanField(default=False, help_text="スタッフ権限フラグ")
|
||||||
|
can_access_private_events = models.BooleanField(default=False, help_text="非公開イベント参加権限")
|
||||||
|
|
||||||
|
VALIDATION_STATUS_CHOICES = [
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
]
|
||||||
|
team_validation_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=VALIDATION_STATUS_CHOICES,
|
||||||
|
default='approved',
|
||||||
|
help_text="チーム承認状況"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('zekken_number', 'event','date')
|
unique_together = ('zekken_number', 'event','date')
|
||||||
|
|||||||
424
rog/multi_image_upload_views.py
Normal file
424
rog/multi_image_upload_views.py
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
Multi Image Upload API Views
|
||||||
|
複数画像一括アップロード機能
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.db import transaction
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
from .models import UploadedImage, NewEvent2, Entry
|
||||||
|
from .serializers import (
|
||||||
|
MultiImageUploadSerializer,
|
||||||
|
MultiImageUploadResponseSerializer,
|
||||||
|
UploadedImageSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def multi_image_upload(request):
|
||||||
|
"""
|
||||||
|
複数画像一括アップロードAPI
|
||||||
|
|
||||||
|
POST /api/images/multi-upload
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"team_name": "チーム名",
|
||||||
|
"cp_number": 1,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"file_data": "base64_encoded_image_data",
|
||||||
|
"filename": "checkpoint1_photo1.jpg",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_size": 2048576,
|
||||||
|
"capture_timestamp": "2025-09-15T11:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"upload_source": "sharing_intent",
|
||||||
|
"device_platform": "ios"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# リクエストデータ検証
|
||||||
|
serializer = MultiImageUploadSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid request data',
|
||||||
|
'errors': serializer.errors
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
event_code = validated_data['event_code']
|
||||||
|
team_name = validated_data['team_name']
|
||||||
|
cp_number = validated_data['cp_number']
|
||||||
|
images_data = validated_data['images']
|
||||||
|
upload_source = validated_data.get('upload_source', 'direct')
|
||||||
|
device_platform = validated_data.get('device_platform')
|
||||||
|
|
||||||
|
# イベントの存在確認
|
||||||
|
try:
|
||||||
|
event = NewEvent2.objects.get(event_name=event_code)
|
||||||
|
except NewEvent2.DoesNotExist:
|
||||||
|
return Response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'イベント "{event_code}" が見つかりません'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# エントリーの存在確認
|
||||||
|
try:
|
||||||
|
entry = Entry.objects.filter(
|
||||||
|
event=event,
|
||||||
|
team__team_name=team_name
|
||||||
|
).first()
|
||||||
|
except Entry.DoesNotExist:
|
||||||
|
entry = None
|
||||||
|
|
||||||
|
uploaded_files = []
|
||||||
|
failed_files = []
|
||||||
|
total_upload_size = 0
|
||||||
|
|
||||||
|
# トランザクション開始
|
||||||
|
with transaction.atomic():
|
||||||
|
for i, image_data in enumerate(images_data):
|
||||||
|
try:
|
||||||
|
uploaded_image = _process_single_image(
|
||||||
|
image_data,
|
||||||
|
event_code,
|
||||||
|
team_name,
|
||||||
|
cp_number,
|
||||||
|
upload_source,
|
||||||
|
device_platform,
|
||||||
|
entry,
|
||||||
|
i
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded_files.append({
|
||||||
|
'original_filename': uploaded_image.original_filename,
|
||||||
|
'server_filename': uploaded_image.server_filename,
|
||||||
|
'file_url': uploaded_image.file_url,
|
||||||
|
'file_size': uploaded_image.file_size
|
||||||
|
})
|
||||||
|
|
||||||
|
total_upload_size += uploaded_image.file_size
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to process image {i}: {e}")
|
||||||
|
failed_files.append({
|
||||||
|
'filename': image_data.get('filename', f'image_{i}'),
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 処理時間計算
|
||||||
|
processing_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# レスポンス作成
|
||||||
|
response_data = {
|
||||||
|
'status': 'success' if not failed_files else 'partial_success',
|
||||||
|
'uploaded_count': len(uploaded_files),
|
||||||
|
'failed_count': len(failed_files),
|
||||||
|
'uploaded_files': uploaded_files,
|
||||||
|
'failed_files': failed_files,
|
||||||
|
'total_upload_size': total_upload_size,
|
||||||
|
'processing_time_ms': processing_time_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed_files:
|
||||||
|
response_data['message'] = f"{len(uploaded_files)}個のファイルがアップロードされ、{len(failed_files)}個が失敗しました"
|
||||||
|
else:
|
||||||
|
response_data['message'] = f"{len(uploaded_files)}個のファイルが正常にアップロードされました"
|
||||||
|
|
||||||
|
return Response(response_data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Multi image upload error: {e}")
|
||||||
|
return Response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'サーバーエラーが発生しました',
|
||||||
|
'error_details': str(e)
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def image_list(request):
|
||||||
|
"""
|
||||||
|
アップロード済み画像一覧取得
|
||||||
|
|
||||||
|
GET /api/images/list/
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- entry_id: エントリーID(オプション)
|
||||||
|
- event_code: イベントコード(オプション)
|
||||||
|
- limit: 取得数上限(デフォルト50)
|
||||||
|
- offset: オフセット(デフォルト0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry_id = request.GET.get('entry_id')
|
||||||
|
event_code = request.GET.get('event_code')
|
||||||
|
limit = int(request.GET.get('limit', 50))
|
||||||
|
offset = int(request.GET.get('offset', 0))
|
||||||
|
|
||||||
|
# 基本クエリ
|
||||||
|
queryset = UploadedImage.objects.all()
|
||||||
|
|
||||||
|
# フィルタリング
|
||||||
|
if entry_id:
|
||||||
|
queryset = queryset.filter(entry_id=entry_id)
|
||||||
|
if event_code:
|
||||||
|
queryset = queryset.filter(entry__event_name=event_code)
|
||||||
|
|
||||||
|
# 並び順と取得数制限
|
||||||
|
queryset = queryset.order_by('-uploaded_at')[offset:offset+limit]
|
||||||
|
|
||||||
|
# シリアライズ
|
||||||
|
serializer = UploadedImageSerializer(queryset, many=True)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'images': serializer.data,
|
||||||
|
'count': len(serializer.data),
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Image list error: {e}")
|
||||||
|
return Response({
|
||||||
|
'error': 'Failed to get image list',
|
||||||
|
'message': str(e)
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET', 'DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def image_detail(request, image_id):
|
||||||
|
"""
|
||||||
|
画像詳細取得・削除
|
||||||
|
|
||||||
|
GET /api/images/{image_id}/ - 画像詳細取得
|
||||||
|
DELETE /api/images/{image_id}/ - 画像削除
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = UploadedImage.objects.get(id=image_id)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
serializer = UploadedImageSerializer(image)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
elif request.method == 'DELETE':
|
||||||
|
# ファイル削除
|
||||||
|
if image.image_file and os.path.exists(image.image_file.path):
|
||||||
|
os.remove(image.image_file.path)
|
||||||
|
if image.thumbnail and os.path.exists(image.thumbnail.path):
|
||||||
|
os.remove(image.thumbnail.path)
|
||||||
|
|
||||||
|
# データベースレコード削除
|
||||||
|
image.delete()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Image deleted successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
except UploadedImage.DoesNotExist:
|
||||||
|
return Response({
|
||||||
|
'error': 'Image not found'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Image detail error: {e}")
|
||||||
|
return Response({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': str(e)
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_single_image(image_data, event_code, team_name, cp_number,
|
||||||
|
upload_source, device_platform, entry, index):
|
||||||
|
"""単一画像の処理"""
|
||||||
|
|
||||||
|
# Base64デコード
|
||||||
|
try:
|
||||||
|
if ',' in image_data['file_data']:
|
||||||
|
# data:image/jpeg;base64,... 形式の場合
|
||||||
|
file_data = image_data['file_data'].split(',')[1]
|
||||||
|
else:
|
||||||
|
file_data = image_data['file_data']
|
||||||
|
|
||||||
|
image_binary = base64.b64decode(file_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Base64デコードに失敗しました: {e}")
|
||||||
|
|
||||||
|
# ファイル名生成
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
file_extension = _get_file_extension(image_data['mime_type'])
|
||||||
|
server_filename = f"{event_code}_{team_name}_cp{cp_number}_{timestamp}_{index:03d}{file_extension}"
|
||||||
|
|
||||||
|
# ディレクトリ作成
|
||||||
|
upload_dir = f"uploads/{datetime.now().strftime('%Y/%m/%d')}"
|
||||||
|
full_upload_dir = os.path.join(settings.MEDIA_ROOT, upload_dir)
|
||||||
|
os.makedirs(full_upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# ファイル保存
|
||||||
|
file_path = os.path.join(upload_dir, server_filename)
|
||||||
|
full_file_path = os.path.join(settings.MEDIA_ROOT, file_path)
|
||||||
|
|
||||||
|
with open(full_file_path, 'wb') as f:
|
||||||
|
f.write(image_binary)
|
||||||
|
|
||||||
|
# ファイルURL生成
|
||||||
|
file_url = f"{settings.MEDIA_URL}{file_path}"
|
||||||
|
|
||||||
|
# HEICからJPEGへの変換(iOS対応)
|
||||||
|
if image_data['mime_type'] == 'image/heic' and device_platform == 'ios':
|
||||||
|
try:
|
||||||
|
file_url, server_filename = _convert_heic_to_jpeg(full_file_path, file_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"HEIC conversion failed: {e}")
|
||||||
|
|
||||||
|
# サムネイル生成
|
||||||
|
thumbnail_url = _generate_thumbnail(full_file_path, file_path)
|
||||||
|
|
||||||
|
# データベース保存
|
||||||
|
uploaded_image = UploadedImage.objects.create(
|
||||||
|
original_filename=image_data['filename'],
|
||||||
|
server_filename=server_filename,
|
||||||
|
file_url=file_url,
|
||||||
|
file_size=image_data['file_size'],
|
||||||
|
mime_type=image_data['mime_type'],
|
||||||
|
event_code=event_code,
|
||||||
|
team_name=team_name,
|
||||||
|
cp_number=cp_number,
|
||||||
|
upload_source=upload_source,
|
||||||
|
device_platform=device_platform,
|
||||||
|
capture_timestamp=image_data.get('capture_timestamp'),
|
||||||
|
device_info=image_data.get('device_info'),
|
||||||
|
processing_status='processed',
|
||||||
|
thumbnail_url=thumbnail_url,
|
||||||
|
entry=entry
|
||||||
|
)
|
||||||
|
|
||||||
|
return uploaded_image
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_extension(mime_type):
|
||||||
|
"""MIMEタイプからファイル拡張子を取得"""
|
||||||
|
mime_to_ext = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/heic': '.heic',
|
||||||
|
'image/webp': '.webp'
|
||||||
|
}
|
||||||
|
return mime_to_ext.get(mime_type, '.jpg')
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_heic_to_jpeg(heic_path, original_path):
|
||||||
|
"""HEICファイルをJPEGに変換"""
|
||||||
|
try:
|
||||||
|
# PIL-HEICライブラリが必要(要インストール)
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# HEICファイルを開いてJPEGで保存
|
||||||
|
with Image.open(heic_path) as img:
|
||||||
|
jpeg_path = heic_path.replace('.heic', '.jpg')
|
||||||
|
rgb_img = img.convert('RGB')
|
||||||
|
rgb_img.save(jpeg_path, 'JPEG', quality=85)
|
||||||
|
|
||||||
|
# 元のHEICファイルを削除
|
||||||
|
os.remove(heic_path)
|
||||||
|
|
||||||
|
# 新しいファイル情報を返す
|
||||||
|
new_file_path = original_path.replace('.heic', '.jpg')
|
||||||
|
new_file_url = f"{settings.MEDIA_URL}{new_file_path}"
|
||||||
|
new_filename = os.path.basename(new_file_path)
|
||||||
|
|
||||||
|
return new_file_url, new_filename
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("PIL-HEIC not available, keeping original HEIC file")
|
||||||
|
return f"{settings.MEDIA_URL}{original_path}", os.path.basename(original_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_thumbnail(image_path, original_path):
|
||||||
|
"""サムネイル画像生成"""
|
||||||
|
try:
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
# サムネイルサイズ(300x300)
|
||||||
|
img.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# サムネイルファイル名
|
||||||
|
path_parts = original_path.split('.')
|
||||||
|
thumbnail_path = f"{'.'.join(path_parts[:-1])}_thumb.{path_parts[-1]}"
|
||||||
|
thumbnail_full_path = os.path.join(settings.MEDIA_ROOT, thumbnail_path)
|
||||||
|
|
||||||
|
# サムネイル保存
|
||||||
|
img.save(thumbnail_full_path, quality=75)
|
||||||
|
|
||||||
|
return f"{settings.MEDIA_URL}{thumbnail_path}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Thumbnail generation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def uploaded_images_list(request):
|
||||||
|
"""アップロード済み画像一覧取得"""
|
||||||
|
|
||||||
|
event_code = request.GET.get('event_code')
|
||||||
|
team_name = request.GET.get('team_name')
|
||||||
|
cp_number = request.GET.get('cp_number')
|
||||||
|
|
||||||
|
queryset = UploadedImage.objects.all().order_by('-upload_timestamp')
|
||||||
|
|
||||||
|
# フィルタリング
|
||||||
|
if event_code:
|
||||||
|
queryset = queryset.filter(event_code=event_code)
|
||||||
|
if team_name:
|
||||||
|
queryset = queryset.filter(team_name=team_name)
|
||||||
|
if cp_number:
|
||||||
|
queryset = queryset.filter(cp_number=cp_number)
|
||||||
|
|
||||||
|
# ページネーション(50件ずつ)
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
page_size = 50
|
||||||
|
start_index = (page - 1) * page_size
|
||||||
|
end_index = start_index + page_size
|
||||||
|
|
||||||
|
total_count = queryset.count()
|
||||||
|
images = queryset[start_index:end_index]
|
||||||
|
|
||||||
|
serializer = UploadedImageSerializer(images, many=True)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'images': serializer.data,
|
||||||
|
'pagination': {
|
||||||
|
'total_count': total_count,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'has_next': end_index < total_count,
|
||||||
|
'has_previous': page > 1
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -14,7 +14,7 @@ from django.db import transaction
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework_gis.serializers import GeoFeatureModelSerializer
|
from rest_framework_gis.serializers import GeoFeatureModelSerializer
|
||||||
from sqlalchemy.sql.functions import mode
|
from sqlalchemy.sql.functions import mode
|
||||||
from .models import Location, Location_line, Location_polygon, JpnAdminMainPerf, Useractions, GifuAreas, RogUser, UserTracks, GoalImages, CheckinImages,CustomUser,NewEvent,NewEvent2, Team, NewCategory, Category, Entry, Member, TempUser,EntryMember
|
from .models import Location, Location_line, Location_polygon, JpnAdminMainPerf, Useractions, GifuAreas, RogUser, UserTracks, GoalImages, CheckinImages,CustomUser,NewEvent,NewEvent2, Team, NewCategory, Category, Entry, Member, TempUser,EntryMember, AppVersion, UploadedImage
|
||||||
from drf_extra_fields.fields import Base64ImageField
|
from drf_extra_fields.fields import Base64ImageField
|
||||||
|
|
||||||
#from django.contrib.auth.models import User
|
#from django.contrib.auth.models import User
|
||||||
@ -37,11 +37,55 @@ class LocationCatSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class LocationSerializer(GeoFeatureModelSerializer):
|
class LocationSerializer(GeoFeatureModelSerializer):
|
||||||
|
# evaluation_valueに基づくインタラクション情報を追加
|
||||||
|
interaction_type = serializers.SerializerMethodField()
|
||||||
|
requires_photo = serializers.SerializerMethodField()
|
||||||
|
requires_qr_code = serializers.SerializerMethodField()
|
||||||
|
interaction_instructions = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model=Location
|
model=Location
|
||||||
geo_field='geom'
|
geo_field='geom'
|
||||||
fields="__all__"
|
fields="__all__"
|
||||||
|
|
||||||
|
def get_interaction_type(self, obj):
|
||||||
|
"""evaluation_valueに基づくインタラクションタイプを返す"""
|
||||||
|
try:
|
||||||
|
from .location_interaction import get_interaction_type
|
||||||
|
return get_interaction_type(obj)['type']
|
||||||
|
except ImportError:
|
||||||
|
return obj.evaluation_value or "0"
|
||||||
|
|
||||||
|
def get_requires_photo(self, obj):
|
||||||
|
"""写真撮影が必要かどうかを返す"""
|
||||||
|
try:
|
||||||
|
from .location_interaction import get_interaction_type
|
||||||
|
return get_interaction_type(obj)['requires_photo']
|
||||||
|
except ImportError:
|
||||||
|
return obj.evaluation_value == "1"
|
||||||
|
|
||||||
|
def get_requires_qr_code(self, obj):
|
||||||
|
"""QRコードスキャンが必要かどうかを返す"""
|
||||||
|
try:
|
||||||
|
from .location_interaction import should_use_qr_code
|
||||||
|
return should_use_qr_code(obj)
|
||||||
|
except ImportError:
|
||||||
|
return obj.evaluation_value == "2" or getattr(obj, 'use_qr_code', False)
|
||||||
|
|
||||||
|
def get_interaction_instructions(self, obj):
|
||||||
|
"""インタラクション手順を返す"""
|
||||||
|
try:
|
||||||
|
from .location_interaction import get_interaction_type
|
||||||
|
return get_interaction_type(obj)['instructions']
|
||||||
|
except ImportError:
|
||||||
|
evaluation_value = obj.evaluation_value or "0"
|
||||||
|
if evaluation_value == "1":
|
||||||
|
return "商品の写真を撮影してください。買い物をすることでポイントを獲得できます"
|
||||||
|
elif evaluation_value == "2":
|
||||||
|
return "QRコードをスキャンしてクイズに答えてください"
|
||||||
|
else:
|
||||||
|
return "この場所でチェックインしてポイントを獲得してください"
|
||||||
|
|
||||||
|
|
||||||
class Location_lineSerializer(GeoFeatureModelSerializer):
|
class Location_lineSerializer(GeoFeatureModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -343,9 +387,29 @@ class NewCategorySerializer(serializers.ModelSerializer):
|
|||||||
#fields = ['id','category_name', 'category_number']
|
#fields = ['id','category_name', 'category_number']
|
||||||
|
|
||||||
class NewEvent2Serializer(serializers.ModelSerializer):
|
class NewEvent2Serializer(serializers.ModelSerializer):
|
||||||
|
# API変更要求書対応: deadline_datetime フィールド追加
|
||||||
|
deadline_datetime = serializers.DateTimeField(source='deadlineDateTime', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NewEvent2
|
model = NewEvent2
|
||||||
fields = ['id','event_name', 'start_datetime', 'end_datetime', 'deadlineDateTime', 'public', 'hour_3', 'hour_5', 'class_general','class_family','class_solo_male','class_solo_female']
|
fields = [
|
||||||
|
'id', 'event_name', 'start_datetime', 'end_datetime',
|
||||||
|
'deadlineDateTime', 'deadline_datetime', 'status', 'public',
|
||||||
|
'hour_3', 'hour_5', 'class_general', 'class_family',
|
||||||
|
'class_solo_male', 'class_solo_female'
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
"""レスポンス形式を調整"""
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
|
||||||
|
# publicフィールドからstatusへの移行サポート
|
||||||
|
if not data.get('status') and data.get('public'):
|
||||||
|
data['status'] = 'public'
|
||||||
|
elif not data.get('status'):
|
||||||
|
data['status'] = 'draft'
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
class NewEventSerializer(serializers.ModelSerializer):
|
class NewEventSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -450,8 +514,13 @@ class EntrySerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Entry
|
model = Entry
|
||||||
fields = ['id','team', 'event', 'category', 'date','zekken_number','owner','is_active', 'hasParticipated', 'hasGoaled']
|
fields = [
|
||||||
read_only_fields = ['id','owner']
|
'id', 'team', 'event', 'category', 'date', 'zekken_number', 'owner',
|
||||||
|
'is_active', 'hasParticipated', 'hasGoaled',
|
||||||
|
# API変更要求書対応: 新フィールド追加
|
||||||
|
'staff_privileges', 'can_access_private_events', 'team_validation_status'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'owner']
|
||||||
|
|
||||||
def validate_date(self, value):
|
def validate_date(self, value):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@ -912,3 +981,119 @@ class LoginUserSerializer_old(serializers.Serializer):
|
|||||||
else:
|
else:
|
||||||
raise serializers.ValidationError('認証情報が正しくありません。')
|
raise serializers.ValidationError('認証情報が正しくありません。')
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersionSerializer(serializers.ModelSerializer):
|
||||||
|
"""アプリバージョン管理シリアライザー"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AppVersion
|
||||||
|
fields = [
|
||||||
|
'id', 'version', 'platform', 'build_number',
|
||||||
|
'is_latest', 'is_required', 'update_message',
|
||||||
|
'download_url', 'release_date', 'created_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersionCheckSerializer(serializers.Serializer):
|
||||||
|
"""アプリバージョンチェック用シリアライザー"""
|
||||||
|
|
||||||
|
current_version = serializers.CharField(max_length=20, help_text="現在のアプリバージョン")
|
||||||
|
platform = serializers.ChoiceField(
|
||||||
|
choices=[('android', 'Android'), ('ios', 'iOS')],
|
||||||
|
help_text="プラットフォーム"
|
||||||
|
)
|
||||||
|
build_number = serializers.CharField(max_length=20, required=False, help_text="ビルド番号")
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersionResponseSerializer(serializers.Serializer):
|
||||||
|
"""アプリバージョンチェックレスポンス用シリアライザー"""
|
||||||
|
|
||||||
|
latest_version = serializers.CharField(help_text="最新バージョン")
|
||||||
|
update_required = serializers.BooleanField(help_text="強制更新が必要かどうか")
|
||||||
|
update_available = serializers.BooleanField(help_text="更新が利用可能かどうか")
|
||||||
|
update_message = serializers.CharField(help_text="更新メッセージ")
|
||||||
|
download_url = serializers.URLField(help_text="ダウンロードURL")
|
||||||
|
release_date = serializers.DateTimeField(help_text="リリース日時")
|
||||||
|
|
||||||
|
|
||||||
|
class UploadedImageSerializer(serializers.ModelSerializer):
|
||||||
|
"""画像アップロード情報シリアライザー"""
|
||||||
|
|
||||||
|
file_size_mb = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UploadedImage
|
||||||
|
fields = [
|
||||||
|
'id', 'original_filename', 'server_filename', 'file_url',
|
||||||
|
'file_size', 'file_size_mb', 'mime_type', 'event_code',
|
||||||
|
'team_name', 'cp_number', 'upload_source', 'device_platform',
|
||||||
|
'capture_timestamp', 'upload_timestamp', 'device_info',
|
||||||
|
'processing_status', 'thumbnail_url', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'server_filename', 'file_url', 'upload_timestamp', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MultiImageUploadSerializer(serializers.Serializer):
|
||||||
|
"""マルチ画像アップロード用シリアライザー"""
|
||||||
|
|
||||||
|
event_code = serializers.CharField(max_length=50)
|
||||||
|
team_name = serializers.CharField(max_length=255)
|
||||||
|
cp_number = serializers.IntegerField()
|
||||||
|
images = serializers.ListField(
|
||||||
|
child=serializers.DictField(),
|
||||||
|
max_length=10, # 最大10ファイル
|
||||||
|
help_text="アップロードする画像情報のリスト"
|
||||||
|
)
|
||||||
|
upload_source = serializers.ChoiceField(
|
||||||
|
choices=['direct', 'sharing_intent', 'bulk_upload'],
|
||||||
|
default='direct'
|
||||||
|
)
|
||||||
|
device_platform = serializers.ChoiceField(
|
||||||
|
choices=['ios', 'android', 'web'],
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_images(self, value):
|
||||||
|
"""画像データの検証"""
|
||||||
|
if not value:
|
||||||
|
raise serializers.ValidationError("画像が指定されていません")
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for image_data in value:
|
||||||
|
# 必須フィールドチェック
|
||||||
|
required_fields = ['file_data', 'filename', 'mime_type', 'file_size']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in image_data:
|
||||||
|
raise serializers.ValidationError(f"画像データに{field}が含まれていません")
|
||||||
|
|
||||||
|
# ファイルサイズチェック
|
||||||
|
file_size = image_data.get('file_size', 0)
|
||||||
|
if file_size > 10485760: # 10MB
|
||||||
|
raise serializers.ValidationError(f"ファイル{image_data['filename']}のサイズが10MBを超えています")
|
||||||
|
|
||||||
|
total_size += file_size
|
||||||
|
|
||||||
|
# MIMEタイプチェック
|
||||||
|
allowed_types = ['image/jpeg', 'image/png', 'image/heic', 'image/webp']
|
||||||
|
if image_data.get('mime_type') not in allowed_types:
|
||||||
|
raise serializers.ValidationError(f"サポートされていないファイル形式: {image_data.get('mime_type')}")
|
||||||
|
|
||||||
|
# 合計サイズチェック(50MB)
|
||||||
|
if total_size > 52428800:
|
||||||
|
raise serializers.ValidationError("合計ファイルサイズが50MBを超えています")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class MultiImageUploadResponseSerializer(serializers.Serializer):
|
||||||
|
"""マルチ画像アップロードレスポンス用シリアライザー"""
|
||||||
|
|
||||||
|
status = serializers.CharField()
|
||||||
|
uploaded_count = serializers.IntegerField()
|
||||||
|
failed_count = serializers.IntegerField()
|
||||||
|
uploaded_files = serializers.ListField(
|
||||||
|
child=serializers.DictField()
|
||||||
|
)
|
||||||
|
total_upload_size = serializers.IntegerField()
|
||||||
|
processing_time_ms = serializers.IntegerField()
|
||||||
|
|||||||
18
rog/urls.py
18
rog/urls.py
@ -19,6 +19,9 @@ from .views_apis.api_bulk_upload import bulk_upload_photos, confirm_checkin_vali
|
|||||||
from .views_apis.api_admin_validation import get_event_participants_ranking, get_participant_validation_details, get_event_zekken_list
|
from .views_apis.api_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_simulator import rogaining_simulator
|
||||||
from .views_apis.api_test import test_gifuroge,practice
|
from .views_apis.api_test import test_gifuroge,practice
|
||||||
|
from .app_version_views import app_version_check, AppVersionManagementView
|
||||||
|
from .multi_image_upload_views import multi_image_upload, image_list, image_detail
|
||||||
|
from .gpx_route_views import gpx_test_data, available_routes
|
||||||
|
|
||||||
|
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
@ -79,6 +82,8 @@ urlpatterns += [
|
|||||||
path('insubperf', LocationsInSubPerf, name='location_subperf'),
|
path('insubperf', LocationsInSubPerf, name='location_subperf'),
|
||||||
path('inbound', LocationInBound, name='location_bound'),
|
path('inbound', LocationInBound, name='location_bound'),
|
||||||
path('inbound2', LocationInBound2, name='location_bound'),
|
path('inbound2', LocationInBound2, name='location_bound'),
|
||||||
|
path('location-checkin/', views.LocationCheckinView.as_view(), name='location_checkin'),
|
||||||
|
path('location-checkin-test/', views.location_checkin_test, name='location_checkin_test'),
|
||||||
path('customarea/', CustomAreaLocations, name='custom_area_location'),
|
path('customarea/', CustomAreaLocations, name='custom_area_location'),
|
||||||
path('subperfinmain/', SubPerfInMainPerf, name="sub_perf"),
|
path('subperfinmain/', SubPerfInMainPerf, name="sub_perf"),
|
||||||
path('allgifuareas/', GetAllGifuAreas, name="gifu_area"),
|
path('allgifuareas/', GetAllGifuAreas, name="gifu_area"),
|
||||||
@ -236,6 +241,19 @@ urlpatterns += [
|
|||||||
path('participant-validation-details/', get_participant_validation_details, name='get_participant_validation_details'),
|
path('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'),
|
path('event-zekken-list/', get_event_zekken_list, name='get_event_zekken_list'),
|
||||||
|
|
||||||
|
# App Version Management
|
||||||
|
path('app/version-check/', app_version_check, name='app_version_check'),
|
||||||
|
path('app/version-management/', AppVersionManagementView.as_view(), name='app_version_management'),
|
||||||
|
|
||||||
|
# Multi-Image Upload API
|
||||||
|
path('api/images/multi-upload/', multi_image_upload, name='multi_image_upload'),
|
||||||
|
path('api/images/list/', image_list, name='image_list'),
|
||||||
|
path('api/images/<int:image_id>/', image_detail, name='image_detail'),
|
||||||
|
|
||||||
|
# GPX Route Test Data API
|
||||||
|
path('api/routes/gpx-test-data/', gpx_test_data, name='gpx_test_data'),
|
||||||
|
path('api/routes/available/', available_routes, name='available_routes'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@ -3892,3 +3892,11 @@ def index_view(request):
|
|||||||
"<h1>System Error</h1><p>Failed to load supervisor interface</p>",
|
"<h1>System Error</h1><p>Failed to load supervisor interface</p>",
|
||||||
status=500
|
status=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import LocationCheckinView for evaluation_value-based interactions
|
||||||
|
from .location_checkin_view import LocationCheckinView
|
||||||
|
|
||||||
|
def location_checkin_test(request):
|
||||||
|
"""ロケーションチェックインのテストページ"""
|
||||||
|
from django.shortcuts import render
|
||||||
|
return render(request, 'location_checkin_test.html')
|
||||||
|
|||||||
@ -329,6 +329,9 @@ def checkin_from_rogapp(request):
|
|||||||
- team_name: チーム名
|
- team_name: チーム名
|
||||||
- cp_number: チェックポイント番号
|
- cp_number: チェックポイント番号
|
||||||
- image: 画像URL
|
- image: 画像URL
|
||||||
|
- buy_flag: 購入フラグ (新規)
|
||||||
|
- gps_coordinates: GPS座標情報 (新規)
|
||||||
|
- camera_metadata: カメラメタデータ (新規)
|
||||||
"""
|
"""
|
||||||
logger.info("checkin_from_rogapp called")
|
logger.info("checkin_from_rogapp called")
|
||||||
|
|
||||||
@ -338,6 +341,11 @@ def checkin_from_rogapp(request):
|
|||||||
cp_number = request.data.get('cp_number')
|
cp_number = request.data.get('cp_number')
|
||||||
image_url = request.data.get('image')
|
image_url = request.data.get('image')
|
||||||
|
|
||||||
|
# API変更要求書対応: 新パラメータ追加
|
||||||
|
buy_flag = request.data.get('buy_flag', False)
|
||||||
|
gps_coordinates = request.data.get('gps_coordinates', {})
|
||||||
|
camera_metadata = request.data.get('camera_metadata', {})
|
||||||
|
|
||||||
logger.debug(f"Parameters: event_code={event_code}, team_name={team_name}, "
|
logger.debug(f"Parameters: event_code={event_code}, team_name={team_name}, "
|
||||||
f"cp_number={cp_number}, image={image_url}")
|
f"cp_number={cp_number}, image={image_url}")
|
||||||
|
|
||||||
@ -420,6 +428,37 @@ def checkin_from_rogapp(request):
|
|||||||
|
|
||||||
# 獲得ポイントの計算(イベントCPが定義されている場合)
|
# 獲得ポイントの計算(イベントCPが定義されている場合)
|
||||||
point_value = event_cp.cp_point if event_cp else 0
|
point_value = event_cp.cp_point if event_cp else 0
|
||||||
|
bonus_points = 0
|
||||||
|
scoring_breakdown = {
|
||||||
|
"base_points": point_value,
|
||||||
|
"camera_bonus": 0,
|
||||||
|
"total_points": point_value
|
||||||
|
}
|
||||||
|
|
||||||
|
# カメラボーナス計算
|
||||||
|
if image_url and event_cp and hasattr(event_cp, 'evaluation_value'):
|
||||||
|
if event_cp.evaluation_value == "1": # 写真撮影必須ポイント
|
||||||
|
bonus_points += 5
|
||||||
|
scoring_breakdown["camera_bonus"] = 5
|
||||||
|
scoring_breakdown["total_points"] += 5
|
||||||
|
|
||||||
|
# 拡張情報があれば保存
|
||||||
|
if gps_coordinates or camera_metadata:
|
||||||
|
try:
|
||||||
|
from ..models import CheckinExtended
|
||||||
|
CheckinExtended.objects.create(
|
||||||
|
gpslog=checkpoint,
|
||||||
|
gps_latitude=gps_coordinates.get('latitude'),
|
||||||
|
gps_longitude=gps_coordinates.get('longitude'),
|
||||||
|
gps_accuracy=gps_coordinates.get('accuracy'),
|
||||||
|
gps_timestamp=gps_coordinates.get('timestamp'),
|
||||||
|
camera_capture_time=camera_metadata.get('capture_time'),
|
||||||
|
device_info=camera_metadata.get('device_info'),
|
||||||
|
bonus_points=bonus_points,
|
||||||
|
scoring_breakdown=scoring_breakdown
|
||||||
|
)
|
||||||
|
except Exception as ext_error:
|
||||||
|
logger.warning(f"Failed to save extended checkin info: {ext_error}")
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
"status": "OK",
|
"status": "OK",
|
||||||
@ -428,7 +467,11 @@ def checkin_from_rogapp(request):
|
|||||||
"cp_number": cp_number,
|
"cp_number": cp_number,
|
||||||
"checkpoint_id": checkpoint.id,
|
"checkpoint_id": checkpoint.id,
|
||||||
"checkin_time": checkpoint.checkin_time.strftime("%Y-%m-%d %H:%M:%S"),
|
"checkin_time": checkpoint.checkin_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"point_value": point_value
|
"point_value": point_value,
|
||||||
|
"bonus_points": bonus_points,
|
||||||
|
"scoring_breakdown": scoring_breakdown,
|
||||||
|
"validation_status": "pending",
|
||||||
|
"requires_manual_review": bool(gps_coordinates.get('accuracy', 0) > 10) # 10m以上は要審査
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
332
templates/location_checkin_test.html
Normal file
332
templates/location_checkin_test.html
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ロケーションチェックイン テスト</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
.location-info {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.interaction-requirements {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ロケーションチェックイン テスト</h1>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="locationSelect">ロケーション選択:</label>
|
||||||
|
<select id="locationSelect" onchange="updateLocationInfo()">
|
||||||
|
<option value="">ロケーションを選択してください</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="locationInfo" class="location-info" style="display:none;">
|
||||||
|
<h3>ロケーション情報</h3>
|
||||||
|
<div id="locationDetails"></div>
|
||||||
|
<div id="interactionRequirements" class="interaction-requirements"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="checkinForm" onsubmit="submitCheckin(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="latitude">緯度:</label>
|
||||||
|
<input type="number" id="latitude" step="any" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="longitude">経度:</label>
|
||||||
|
<input type="number" id="longitude" step="any" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="photoGroup" class="form-group" style="display:none;">
|
||||||
|
<label for="photo">写真撮影 (Base64):</label>
|
||||||
|
<input type="file" id="photoFile" accept="image/*" onchange="handlePhotoUpload()">
|
||||||
|
<textarea id="photo" rows="3" placeholder="Base64エンコードされた写真データ"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="qrGroup" class="form-group" style="display:none;">
|
||||||
|
<label for="qrCodeData">QRコードデータ:</label>
|
||||||
|
<textarea id="qrCodeData" rows="3" placeholder='{"quiz_id": 1, "correct_answer": "正解"}'></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="quizGroup" class="form-group" style="display:none;">
|
||||||
|
<label for="quizAnswer">クイズ回答:</label>
|
||||||
|
<input type="text" id="quizAnswer" placeholder="回答を入力してください">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">チェックイン実行</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="result" class="result" style="display:none;"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let locations = [];
|
||||||
|
|
||||||
|
// 初期化
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadLocations();
|
||||||
|
getCurrentLocation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ロケーション一覧を取得
|
||||||
|
async function loadLocations() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/inbound2');
|
||||||
|
const data = await response.json();
|
||||||
|
locations = data.features || [];
|
||||||
|
|
||||||
|
const select = document.getElementById('locationSelect');
|
||||||
|
select.innerHTML = '<option value="">ロケーションを選択してください</option>';
|
||||||
|
|
||||||
|
locations.forEach((location, index) => {
|
||||||
|
const props = location.properties;
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = index;
|
||||||
|
option.textContent = `${props.location_name} (ID: ${props.id}) - evaluation_value: ${props.evaluation_value || '0'}`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ロケーション取得エラー:', error);
|
||||||
|
showResult('ロケーション一覧の取得に失敗しました', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 現在地を取得
|
||||||
|
function getCurrentLocation() {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(function(position) {
|
||||||
|
document.getElementById('latitude').value = position.coords.latitude;
|
||||||
|
document.getElementById('longitude').value = position.coords.longitude;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ロケーション情報を更新
|
||||||
|
function updateLocationInfo() {
|
||||||
|
const select = document.getElementById('locationSelect');
|
||||||
|
const index = select.value;
|
||||||
|
|
||||||
|
if (index === '') {
|
||||||
|
document.getElementById('locationInfo').style.display = 'none';
|
||||||
|
hideInteractionInputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = locations[index];
|
||||||
|
const props = location.properties;
|
||||||
|
const evaluationValue = props.evaluation_value || '0';
|
||||||
|
|
||||||
|
// ロケーション詳細表示
|
||||||
|
const detailsDiv = document.getElementById('locationDetails');
|
||||||
|
detailsDiv.innerHTML = `
|
||||||
|
<p><strong>名前:</strong> ${props.location_name}</p>
|
||||||
|
<p><strong>ID:</strong> ${props.id}</p>
|
||||||
|
<p><strong>評価値:</strong> ${evaluationValue}</p>
|
||||||
|
<p><strong>カテゴリ:</strong> ${props.sub_category_name || 'N/A'}</p>
|
||||||
|
<p><strong>説明:</strong> ${props.text || 'N/A'}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// インタラクション要件表示
|
||||||
|
const requirementsDiv = document.getElementById('interactionRequirements');
|
||||||
|
let requirementText = '';
|
||||||
|
|
||||||
|
switch (evaluationValue) {
|
||||||
|
case '0':
|
||||||
|
requirementText = '📍 通常ポイント: 位置情報のみでチェックイン可能';
|
||||||
|
hideInteractionInputs();
|
||||||
|
break;
|
||||||
|
case '1':
|
||||||
|
requirementText = '📸 写真撮影ポイント: 写真撮影が必要(買い物ポイント)';
|
||||||
|
showPhotoInput();
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
requirementText = '🔍 QRコードポイント: QRコードスキャンとクイズ回答が必要';
|
||||||
|
showQRInput();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
requirementText = '❓ 不明な評価値: 通常ポイントとして処理';
|
||||||
|
hideInteractionInputs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
requirementsDiv.textContent = requirementText;
|
||||||
|
document.getElementById('locationInfo').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// インタラクション入力フィールドの表示制御
|
||||||
|
function hideInteractionInputs() {
|
||||||
|
document.getElementById('photoGroup').style.display = 'none';
|
||||||
|
document.getElementById('qrGroup').style.display = 'none';
|
||||||
|
document.getElementById('quizGroup').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPhotoInput() {
|
||||||
|
hideInteractionInputs();
|
||||||
|
document.getElementById('photoGroup').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQRInput() {
|
||||||
|
hideInteractionInputs();
|
||||||
|
document.getElementById('qrGroup').style.display = 'block';
|
||||||
|
document.getElementById('quizGroup').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写真アップロード処理
|
||||||
|
function handlePhotoUpload() {
|
||||||
|
const file = document.getElementById('photoFile').files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
const base64 = e.target.result.split(',')[1]; // data:image/jpeg;base64, を除去
|
||||||
|
document.getElementById('photo').value = base64;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// チェックイン実行
|
||||||
|
async function submitCheckin(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const select = document.getElementById('locationSelect');
|
||||||
|
const index = select.value;
|
||||||
|
|
||||||
|
if (index === '') {
|
||||||
|
showResult('ロケーションを選択してください', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = locations[index];
|
||||||
|
const locationId = location.properties.id;
|
||||||
|
const evaluationValue = location.properties.evaluation_value || '0';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
location_id: locationId,
|
||||||
|
latitude: parseFloat(document.getElementById('latitude').value),
|
||||||
|
longitude: parseFloat(document.getElementById('longitude').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
// evaluation_valueに応じたデータ追加
|
||||||
|
if (evaluationValue === '1') {
|
||||||
|
const photo = document.getElementById('photo').value;
|
||||||
|
if (!photo) {
|
||||||
|
showResult('写真撮影が必要です', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.photo = photo;
|
||||||
|
} else if (evaluationValue === '2') {
|
||||||
|
const qrData = document.getElementById('qrCodeData').value;
|
||||||
|
const quizAnswer = document.getElementById('quizAnswer').value;
|
||||||
|
|
||||||
|
if (!qrData || !quizAnswer) {
|
||||||
|
showResult('QRコードデータとクイズ回答が必要です', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.qr_code_data = qrData;
|
||||||
|
data.quiz_answer = quizAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/location-checkin/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showResult(`チェックイン成功!\n${JSON.stringify(result, null, 2)}`, 'success');
|
||||||
|
} else {
|
||||||
|
showResult(`チェックイン失敗: ${result.error}\n${JSON.stringify(result, null, 2)}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('チェックインエラー:', error);
|
||||||
|
showResult('チェックイン処理でエラーが発生しました', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 結果表示
|
||||||
|
function showResult(message, type) {
|
||||||
|
const resultDiv = document.getElementById('result');
|
||||||
|
resultDiv.textContent = message;
|
||||||
|
resultDiv.className = `result ${type}`;
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRFトークン取得
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
724
サーバーAPI変更要求書.md
Normal file
724
サーバーAPI変更要求書.md
Normal file
@ -0,0 +1,724 @@
|
|||||||
|
# サーバーAPI変更要求書
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
2025年8月27日時点のFlutterアプリコード解析に基づき、サーバー側APIで新規実装・変更が必要な機能を特定しました。
|
||||||
|
|
||||||
|
### 📋 最新の実装状況
|
||||||
|
- ✅ APKビルドシステム修正完了
|
||||||
|
- ✅ 画像マルチアップロード機能実装完了(iOS/Android対応)
|
||||||
|
- ✅ QRコードスキャナー統合完了
|
||||||
|
- ✅ GPXルートシミュレーション機能実装完了
|
||||||
|
- ✅ アプリバージョンチェック機能実装完了(クライアント側)
|
||||||
|
- ⚠️ サーバー側API実装が必要な項目を以下に記載
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆕 緊急実装が必要なAPI
|
||||||
|
|
||||||
|
### 1. **画像マルチアップロードAPI** 🔴最優先
|
||||||
|
|
||||||
|
#### **エンドポイント**: `POST /api/images/multi-upload`
|
||||||
|
|
||||||
|
**目的**: 複数画像の一括アップロード(iOS Share Extension / Android Intent対応)
|
||||||
|
|
||||||
|
**リクエストパラメータ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"team_name": "チーム名",
|
||||||
|
"cp_number": 1,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"file_data": "base64_encoded_image_data",
|
||||||
|
"filename": "checkpoint1_photo1.jpg",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_size": 2048576,
|
||||||
|
"capture_timestamp": "2025-09-15T11:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_data": "base64_encoded_image_data",
|
||||||
|
"filename": "checkpoint1_photo2.jpg",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_size": 1854232,
|
||||||
|
"capture_timestamp": "2025-09-15T11:30:15Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"upload_source": "sharing_intent",
|
||||||
|
"device_platform": "ios"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**レスポンス**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"uploaded_count": 2,
|
||||||
|
"failed_count": 0,
|
||||||
|
"uploaded_files": [
|
||||||
|
{
|
||||||
|
"original_filename": "checkpoint1_photo1.jpg",
|
||||||
|
"server_filename": "uploads/2025/08/27/cp1_team1_001.jpg",
|
||||||
|
"file_url": "https://server.com/uploads/2025/08/27/cp1_team1_001.jpg",
|
||||||
|
"file_size": 2048576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original_filename": "checkpoint1_photo2.jpg",
|
||||||
|
"server_filename": "uploads/2025/08/27/cp1_team1_002.jpg",
|
||||||
|
"file_url": "https://server.com/uploads/2025/08/27/cp1_team1_002.jpg",
|
||||||
|
"file_size": 1854232
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_upload_size": 3902808,
|
||||||
|
"processing_time_ms": 1250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- 複数ファイルの同時処理(最大10ファイル)
|
||||||
|
- MIMEタイプ検証(image/jpeg, image/png, image/heic対応)
|
||||||
|
- ファイルサイズ制限(単一ファイル最大10MB、合計最大50MB)
|
||||||
|
- 重複ファイル検出とスキップ
|
||||||
|
- プラットフォーム別の最適化(iOS HEIC→JPEG変換等)
|
||||||
|
- 非同期処理によるタイムアウト防止
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **GPXテストルート情報API** 🔴最優先
|
||||||
|
|
||||||
|
#### **エンドポイント**: `GET /api/routes/gpx-test-data`
|
||||||
|
|
||||||
|
**目的**: GPXシミュレーション用のテストルートデータ取得
|
||||||
|
|
||||||
|
**リクエストパラメータ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"route_type": "sample"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**レスポンス**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"route_name": "岐阜市内サンプルルート",
|
||||||
|
"description": "チェックポイント1-5を巡回するテストルート",
|
||||||
|
"estimated_time": "45分",
|
||||||
|
"waypoints": [
|
||||||
|
{
|
||||||
|
"lat": 35.4122,
|
||||||
|
"lng": 136.7514,
|
||||||
|
"timestamp": "2025-09-15T10:00:00Z",
|
||||||
|
"cp_number": 1,
|
||||||
|
"description": "岐阜公園"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lat": 35.4089,
|
||||||
|
"lng": 136.7581,
|
||||||
|
"timestamp": "2025-09-15T10:15:00Z",
|
||||||
|
"cp_number": 2,
|
||||||
|
"description": "岐阜城天守閣"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gpx_data": "<?xml version=\"1.0\"?>...</gpx>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- イベント毎のサンプルルート管理
|
||||||
|
- GPXフォーマットでのウェイポイント情報
|
||||||
|
- 所要時間の見積もり情報
|
||||||
|
- チェックポイント連携情報
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. アプリバージョンチェックAPI
|
||||||
|
|
||||||
|
#### **エンドポイント**: `POST /api/app/version-check`
|
||||||
|
|
||||||
|
**目的**: アプリ起動時のバージョンチェックと強制/任意更新制御
|
||||||
|
|
||||||
|
**リクエストパラメータ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current_version": "1.2.3",
|
||||||
|
"platform": "android",
|
||||||
|
"build_number": "123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**レスポンス(成功時)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"latest_version": "1.3.0",
|
||||||
|
"update_required": false,
|
||||||
|
"update_available": true,
|
||||||
|
"update_message": "新機能が追加されました。更新をお勧めします。",
|
||||||
|
"download_url": "https://play.google.com/store/apps/details?id=com.example.app",
|
||||||
|
"release_date": "2025-08-25T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- バージョン比較ロジック(セマンティックバージョニング対応)
|
||||||
|
- プラットフォーム別(Android/iOS)の管理
|
||||||
|
- 強制更新フラグ制御
|
||||||
|
- カスタムメッセージ設定機能
|
||||||
|
- アプリストアURL管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. イベントステータス管理の拡張
|
||||||
|
|
||||||
|
#### **エンドポイント**: `GET /newevent2-list/` (既存APIの拡張)
|
||||||
|
|
||||||
|
**変更内容**: イベントのステータス情報追加
|
||||||
|
|
||||||
|
**現在のレスポンス**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"event_name": "岐阜ロゲイニング2025",
|
||||||
|
"start_datetime": "2025-09-15T10:00:00Z",
|
||||||
|
"end_datetime": "2025-09-15T16:00:00Z",
|
||||||
|
"deadlineDateTime": "2025-09-10T23:59:59Z",
|
||||||
|
"public": true,
|
||||||
|
"hour_3": true,
|
||||||
|
"hour_5": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**変更後のレスポンス**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"event_name": "岐阜ロゲイニング2025",
|
||||||
|
"start_datetime": "2025-09-15T10:00:00Z",
|
||||||
|
"end_datetime": "2025-09-15T16:00:00Z",
|
||||||
|
"deadline_datetime": "2025-09-10T23:59:59Z",
|
||||||
|
"status": "public",
|
||||||
|
"hour_3": true,
|
||||||
|
"hour_5": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- `status`フィールド追加: `"public"`, `"private"`, `"draft"`, `"closed"`
|
||||||
|
- `public`フィールドから`status`フィールドへの移行
|
||||||
|
- スタッフ権限による非公開イベント参加制御
|
||||||
|
- `deadlineDateTime`から`deadline_datetime`への統一
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. チェックポイント詳細情報API (Location2025対応)
|
||||||
|
|
||||||
|
#### **エンドポイント**: `GET /api/checkpoints/detail`
|
||||||
|
|
||||||
|
**目的**: 拡張されたチェックポイント情報の取得
|
||||||
|
|
||||||
|
**リクエストパラメータ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"cp_number": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**レスポンス**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cp_number": 5,
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"cp_name": "岐阜公園",
|
||||||
|
"latitude": 35.4122,
|
||||||
|
"longitude": 136.7514,
|
||||||
|
"point_value": 15,
|
||||||
|
"description": "信長居館跡",
|
||||||
|
"image_path": "/static/checkpoints/gifu_park.jpg",
|
||||||
|
"buy_flag": false,
|
||||||
|
"evaluation_type": 1,
|
||||||
|
"tags": "歴史,公園",
|
||||||
|
"detailed_scoring": {
|
||||||
|
"base_points": 15,
|
||||||
|
"bonus_conditions": [
|
||||||
|
{
|
||||||
|
"condition": "camera_required",
|
||||||
|
"bonus_points": 5,
|
||||||
|
"description": "写真撮影必須"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- `rog_location2025`テーブルとの連携
|
||||||
|
- 詳細スコアリング情報
|
||||||
|
- チェックポイントタグ情報
|
||||||
|
- 評価方法の詳細(カメラ撮影、QR等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 既存APIの拡張が必要な項目
|
||||||
|
|
||||||
|
### 1. **チェックイン登録API拡張(マルチ画像対応)** 🟡高優先度
|
||||||
|
|
||||||
|
#### **エンドポイント**: `POST /checkin_from_rogapp` (既存APIの拡張)
|
||||||
|
|
||||||
|
**追加パラメータ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"team_name": "チーム名",
|
||||||
|
"cp_number": 1,
|
||||||
|
"images": [
|
||||||
|
"https://server.com/uploads/2025/08/27/cp1_team1_001.jpg",
|
||||||
|
"https://server.com/uploads/2025/08/27/cp1_team1_002.jpg"
|
||||||
|
],
|
||||||
|
"buy_flag": false,
|
||||||
|
"gps_coordinates": {
|
||||||
|
"latitude": 35.4091,
|
||||||
|
"longitude": 136.7581,
|
||||||
|
"accuracy": 5.2,
|
||||||
|
"timestamp": "2025-09-15T11:30:00Z"
|
||||||
|
},
|
||||||
|
"camera_metadata": {
|
||||||
|
"capture_time": "2025-09-15T11:30:00Z",
|
||||||
|
"device_info": "iPhone 16 Plus",
|
||||||
|
"sharing_source": "photos_app"
|
||||||
|
},
|
||||||
|
"checkin_method": "multi_image_upload"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- 複数画像URLの配列対応
|
||||||
|
- 画像アップロードAPIとの連携
|
||||||
|
- 共有アプリ経由でのチェックイン識別
|
||||||
|
- GPS精度向上による位置検証
|
||||||
|
|
||||||
|
### 2. エントリー情報API拡張
|
||||||
|
|
||||||
|
#### **エンドポイント**: `GET /entry/` (既存APIの拡張)
|
||||||
|
|
||||||
|
**追加フィールド**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"team": 1,
|
||||||
|
"event": 1,
|
||||||
|
"category": 1,
|
||||||
|
"zekken_number": 101,
|
||||||
|
"date": "2025-09-15T10:00:00Z",
|
||||||
|
"hasParticipated": false,
|
||||||
|
"hasGoaled": false,
|
||||||
|
"staff_privileges": false,
|
||||||
|
"can_access_private_events": false,
|
||||||
|
"team_validation_status": "approved",
|
||||||
|
"app_permissions": {
|
||||||
|
"can_upload_multiple_images": true,
|
||||||
|
"can_use_sharing_intent": true,
|
||||||
|
"can_simulate_gps": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- スタッフ権限フラグ
|
||||||
|
- 非公開イベント参加権限
|
||||||
|
- チーム承認状況
|
||||||
|
- アプリ機能別権限制御
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. チェックイン登録API拡張
|
||||||
|
|
||||||
|
#### **エンドポイント**: `POST /checkin_from_rogapp` (既存APIの拡張)
|
||||||
|
|
||||||
|
**追加パラメータ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"team_name": "チーム名",
|
||||||
|
"cp_number": 1,
|
||||||
|
"image": "https://example.com/photos/checkpoint1.jpg",
|
||||||
|
"buy_flag": false,
|
||||||
|
"gps_coordinates": {
|
||||||
|
"latitude": 35.4091,
|
||||||
|
"longitude": 136.7581,
|
||||||
|
"accuracy": 5.2,
|
||||||
|
"timestamp": "2025-09-15T11:30:00Z"
|
||||||
|
},
|
||||||
|
"camera_metadata": {
|
||||||
|
"capture_time": "2025-09-15T11:30:00Z",
|
||||||
|
"device_info": "iPhone 12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**追加レスポンス**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"message": "チェックポイントが正常に登録されました",
|
||||||
|
"team_name": "チーム名",
|
||||||
|
"cp_number": 1,
|
||||||
|
"checkpoint_id": 123,
|
||||||
|
"checkin_time": "2025-09-15 11:30:00",
|
||||||
|
"point_value": 10,
|
||||||
|
"bonus_points": 5,
|
||||||
|
"scoring_breakdown": {
|
||||||
|
"base_points": 10,
|
||||||
|
"camera_bonus": 5,
|
||||||
|
"total_points": 15
|
||||||
|
},
|
||||||
|
"validation_status": "pending",
|
||||||
|
"requires_manual_review": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- GPS座標と精度情報の記録
|
||||||
|
- カメラメタデータの保存
|
||||||
|
- 詳細スコアリング
|
||||||
|
- 自動審査機能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ データベース拡張要件
|
||||||
|
|
||||||
|
### 1. **画像管理テーブル新規作成** 🔴最優先
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE rog_uploaded_images (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_code VARCHAR(50) NOT NULL,
|
||||||
|
team_name VARCHAR(100),
|
||||||
|
cp_number INTEGER,
|
||||||
|
original_filename VARCHAR(255) NOT NULL,
|
||||||
|
server_filename VARCHAR(255) NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_url TEXT NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
mime_type VARCHAR(50) NOT NULL,
|
||||||
|
capture_timestamp TIMESTAMP WITH TIME ZONE,
|
||||||
|
upload_timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
upload_source VARCHAR(20) DEFAULT 'camera' CHECK (upload_source IN ('camera', 'sharing_intent', 'gallery')),
|
||||||
|
device_platform VARCHAR(10) CHECK (device_platform IN ('android', 'ios')),
|
||||||
|
is_processed BOOLEAN DEFAULT FALSE,
|
||||||
|
processing_status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_uploaded_images_event_team ON rog_uploaded_images(event_code, team_name);
|
||||||
|
CREATE INDEX idx_uploaded_images_cp ON rog_uploaded_images(cp_number);
|
||||||
|
CREATE INDEX idx_uploaded_images_upload_time ON rog_uploaded_images(upload_timestamp);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. アプリバージョン管理テーブル
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE app_versions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
platform VARCHAR(10) NOT NULL CHECK (platform IN ('android', 'ios')),
|
||||||
|
build_number VARCHAR(20),
|
||||||
|
is_latest BOOLEAN DEFAULT FALSE,
|
||||||
|
is_required BOOLEAN DEFAULT FALSE,
|
||||||
|
update_message TEXT,
|
||||||
|
download_url TEXT,
|
||||||
|
release_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(version, platform)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **GPXテストルート管理テーブル**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE rog_gpx_test_routes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_code VARCHAR(50) NOT NULL,
|
||||||
|
route_name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
estimated_time VARCHAR(20),
|
||||||
|
gpx_data TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_by INTEGER REFERENCES rog_customuser(id),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE rog_gpx_waypoints (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
route_id INTEGER REFERENCES rog_gpx_test_routes(id),
|
||||||
|
latitude DECIMAL(10, 8) NOT NULL,
|
||||||
|
longitude DECIMAL(11, 8) NOT NULL,
|
||||||
|
timestamp_offset INTEGER NOT NULL, -- 開始からの秒数
|
||||||
|
cp_number INTEGER,
|
||||||
|
description TEXT,
|
||||||
|
waypoint_order INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. イベントテーブル拡張
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- rog_newevent2テーブルにstatus列追加
|
||||||
|
ALTER TABLE rog_newevent2 ADD COLUMN status VARCHAR(20) DEFAULT 'draft'
|
||||||
|
CHECK (status IN ('public', 'private', 'draft', 'closed'));
|
||||||
|
|
||||||
|
-- 既存のpublicフィールドからstatusフィールドへの移行
|
||||||
|
UPDATE rog_newevent2 SET status = CASE
|
||||||
|
WHEN public = true THEN 'public'
|
||||||
|
ELSE 'draft'
|
||||||
|
END;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. エントリーテーブル拡張
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- rog_entryテーブルにスタッフ権限追加
|
||||||
|
ALTER TABLE rog_entry ADD COLUMN staff_privileges BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE rog_entry ADD COLUMN team_validation_status VARCHAR(20) DEFAULT 'approved';
|
||||||
|
ALTER TABLE rog_entry ADD COLUMN app_permissions JSONB DEFAULT '{}';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. チェックイン拡張情報テーブル
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE rog_checkin_extended (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
gpslog_id INTEGER REFERENCES rog_gpslog(id),
|
||||||
|
uploaded_images_ids INTEGER[] DEFAULT '{}', -- 関連画像IDの配列
|
||||||
|
gps_latitude DECIMAL(10, 8),
|
||||||
|
gps_longitude DECIMAL(11, 8),
|
||||||
|
gps_accuracy DECIMAL(6, 2),
|
||||||
|
gps_timestamp TIMESTAMP WITH TIME ZONE,
|
||||||
|
camera_capture_time TIMESTAMP WITH TIME ZONE,
|
||||||
|
device_info TEXT,
|
||||||
|
sharing_source VARCHAR(50), -- photos_app, file_manager等
|
||||||
|
checkin_method VARCHAR(30) DEFAULT 'camera' CHECK (checkin_method IN ('camera', 'qr_code', 'multi_image_upload')),
|
||||||
|
validation_status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
validation_comment TEXT,
|
||||||
|
validated_by INTEGER REFERENCES rog_customuser(id),
|
||||||
|
validated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
bonus_points INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 認証・権限管理拡張
|
||||||
|
|
||||||
|
### 1. スタッフ権限システム
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- ユーザーレベルでのスタッフ権限管理
|
||||||
|
- イベントレベルでのスタッフ権限付与
|
||||||
|
- 非公開イベント参加権限制御
|
||||||
|
- 管理者向けAPI認証強化
|
||||||
|
|
||||||
|
### 2. APIキー管理システム
|
||||||
|
|
||||||
|
**新規エンドポイント**: `POST /api/auth/api-key-generate`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app_version": "1.3.0",
|
||||||
|
"platform": "android",
|
||||||
|
"device_id": "unique_device_identifier"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**レスポンス**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "app_1234567890abcdef",
|
||||||
|
"expires_at": "2025-12-31T23:59:59Z",
|
||||||
|
"rate_limit": 1000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 管理者向け機能拡張
|
||||||
|
|
||||||
|
### 1. リアルタイム監視API
|
||||||
|
|
||||||
|
#### **エンドポイント**: `GET /api/admin/realtime-stats`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"current_participants": 150,
|
||||||
|
"active_teams": 45,
|
||||||
|
"total_checkins": 1250,
|
||||||
|
"pending_validations": 23,
|
||||||
|
"system_status": "normal",
|
||||||
|
"last_updated": "2025-09-15T12:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 一括操作API
|
||||||
|
|
||||||
|
#### **エンドポイント**: `POST /api/admin/bulk-operations`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "approve_checkins",
|
||||||
|
"target": {
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"zekken_numbers": ["001", "002", "003"],
|
||||||
|
"cp_numbers": [1, 2, 3]
|
||||||
|
},
|
||||||
|
"comment": "GPS位置確認により一括承認"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 パフォーマンス最適化要件
|
||||||
|
|
||||||
|
### 1. キャッシュ機能
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- チェックポイント情報のRedisキャッシュ
|
||||||
|
- イベント情報のメモリキャッシュ
|
||||||
|
- バージョン情報のキャッシュ(1時間TTL)
|
||||||
|
|
||||||
|
### 2. 非同期処理
|
||||||
|
|
||||||
|
**実装要件**:
|
||||||
|
- 写真アップロード処理の非同期化
|
||||||
|
- スコア計算の非同期処理
|
||||||
|
- 通知配信の非同期処理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 プッシュ通知システム
|
||||||
|
|
||||||
|
### 1. 通知管理API
|
||||||
|
|
||||||
|
#### **エンドポイント**: `POST /api/notifications/register-device`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_token": "fcm_token_here",
|
||||||
|
"platform": "android",
|
||||||
|
"app_version": "1.3.0",
|
||||||
|
"user_id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 通知配信API
|
||||||
|
|
||||||
|
#### **エンドポイント**: `POST /api/notifications/send`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target": "event_participants",
|
||||||
|
"event_code": "岐阜ロゲイニング2025",
|
||||||
|
"message": {
|
||||||
|
"title": "重要なお知らせ",
|
||||||
|
"body": "イベント開始時刻が変更されました",
|
||||||
|
"data": {
|
||||||
|
"type": "event_update",
|
||||||
|
"event_id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 実装優先度
|
||||||
|
|
||||||
|
### 🔴 最高優先度(即時実装必要)
|
||||||
|
1. **画像マルチアップロードAPI** - iOS/Android共有機能に必須
|
||||||
|
2. **GPXテストルート情報API** - GPS機能テストに必要
|
||||||
|
3. **アプリバージョンチェックAPI** - アプリの更新制御に必須
|
||||||
|
4. **画像管理データベーステーブル** - 画像アップロード機能の基盤
|
||||||
|
5. **チェックイン拡張(マルチ画像対応)** - 新しいチェックイン方式に対応
|
||||||
|
|
||||||
|
### 🟡 高優先度(2週間以内)
|
||||||
|
1. **イベントステータス管理拡張** - 非公開イベント制御に必要
|
||||||
|
2. **チェックポイント詳細情報API** - 新機能の完全動作に必要
|
||||||
|
3. **スタッフ権限システム** - 運営機能強化
|
||||||
|
4. **GPXルート管理システム** - テスト機能の完全実装
|
||||||
|
|
||||||
|
### 🟢 中優先度(1ヶ月以内)
|
||||||
|
1. **管理者向け機能拡張** - 運営効率化
|
||||||
|
2. **パフォーマンス最適化** - スケーラビリティ向上
|
||||||
|
3. **プッシュ通知システム** - ユーザー体験向上
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 実装チェックリスト
|
||||||
|
|
||||||
|
### バックエンド実装
|
||||||
|
- [ ] **画像マルチアップロードAPI実装** 🔴
|
||||||
|
- [ ] **画像管理データベーステーブル作成** 🔴
|
||||||
|
- [ ] **GPXテストルート情報API実装** 🔴
|
||||||
|
- [ ] アプリバージョン管理テーブル作成
|
||||||
|
- [ ] バージョンチェックAPI実装
|
||||||
|
- [ ] イベントステータス管理拡張
|
||||||
|
- [ ] チェックポイント詳細API実装
|
||||||
|
- [ ] **チェックイン拡張(マルチ画像対応)** 🟡
|
||||||
|
- [ ] GPS拡張情報記録
|
||||||
|
- [ ] スタッフ権限システム
|
||||||
|
- [ ] 管理者向けAPI拡張
|
||||||
|
|
||||||
|
### フロントエンド連携
|
||||||
|
- [x] **iOS Share Extension設定完了**
|
||||||
|
- [x] **Android Intent Filter設定完了**
|
||||||
|
- [x] **画像マルチアップロード機能実装完了**
|
||||||
|
- [x] **GPXシミュレーション機能実装完了**
|
||||||
|
- [x] **QRコードスキャナー統合完了**
|
||||||
|
- [ ] API仕様書更新
|
||||||
|
- [ ] エラーコード追加定義
|
||||||
|
- [ ] レスポンス形式統一
|
||||||
|
- [ ] 認証方式更新
|
||||||
|
|
||||||
|
### テスト・運用
|
||||||
|
- [ ] 単体テスト作成
|
||||||
|
- [ ] 統合テスト実装
|
||||||
|
- [ ] パフォーマンステスト
|
||||||
|
- [ ] 運用監視設定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 連絡事項
|
||||||
|
|
||||||
|
### 技術的な質問・相談窓口
|
||||||
|
- **Flutter開発チーム**: アプリ側実装の詳細
|
||||||
|
- **サーバー開発チーム**: API仕様の詳細
|
||||||
|
- **インフラチーム**: データベース設計の相談
|
||||||
|
|
||||||
|
### 実装期間の目安
|
||||||
|
- **画像マルチアップロード関連**: 2-3営業日(🔴緊急)
|
||||||
|
- **GPXルート管理関連**: 2-3営業日(🔴緊急)
|
||||||
|
- **最高優先度項目**: 3-5営業日
|
||||||
|
- **高優先度項目**: 1-2週間
|
||||||
|
- **中優先度項目**: 2-4週間
|
||||||
|
|
||||||
|
### 特記事項
|
||||||
|
- **iOS Share Extension対応完了**: iOSデバイスから写真アプリ経由で直接画像アップロード可能
|
||||||
|
- **Android Intent対応完了**: Androidデバイスからギャラリーアプリ経由で画像共有可能
|
||||||
|
- **GPXシミュレーション完了**: 開発・テスト用のGPS位置シミュレーション機能実装済み
|
||||||
|
- **QRコードスキャン完了**: カメラとQRコードの両方でチェックイン可能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**作成日**: 2025年8月27日
|
||||||
|
**作成者**: Flutter開発チーム
|
||||||
|
**版数**: v2.0(マルチ画像アップロード・GPX機能追加版)
|
||||||
|
**次回レビュー予定**: 2025年9月3日
|
||||||
Reference in New Issue
Block a user