1042 lines
25 KiB
Markdown
1042 lines
25 KiB
Markdown
# 詳細機能設計書 - 外部システム連携
|
||
|
||
## 1. 概要
|
||
|
||
本文書は、ロゲイニング大会管理システムの外部システム連携機能について詳細に記述したものです。
|
||
|
||
### システム構成
|
||
- **Django RESTフレームワーク**: メインAPI(Python)
|
||
- **Ruby Sinatra Server**: 外部システム連携サーバー(MobServer_gifuroge.rb)
|
||
- **AWS S3**: ファイルストレージ
|
||
- **外部ロゲイニングシステム**: チーム登録情報との連携
|
||
|
||
## 2. 外部連携アーキテクチャ
|
||
|
||
```
|
||
Django API → Ruby Server → External Rogaining System
|
||
↓
|
||
AWS S3 Storage
|
||
```
|
||
|
||
### 2.1 通信フロー
|
||
1. Django APIが外部システム連携要求を受信
|
||
2. Ruby Sinatraサーバーにリクエスト転送
|
||
3. Ruby Serverから外部ロゲイニングシステムに情報送信
|
||
4. 処理結果をS3にアップロード
|
||
5. レスポンスをDjango API経由でクライアントに返却
|
||
|
||
## 3. 外部連携API仕様
|
||
|
||
### 3.1 チーム登録API
|
||
|
||
#### POST /register_team
|
||
|
||
**目的**: 外部ロゲイニングシステムにチーム情報を登録する
|
||
|
||
**リクエスト仕様**:
|
||
```http
|
||
POST /register_team
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"zekken_number": "string", # ゼッケン番号
|
||
"team_name": "string", # チーム名
|
||
"event_code": "string", # イベントコード
|
||
"class_name": "string", # 参加クラス
|
||
"member_count": "integer", # メンバー数
|
||
"password": "string" # チーム認証パスワード
|
||
}
|
||
```
|
||
|
||
**レスポンス仕様**:
|
||
```json
|
||
{
|
||
"status": "OK|ERROR",
|
||
"message": "処理結果メッセージ",
|
||
"team_id": "登録されたチームID",
|
||
"timestamp": "2024-01-01T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
**処理フロー**:
|
||
1. リクエストパラメータの検証
|
||
2. team_tableからチーム情報の取得
|
||
3. 外部システムへの登録データ送信
|
||
4. 登録結果の確認とレスポンス返却
|
||
|
||
#### コード実装詳細
|
||
|
||
```ruby
|
||
# MobServer_gifuroge.rbから抜粋
|
||
app.post '/register_team' do
|
||
crossdomain
|
||
headjson
|
||
|
||
# パラメータ取得
|
||
zekken_number = params[:zekken_number]
|
||
team_name = params[:team_name]
|
||
event_code = params[:event_code]
|
||
|
||
# チーム情報の検証
|
||
team_data = getTeamDataByZekken_number(zekken_number, event_code)
|
||
|
||
if team_data['result'] == "ERROR"
|
||
return {
|
||
status: "ERROR",
|
||
message: "チーム情報が見つかりません"
|
||
}.to_json
|
||
end
|
||
|
||
# 外部システムへの登録処理
|
||
result = register_to_external_system(team_data)
|
||
|
||
# 結果の返却
|
||
{
|
||
status: result[:status],
|
||
message: result[:message],
|
||
team_id: result[:team_id],
|
||
timestamp: DateTime.now.iso8601
|
||
}.to_json
|
||
end
|
||
```
|
||
|
||
### 3.2 チーム名更新API
|
||
|
||
#### POST /update_team_name
|
||
|
||
**目的**: 外部システムに登録済みのチーム名を更新する
|
||
|
||
**リクエスト仕様**:
|
||
```http
|
||
POST /update_team_name
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"zekken_number": "string", # ゼッケン番号
|
||
"new_team_name": "string", # 新しいチーム名
|
||
"event_code": "string" # イベントコード
|
||
}
|
||
```
|
||
|
||
**レスポンス仕様**:
|
||
```json
|
||
{
|
||
"status": "OK|ERROR",
|
||
"message": "更新結果メッセージ",
|
||
"old_name": "変更前のチーム名",
|
||
"new_name": "変更後のチーム名",
|
||
"timestamp": "2024-01-01T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
## 4. スコアボード生成・配信システム
|
||
|
||
### 4.1 スコアボード自動生成
|
||
|
||
#### 機能概要
|
||
- チーム毎のスコアボードを自動生成
|
||
- ExcelファイルからPDFに変換
|
||
- AWS S3への自動アップロード
|
||
|
||
#### POST /generate_scoreboard
|
||
|
||
**処理フロー**:
|
||
1. GPS情報とチェックポイント通過記録の取得
|
||
2. Excel形式でのスコアボード生成
|
||
3. PDF変換処理
|
||
4. S3へのアップロード
|
||
5. 外部システムへの配信URL通知
|
||
|
||
```ruby
|
||
def makeScoreboard(zekken_number, event_code, reprintF = false, budleF = false)
|
||
# スコアボード生成処理
|
||
filepath = getScoreboardGeneral(zekken_number, event_code)
|
||
|
||
if filepath == "no_data"
|
||
return "no data error"
|
||
end
|
||
|
||
# PDF変換
|
||
command = "/home/mobilousInstance/jodconverter-cli-4.4.5-SNAPSHOT/bin/jodconverter-cli -o #{docpath_user}/#{pdffile}.xlsx #{docpath_user}/#{pdffile}.pdf"
|
||
system(command)
|
||
|
||
# S3アップロード
|
||
aws_url = s3Uploader("#{event_code}/scoreboard", docpath_user, pdffile + '.pdf')
|
||
|
||
# レポート情報の更新
|
||
if getFinalReport(zekken_number, event_code) == "no report"
|
||
inputFinalReport(zekken_number, event_code, aws_url)
|
||
else
|
||
changeFinalReport(zekken_number, event_code, aws_url)
|
||
end
|
||
|
||
aws_url
|
||
end
|
||
```
|
||
|
||
### 4.2 非同期処理による大量生成
|
||
|
||
```ruby
|
||
# キューイング処理
|
||
$queue = Queue.new
|
||
|
||
Thread.new do
|
||
loop do
|
||
begin
|
||
item = JSON.parse($queue.pop)
|
||
makeScoreboard(item["zekken_number"], item["event_code"], to_boolean(item["reprintF"]), false)
|
||
result = removeQueueMemory()
|
||
rescue => e
|
||
# エラーログ記録
|
||
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
||
error_message = "#{timestamp}=> #{item} : #{e.message}\n"
|
||
File.open(queue_error_log_base(), "a") do |file|
|
||
file.write(error_message)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
## 5. AWS S3連携
|
||
|
||
### 5.1 設定情報
|
||
|
||
```ruby
|
||
def s3_key
|
||
return "AKIA6LVMTADSVEB5LZ2H"
|
||
end
|
||
|
||
def s3_Skey
|
||
return "KIbm47dqVBxSmeHygrh5ENV1uXzJMc7fLnJOvtUm"
|
||
end
|
||
|
||
def s3_bucket
|
||
return "sumasenrogaining"
|
||
end
|
||
|
||
def s3_region
|
||
return "us-west-2"
|
||
end
|
||
|
||
def s3_domain
|
||
return "https://sumasenrogaining.s3.us-west-2.amazonaws.com"
|
||
end
|
||
```
|
||
|
||
### 5.2 ファイルアップロード機能
|
||
|
||
```ruby
|
||
def s3Uploader(data_dir, filepath, filename)
|
||
bucket = s3_bucket().freeze
|
||
region = s3_region().freeze
|
||
access_key = s3_key().freeze
|
||
secret_key = s3_Skey().freeze
|
||
|
||
aws_storage = S3_StorageUtil.new(access_key, secret_key, region, bucket, data_dir)
|
||
aws_bucket = S3_FolderUtil.new("offlineRecog/work", aws_storage)
|
||
|
||
originPath = filepath
|
||
destPath = data_dir
|
||
filename = filename
|
||
|
||
aws_bucket.uploadFile(originPath, destPath, filename)
|
||
|
||
aws_url = s3_domain() + '/' + data_dir + '/' + filename
|
||
|
||
return aws_url
|
||
end
|
||
```
|
||
|
||
## 6. データベース連携
|
||
|
||
### 6.1 PostgreSQL接続設定
|
||
|
||
```ruby
|
||
def set_dbname
|
||
return "gifuroge"
|
||
end
|
||
|
||
# データベース接続
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
```
|
||
|
||
### 6.2 主要テーブル操作
|
||
|
||
#### team_table操作
|
||
|
||
```ruby
|
||
def getTeamDataByZekken_number(zekken_number, event_code)
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
|
||
anytable = UserPostgresTable.new
|
||
anytable.useTable(@pgconn, 'team_table')
|
||
|
||
where = "zekken_number = '#{zekken_number}' AND event_code = '#{event_code}'"
|
||
list = anytable.find2(where)
|
||
|
||
result = {}
|
||
list.each { |rec|
|
||
result["zekken_number"] = rec["zekken_number"]
|
||
result["team_name"] = rec["team_name"]
|
||
result["class_name"] = rec["class_name"]
|
||
}
|
||
|
||
@pgconn.disconnect
|
||
|
||
if result != {} && result != nil
|
||
result["result"] = "OK"
|
||
return result
|
||
else
|
||
result["result"] = "ERROR"
|
||
return result
|
||
end
|
||
end
|
||
```
|
||
|
||
#### gps_information操作
|
||
|
||
```ruby
|
||
def getGpsInfo(zekken_number, event_code)
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
|
||
anytable = UserPostgresTable.new
|
||
anytable.useTable(@pgconn, 'gps_information')
|
||
|
||
where = "zekken_number = '#{zekken_number}' AND event_code = '#{event_code}' ORDER BY serial_number"
|
||
list = anytable.find2(where)
|
||
|
||
result = []
|
||
count = 0
|
||
|
||
list.each { |rec|
|
||
result[count] = {"cp_number" => rec['cp_number']}
|
||
count += 1
|
||
}
|
||
|
||
@pgconn.disconnect
|
||
|
||
return result
|
||
end
|
||
```
|
||
|
||
## 7. 画像処理・サムネイル生成
|
||
|
||
### 7.1 画像リサイズ処理
|
||
|
||
```ruby
|
||
def image_size_width
|
||
return 150
|
||
end
|
||
|
||
def image_size_height
|
||
return 105
|
||
end
|
||
|
||
# 画像処理実装
|
||
filename = rec["cp#{i}_ph"].split('/').last
|
||
fullpath = docpath + '/' + filename
|
||
|
||
# S3からの画像取得
|
||
fullpath2 = s3_domain + '/' + URI.encode_www_form_component(event_code, enc=nil) + '/' + zekken_number + '/' + filename
|
||
URI.open(fullpath2) do |image|
|
||
File.open(fullpath, 'w') do |file|
|
||
file.write(image.read)
|
||
end
|
||
end
|
||
|
||
# サムネイル生成
|
||
extension = filename.split('.').last
|
||
filenameTh = filename.gsub(".#{extension}", "_th.#{extension}")
|
||
|
||
imageOri = Magick::Image.read(fullpath).first
|
||
imageTh = imageOri.scale(image_size_width(), image_size_height())
|
||
imageTh.write("temp/#{filenameTh}")
|
||
|
||
nextpath = docpath + '/' + filenameTh
|
||
FileUtils.mv("/var/www/temp/#{filenameTh}", nextpath)
|
||
|
||
result["cp#{i}_ph"] = nextpath
|
||
```
|
||
|
||
## 8. エラーハンドリング
|
||
|
||
### 8.1 エラーコード体系
|
||
|
||
```ruby
|
||
# エラーコード一覧
|
||
# STA-001 : パスワード入力のキャンセルに失敗した状態
|
||
# STA-002 : 処理には成功したがchat_statusのレコードデリートに失敗した状態
|
||
# STA-003 : 写真受付のキャンセルに失敗した状態
|
||
# USR-001 : LINEのユーザー名を取得できていない状態
|
||
# USR-002 : ログインに成功したが、user_tableのレコードアップデートに失敗した状態
|
||
# USR-003 : ログアウトの処理に失敗した状態
|
||
# DBS-001 : そのユーザーが何時間部門のユーザーなのかを認識できなくなった状態
|
||
|
||
def text_importantError(errorCode)
|
||
return "エラーコード:#{errorCode}\nゼッケン番号とエラーコードを添えて、運営に問い合わせて下さい。"
|
||
end
|
||
```
|
||
|
||
### 8.2 例外処理パターン
|
||
|
||
```ruby
|
||
begin
|
||
ret = anytable.exec(sql)
|
||
rescue => error
|
||
p error
|
||
return "UPDATE ERROR"
|
||
end
|
||
|
||
# データベース接続の確実な切断
|
||
@pgconn.disconnect
|
||
|
||
# キューエラーハンドリング
|
||
rescue => e
|
||
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
||
item_value = defined?(item) ? item.to_s : ""
|
||
error_message = "#{timestamp}=> #{item_value} : #{e.message}\n"
|
||
File.open(queue_error_log_base(), "a") do |file|
|
||
file.write(error_message)
|
||
end
|
||
end
|
||
```
|
||
|
||
## 9. セキュリティ対策
|
||
|
||
### 9.1 認証・認可
|
||
|
||
#### チーム認証
|
||
```ruby
|
||
def zekkenAuthorization(zekken, password)
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
|
||
anytable = UserPostgresTable.new
|
||
anytable.useTable(@pgconn, 'team_table')
|
||
|
||
where = "zekken_number = '#{zekken}' AND password = '#{password}'"
|
||
list = anytable.find2(where)
|
||
|
||
result = {}
|
||
if list == nil
|
||
return { "zekken_number" => "ERROR" }
|
||
end
|
||
|
||
list.each { |rec|
|
||
result["zekken_number"] = rec["zekken_number"]
|
||
result["team_name"] = rec["team_name"]
|
||
result["event_code"] = rec["event_code"]
|
||
}
|
||
|
||
@pgconn.disconnect
|
||
|
||
if result != {} && result != nil
|
||
result["result"] = "OK"
|
||
return result
|
||
else
|
||
result["result"] = "ERROR"
|
||
return result
|
||
end
|
||
end
|
||
```
|
||
|
||
#### 代理人認証
|
||
```ruby
|
||
def agentAuthorization(password)
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
|
||
anytable = UserPostgresTable.new
|
||
anytable.useTable(@pgconn, 'agent_table')
|
||
|
||
where = "password = '#{password}'"
|
||
list = anytable.find2(where)
|
||
|
||
result = ""
|
||
if list == nil
|
||
return "ERROR"
|
||
end
|
||
|
||
list.each { |rec|
|
||
result = rec["agent_id"]
|
||
}
|
||
|
||
@pgconn.disconnect
|
||
|
||
if result != "" && result != nil
|
||
return result
|
||
else
|
||
return "ERROR"
|
||
end
|
||
end
|
||
```
|
||
|
||
### 9.2 SQLインジェクション対策
|
||
|
||
```ruby
|
||
# パラメータのエスケープ処理
|
||
if team_name.include?("'")
|
||
team_name.gsub!("'", "''")
|
||
end
|
||
|
||
where = "team_name = '#{team_name}' AND event_code = '#{event_code}'"
|
||
```
|
||
|
||
### 9.3 CORS設定
|
||
|
||
```ruby
|
||
def crossdomain
|
||
headers 'Access-Control-Allow-Origin' => '*'
|
||
end
|
||
|
||
def headjson
|
||
headers 'Content-Type' => 'application/json; charset=utf-8'
|
||
end
|
||
```
|
||
|
||
## 10. ログ・監視
|
||
|
||
### 10.1 チャットログ
|
||
|
||
```ruby
|
||
def chatLogger(userId, talker, type, detail)
|
||
p "#{talker} : #{detail}"
|
||
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
|
||
anytable = UserPostgresTable.new
|
||
anytable.useTable(@pgconn, 'chat_log')
|
||
|
||
title = ["userid", "talker", "message_type", "message_detail", "create_at"]
|
||
record = {"userid" => userId, "talker" => talker, "message_type" => type, "message_detail" => detail, "create_at" => DateTime.now}
|
||
|
||
sql = anytable.makeInsertKeySet(title, record)
|
||
begin
|
||
ret = anytable.exec(sql)
|
||
rescue => error
|
||
p error
|
||
end
|
||
|
||
@pgconn.disconnect
|
||
|
||
return "OK"
|
||
end
|
||
```
|
||
|
||
### 10.2 キューエラーログ
|
||
|
||
```ruby
|
||
def queue_error_log_base()
|
||
return "/var/log/queue_error.log"
|
||
end
|
||
|
||
# エラーログ記録
|
||
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
||
error_message = "#{timestamp}=> #{item} : #{e.message}\n"
|
||
File.open(queue_error_log_base(), "a") do |file|
|
||
file.write(error_message)
|
||
end
|
||
```
|
||
|
||
## 11. パフォーマンス最適化
|
||
|
||
### 11.1 データベース接続管理
|
||
|
||
```ruby
|
||
# 接続の確実な切断
|
||
@pgconn.disconnect
|
||
|
||
# 接続プールの活用
|
||
def with_db_connection
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
|
||
begin
|
||
yield @pgconn
|
||
ensure
|
||
@pgconn.disconnect
|
||
end
|
||
end
|
||
```
|
||
|
||
### 11.2 画像処理の最適化
|
||
|
||
```ruby
|
||
# サムネイル生成のバッチ処理
|
||
def batch_thumbnail_generation(image_list)
|
||
image_list.each do |image_path|
|
||
imageOri = Magick::Image.read(image_path).first
|
||
imageTh = imageOri.scale(image_size_width(), image_size_height())
|
||
imageTh.write(thumbnail_path(image_path))
|
||
end
|
||
end
|
||
```
|
||
|
||
### 11.3 キューイング最適化
|
||
|
||
```ruby
|
||
# 非同期処理による負荷分散
|
||
Thread.new do
|
||
loop do
|
||
begin
|
||
item = JSON.parse($queue.pop)
|
||
process_queue_item(item)
|
||
rescue => e
|
||
log_queue_error(e, item)
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
## 12. 運用・保守
|
||
|
||
### 12.1 バックアップ戦略
|
||
|
||
```ruby
|
||
# データベースバックアップ
|
||
def backup_database(event_code)
|
||
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
||
backup_file = "backup_#{event_code}_#{timestamp}.sql"
|
||
|
||
system("pg_dump #{set_dbname()} > #{backup_file}")
|
||
|
||
# S3へのバックアップアップロード
|
||
s3Uploader("backups", ".", backup_file)
|
||
end
|
||
```
|
||
|
||
### 12.2 ヘルスチェック
|
||
|
||
```ruby
|
||
app.get '/health' do
|
||
crossdomain
|
||
headjson
|
||
|
||
{
|
||
status: "OK",
|
||
timestamp: DateTime.now.iso8601,
|
||
version: "1.0.0",
|
||
database: check_database_connection(),
|
||
s3: check_s3_connection()
|
||
}.to_json
|
||
end
|
||
|
||
def check_database_connection()
|
||
begin
|
||
@pgconn = UserPostgres.new
|
||
dbname = set_dbname()
|
||
@pgconn.connectPg("localhost", "mobilous", 0, dbname)
|
||
@pgconn.disconnect
|
||
return "OK"
|
||
rescue => e
|
||
return "ERROR: #{e.message}"
|
||
end
|
||
end
|
||
```
|
||
|
||
## 13. 今後の拡張計画
|
||
|
||
### 13.1 マイクロサービス化
|
||
- Ruby ServerをDockerコンテナ化
|
||
- APIゲートウェイの導入
|
||
- サービス間通信の最適化
|
||
|
||
### 13.2 リアルタイム機能
|
||
- WebSocketによるリアルタイム順位更新
|
||
- プッシュ通知機能の実装
|
||
|
||
### 13.3 多言語対応
|
||
- 国際大会対応のため多言語化
|
||
- タイムゾーン対応の強化
|
||
|
||
---
|
||
|
||
この詳細機能設計書により、外部システム連携の実装、運用、保守に必要な全ての技術的詳細が文書化されています。
|
||
|
||
---
|
||
|
||
## 14. 🆕 管理者向け機能拡張 (2025年8月実装)
|
||
|
||
### 14.1 一括写真アップロード・自動チェックイン機能
|
||
|
||
#### 機能概要
|
||
複数の写真を一括でアップロードし、EXIF情報から自動的にチェックイン処理を実行する機能です。
|
||
|
||
#### 技術仕様
|
||
|
||
**実装ファイル**: `rog/views_apis/api_bulk_upload.py`
|
||
|
||
**主要クラス・関数**:
|
||
```python
|
||
def bulk_upload_photos(request):
|
||
"""
|
||
一括写真アップロード処理
|
||
|
||
処理フロー:
|
||
1. アップロードファイルの検証
|
||
2. EXIF情報抽出(GPS座標、撮影時刻)
|
||
3. 近接チェックポイント検索
|
||
4. 自動チェックイン処理
|
||
5. S3ストレージへの画像保存
|
||
"""
|
||
```
|
||
|
||
**依存ライブラリ**:
|
||
- `piexif`: EXIF情報抽出
|
||
- `PIL`: 画像処理
|
||
- `boto3`: S3連携
|
||
|
||
**処理フロー図**:
|
||
```
|
||
写真アップロード → EXIF抽出 → GPS検証 → チェックポイント検索 → 自動チェックイン
|
||
↓ ↓ ↓ ↓ ↓
|
||
ファイル検証 座標・時刻 位置精度確認 距離計算 DB記録
|
||
↓ ↓ ↓ ↓ ↓
|
||
S3アップロード メタデータ タイムスタンプ ポイント照合 確定処理
|
||
```
|
||
|
||
#### GPS精度検証ロジック
|
||
```python
|
||
def validate_gps_proximity(gps_coords, checkin_time, event_code):
|
||
"""
|
||
GPS座標の妥当性検証
|
||
|
||
検証項目:
|
||
- チェックポイントとの距離(50m以内)
|
||
- 撮影時刻の妥当性(イベント期間内)
|
||
- 重複チェックイン防止(同一CP 30分以内)
|
||
"""
|
||
max_distance = 50 # メートル
|
||
min_interval = 30 # 分
|
||
```
|
||
|
||
#### エラーハンドリング
|
||
```python
|
||
class BulkUploadError(Exception):
|
||
"""一括アップロード専用例外クラス"""
|
||
pass
|
||
|
||
# エラーパターン
|
||
- GPS情報なし: "GPS情報が見つかりません"
|
||
- 距離超過: "チェックポイントから50m以上離れています"
|
||
- 時刻異常: "撮影時刻がイベント期間外です"
|
||
- ファイル形式: "対応していないファイル形式です"
|
||
```
|
||
|
||
---
|
||
|
||
### 14.2 通過審査管理機能
|
||
|
||
#### 機能概要
|
||
チェックインの確定・否認を管理し、審査状況を追跡する機能です。
|
||
|
||
#### データベース拡張
|
||
**テーブル**: `rog_gpsCheckin`
|
||
|
||
**新規フィールド**:
|
||
```sql
|
||
ALTER TABLE rog_gpsCheckin ADD COLUMN validation_status VARCHAR(20) DEFAULT 'PENDING';
|
||
ALTER TABLE rog_gpsCheckin ADD COLUMN validation_comment TEXT;
|
||
ALTER TABLE rog_gpsCheckin ADD COLUMN validated_at TIMESTAMP;
|
||
ALTER TABLE rog_gpsCheckin ADD COLUMN validated_by VARCHAR(255);
|
||
```
|
||
|
||
**ステータス定義**:
|
||
```python
|
||
VALIDATION_STATUS_CHOICES = [
|
||
('PENDING', '審査待ち'),
|
||
('APPROVED', '承認'),
|
||
('REJECTED', '却下'),
|
||
('AUTO_APPROVED', '自動承認'),
|
||
]
|
||
```
|
||
|
||
#### API実装
|
||
**実装ファイル**: `rog/views_apis/api_bulk_upload.py`
|
||
|
||
```python
|
||
@api_view(['POST'])
|
||
@permission_classes([IsAuthenticated])
|
||
def confirm_checkin_validation(request):
|
||
"""
|
||
チェックイン確定・否認処理
|
||
|
||
パラメータ:
|
||
- checkin_id: 対象チェックインID
|
||
- action: APPROVED/REJECTED
|
||
- comment: 審査コメント
|
||
"""
|
||
```
|
||
|
||
#### 審査ワークフロー
|
||
```
|
||
写真アップロード → AUTO_APPROVED → 管理者審査 → APPROVED/REJECTED
|
||
↓ ↓ ↓ ↓
|
||
自動処理 システム判定 手動確認 最終決定
|
||
↓ ↓ ↓ ↓
|
||
即座反映 暫定得点 審査待ち 確定得点
|
||
```
|
||
|
||
---
|
||
|
||
### 14.3 全参加者ランキング表示機能
|
||
|
||
#### 機能概要
|
||
イベント全参加者の得点と審査状況をクラス別に一覧表示する機能です。
|
||
|
||
#### 実装仕様
|
||
**実装ファイル**: `rog/views_apis/api_admin_validation.py`
|
||
|
||
**主要関数**:
|
||
```python
|
||
def get_event_participants_ranking(request):
|
||
"""
|
||
全参加者ランキング取得
|
||
|
||
集計項目:
|
||
- 確定得点(APPROVED)
|
||
- 未確定得点(PENDING)
|
||
- 確定率(確定数/総数)
|
||
- クラス別順位
|
||
"""
|
||
```
|
||
|
||
#### SQLクエリ最適化
|
||
```sql
|
||
SELECT
|
||
e.zekken_number,
|
||
e.team_name,
|
||
e.class_name,
|
||
SUM(CASE WHEN g.validation_status = 'APPROVED' THEN l.point_value ELSE 0 END) as confirmed_points,
|
||
SUM(CASE WHEN g.validation_status = 'PENDING' THEN l.point_value ELSE 0 END) as pending_points,
|
||
COUNT(g.id) as total_checkins,
|
||
COUNT(CASE WHEN g.validation_status = 'APPROVED' THEN 1 END) as confirmed_checkins
|
||
FROM rog_entry e
|
||
LEFT JOIN rog_gpsCheckin g ON e.zekken_number = g.zekken_number
|
||
LEFT JOIN rog_location2025 l ON g.cp_number = l.cp_number
|
||
WHERE e.event_code = %s
|
||
GROUP BY e.zekken_number, e.team_name, e.class_name
|
||
ORDER BY confirmed_points DESC;
|
||
```
|
||
|
||
#### キャッシュ戦略
|
||
```python
|
||
from django.core.cache import cache
|
||
|
||
def get_cached_ranking(event_code):
|
||
"""
|
||
ランキングデータのキャッシュ
|
||
|
||
キャッシュキー: f"ranking_{event_code}"
|
||
有効期限: 300秒(5分)
|
||
更新トリガー: チェックイン確定時
|
||
"""
|
||
cache_key = f"ranking_{event_code}"
|
||
cached_data = cache.get(cache_key)
|
||
|
||
if cached_data is None:
|
||
cached_data = calculate_ranking(event_code)
|
||
cache.set(cache_key, cached_data, 300)
|
||
|
||
return cached_data
|
||
```
|
||
|
||
---
|
||
|
||
### 14.4 管理画面UI拡張
|
||
|
||
#### フロントエンド実装
|
||
**実装ファイル**: `supervisor/html/index.html`
|
||
|
||
**新機能**:
|
||
1. **表示モード切り替え**: 個別表示 ⇄ ランキング表示
|
||
2. **一括操作**: 確定・否認ボタン
|
||
3. **ファイルアップロード**: ドラッグ&ドロップ対応
|
||
4. **リアルタイム更新**: AJAX通信
|
||
|
||
#### JavaScript実装概要
|
||
```javascript
|
||
class AdminValidationManager {
|
||
constructor() {
|
||
this.apiBaseUrl = '/api';
|
||
this.currentEventCode = '';
|
||
this.displayMode = 'individual';
|
||
}
|
||
|
||
// 一括写真アップロード
|
||
async handleBulkPhotoUpload(files) {
|
||
const formData = new FormData();
|
||
files.forEach(file => formData.append('photos', file));
|
||
formData.append('event_code', this.currentEventCode);
|
||
|
||
const response = await fetch(`${this.apiBaseUrl}/bulk-upload-photos/`, {
|
||
method: 'POST',
|
||
headers: this.getAuthHeaders(),
|
||
body: formData
|
||
});
|
||
|
||
return await response.json();
|
||
}
|
||
|
||
// チェックイン確定・否認
|
||
async confirmCheckin(checkinId, action) {
|
||
const response = await fetch(`${this.apiBaseUrl}/confirm-checkin-validation/`, {
|
||
method: 'POST',
|
||
headers: this.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
checkin_id: checkinId,
|
||
action: action,
|
||
comment: action === 'REJECTED' ? '手動確認により却下' : '手動確認により承認'
|
||
})
|
||
});
|
||
|
||
return await response.json();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### レスポンシブデザイン
|
||
```css
|
||
/* 管理画面専用スタイル */
|
||
.admin-panel {
|
||
display: grid;
|
||
grid-template-columns: 1fr 3fr;
|
||
gap: 20px;
|
||
}
|
||
|
||
.ranking-table {
|
||
overflow-x: auto;
|
||
max-height: 70vh;
|
||
}
|
||
|
||
.validation-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.admin-panel {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.validation-buttons {
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 14.5 セキュリティ対策
|
||
|
||
#### 認証・認可
|
||
```python
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from django.contrib.auth.decorators import user_passes_test
|
||
|
||
def is_admin_user(user):
|
||
"""管理者権限チェック"""
|
||
return user.is_authenticated and user.is_superuser
|
||
|
||
@user_passes_test(is_admin_user)
|
||
def admin_only_view(request):
|
||
"""管理者専用ビュー"""
|
||
pass
|
||
```
|
||
|
||
#### ファイルアップロード検証
|
||
```python
|
||
def validate_upload_file(file):
|
||
"""
|
||
アップロードファイル検証
|
||
|
||
検証項目:
|
||
- ファイルサイズ(10MB以下)
|
||
- ファイル形式(JPEG/PNG)
|
||
- EXIF情報の存在
|
||
- マルウェアスキャン
|
||
"""
|
||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||
ALLOWED_TYPES = ['image/jpeg', 'image/png']
|
||
|
||
if file.size > MAX_FILE_SIZE:
|
||
raise ValidationError('ファイルサイズが制限を超えています')
|
||
|
||
if file.content_type not in ALLOWED_TYPES:
|
||
raise ValidationError('対応していないファイル形式です')
|
||
```
|
||
|
||
#### レート制限
|
||
```python
|
||
from django_ratelimit.decorators import ratelimit
|
||
|
||
@ratelimit(key='user', rate='10/h', method=['POST'])
|
||
def bulk_upload_photos(request):
|
||
"""
|
||
レート制限: 1時間あたり10回まで
|
||
"""
|
||
pass
|
||
```
|
||
|
||
---
|
||
|
||
### 14.6 監視・ログ機能
|
||
|
||
#### 操作ログ
|
||
```python
|
||
import logging
|
||
|
||
logger = logging.getLogger('admin_operations')
|
||
|
||
def log_admin_action(user, action, target, details=None):
|
||
"""
|
||
管理者操作ログ記録
|
||
|
||
ログ項目:
|
||
- 操作者
|
||
- 操作内容
|
||
- 対象データ
|
||
- タイムスタンプ
|
||
- 詳細情報
|
||
"""
|
||
logger.info(f"Admin Action: {user.username} performed {action} on {target}", extra={
|
||
'user_id': user.id,
|
||
'action': action,
|
||
'target': target,
|
||
'details': details,
|
||
'timestamp': timezone.now().isoformat()
|
||
})
|
||
```
|
||
|
||
#### パフォーマンス監視
|
||
```python
|
||
from django.db import connection
|
||
from django.conf import settings
|
||
|
||
def monitor_query_performance():
|
||
"""
|
||
クエリパフォーマンス監視
|
||
"""
|
||
if settings.DEBUG:
|
||
query_count = len(connection.queries)
|
||
slow_queries = [q for q in connection.queries if float(q['time']) > 0.1]
|
||
|
||
if slow_queries:
|
||
logger.warning(f"Slow queries detected: {len(slow_queries)} queries > 0.1s")
|
||
```
|
||
|
||
---
|