feat(phase6): Add sounds, team chat, reactions, monitor, settings, and CSV import/export
Sound System: - Add soundStore with volume/mute persistence - Add useSound hook with Web Audio API fallback - Add SoundControl component for in-game volume adjustment - Play sounds for correct/incorrect, steal, timer, victory/defeat Team Chat: - Add TeamChat component with collapsible panel - Add team_message WebSocket event (team-only visibility) - Store up to 50 messages per session Emoji Reactions: - Add EmojiReactions bar with 8 emojis - Add ReactionOverlay with floating animations (Framer Motion) - Add rate limiting (1 reaction per 3 seconds) - Broadcast reactions to all players in room Admin Monitor: - Add Monitor page showing active rooms from Redis - Display player counts, team composition, status - Add ability to close problematic rooms Admin Settings: - Add Settings page for game configuration - Configure points/times by difficulty, steal penalty, max players - Store config in JSON file with service helpers CSV Import/Export: - Add export endpoint with optional filters - Add import endpoint with validation and error reporting - Add UI buttons and import result modal in Questions page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from typing import List
|
||||
import csv
|
||||
import json
|
||||
from io import StringIO
|
||||
|
||||
from app.models.base import get_db
|
||||
from app.models.admin import Admin
|
||||
@@ -17,6 +21,9 @@ from app.schemas.question import (
|
||||
AIGenerateRequest
|
||||
)
|
||||
from app.services.ai_generator import ai_generator
|
||||
from app.services.room_manager import room_manager
|
||||
from app.services.game_config import get_game_settings, update_game_settings
|
||||
from app.schemas.game_config import GameSettingsSchema
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
@@ -294,3 +301,348 @@ async def create_category(
|
||||
await db.commit()
|
||||
await db.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
# CSV Import/Export
|
||||
|
||||
@router.get("/questions/export")
|
||||
async def export_questions(
|
||||
category_id: int = None,
|
||||
status: str = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""
|
||||
Export questions to CSV format.
|
||||
Query params: category_id (optional), status (optional)
|
||||
Returns CSV file as download.
|
||||
"""
|
||||
# Build query with filters
|
||||
query = select(Question, Category.name.label("category_name")).join(
|
||||
Category, Question.category_id == Category.id
|
||||
)
|
||||
if category_id:
|
||||
query = query.where(Question.category_id == category_id)
|
||||
if status:
|
||||
query = query.where(Question.status == status)
|
||||
|
||||
result = await db.execute(query.order_by(Question.created_at.desc()))
|
||||
rows = result.all()
|
||||
|
||||
# Create CSV in memory
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write header
|
||||
writer.writerow([
|
||||
"category", "question", "correct_answer", "alt_answers",
|
||||
"difficulty", "fun_fact", "status", "date_active"
|
||||
])
|
||||
|
||||
# Write data rows
|
||||
for row in rows:
|
||||
question = row[0]
|
||||
category_name = row[1]
|
||||
|
||||
# Join alt_answers with pipe separator
|
||||
alt_answers_str = "|".join(question.alt_answers) if question.alt_answers else ""
|
||||
|
||||
# Format date_active
|
||||
date_active_str = question.date_active.isoformat() if question.date_active else ""
|
||||
|
||||
writer.writerow([
|
||||
category_name,
|
||||
question.question_text,
|
||||
question.correct_answer,
|
||||
alt_answers_str,
|
||||
question.difficulty,
|
||||
question.fun_fact or "",
|
||||
question.status,
|
||||
date_active_str
|
||||
])
|
||||
|
||||
# Prepare response
|
||||
output.seek(0)
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"questions_export_{timestamp}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Type": "text/csv; charset=utf-8"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/questions/import")
|
||||
async def import_questions(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""
|
||||
Import questions from CSV file.
|
||||
Expected columns: category, question, correct_answer, alt_answers, difficulty, fun_fact
|
||||
alt_answers should be separated by pipe |
|
||||
Returns: {imported: count, errors: [{row, error}]}
|
||||
"""
|
||||
# Validate file type
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="File must be a CSV"
|
||||
)
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
content = await file.read()
|
||||
decoded_content = content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
# Try with latin-1 encoding as fallback
|
||||
try:
|
||||
decoded_content = content.decode('latin-1')
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Could not decode file. Please use UTF-8 encoding."
|
||||
)
|
||||
|
||||
# Parse CSV
|
||||
csv_reader = csv.DictReader(StringIO(decoded_content))
|
||||
|
||||
# Required columns
|
||||
required_columns = {"category", "question", "correct_answer", "difficulty"}
|
||||
|
||||
# Validate headers
|
||||
if not csv_reader.fieldnames:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="CSV file is empty or has no headers"
|
||||
)
|
||||
|
||||
headers = set(csv_reader.fieldnames)
|
||||
missing_columns = required_columns - headers
|
||||
if missing_columns:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Missing required columns: {', '.join(missing_columns)}"
|
||||
)
|
||||
|
||||
# Get all categories for lookup
|
||||
categories_result = await db.execute(select(Category))
|
||||
categories = {cat.name.lower(): cat.id for cat in categories_result.scalars().all()}
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (1 is header)
|
||||
try:
|
||||
# Get category
|
||||
category_name = row.get("category", "").strip()
|
||||
if not category_name:
|
||||
errors.append({"row": row_num, "error": "Category is required"})
|
||||
continue
|
||||
|
||||
category_id = categories.get(category_name.lower())
|
||||
if not category_id:
|
||||
errors.append({"row": row_num, "error": f"Category '{category_name}' not found"})
|
||||
continue
|
||||
|
||||
# Get question text
|
||||
question_text = row.get("question", "").strip()
|
||||
if not question_text:
|
||||
errors.append({"row": row_num, "error": "Question text is required"})
|
||||
continue
|
||||
|
||||
# Get correct answer
|
||||
correct_answer = row.get("correct_answer", "").strip()
|
||||
if not correct_answer:
|
||||
errors.append({"row": row_num, "error": "Correct answer is required"})
|
||||
continue
|
||||
|
||||
# Get difficulty
|
||||
try:
|
||||
difficulty = int(row.get("difficulty", "3"))
|
||||
if difficulty < 1 or difficulty > 5:
|
||||
errors.append({"row": row_num, "error": "Difficulty must be between 1 and 5"})
|
||||
continue
|
||||
except ValueError:
|
||||
errors.append({"row": row_num, "error": "Difficulty must be a number"})
|
||||
continue
|
||||
|
||||
# Parse alt_answers (pipe separated)
|
||||
alt_answers_str = row.get("alt_answers", "").strip()
|
||||
alt_answers = [a.strip() for a in alt_answers_str.split("|") if a.strip()] if alt_answers_str else []
|
||||
|
||||
# Get fun_fact (optional)
|
||||
fun_fact = row.get("fun_fact", "").strip() or None
|
||||
|
||||
# Calculate points and time based on difficulty
|
||||
points = settings.default_points.get(difficulty, 300)
|
||||
time_seconds = settings.default_times.get(difficulty, 25)
|
||||
|
||||
# Create question with pending status
|
||||
question = Question(
|
||||
category_id=category_id,
|
||||
question_text=question_text,
|
||||
correct_answer=correct_answer,
|
||||
alt_answers=alt_answers,
|
||||
difficulty=difficulty,
|
||||
points=points,
|
||||
time_seconds=time_seconds,
|
||||
fun_fact=fun_fact,
|
||||
status="pending"
|
||||
)
|
||||
db.add(question)
|
||||
imported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append({"row": row_num, "error": str(e)})
|
||||
|
||||
# Commit all valid questions
|
||||
if imported_count > 0:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"imported": imported_count,
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
|
||||
# Game Settings
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings_endpoint(
|
||||
admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""
|
||||
Get current game settings.
|
||||
Returns configuration for points, times, steal mechanics, and team limits.
|
||||
"""
|
||||
return get_game_settings()
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings_endpoint(
|
||||
settings_data: GameSettingsSchema,
|
||||
admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""
|
||||
Update game settings.
|
||||
Expects a complete settings object with all fields.
|
||||
"""
|
||||
return update_game_settings(settings_data)
|
||||
|
||||
|
||||
# Room Monitor
|
||||
|
||||
async def get_active_rooms_from_redis() -> List[dict]:
|
||||
"""
|
||||
Helper function to scan and retrieve all active rooms from Redis.
|
||||
Returns list of room summaries with player counts and team info.
|
||||
"""
|
||||
await room_manager.connect()
|
||||
|
||||
rooms = []
|
||||
cursor = 0
|
||||
|
||||
# Scan for all room:* keys
|
||||
while True:
|
||||
cursor, keys = await room_manager.redis.scan(cursor, match="room:*", count=100)
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
data = await room_manager.redis.get(key)
|
||||
if data:
|
||||
room_data = json.loads(data)
|
||||
|
||||
# Count players per team
|
||||
team_a_count = len(room_data.get("teams", {}).get("A", []))
|
||||
team_b_count = len(room_data.get("teams", {}).get("B", []))
|
||||
total_players = team_a_count + team_b_count
|
||||
|
||||
# Get TTL for time remaining
|
||||
ttl = await room_manager.redis.ttl(key)
|
||||
|
||||
rooms.append({
|
||||
"room_code": room_data.get("code", ""),
|
||||
"players_count": total_players,
|
||||
"teams": {
|
||||
"A": team_a_count,
|
||||
"B": team_b_count
|
||||
},
|
||||
"status": room_data.get("status", "unknown"),
|
||||
"host": room_data.get("host", ""),
|
||||
"ttl_seconds": ttl if ttl > 0 else 0,
|
||||
"scores": room_data.get("scores", {"A": 0, "B": 0})
|
||||
})
|
||||
except (json.JSONDecodeError, Exception):
|
||||
# Skip malformed room data
|
||||
continue
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return rooms
|
||||
|
||||
|
||||
@router.get("/rooms/active")
|
||||
async def get_active_rooms(
|
||||
admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""
|
||||
Get list of all active game rooms from Redis.
|
||||
Returns: list of {room_code, players_count, teams: {A: count, B: count}, status, host, ttl_seconds}
|
||||
"""
|
||||
rooms = await get_active_rooms_from_redis()
|
||||
return {"rooms": rooms, "total": len(rooms)}
|
||||
|
||||
|
||||
@router.delete("/rooms/{room_code}")
|
||||
async def close_room(
|
||||
room_code: str,
|
||||
admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""
|
||||
Close a room by removing it from Redis.
|
||||
Also removes all player mappings associated with the room.
|
||||
Socket notifications should be handled by the caller through the socket server.
|
||||
"""
|
||||
await room_manager.connect()
|
||||
|
||||
# Check if room exists
|
||||
room_data = await room_manager.get_room(room_code)
|
||||
if not room_data:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Get all player socket IDs to clean up player mappings
|
||||
player_sockets = []
|
||||
for team in ["A", "B"]:
|
||||
for player in room_data.get("teams", {}).get(team, []):
|
||||
socket_id = player.get("socket_id")
|
||||
if socket_id:
|
||||
player_sockets.append(socket_id)
|
||||
|
||||
# Delete player mappings
|
||||
for socket_id in player_sockets:
|
||||
await room_manager.redis.delete(f"player:{socket_id}")
|
||||
|
||||
# Delete room stats for all players
|
||||
for team in ["A", "B"]:
|
||||
for player in room_data.get("teams", {}).get(team, []):
|
||||
player_name = player.get("name")
|
||||
if player_name:
|
||||
await room_manager.redis.delete(f"stats:{room_code}:{player_name}")
|
||||
|
||||
# Delete the room itself
|
||||
await room_manager.redis.delete(f"room:{room_code}")
|
||||
|
||||
return {
|
||||
"status": "closed",
|
||||
"room_code": room_code,
|
||||
"players_affected": len(player_sockets)
|
||||
}
|
||||
|
||||
19
backend/app/data/config.json
Normal file
19
backend/app/data/config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"points_by_difficulty": {
|
||||
"1": 100,
|
||||
"2": 200,
|
||||
"3": 300,
|
||||
"4": 400,
|
||||
"5": 500
|
||||
},
|
||||
"times_by_difficulty": {
|
||||
"1": 15,
|
||||
"2": 20,
|
||||
"3": 25,
|
||||
"4": 35,
|
||||
"5": 45
|
||||
},
|
||||
"steal_penalty_percent": 50,
|
||||
"max_players_per_team": 4,
|
||||
"steal_time_percent": 50
|
||||
}
|
||||
22
backend/app/schemas/game_config.py
Normal file
22
backend/app/schemas/game_config.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class GameSettingsSchema(BaseModel):
|
||||
"""Schema for game configuration settings."""
|
||||
points_by_difficulty: Dict[str, int]
|
||||
times_by_difficulty: Dict[str, int]
|
||||
steal_penalty_percent: int
|
||||
max_players_per_team: int
|
||||
steal_time_percent: int
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"points_by_difficulty": {"1": 100, "2": 200, "3": 300, "4": 400, "5": 500},
|
||||
"times_by_difficulty": {"1": 15, "2": 20, "3": 25, "4": 35, "5": 45},
|
||||
"steal_penalty_percent": 50,
|
||||
"max_players_per_team": 4,
|
||||
"steal_time_percent": 50
|
||||
}
|
||||
}
|
||||
78
backend/app/services/game_config.py
Normal file
78
backend/app/services/game_config.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Service for managing dynamic game configuration."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.schemas.game_config import GameSettingsSchema
|
||||
|
||||
|
||||
# Path to the config file
|
||||
CONFIG_FILE = Path(__file__).parent.parent / "data" / "config.json"
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"points_by_difficulty": {"1": 100, "2": 200, "3": 300, "4": 400, "5": 500},
|
||||
"times_by_difficulty": {"1": 15, "2": 20, "3": 25, "4": 35, "5": 45},
|
||||
"steal_penalty_percent": 50,
|
||||
"max_players_per_team": 4,
|
||||
"steal_time_percent": 50,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_config_file():
|
||||
"""Ensure the config file exists with default values."""
|
||||
if not CONFIG_FILE.exists():
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(DEFAULT_CONFIG, f, indent=2)
|
||||
|
||||
|
||||
def get_game_settings() -> Dict[str, Any]:
|
||||
"""Load game settings from JSON file."""
|
||||
_ensure_config_file()
|
||||
try:
|
||||
with open(CONFIG_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
# Return defaults if file is corrupted
|
||||
return DEFAULT_CONFIG.copy()
|
||||
|
||||
|
||||
def update_game_settings(settings: GameSettingsSchema) -> Dict[str, Any]:
|
||||
"""Update game settings in JSON file."""
|
||||
_ensure_config_file()
|
||||
config = settings.model_dump()
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
return config
|
||||
|
||||
|
||||
def get_points_for_difficulty(difficulty: int) -> int:
|
||||
"""Get points for a specific difficulty level."""
|
||||
settings = get_game_settings()
|
||||
return settings["points_by_difficulty"].get(str(difficulty), 300)
|
||||
|
||||
|
||||
def get_time_for_difficulty(difficulty: int) -> int:
|
||||
"""Get time (in seconds) for a specific difficulty level."""
|
||||
settings = get_game_settings()
|
||||
return settings["times_by_difficulty"].get(str(difficulty), 25)
|
||||
|
||||
|
||||
def get_steal_penalty_multiplier() -> float:
|
||||
"""Get steal penalty as a multiplier (0-1)."""
|
||||
settings = get_game_settings()
|
||||
return settings["steal_penalty_percent"] / 100.0
|
||||
|
||||
|
||||
def get_steal_time_multiplier() -> float:
|
||||
"""Get steal time as a multiplier (0-1)."""
|
||||
settings = get_game_settings()
|
||||
return settings["steal_time_percent"] / 100.0
|
||||
|
||||
|
||||
def get_max_players_per_team() -> int:
|
||||
"""Get maximum players allowed per team."""
|
||||
settings = get_game_settings()
|
||||
return settings["max_players_per_team"]
|
||||
@@ -1,4 +1,5 @@
|
||||
import socketio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from app.services.room_manager import room_manager
|
||||
from app.services.game_manager import game_manager
|
||||
@@ -8,6 +9,11 @@ from app.schemas.achievement import PlayerStats
|
||||
from app.models.base import get_async_session
|
||||
|
||||
|
||||
# Rate limiting para reacciones: {room_code: {player_name: last_reaction_timestamp}}
|
||||
reaction_rate_limits: dict[str, dict[str, float]] = {}
|
||||
REACTION_COOLDOWN_SECONDS = 3
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
"""Helper para obtener sesion de BD en contexto de socket."""
|
||||
AsyncSessionLocal = get_async_session()
|
||||
@@ -353,13 +359,56 @@ def register_socket_events(sio: socketio.AsyncServer):
|
||||
)
|
||||
|
||||
@sio.event
|
||||
async def emoji_reaction(sid, data):
|
||||
"""Send an emoji reaction visible to all."""
|
||||
async def team_message(sid, data):
|
||||
"""Send a team chat message - only visible to teammates."""
|
||||
room_code = data.get("room_code", "")
|
||||
team = data.get("team", "")
|
||||
player_name = data.get("player_name", "")
|
||||
message = data.get("message", "")[:500] # Limit message length
|
||||
|
||||
if not all([room_code, team, player_name, message]):
|
||||
return
|
||||
|
||||
# Validate player exists in room
|
||||
player = await room_manager.get_player(sid)
|
||||
if not player or player["room"] != room_code or player["team"] != team:
|
||||
return
|
||||
|
||||
# Get room data to find team members
|
||||
room = await room_manager.get_room(room_code)
|
||||
if not room:
|
||||
return
|
||||
|
||||
# Get socket IDs of team members
|
||||
team_sockets = [
|
||||
p["socket_id"] for p in room["teams"][team]
|
||||
if p.get("socket_id")
|
||||
]
|
||||
|
||||
# Emit only to team members
|
||||
message_data = {
|
||||
"player_name": player_name,
|
||||
"team": team,
|
||||
"message": message,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
for socket_id in team_sockets:
|
||||
await sio.emit(
|
||||
"receive_team_message",
|
||||
message_data,
|
||||
to=socket_id
|
||||
)
|
||||
|
||||
@sio.event
|
||||
async def send_reaction(sid, data):
|
||||
"""Send an emoji reaction visible to all players in the room."""
|
||||
player = await room_manager.get_player(sid)
|
||||
if not player:
|
||||
return
|
||||
|
||||
room_code = player["room"]
|
||||
room_code = data.get("room_code", player["room"])
|
||||
player_name = data.get("player_name", player["name"])
|
||||
emoji = data.get("emoji", "")
|
||||
|
||||
# Validate emoji
|
||||
@@ -367,16 +416,37 @@ def register_socket_events(sio: socketio.AsyncServer):
|
||||
if emoji not in allowed_emojis:
|
||||
return
|
||||
|
||||
# Rate limiting: max 1 reaction every 3 seconds per player
|
||||
current_time = time.time()
|
||||
if room_code not in reaction_rate_limits:
|
||||
reaction_rate_limits[room_code] = {}
|
||||
|
||||
last_reaction = reaction_rate_limits[room_code].get(player_name, 0)
|
||||
if current_time - last_reaction < REACTION_COOLDOWN_SECONDS:
|
||||
# Player is rate limited, ignore the reaction
|
||||
return
|
||||
|
||||
# Update last reaction time
|
||||
reaction_rate_limits[room_code][player_name] = current_time
|
||||
|
||||
# Emit to ALL players in the room (both teams)
|
||||
await sio.emit(
|
||||
"emoji_reaction",
|
||||
"receive_reaction",
|
||||
{
|
||||
"player_name": player["name"],
|
||||
"player_name": player_name,
|
||||
"team": player["team"],
|
||||
"emoji": emoji
|
||||
"emoji": emoji,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
},
|
||||
room=room_code
|
||||
)
|
||||
|
||||
# Keep old event name for backwards compatibility
|
||||
@sio.event
|
||||
async def emoji_reaction(sid, data):
|
||||
"""Alias for send_reaction (backwards compatibility)."""
|
||||
await send_reaction(sid, data)
|
||||
|
||||
@sio.event
|
||||
async def timer_expired(sid, data):
|
||||
"""Handle timer expiration."""
|
||||
|
||||
Reference in New Issue
Block a user