Files
rogaining_srv/詳細機能設計書-外部連携.md

1042 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 詳細機能設計書 - 外部システム連携
## 1. 概要
本文書は、ロゲイニング大会管理システムの外部システム連携機能について詳細に記述したものです。
### システム構成
- **Django RESTフレームワーク**: メインAPIPython
- **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")
```
---