initial
This commit is contained in:
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
89
backend/app.py
Normal file
89
backend/app.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
from flask import Flask, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from flask_migrate import Migrate
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
from backend.models import db
|
||||
from backend.config import config
|
||||
|
||||
# Initialize extensions
|
||||
migrate = Migrate()
|
||||
socketio = SocketIO(cors_allowed_origins="*", async_mode='eventlet')
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""Flask application factory"""
|
||||
if config_name is None:
|
||||
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||
|
||||
app = Flask(__name__,
|
||||
static_folder='static',
|
||||
static_url_path='/static')
|
||||
|
||||
# Load configuration
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Configure session for OAuth state management
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Allow cross-site on redirects
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
|
||||
# Ensure required directories exist
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
os.makedirs(app.config['AUDIO_FOLDER'], exist_ok=True)
|
||||
|
||||
# Ensure database instance directory exists
|
||||
db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
|
||||
if db_path and db_path != ':memory:':
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
socketio.init_app(app)
|
||||
CORS(app,
|
||||
resources={r"/api/*": {"origins": app.config['CORS_ORIGINS']}},
|
||||
supports_credentials=True)
|
||||
|
||||
# Initialize OAuth/OIDC
|
||||
from backend.auth import init_oauth
|
||||
init_oauth(app)
|
||||
|
||||
# Register blueprints
|
||||
from backend.routes import questions, games, teams, admin, categories, download_jobs, auth
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(questions.bp)
|
||||
app.register_blueprint(games.bp)
|
||||
app.register_blueprint(teams.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(categories.bp)
|
||||
app.register_blueprint(download_jobs.bp)
|
||||
|
||||
# Register socket events
|
||||
from backend.sockets import events
|
||||
|
||||
# Serve React frontend in production
|
||||
@app.route('/', defaults={'path': ''})
|
||||
@app.route('/<path:path>')
|
||||
def serve_frontend(path):
|
||||
"""Serve React frontend"""
|
||||
if path and os.path.exists(os.path.join(app.static_folder, path)):
|
||||
return send_from_directory(app.static_folder, path)
|
||||
elif path.startswith('api/'):
|
||||
# API routes should 404 if not found
|
||||
return {'error': 'Not found'}, 404
|
||||
else:
|
||||
# Serve index.html for all other routes (React Router)
|
||||
index_path = os.path.join(app.static_folder, 'index.html')
|
||||
if os.path.exists(index_path):
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
else:
|
||||
return {'message': 'Trivia Game API', 'status': 'running'}, 200
|
||||
|
||||
# Health check endpoint
|
||||
@app.route('/api/health')
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {'status': 'ok', 'message': 'Trivia Game API is running'}, 200
|
||||
|
||||
return app
|
||||
25
backend/auth/__init__.py
Normal file
25
backend/auth/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
oauth = OAuth()
|
||||
|
||||
|
||||
def init_oauth(app):
|
||||
"""Initialize OAuth/OIDC client"""
|
||||
oauth.init_app(app)
|
||||
|
||||
# Only register Authelia provider if OIDC_ISSUER is configured
|
||||
if app.config.get('OIDC_ISSUER'):
|
||||
oauth.register(
|
||||
name='authelia',
|
||||
client_id=app.config['OIDC_CLIENT_ID'],
|
||||
client_secret=app.config['OIDC_CLIENT_SECRET'],
|
||||
server_metadata_url=app.config['OIDC_ISSUER'] + '/.well-known/openid-configuration',
|
||||
client_kwargs={
|
||||
'scope': 'openid email profile',
|
||||
'token_endpoint_auth_method': 'client_secret_basic'
|
||||
}
|
||||
)
|
||||
else:
|
||||
app.logger.warning('OIDC_ISSUER not configured - OAuth authentication disabled')
|
||||
|
||||
return oauth
|
||||
131
backend/auth/middleware.py
Normal file
131
backend/auth/middleware.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, current_app, g
|
||||
from authlib.jose import jwt, JoseError, JsonWebKey
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from backend.models import db, User
|
||||
|
||||
|
||||
# Simple in-memory cache for JWKS (in production, use Redis with TTL)
|
||||
_jwks_cache = {'data': None, 'timestamp': None}
|
||||
|
||||
|
||||
def get_jwks():
|
||||
"""Fetch JWKS from Authelia (with basic caching)"""
|
||||
from datetime import timedelta
|
||||
|
||||
# Check cache (24-hour TTL)
|
||||
if _jwks_cache['data'] and _jwks_cache['timestamp']:
|
||||
if datetime.utcnow() - _jwks_cache['timestamp'] < timedelta(hours=24):
|
||||
return _jwks_cache['data']
|
||||
|
||||
# Fetch JWKS
|
||||
jwks_uri = current_app.config.get('OIDC_JWKS_URI')
|
||||
if not jwks_uri:
|
||||
# Fetch from discovery document
|
||||
issuer = current_app.config['OIDC_ISSUER']
|
||||
discovery_url = f"{issuer}/.well-known/openid-configuration"
|
||||
discovery = requests.get(discovery_url, timeout=10).json()
|
||||
jwks_uri = discovery['jwks_uri']
|
||||
|
||||
jwks_data = requests.get(jwks_uri, timeout=10).json()
|
||||
|
||||
# Update cache
|
||||
_jwks_cache['data'] = jwks_data
|
||||
_jwks_cache['timestamp'] = datetime.utcnow()
|
||||
|
||||
return jwks_data
|
||||
|
||||
|
||||
def validate_jwt(token):
|
||||
"""Validate JWT token and return claims"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
jwks = get_jwks()
|
||||
|
||||
# Decode and validate JWT
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
jwks,
|
||||
claims_options={
|
||||
'iss': {'essential': True, 'value': current_app.config['OIDC_ISSUER']},
|
||||
'aud': {'essential': True, 'values': [current_app.config['OIDC_AUDIENCE']]},
|
||||
}
|
||||
)
|
||||
claims.validate()
|
||||
return claims
|
||||
|
||||
except JoseError as e:
|
||||
current_app.logger.error(f"JWT validation failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Unexpected error during JWT validation: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""Decorator to require authentication on any route"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
|
||||
|
||||
token = auth_header.split(' ')[1]
|
||||
claims = validate_jwt(token)
|
||||
|
||||
if not claims:
|
||||
return jsonify({'error': 'Invalid or expired token'}), 401
|
||||
|
||||
# Get or create user from claims
|
||||
user = User.query.filter_by(authelia_sub=claims['sub']).first()
|
||||
if not user:
|
||||
# Auto-create user on first login
|
||||
groups_claim = current_app.config.get('OIDC_GROUPS_CLAIM', 'groups')
|
||||
user = User(
|
||||
authelia_sub=claims['sub'],
|
||||
email=claims.get('email'),
|
||||
name=claims.get('name'),
|
||||
preferred_username=claims.get('preferred_username'),
|
||||
groups=claims.get(groups_claim, [])
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
else:
|
||||
# Update user info from latest token
|
||||
groups_claim = current_app.config.get('OIDC_GROUPS_CLAIM', 'groups')
|
||||
user.email = claims.get('email')
|
||||
user.name = claims.get('name')
|
||||
user.preferred_username = claims.get('preferred_username')
|
||||
user.groups = claims.get(groups_claim, [])
|
||||
user.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
return jsonify({'error': 'User account is disabled'}), 403
|
||||
|
||||
# Store user in Flask's g object for access in route handlers
|
||||
g.current_user = user
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_admin(f):
|
||||
"""Decorator to require admin role (must be used WITH @require_auth)"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not hasattr(g, 'current_user'):
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
|
||||
if not g.current_user.is_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
29
backend/celery_app.py
Normal file
29
backend/celery_app.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from celery import Celery
|
||||
from backend.config import config
|
||||
import os
|
||||
|
||||
|
||||
def make_celery():
|
||||
"""Create Celery instance"""
|
||||
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||
app_config = config.get(config_name, config['default'])
|
||||
|
||||
celery = Celery(
|
||||
'trivia_tasks',
|
||||
broker=app_config.CELERY_BROKER_URL,
|
||||
backend=app_config.CELERY_RESULT_BACKEND
|
||||
)
|
||||
|
||||
celery.conf.update(
|
||||
task_track_started=True,
|
||||
task_time_limit=app_config.CELERY_TASK_TIME_LIMIT,
|
||||
result_expires=3600, # Results expire after 1 hour
|
||||
)
|
||||
|
||||
return celery
|
||||
|
||||
|
||||
celery = make_celery()
|
||||
|
||||
# Import tasks to register them with Celery
|
||||
from backend.tasks import youtube_tasks # noqa: E402, F401
|
||||
84
backend/config.py
Normal file
84
backend/config.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Base directory
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
|
||||
# Database configuration
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or \
|
||||
f'sqlite:///{(BASE_DIR / "backend" / "instance" / "trivia.db").absolute()}'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# File upload configuration
|
||||
UPLOAD_FOLDER = BASE_DIR / "backend" / "static" / "images"
|
||||
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB max file size
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||
|
||||
# Audio upload configuration
|
||||
AUDIO_FOLDER = BASE_DIR / "backend" / "static" / "audio"
|
||||
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'm4a', 'aac', 'wav'}
|
||||
MAX_AUDIO_LENGTH = 300 # 5 minutes in seconds
|
||||
MAX_AUDIO_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
# Celery configuration
|
||||
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
|
||||
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 600 # 10 minutes max per task
|
||||
|
||||
# yt-dlp configuration
|
||||
YTDLP_FORMAT = 'bestaudio/best'
|
||||
YTDLP_POSTPROCESSOR = 'mp3'
|
||||
YTDLP_QUALITY = '192' # kbps
|
||||
|
||||
# CORS configuration
|
||||
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',')
|
||||
CORS_SUPPORTS_CREDENTIALS = True
|
||||
|
||||
# OIDC/Authelia configuration
|
||||
OIDC_ISSUER = os.environ.get('OIDC_ISSUER')
|
||||
OIDC_CLIENT_ID = os.environ.get('OIDC_CLIENT_ID', 'trivia-app')
|
||||
OIDC_CLIENT_SECRET = os.environ.get('OIDC_CLIENT_SECRET')
|
||||
OIDC_REDIRECT_URI = os.environ.get('OIDC_REDIRECT_URI', 'http://localhost:5001/api/auth/callback')
|
||||
OIDC_AUDIENCE = os.environ.get('OIDC_AUDIENCE', OIDC_CLIENT_ID)
|
||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
|
||||
OIDC_JWKS_URI = os.environ.get('OIDC_JWKS_URI') # Optional, auto-fetched if not set
|
||||
|
||||
# Cookie security
|
||||
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Strict'
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
DEBUG = True
|
||||
FLASK_ENV = 'development'
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
DEBUG = False
|
||||
FLASK_ENV = 'production'
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') # Must be set in production
|
||||
|
||||
|
||||
class TestConfig(Config):
|
||||
"""Test configuration"""
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
|
||||
# Configuration dictionary
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'test': TestConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
278
backend/models.py
Normal file
278
backend/models.py
Normal file
@@ -0,0 +1,278 @@
|
||||
from datetime import datetime
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import Enum
|
||||
import enum
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User model for authenticated users via Authelia OIDC"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
authelia_sub = db.Column(db.String(255), unique=True, nullable=False) # OIDC 'sub' claim
|
||||
email = db.Column(db.String(255), nullable=True) # Email may not always be provided by OIDC
|
||||
name = db.Column(db.String(255), nullable=True)
|
||||
preferred_username = db.Column(db.String(100), nullable=True)
|
||||
groups = db.Column(db.JSON, default=list, nullable=False) # Array of group names from OIDC
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
"""Check if user is in trivia-admins group"""
|
||||
return 'trivia-admins' in (self.groups or [])
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert user to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'email': self.email,
|
||||
'name': self.name,
|
||||
'username': self.preferred_username,
|
||||
'groups': self.groups,
|
||||
'is_admin': self.is_admin,
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None
|
||||
}
|
||||
|
||||
|
||||
class QuestionType(enum.Enum):
|
||||
"""Enum for question types"""
|
||||
TEXT = "text"
|
||||
IMAGE = "image"
|
||||
YOUTUBE_AUDIO = "youtube_audio"
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
"""Category model for organizing questions"""
|
||||
__tablename__ = 'categories'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert category to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
class Question(db.Model):
|
||||
"""Question model for trivia questions"""
|
||||
__tablename__ = 'questions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
type = db.Column(Enum(QuestionType), nullable=False, default=QuestionType.TEXT)
|
||||
question_content = db.Column(db.Text, nullable=False)
|
||||
answer = db.Column(db.Text, nullable=False)
|
||||
image_path = db.Column(db.String(255), nullable=True) # For image questions
|
||||
category = db.Column(db.String(100), nullable=True) # Question category (e.g., "History", "Science")
|
||||
|
||||
# YouTube audio fields
|
||||
youtube_url = db.Column(db.String(500), nullable=True) # YouTube video URL
|
||||
audio_path = db.Column(db.String(255), nullable=True) # Path to trimmed audio file
|
||||
start_time = db.Column(db.Integer, nullable=True) # Start time in seconds
|
||||
end_time = db.Column(db.Integer, nullable=True) # End time in seconds
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
game_questions = db.relationship('GameQuestion', back_populates='question', cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self, include_answer=False):
|
||||
"""Convert question to dictionary"""
|
||||
data = {
|
||||
'id': self.id,
|
||||
'type': self.type.value,
|
||||
'question_content': self.question_content,
|
||||
'image_path': self.image_path,
|
||||
'youtube_url': self.youtube_url,
|
||||
'audio_path': self.audio_path,
|
||||
'start_time': self.start_time,
|
||||
'end_time': self.end_time,
|
||||
'category': self.category,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
if include_answer:
|
||||
data['answer'] = self.answer
|
||||
return data
|
||||
|
||||
|
||||
class Game(db.Model):
|
||||
"""Game model representing a trivia game session"""
|
||||
__tablename__ = 'games'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
is_active = db.Column(db.Boolean, default=False) # Only one game active at a time
|
||||
current_question_index = db.Column(db.Integer, default=0) # Track current question
|
||||
is_template = db.Column(db.Boolean, default=False) # Mark as reusable template
|
||||
|
||||
# Relationships
|
||||
teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan')
|
||||
game_questions = db.relationship('GameQuestion', back_populates='game',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='GameQuestion.order')
|
||||
scores = db.relationship('Score', back_populates='game', cascade='all, delete-orphan')
|
||||
|
||||
@classmethod
|
||||
def get_active(cls):
|
||||
"""Get the currently active game"""
|
||||
return cls.query.filter_by(is_active=True).first()
|
||||
|
||||
def get_current_question(self):
|
||||
"""Get the current question in the game"""
|
||||
if 0 <= self.current_question_index < len(self.game_questions):
|
||||
return self.game_questions[self.current_question_index].question
|
||||
return None
|
||||
|
||||
def to_dict(self, include_questions=False, include_teams=False):
|
||||
"""Convert game to dictionary"""
|
||||
data = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'is_active': self.is_active,
|
||||
'current_question_index': self.current_question_index,
|
||||
'total_questions': len(self.game_questions),
|
||||
'is_template': self.is_template
|
||||
}
|
||||
|
||||
if include_questions:
|
||||
data['questions'] = [gq.to_dict() for gq in self.game_questions]
|
||||
|
||||
if include_teams:
|
||||
data['teams'] = [team.to_dict() for team in self.teams]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class GameQuestion(db.Model):
|
||||
"""Junction table linking games to questions with ordering"""
|
||||
__tablename__ = 'game_questions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False)
|
||||
question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False)
|
||||
order = db.Column(db.Integer, nullable=False) # Question order in game
|
||||
|
||||
# Relationships
|
||||
game = db.relationship('Game', back_populates='game_questions')
|
||||
question = db.relationship('Question', back_populates='game_questions')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('game_id', 'order', name='unique_game_question_order'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'order': self.order,
|
||||
'question': self.question.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
"""Team model representing a trivia team"""
|
||||
__tablename__ = 'teams'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
phone_a_friend_count = db.Column(db.Integer, default=5) # Number of phone-a-friend lifelines
|
||||
|
||||
# Relationships
|
||||
game = db.relationship('Game', back_populates='teams')
|
||||
scores = db.relationship('Score', back_populates='team', cascade='all, delete-orphan')
|
||||
|
||||
@property
|
||||
def total_score(self):
|
||||
"""Calculate total score for the team"""
|
||||
return sum(score.points for score in self.scores)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert team to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'game_id': self.game_id,
|
||||
'total_score': self.total_score,
|
||||
'phone_a_friend_count': self.phone_a_friend_count,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
class Score(db.Model):
|
||||
"""Score model tracking points per team per question"""
|
||||
__tablename__ = 'scores'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||
game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False)
|
||||
question_index = db.Column(db.Integer, nullable=False) # Which question this score is for
|
||||
points = db.Column(db.Integer, default=0)
|
||||
awarded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
team = db.relationship('Team', back_populates='scores')
|
||||
game = db.relationship('Game', back_populates='scores')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('team_id', 'question_index', name='unique_team_question_score'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert score to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'team_id': self.team_id,
|
||||
'game_id': self.game_id,
|
||||
'question_index': self.question_index,
|
||||
'points': self.points,
|
||||
'awarded_at': self.awarded_at.isoformat() if self.awarded_at else None
|
||||
}
|
||||
|
||||
|
||||
class DownloadJobStatus(enum.Enum):
|
||||
"""Enum for download job statuses"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DownloadJob(db.Model):
|
||||
"""Track YouTube audio download jobs"""
|
||||
__tablename__ = 'download_jobs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False)
|
||||
celery_task_id = db.Column(db.String(255), nullable=False, unique=True)
|
||||
status = db.Column(Enum(DownloadJobStatus), default=DownloadJobStatus.PENDING, nullable=False)
|
||||
progress = db.Column(db.Integer, default=0) # 0-100
|
||||
error_message = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
question = db.relationship('Question', backref='download_job')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert download job to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'question_id': self.question_id,
|
||||
'task_id': self.celery_task_id,
|
||||
'status': self.status.value,
|
||||
'progress': self.progress,
|
||||
'error_message': self.error_message,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None
|
||||
}
|
||||
0
backend/routes/__init__.py
Normal file
0
backend/routes/__init__.py
Normal file
304
backend/routes/admin.py
Normal file
304
backend/routes/admin.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from backend.models import db, Game, Team
|
||||
from backend.services import game_service
|
||||
from backend.app import socketio
|
||||
from backend.auth.middleware import require_auth
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/start', methods=['POST'])
|
||||
@require_auth
|
||||
def start_game(game_id):
|
||||
"""Start/activate a game"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game_service.start_game(game, socketio)
|
||||
return jsonify({'message': 'Game started successfully', 'game': game.to_dict()}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/next', methods=['POST'])
|
||||
@require_auth
|
||||
def next_question(game_id):
|
||||
"""Move to next question"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
if game_service.next_question(game, socketio):
|
||||
return jsonify({'message': 'Moved to next question', 'current_index': game.current_question_index}), 200
|
||||
else:
|
||||
return jsonify({'error': 'Already at last question'}), 400
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/prev', methods=['POST'])
|
||||
@require_auth
|
||||
def previous_question(game_id):
|
||||
"""Move to previous question"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
if game_service.previous_question(game, socketio):
|
||||
return jsonify({'message': 'Moved to previous question', 'current_index': game.current_question_index}), 200
|
||||
else:
|
||||
return jsonify({'error': 'Already at first question'}), 400
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/award', methods=['POST'])
|
||||
@require_auth
|
||||
def award_points(game_id):
|
||||
"""Award points to a team
|
||||
|
||||
Expected JSON: { "team_id": int, "points": int }
|
||||
"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'team_id' not in data or 'points' not in data:
|
||||
return jsonify({'error': 'team_id and points are required'}), 400
|
||||
|
||||
team_id = data['team_id']
|
||||
points = data['points']
|
||||
|
||||
team = Team.query.get_or_404(team_id)
|
||||
|
||||
# Verify team belongs to this game
|
||||
if team.game_id != game_id:
|
||||
return jsonify({'error': 'Team does not belong to this game'}), 400
|
||||
|
||||
try:
|
||||
game_service.award_points(game, team, points, socketio)
|
||||
return jsonify({'message': 'Points awarded successfully', 'team': team.to_dict()}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/current', methods=['GET'])
|
||||
@require_auth
|
||||
def get_current_state(game_id):
|
||||
"""Get current game state with answer (admin only)"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
state = game_service.get_admin_game_state(game)
|
||||
return jsonify(state), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/toggle-answer', methods=['POST'])
|
||||
@require_auth
|
||||
def toggle_answer_visibility(game_id):
|
||||
"""Toggle answer visibility on contestant screen
|
||||
|
||||
Expected JSON: { "show_answer": bool }
|
||||
"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'show_answer' not in data:
|
||||
return jsonify({'error': 'show_answer is required'}), 400
|
||||
|
||||
show_answer = data['show_answer']
|
||||
|
||||
try:
|
||||
game_service.toggle_answer_visibility(game, show_answer, socketio)
|
||||
return jsonify({'message': 'Answer visibility toggled', 'show_answer': show_answer}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/pause-timer', methods=['POST'])
|
||||
@require_auth
|
||||
def pause_timer(game_id):
|
||||
"""Pause or resume the timer
|
||||
|
||||
Expected JSON: { "paused": bool }
|
||||
"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'paused' not in data:
|
||||
return jsonify({'error': 'paused is required'}), 400
|
||||
|
||||
paused = data['paused']
|
||||
|
||||
try:
|
||||
game_service.toggle_timer_pause(game, paused, socketio)
|
||||
return jsonify({'message': 'Timer pause state updated', 'paused': paused}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/reset-timer', methods=['POST'])
|
||||
@require_auth
|
||||
def reset_timer(game_id):
|
||||
"""Reset the timer to 30 seconds"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game_service.reset_timer(game, socketio)
|
||||
return jsonify({'message': 'Timer reset'}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/end', methods=['POST'])
|
||||
@require_auth
|
||||
def end_game(game_id):
|
||||
"""End/deactivate a game"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game_service.end_game(game, socketio)
|
||||
return jsonify({'message': 'Game ended successfully', 'game': game.to_dict()}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/restart', methods=['POST'])
|
||||
@require_auth
|
||||
def restart_game(game_id):
|
||||
"""Restart a game (clear scores and reset to waiting state)"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game_service.restart_game(game, socketio)
|
||||
return jsonify({'message': 'Game restarted successfully', 'game': game.to_dict()}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/team/<int:team_id>/use-lifeline', methods=['POST'])
|
||||
@require_auth
|
||||
def use_lifeline(game_id, team_id):
|
||||
"""Use a phone-a-friend lifeline for a team"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
team = Team.query.get_or_404(team_id)
|
||||
|
||||
# Verify team belongs to this game
|
||||
if team.game_id != game_id:
|
||||
return jsonify({'error': 'Team does not belong to this game'}), 400
|
||||
|
||||
if team.phone_a_friend_count <= 0:
|
||||
return jsonify({'error': 'No lifelines remaining'}), 400
|
||||
|
||||
try:
|
||||
team.phone_a_friend_count -= 1
|
||||
db.session.commit()
|
||||
|
||||
# Broadcast lifeline update
|
||||
game_service.broadcast_lifeline_update(game, team, socketio)
|
||||
|
||||
return jsonify({'message': 'Lifeline used', 'team': team.to_dict()}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/team/<int:team_id>/add-lifeline', methods=['POST'])
|
||||
@require_auth
|
||||
def add_lifeline(game_id, team_id):
|
||||
"""Add a phone-a-friend lifeline to a team"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
team = Team.query.get_or_404(team_id)
|
||||
|
||||
# Verify team belongs to this game
|
||||
if team.game_id != game_id:
|
||||
return jsonify({'error': 'Team does not belong to this game'}), 400
|
||||
|
||||
try:
|
||||
team.phone_a_friend_count += 1
|
||||
db.session.commit()
|
||||
|
||||
# Broadcast lifeline update
|
||||
game_service.broadcast_lifeline_update(game, team, socketio)
|
||||
|
||||
return jsonify({'message': 'Lifeline added', 'team': team.to_dict()}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/audio/play', methods=['POST'])
|
||||
@require_auth
|
||||
def play_audio(game_id):
|
||||
"""Admin controls audio playback for contestants"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game_service.broadcast_audio_play(game, socketio)
|
||||
return jsonify({'message': 'Audio play command sent'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/audio/pause', methods=['POST'])
|
||||
@require_auth
|
||||
def pause_audio(game_id):
|
||||
"""Pause audio for all contestants"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game_service.broadcast_audio_pause(game, socketio)
|
||||
return jsonify({'message': 'Audio pause command sent'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/audio/stop', methods=['POST'])
|
||||
@require_auth
|
||||
def stop_audio(game_id):
|
||||
"""Stop and reset audio for all contestants"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game_service.broadcast_audio_stop(game, socketio)
|
||||
return jsonify({'message': 'Audio stop command sent'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/game/<int:game_id>/audio/seek', methods=['POST'])
|
||||
@require_auth
|
||||
def seek_audio(game_id):
|
||||
"""Seek audio to specific position
|
||||
|
||||
Expected JSON: { "position": float }
|
||||
"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'position' not in data:
|
||||
return jsonify({'error': 'position is required'}), 400
|
||||
|
||||
try:
|
||||
position = float(data['position'])
|
||||
game_service.broadcast_audio_seek(game, position, socketio)
|
||||
return jsonify({'message': f'Audio seeked to {position}s'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
124
backend/routes/auth.py
Normal file
124
backend/routes/auth.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from flask import Blueprint, request, jsonify, redirect, make_response, current_app, g
|
||||
from backend.auth import oauth
|
||||
from backend.auth.middleware import require_auth
|
||||
from backend.models import db, User
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/api/auth')
|
||||
|
||||
|
||||
@bp.route('/login')
|
||||
def login():
|
||||
"""Redirect to Authelia login page"""
|
||||
redirect_uri = current_app.config['OIDC_REDIRECT_URI']
|
||||
return oauth.authelia.authorize_redirect(redirect_uri)
|
||||
|
||||
|
||||
@bp.route('/callback')
|
||||
def callback():
|
||||
"""Handle OIDC callback from Authelia"""
|
||||
try:
|
||||
# Exchange authorization code for tokens
|
||||
token = oauth.authelia.authorize_access_token()
|
||||
|
||||
# Parse ID token to get user info
|
||||
user_info = token.get('userinfo')
|
||||
if not user_info:
|
||||
user_info = oauth.authelia.parse_id_token(token)
|
||||
|
||||
# Get or create user
|
||||
user = User.query.filter_by(authelia_sub=user_info['sub']).first()
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
authelia_sub=user_info['sub'],
|
||||
email=user_info.get('email'),
|
||||
name=user_info.get('name'),
|
||||
preferred_username=user_info.get('preferred_username'),
|
||||
groups=user_info.get('groups', [])
|
||||
)
|
||||
db.session.add(user)
|
||||
else:
|
||||
user.email = user_info.get('email')
|
||||
user.name = user_info.get('name')
|
||||
user.preferred_username = user_info.get('preferred_username')
|
||||
user.groups = user_info.get('groups', [])
|
||||
user.last_login = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Redirect to frontend with tokens in URL fragment (SPA pattern)
|
||||
frontend_url = current_app.config.get('FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
# Create response with refresh token in HTTP-only cookie
|
||||
response = make_response(redirect(
|
||||
f"{frontend_url}/auth/callback#access_token={token['access_token']}"
|
||||
f"&id_token={token['id_token']}"
|
||||
f"&expires_in={token.get('expires_in', 900)}"
|
||||
))
|
||||
|
||||
# Set refresh token as HTTP-only cookie
|
||||
if token.get('refresh_token'):
|
||||
response.set_cookie(
|
||||
'refresh_token',
|
||||
value=token['refresh_token'],
|
||||
httponly=True,
|
||||
secure=current_app.config.get('SESSION_COOKIE_SECURE', False),
|
||||
samesite='Strict',
|
||||
max_age=7*24*60*60 # 7 days
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"OIDC callback error: {e}")
|
||||
frontend_url = current_app.config.get('FRONTEND_URL', 'http://localhost:3000')
|
||||
return redirect(f"{frontend_url}/login?error=auth_failed")
|
||||
|
||||
|
||||
@bp.route('/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
"""Refresh access token using refresh token"""
|
||||
refresh_token = request.cookies.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
return jsonify({'error': 'No refresh token'}), 401
|
||||
|
||||
try:
|
||||
# Exchange refresh token for new access token
|
||||
new_token = oauth.authelia.fetch_access_token(
|
||||
grant_type='refresh_token',
|
||||
refresh_token=refresh_token
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'access_token': new_token['access_token'],
|
||||
'expires_in': new_token.get('expires_in', 900)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Token refresh failed: {e}")
|
||||
return jsonify({'error': 'Token refresh failed'}), 401
|
||||
|
||||
|
||||
@bp.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""Logout user and revoke tokens"""
|
||||
# Clear refresh token cookie
|
||||
response = make_response(jsonify({'message': 'Logged out'}), 200)
|
||||
response.set_cookie('refresh_token', '', expires=0)
|
||||
|
||||
# Return Authelia logout URL for frontend to redirect
|
||||
authelia_logout_url = f"{current_app.config['OIDC_ISSUER']}/logout"
|
||||
|
||||
return jsonify({
|
||||
'message': 'Logged out',
|
||||
'logout_url': authelia_logout_url
|
||||
}), 200
|
||||
|
||||
|
||||
@bp.route('/me')
|
||||
@require_auth
|
||||
def get_current_user():
|
||||
"""Get current user info (requires auth)"""
|
||||
return jsonify(g.current_user.to_dict()), 200
|
||||
91
backend/routes/categories.py
Normal file
91
backend/routes/categories.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from backend.models import db, Category
|
||||
|
||||
bp = Blueprint('categories', __name__, url_prefix='/api/categories')
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
def list_categories():
|
||||
"""Get all categories"""
|
||||
categories = Category.query.order_by(Category.name).all()
|
||||
return jsonify([c.to_dict() for c in categories]), 200
|
||||
|
||||
|
||||
@bp.route('/<int:category_id>', methods=['GET'])
|
||||
def get_category(category_id):
|
||||
"""Get a single category by ID"""
|
||||
category = Category.query.get_or_404(category_id)
|
||||
return jsonify(category.to_dict()), 200
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
def create_category():
|
||||
"""Create a new category"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Category name is required'}), 400
|
||||
|
||||
# Check if category already exists
|
||||
existing = Category.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Category already exists'}), 409
|
||||
|
||||
try:
|
||||
category = Category(name=name)
|
||||
db.session.add(category)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(category.to_dict()), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:category_id>', methods=['PUT'])
|
||||
def update_category(category_id):
|
||||
"""Update an existing category"""
|
||||
category = Category.query.get_or_404(category_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Category name is required'}), 400
|
||||
|
||||
# Check if another category with this name exists
|
||||
existing = Category.query.filter(Category.name == name, Category.id != category_id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Category already exists'}), 409
|
||||
|
||||
try:
|
||||
category.name = name
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(category.to_dict()), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:category_id>', methods=['DELETE'])
|
||||
def delete_category(category_id):
|
||||
"""Delete a category"""
|
||||
category = Category.query.get_or_404(category_id)
|
||||
|
||||
try:
|
||||
db.session.delete(category)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Category deleted successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
18
backend/routes/download_jobs.py
Normal file
18
backend/routes/download_jobs.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from backend.models import DownloadJob
|
||||
|
||||
bp = Blueprint('download_jobs', __name__, url_prefix='/api/download-jobs')
|
||||
|
||||
|
||||
@bp.route('/<int:job_id>', methods=['GET'])
|
||||
def get_job_status(job_id):
|
||||
"""Get download job status"""
|
||||
job = DownloadJob.query.get_or_404(job_id)
|
||||
return jsonify(job.to_dict()), 200
|
||||
|
||||
|
||||
@bp.route('/question/<int:question_id>', methods=['GET'])
|
||||
def get_job_by_question(question_id):
|
||||
"""Get download job for a question"""
|
||||
job = DownloadJob.query.filter_by(question_id=question_id).first_or_404()
|
||||
return jsonify(job.to_dict()), 200
|
||||
183
backend/routes/games.py
Normal file
183
backend/routes/games.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from backend.models import db, Game, GameQuestion, Team, Question
|
||||
|
||||
bp = Blueprint('games', __name__, url_prefix='/api/games')
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
def list_games():
|
||||
"""Get all games"""
|
||||
games = Game.query.order_by(Game.created_at.desc()).all()
|
||||
return jsonify([g.to_dict(include_teams=True) for g in games]), 200
|
||||
|
||||
|
||||
@bp.route('/<int:game_id>', methods=['GET'])
|
||||
def get_game(game_id):
|
||||
"""Get a single game by ID with full details"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
return jsonify(game.to_dict(include_questions=True, include_teams=True)), 200
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
def create_game():
|
||||
"""Create a new game"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
name = data.get('name')
|
||||
if not name:
|
||||
return jsonify({'error': 'Game name is required'}), 400
|
||||
|
||||
try:
|
||||
game = Game(name=name)
|
||||
db.session.add(game)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(game.to_dict()), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:game_id>', methods=['DELETE'])
|
||||
def delete_game(game_id):
|
||||
"""Delete a game"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
db.session.delete(game)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Game deleted successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:game_id>/questions', methods=['POST'])
|
||||
def add_questions_to_game(game_id):
|
||||
"""Add questions to a game
|
||||
|
||||
Expects JSON: { "question_ids": [1, 2, 3, ...] }
|
||||
"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'question_ids' not in data:
|
||||
return jsonify({'error': 'question_ids array is required'}), 400
|
||||
|
||||
question_ids = data['question_ids']
|
||||
if not isinstance(question_ids, list):
|
||||
return jsonify({'error': 'question_ids must be an array'}), 400
|
||||
|
||||
try:
|
||||
# Remove existing questions for this game
|
||||
GameQuestion.query.filter_by(game_id=game_id).delete()
|
||||
|
||||
# Add new questions with order
|
||||
for order, question_id in enumerate(question_ids):
|
||||
question = Question.query.get(question_id)
|
||||
if not question:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Question with ID {question_id} not found'}), 404
|
||||
|
||||
game_question = GameQuestion(
|
||||
game_id=game_id,
|
||||
question_id=question_id,
|
||||
order=order
|
||||
)
|
||||
db.session.add(game_question)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(game.to_dict(include_questions=True, include_teams=True)), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:game_id>/teams', methods=['POST'])
|
||||
def add_team_to_game(game_id):
|
||||
"""Add a team to a game
|
||||
|
||||
Expects JSON: { "name": "Team Name" }
|
||||
"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'name' not in data:
|
||||
return jsonify({'error': 'Team name is required'}), 400
|
||||
|
||||
try:
|
||||
team = Team(name=data['name'], game_id=game_id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(team.to_dict()), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:game_id>/save-template', methods=['POST'])
|
||||
def save_as_template(game_id):
|
||||
"""Mark a game as a template for reuse"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
try:
|
||||
game.is_template = True
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(game.to_dict(include_questions=True)), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/templates', methods=['GET'])
|
||||
def list_templates():
|
||||
"""Get all game templates"""
|
||||
templates = Game.query.filter_by(is_template=True).order_by(Game.created_at.desc()).all()
|
||||
return jsonify([g.to_dict(include_questions=True) for g in templates]), 200
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/clone', methods=['POST'])
|
||||
def clone_template(template_id):
|
||||
"""Clone a template to create a new game
|
||||
|
||||
Expects JSON: { "name": "New Game Name" }
|
||||
"""
|
||||
template = Game.query.get_or_404(template_id)
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'name' not in data:
|
||||
return jsonify({'error': 'Game name is required'}), 400
|
||||
|
||||
try:
|
||||
# Create new game
|
||||
new_game = Game(name=data['name'], is_template=False)
|
||||
db.session.add(new_game)
|
||||
db.session.flush() # Get new game ID
|
||||
|
||||
# Clone questions
|
||||
for gq in template.game_questions:
|
||||
new_gq = GameQuestion(
|
||||
game_id=new_game.id,
|
||||
question_id=gq.question_id,
|
||||
order=gq.order
|
||||
)
|
||||
db.session.add(new_gq)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(new_game.to_dict(include_questions=True)), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
343
backend/routes/questions.py
Normal file
343
backend/routes/questions.py
Normal file
@@ -0,0 +1,343 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from backend.models import db, Question, QuestionType
|
||||
from backend.services.image_service import save_image, delete_image
|
||||
|
||||
bp = Blueprint('questions', __name__, url_prefix='/api/questions')
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
def list_questions():
|
||||
"""Get all questions"""
|
||||
questions = Question.query.order_by(Question.created_at.desc()).all()
|
||||
return jsonify([q.to_dict(include_answer=True) for q in questions]), 200
|
||||
|
||||
|
||||
@bp.route('/<int:question_id>', methods=['GET'])
|
||||
def get_question(question_id):
|
||||
"""Get a single question by ID"""
|
||||
question = Question.query.get_or_404(question_id)
|
||||
return jsonify(question.to_dict(include_answer=True)), 200
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
def create_question():
|
||||
"""Create a new question"""
|
||||
try:
|
||||
# Check if it's a multipart form (for image uploads) or JSON
|
||||
if request.content_type and 'multipart/form-data' in request.content_type:
|
||||
# Image question
|
||||
data = request.form
|
||||
question_type = data.get('type', 'text')
|
||||
question_content = data.get('question_content', '')
|
||||
answer = data.get('answer', '')
|
||||
category = data.get('category', '')
|
||||
|
||||
image_path = None
|
||||
if question_type == 'image':
|
||||
if 'image' not in request.files:
|
||||
return jsonify({'error': 'Image file required for image questions'}), 400
|
||||
|
||||
file = request.files['image']
|
||||
try:
|
||||
image_path = save_image(
|
||||
file,
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
current_app.config['ALLOWED_EXTENSIONS']
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
youtube_url = None
|
||||
audio_path = None
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
else:
|
||||
# JSON request for text or YouTube audio questions
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
question_type = data.get('type', 'text')
|
||||
question_content = data.get('question_content', '')
|
||||
answer = data.get('answer', '')
|
||||
category = data.get('category', '')
|
||||
image_path = None
|
||||
|
||||
# Handle YouTube audio questions
|
||||
youtube_url = None
|
||||
audio_path = None
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
if question_type == 'youtube_audio':
|
||||
youtube_url = data.get('youtube_url', '')
|
||||
|
||||
if not youtube_url:
|
||||
return jsonify({'error': 'youtube_url required for YouTube audio questions'}), 400
|
||||
|
||||
# Validate YouTube URL
|
||||
from backend.services.youtube_service import validate_youtube_url, validate_timestamps, get_video_duration
|
||||
|
||||
is_valid, result = validate_youtube_url(youtube_url)
|
||||
if not is_valid:
|
||||
return jsonify({'error': result}), 400
|
||||
|
||||
# Get and validate timestamps
|
||||
try:
|
||||
start_time = int(data.get('start_time', 0))
|
||||
end_time = int(data.get('end_time', 0))
|
||||
except ValueError:
|
||||
return jsonify({'error': 'start_time and end_time must be integers'}), 400
|
||||
|
||||
# Validate timestamp range
|
||||
video_duration = get_video_duration(youtube_url)
|
||||
is_valid, error = validate_timestamps(start_time, end_time, video_duration)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
|
||||
# Note: audio_path will be null until download completes
|
||||
|
||||
# Validation
|
||||
if not question_content:
|
||||
return jsonify({'error': 'question_content is required'}), 400
|
||||
if not answer:
|
||||
return jsonify({'error': 'answer is required'}), 400
|
||||
|
||||
# Create question
|
||||
question = Question(
|
||||
type=QuestionType(question_type),
|
||||
question_content=question_content,
|
||||
answer=answer,
|
||||
image_path=image_path,
|
||||
youtube_url=youtube_url,
|
||||
audio_path=audio_path, # Will be None initially for YouTube
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
category=category if category else None
|
||||
)
|
||||
|
||||
db.session.add(question)
|
||||
db.session.commit()
|
||||
|
||||
# For YouTube audio, start async download
|
||||
if question_type == 'youtube_audio':
|
||||
from backend.tasks.youtube_tasks import download_youtube_audio
|
||||
from backend.models import DownloadJob, DownloadJobStatus
|
||||
|
||||
# Start Celery task
|
||||
task = download_youtube_audio.delay(question.id, youtube_url, start_time, end_time)
|
||||
|
||||
# Create job tracking record
|
||||
job = DownloadJob(
|
||||
question_id=question.id,
|
||||
celery_task_id=task.id,
|
||||
status=DownloadJobStatus.PENDING
|
||||
)
|
||||
db.session.add(job)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'question': question.to_dict(include_answer=True),
|
||||
'job': job.to_dict()
|
||||
}), 202 # 202 Accepted (async processing)
|
||||
|
||||
return jsonify(question.to_dict(include_answer=True)), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:question_id>', methods=['PUT'])
|
||||
def update_question(question_id):
|
||||
"""Update an existing question"""
|
||||
question = Question.query.get_or_404(question_id)
|
||||
|
||||
try:
|
||||
# Handle multipart form data or JSON
|
||||
if request.content_type and 'multipart/form-data' in request.content_type:
|
||||
data = request.form
|
||||
|
||||
# Update fields if provided
|
||||
if 'question_content' in data:
|
||||
question.question_content = data['question_content']
|
||||
if 'answer' in data:
|
||||
question.answer = data['answer']
|
||||
if 'type' in data:
|
||||
question.type = QuestionType(data['type'])
|
||||
if 'category' in data:
|
||||
question.category = data['category'] if data['category'] else None
|
||||
|
||||
# Handle new image upload
|
||||
if 'image' in request.files:
|
||||
file = request.files['image']
|
||||
if file and file.filename:
|
||||
# Delete old image if exists
|
||||
if question.image_path:
|
||||
delete_image(question.image_path, current_app.root_path)
|
||||
|
||||
# Save new image
|
||||
try:
|
||||
question.image_path = save_image(
|
||||
file,
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
current_app.config['ALLOWED_EXTENSIONS']
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
else:
|
||||
# JSON request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
if 'question_content' in data:
|
||||
question.question_content = data['question_content']
|
||||
if 'answer' in data:
|
||||
question.answer = data['answer']
|
||||
if 'type' in data:
|
||||
question.type = QuestionType(data['type'])
|
||||
if 'category' in data:
|
||||
question.category = data['category'] if data['category'] else None
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(question.to_dict(include_answer=True)), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:question_id>', methods=['DELETE'])
|
||||
def delete_question(question_id):
|
||||
"""Delete a question"""
|
||||
question = Question.query.get_or_404(question_id)
|
||||
|
||||
try:
|
||||
# Delete associated image if exists
|
||||
if question.image_path:
|
||||
delete_image(question.image_path, current_app.root_path)
|
||||
|
||||
# Delete associated audio if exists
|
||||
if question.audio_path:
|
||||
from backend.services.audio_service import delete_audio
|
||||
delete_audio(question.audio_path, current_app.root_path)
|
||||
|
||||
db.session.delete(question)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Question deleted successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/random', methods=['GET'])
|
||||
def get_random_questions():
|
||||
"""Get random questions by category
|
||||
|
||||
Query parameters:
|
||||
- category: Category name (required)
|
||||
- count: Number of random questions to return (default: 5)
|
||||
"""
|
||||
category = request.args.get('category')
|
||||
if not category:
|
||||
return jsonify({'error': 'category parameter is required'}), 400
|
||||
|
||||
try:
|
||||
count = int(request.args.get('count', 5))
|
||||
if count < 1:
|
||||
return jsonify({'error': 'count must be at least 1'}), 400
|
||||
except ValueError:
|
||||
return jsonify({'error': 'count must be a valid integer'}), 400
|
||||
|
||||
try:
|
||||
# Get all questions for the category
|
||||
questions = Question.query.filter_by(category=category).all()
|
||||
|
||||
if not questions:
|
||||
return jsonify({'error': f'No questions found for category: {category}'}), 404
|
||||
|
||||
# Randomly select questions
|
||||
import random
|
||||
selected = random.sample(questions, min(count, len(questions)))
|
||||
|
||||
return jsonify({
|
||||
'category': category,
|
||||
'requested': count,
|
||||
'returned': len(selected),
|
||||
'questions': [q.to_dict(include_answer=True) for q in selected]
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/bulk', methods=['POST'])
|
||||
def bulk_create_questions():
|
||||
"""Bulk create questions
|
||||
|
||||
Expected JSON: {
|
||||
"questions": [
|
||||
{
|
||||
"question_content": "What is 2+2?",
|
||||
"answer": "4",
|
||||
"category": "Math",
|
||||
"type": "text"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data or 'questions' not in data:
|
||||
return jsonify({'error': 'questions array is required'}), 400
|
||||
|
||||
questions_data = data['questions']
|
||||
if not isinstance(questions_data, list):
|
||||
return jsonify({'error': 'questions must be an array'}), 400
|
||||
|
||||
created_questions = []
|
||||
errors = []
|
||||
|
||||
try:
|
||||
for idx, q_data in enumerate(questions_data):
|
||||
try:
|
||||
# Validate required fields
|
||||
if not q_data.get('question_content'):
|
||||
errors.append({'index': idx, 'error': 'question_content is required'})
|
||||
continue
|
||||
if not q_data.get('answer'):
|
||||
errors.append({'index': idx, 'error': 'answer is required'})
|
||||
continue
|
||||
|
||||
# Create question
|
||||
question = Question(
|
||||
type=QuestionType(q_data.get('type', 'text')),
|
||||
question_content=q_data['question_content'],
|
||||
answer=q_data['answer'],
|
||||
category=q_data.get('category') if q_data.get('category') else None,
|
||||
image_path=None # Bulk import doesn't support images
|
||||
)
|
||||
|
||||
db.session.add(question)
|
||||
created_questions.append(question)
|
||||
|
||||
except Exception as e:
|
||||
errors.append({'index': idx, 'error': str(e)})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'message': f'Successfully created {len(created_questions)} questions',
|
||||
'created': len(created_questions),
|
||||
'errors': errors,
|
||||
'questions': [q.to_dict(include_answer=True) for q in created_questions]
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
20
backend/routes/teams.py
Normal file
20
backend/routes/teams.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from backend.models import db, Team
|
||||
|
||||
bp = Blueprint('teams', __name__, url_prefix='/api/teams')
|
||||
|
||||
|
||||
@bp.route('/<int:team_id>', methods=['DELETE'])
|
||||
def delete_team(team_id):
|
||||
"""Delete a team"""
|
||||
team = Team.query.get_or_404(team_id)
|
||||
|
||||
try:
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Team deleted successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
39
backend/services/audio_service.py
Normal file
39
backend/services/audio_service.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
|
||||
def allowed_audio_file(filename, allowed_extensions):
|
||||
"""Check if file has allowed audio extension"""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||
|
||||
|
||||
def get_audio_path(unique_filename):
|
||||
"""Get URL path for serving audio"""
|
||||
return f"/static/audio/{unique_filename}"
|
||||
|
||||
|
||||
def generate_audio_filename(extension='mp3'):
|
||||
"""Generate unique audio filename"""
|
||||
return f"{uuid.uuid4()}.{extension}"
|
||||
|
||||
|
||||
def delete_audio(audio_path, base_folder):
|
||||
"""
|
||||
Delete an audio file
|
||||
|
||||
Args:
|
||||
audio_path: Relative path to audio (e.g., /static/audio/abc123.mp3)
|
||||
base_folder: Base folder for the application
|
||||
"""
|
||||
if not audio_path:
|
||||
return
|
||||
|
||||
relative_path = audio_path.lstrip('/')
|
||||
full_path = os.path.join(base_folder, relative_path)
|
||||
|
||||
try:
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
except Exception as e:
|
||||
print(f"Error deleting audio {full_path}: {str(e)}")
|
||||
285
backend/services/game_service.py
Normal file
285
backend/services/game_service.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from backend.models import db, Game, Score
|
||||
from flask_socketio import emit
|
||||
|
||||
|
||||
def get_game_state(game):
|
||||
"""Get current game state with all necessary information"""
|
||||
current_question = game.get_current_question()
|
||||
|
||||
state = {
|
||||
'game_id': game.id,
|
||||
'game_name': game.name,
|
||||
'current_question_index': game.current_question_index,
|
||||
'total_questions': len(game.game_questions),
|
||||
'is_active': game.is_active,
|
||||
'teams': [team.to_dict() for team in game.teams]
|
||||
}
|
||||
|
||||
if current_question:
|
||||
state['current_question'] = current_question.to_dict(include_answer=False)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def get_admin_game_state(game):
|
||||
"""Get game state with answer (for admin only)"""
|
||||
state = get_game_state(game)
|
||||
current_question = game.get_current_question()
|
||||
|
||||
if current_question:
|
||||
state['current_question'] = current_question.to_dict(include_answer=True)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def start_game(game, socketio_instance):
|
||||
"""Start/activate a game"""
|
||||
# Deactivate any other active games
|
||||
active_games = Game.query.filter_by(is_active=True).all()
|
||||
for g in active_games:
|
||||
if g.id != game.id:
|
||||
g.is_active = False
|
||||
|
||||
game.is_active = True
|
||||
game.current_question_index = 0
|
||||
db.session.commit()
|
||||
|
||||
# Emit game_started event
|
||||
socketio_instance.emit('game_started', {
|
||||
'game_id': game.id,
|
||||
'game_name': game.name,
|
||||
'total_questions': len(game.game_questions)
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
socketio_instance.emit('game_started', {
|
||||
'game_id': game.id,
|
||||
'game_name': game.name,
|
||||
'total_questions': len(game.game_questions)
|
||||
}, room=f'game_{game.id}_admin')
|
||||
|
||||
# Emit first question
|
||||
broadcast_question_change(game, socketio_instance)
|
||||
|
||||
|
||||
def next_question(game, socketio_instance):
|
||||
"""Move to next question"""
|
||||
if game.current_question_index < len(game.game_questions) - 1:
|
||||
game.current_question_index += 1
|
||||
db.session.commit()
|
||||
broadcast_question_change(game, socketio_instance)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def previous_question(game, socketio_instance):
|
||||
"""Move to previous question"""
|
||||
if game.current_question_index > 0:
|
||||
game.current_question_index -= 1
|
||||
db.session.commit()
|
||||
broadcast_question_change(game, socketio_instance)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def broadcast_question_change(game, socketio_instance):
|
||||
"""Broadcast question change to all connected clients"""
|
||||
current_question = game.get_current_question()
|
||||
|
||||
if not current_question:
|
||||
return
|
||||
|
||||
# Emit to contestant room (without answer)
|
||||
socketio_instance.emit('question_changed', {
|
||||
'question_index': game.current_question_index,
|
||||
'question': current_question.to_dict(include_answer=False),
|
||||
'total_questions': len(game.game_questions)
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
# Emit to admin room (with answer)
|
||||
socketio_instance.emit('question_with_answer', {
|
||||
'question_index': game.current_question_index,
|
||||
'question': current_question.to_dict(include_answer=True),
|
||||
'total_questions': len(game.game_questions)
|
||||
}, room=f'game_{game.id}_admin')
|
||||
|
||||
|
||||
def award_points(game, team, points, socketio_instance):
|
||||
"""Award points to a team for the current question"""
|
||||
# Check if score already exists for this team and question
|
||||
existing_score = Score.query.filter_by(
|
||||
team_id=team.id,
|
||||
question_index=game.current_question_index
|
||||
).first()
|
||||
|
||||
if existing_score:
|
||||
# Add to existing score
|
||||
existing_score.points += points
|
||||
else:
|
||||
# Create new score
|
||||
score = Score(
|
||||
team_id=team.id,
|
||||
game_id=game.id,
|
||||
question_index=game.current_question_index,
|
||||
points=points
|
||||
)
|
||||
db.session.add(score)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Get all team scores with full data including lifelines
|
||||
all_scores = [t.to_dict() for t in game.teams]
|
||||
|
||||
# Broadcast score update
|
||||
score_data = {
|
||||
'team_id': team.id,
|
||||
'team_name': team.name,
|
||||
'new_score': team.total_score,
|
||||
'points_awarded': points,
|
||||
'all_scores': all_scores
|
||||
}
|
||||
|
||||
socketio_instance.emit('score_updated', score_data, room=f'game_{game.id}_contestant')
|
||||
socketio_instance.emit('score_updated', score_data, room=f'game_{game.id}_admin')
|
||||
|
||||
|
||||
def toggle_answer_visibility(game, show_answer, socketio_instance):
|
||||
"""Toggle answer visibility on contestant screen"""
|
||||
current_question = game.get_current_question()
|
||||
|
||||
if not current_question:
|
||||
return
|
||||
|
||||
answer_data = {
|
||||
'show_answer': show_answer
|
||||
}
|
||||
|
||||
if show_answer:
|
||||
answer_data['answer'] = current_question.answer
|
||||
|
||||
# Broadcast to contestant room only
|
||||
socketio_instance.emit('answer_visibility_changed', answer_data, room=f'game_{game.id}_contestant')
|
||||
|
||||
|
||||
def toggle_timer_pause(game, paused, socketio_instance):
|
||||
"""Pause or resume the timer"""
|
||||
# Broadcast timer pause state to both rooms
|
||||
socketio_instance.emit('timer_paused', {
|
||||
'paused': paused
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
socketio_instance.emit('timer_paused', {
|
||||
'paused': paused
|
||||
}, room=f'game_{game.id}_admin')
|
||||
|
||||
|
||||
def reset_timer(game, socketio_instance):
|
||||
"""Reset the timer to 30 seconds"""
|
||||
# Broadcast timer reset to both rooms
|
||||
socketio_instance.emit('timer_reset', {
|
||||
'seconds': 30
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
socketio_instance.emit('timer_reset', {
|
||||
'seconds': 30
|
||||
}, room=f'game_{game.id}_admin')
|
||||
|
||||
|
||||
def end_game(game, socketio_instance):
|
||||
"""End/deactivate a game"""
|
||||
game.is_active = False
|
||||
db.session.commit()
|
||||
|
||||
# Emit game_ended event to all rooms
|
||||
socketio_instance.emit('game_ended', {
|
||||
'game_id': game.id,
|
||||
'game_name': game.name
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
socketio_instance.emit('game_ended', {
|
||||
'game_id': game.id,
|
||||
'game_name': game.name
|
||||
}, room=f'game_{game.id}_admin')
|
||||
|
||||
|
||||
def restart_game(game, socketio_instance):
|
||||
"""Restart a game (clear scores and reset to waiting state)"""
|
||||
# Clear all scores for this game
|
||||
Score.query.filter_by(game_id=game.id).delete()
|
||||
|
||||
# Reset game state
|
||||
game.is_active = False
|
||||
game.current_question_index = 0
|
||||
|
||||
# Reset phone-a-friend lifelines for all teams
|
||||
for team in game.teams:
|
||||
team.phone_a_friend_count = 5
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Emit game_ended event to reset contestant view
|
||||
socketio_instance.emit('game_ended', {
|
||||
'game_id': game.id,
|
||||
'game_name': game.name
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
# Emit score update to show cleared scores and reset lifelines
|
||||
socketio_instance.emit('score_updated', {
|
||||
'team_id': None,
|
||||
'team_name': None,
|
||||
'new_score': 0,
|
||||
'points_awarded': 0,
|
||||
'all_scores': [t.to_dict() for t in game.teams]
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
socketio_instance.emit('score_updated', {
|
||||
'team_id': None,
|
||||
'team_name': None,
|
||||
'new_score': 0,
|
||||
'points_awarded': 0,
|
||||
'all_scores': [t.to_dict() for t in game.teams]
|
||||
}, room=f'game_{game.id}_admin')
|
||||
|
||||
|
||||
def broadcast_lifeline_update(game, team, socketio_instance):
|
||||
"""Broadcast phone-a-friend lifeline update"""
|
||||
# Get all team scores with updated lifeline counts
|
||||
all_scores = [t.to_dict() for t in game.teams]
|
||||
|
||||
lifeline_data = {
|
||||
'team_id': team.id,
|
||||
'team_name': team.name,
|
||||
'phone_a_friend_count': team.phone_a_friend_count,
|
||||
'all_scores': all_scores
|
||||
}
|
||||
|
||||
socketio_instance.emit('lifeline_updated', lifeline_data, room=f'game_{game.id}_contestant')
|
||||
socketio_instance.emit('lifeline_updated', lifeline_data, room=f'game_{game.id}_admin')
|
||||
|
||||
|
||||
def broadcast_audio_play(game, socketio_instance):
|
||||
"""Broadcast audio play command to contestants"""
|
||||
socketio_instance.emit('audio_play', {
|
||||
'game_id': game.id
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
|
||||
def broadcast_audio_pause(game, socketio_instance):
|
||||
"""Broadcast audio pause command to contestants"""
|
||||
socketio_instance.emit('audio_pause', {
|
||||
'game_id': game.id
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
|
||||
def broadcast_audio_stop(game, socketio_instance):
|
||||
"""Broadcast audio stop command to contestants"""
|
||||
socketio_instance.emit('audio_stop', {
|
||||
'game_id': game.id
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
|
||||
|
||||
def broadcast_audio_seek(game, position, socketio_instance):
|
||||
"""Broadcast audio seek command to contestants"""
|
||||
socketio_instance.emit('audio_seek', {
|
||||
'game_id': game.id,
|
||||
'position': position
|
||||
}, room=f'game_{game.id}_contestant')
|
||||
78
backend/services/image_service.py
Normal file
78
backend/services/image_service.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
import uuid
|
||||
from werkzeug.utils import secure_filename
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def allowed_file(filename, allowed_extensions):
|
||||
"""Check if file has allowed extension"""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||
|
||||
|
||||
def save_image(file, upload_folder, allowed_extensions):
|
||||
"""
|
||||
Save uploaded image file with validation
|
||||
|
||||
Args:
|
||||
file: FileStorage object from Flask request
|
||||
upload_folder: Directory to save images
|
||||
allowed_extensions: Set of allowed file extensions
|
||||
|
||||
Returns:
|
||||
str: Relative path to saved image, or None if validation fails
|
||||
|
||||
Raises:
|
||||
ValueError: If file validation fails
|
||||
"""
|
||||
if not file or file.filename == '':
|
||||
raise ValueError("No file provided")
|
||||
|
||||
if not allowed_file(file.filename, allowed_extensions):
|
||||
raise ValueError(f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}")
|
||||
|
||||
# Verify it's actually an image
|
||||
try:
|
||||
img = Image.open(file.stream)
|
||||
img.verify()
|
||||
file.stream.seek(0) # Reset stream after verification
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid image file: {str(e)}")
|
||||
|
||||
# Generate unique filename
|
||||
original_filename = secure_filename(file.filename)
|
||||
extension = original_filename.rsplit('.', 1)[1].lower()
|
||||
unique_filename = f"{uuid.uuid4()}.{extension}"
|
||||
|
||||
# Ensure upload folder exists
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
# Save file
|
||||
filepath = os.path.join(upload_folder, unique_filename)
|
||||
file.save(filepath)
|
||||
|
||||
# Return relative path (for storing in database)
|
||||
return f"/static/images/{unique_filename}"
|
||||
|
||||
|
||||
def delete_image(image_path, base_folder):
|
||||
"""
|
||||
Delete an image file
|
||||
|
||||
Args:
|
||||
image_path: Relative path to image (e.g., /static/images/abc123.jpg)
|
||||
base_folder: Base folder for the application
|
||||
"""
|
||||
if not image_path:
|
||||
return
|
||||
|
||||
# Remove leading slash and construct full path
|
||||
relative_path = image_path.lstrip('/')
|
||||
full_path = os.path.join(base_folder, relative_path)
|
||||
|
||||
try:
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the operation
|
||||
print(f"Error deleting image {full_path}: {str(e)}")
|
||||
71
backend/services/youtube_service.py
Normal file
71
backend/services/youtube_service.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import re
|
||||
import yt_dlp
|
||||
|
||||
|
||||
def validate_youtube_url(url):
|
||||
"""
|
||||
Validate YouTube URL and extract video ID
|
||||
|
||||
Returns:
|
||||
(bool, str): (is_valid, video_id or error_message)
|
||||
"""
|
||||
patterns = [
|
||||
r'(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)',
|
||||
r'youtube\.com\/embed\/([\w-]+)',
|
||||
r'youtube\.com\/v\/([\w-]+)'
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, url)
|
||||
if match:
|
||||
return True, match.group(1)
|
||||
|
||||
return False, "Invalid YouTube URL format"
|
||||
|
||||
|
||||
def get_video_duration(url):
|
||||
"""
|
||||
Get video duration without downloading
|
||||
|
||||
Returns:
|
||||
int: Duration in seconds, or None if failed
|
||||
"""
|
||||
try:
|
||||
ydl_opts = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'extract_flat': True,
|
||||
}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
return info.get('duration')
|
||||
except Exception as e:
|
||||
print(f"Error getting video duration: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def validate_timestamps(start_time, end_time, video_duration=None):
|
||||
"""
|
||||
Validate timestamp range
|
||||
|
||||
Args:
|
||||
start_time: Start time in seconds
|
||||
end_time: End time in seconds
|
||||
video_duration: Optional video duration for validation
|
||||
|
||||
Returns:
|
||||
(bool, str): (is_valid, error_message if invalid)
|
||||
"""
|
||||
if start_time < 0:
|
||||
return False, "Start time must be non-negative"
|
||||
|
||||
if end_time <= start_time:
|
||||
return False, "End time must be greater than start time"
|
||||
|
||||
if end_time - start_time > 300: # 5 minutes
|
||||
return False, "Clip duration cannot exceed 5 minutes"
|
||||
|
||||
if video_duration and end_time > video_duration:
|
||||
return False, f"End time exceeds video duration ({video_duration}s)"
|
||||
|
||||
return True, None
|
||||
0
backend/sockets/__init__.py
Normal file
0
backend/sockets/__init__.py
Normal file
114
backend/sockets/events.py
Normal file
114
backend/sockets/events.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from flask_socketio import emit, join_room, leave_room
|
||||
from backend.app import socketio
|
||||
from backend.auth.middleware import validate_jwt
|
||||
from backend.models import User
|
||||
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect(auth):
|
||||
"""Handle client connection with JWT authentication"""
|
||||
# Extract token from auth parameter
|
||||
token = auth.get('token') if auth else None
|
||||
|
||||
if not token:
|
||||
print('Connection rejected: No token provided')
|
||||
return False # Reject connection
|
||||
|
||||
# Validate JWT
|
||||
claims = validate_jwt(token)
|
||||
if not claims:
|
||||
print('Connection rejected: Invalid token')
|
||||
return False # Reject connection
|
||||
|
||||
# Get user
|
||||
user = User.query.filter_by(authelia_sub=claims['sub']).first()
|
||||
if not user or not user.is_active:
|
||||
print('Connection rejected: User not found or inactive')
|
||||
return False
|
||||
|
||||
print(f'Client connected: {user.email}')
|
||||
emit('connected', {
|
||||
'message': 'Connected to trivia game server',
|
||||
'user': user.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
"""Handle client disconnection"""
|
||||
print('Client disconnected')
|
||||
|
||||
|
||||
@socketio.on('join_game')
|
||||
def handle_join_game(data):
|
||||
"""Handle client joining a game room with role validation
|
||||
|
||||
Expected data: { 'game_id': int, 'role': 'contestant' | 'admin', 'token': str }
|
||||
"""
|
||||
game_id = data.get('game_id')
|
||||
role = data.get('role', 'contestant')
|
||||
token = data.get('token')
|
||||
|
||||
if not game_id or not token:
|
||||
emit('error', {'message': 'game_id and token are required'})
|
||||
return
|
||||
|
||||
# Validate JWT
|
||||
claims = validate_jwt(token)
|
||||
if not claims:
|
||||
emit('error', {'message': 'Invalid or expired token'})
|
||||
return
|
||||
|
||||
# Get user
|
||||
user = User.query.filter_by(authelia_sub=claims['sub']).first()
|
||||
if not user or not user.is_active:
|
||||
emit('error', {'message': 'User not found or inactive'})
|
||||
return
|
||||
|
||||
# Join appropriate room based on role
|
||||
if role == 'admin':
|
||||
room = f'game_{game_id}_admin'
|
||||
join_room(room)
|
||||
print(f'User {user.email} joined admin room: {room}')
|
||||
emit('joined', {
|
||||
'game_id': game_id,
|
||||
'room': 'admin',
|
||||
'user': user.to_dict(),
|
||||
'message': f'Joined admin room for game {game_id}'
|
||||
})
|
||||
else:
|
||||
room = f'game_{game_id}_contestant'
|
||||
join_room(room)
|
||||
print(f'User {user.email} joined contestant room: {room}')
|
||||
emit('joined', {
|
||||
'game_id': game_id,
|
||||
'room': 'contestant',
|
||||
'user': user.to_dict(),
|
||||
'message': f'Joined contestant room for game {game_id}'
|
||||
})
|
||||
|
||||
|
||||
@socketio.on('leave_game')
|
||||
def handle_leave_game(data):
|
||||
"""Handle client leaving a game room
|
||||
|
||||
Expected data: { 'game_id': int, 'role': 'contestant' | 'admin' }
|
||||
"""
|
||||
game_id = data.get('game_id')
|
||||
role = data.get('role', 'contestant')
|
||||
|
||||
if not game_id:
|
||||
emit('error', {'message': 'game_id is required'})
|
||||
return
|
||||
|
||||
# Leave appropriate room based on role
|
||||
if role == 'admin':
|
||||
room = f'game_{game_id}_admin'
|
||||
leave_room(room)
|
||||
print(f'Client left admin room: {room}')
|
||||
else:
|
||||
room = f'game_{game_id}_contestant'
|
||||
leave_room(room)
|
||||
print(f'Client left contestant room: {room}')
|
||||
|
||||
emit('left', {'game_id': game_id, 'message': f'Left game {game_id}'})
|
||||
0
backend/static/images/.gitkeep
Normal file
0
backend/static/images/.gitkeep
Normal file
1
backend/tasks/__init__.py
Normal file
1
backend/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tasks package
|
||||
120
backend/tasks/youtube_tasks.py
Normal file
120
backend/tasks/youtube_tasks.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
import yt_dlp
|
||||
from pydub import AudioSegment
|
||||
from datetime import datetime
|
||||
|
||||
from backend.celery_app import celery
|
||||
from backend.models import db, Question, DownloadJob, DownloadJobStatus
|
||||
from backend.services.audio_service import generate_audio_filename, get_audio_path
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def download_youtube_audio(self, question_id, youtube_url, start_time, end_time):
|
||||
"""
|
||||
Download and trim YouTube audio clip
|
||||
|
||||
Args:
|
||||
question_id: Question ID to update
|
||||
youtube_url: YouTube video URL
|
||||
start_time: Start time in seconds
|
||||
end_time: End time in seconds
|
||||
"""
|
||||
from backend.app import create_app
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
job = DownloadJob.query.filter_by(celery_task_id=self.request.id).first()
|
||||
question = Question.query.get(question_id)
|
||||
|
||||
if not job or not question:
|
||||
return {'success': False, 'error': 'Job or question not found'}
|
||||
|
||||
try:
|
||||
# Update status to processing
|
||||
job.status = DownloadJobStatus.PROCESSING
|
||||
job.progress = 10
|
||||
db.session.commit()
|
||||
|
||||
# Create temp and final directories
|
||||
audio_folder = app.config['AUDIO_FOLDER']
|
||||
os.makedirs(audio_folder, exist_ok=True)
|
||||
temp_dir = os.path.join(audio_folder, 'temp')
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
# Download full audio
|
||||
temp_filename = f"{self.request.id}_full"
|
||||
temp_path = os.path.join(temp_dir, temp_filename)
|
||||
|
||||
def progress_hook(d):
|
||||
"""Update progress during download"""
|
||||
if d['status'] == 'downloading':
|
||||
try:
|
||||
# Extract percentage from string like "50.5%"
|
||||
percent_str = d.get('_percent_str', '0%').strip('%')
|
||||
percent = float(percent_str)
|
||||
# Map download progress to 10-60% range
|
||||
progress = 10 + int(percent * 0.5)
|
||||
job.progress = progress
|
||||
db.session.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
ydl_opts = {
|
||||
'format': app.config['YTDLP_FORMAT'],
|
||||
'outtmpl': temp_path + '.%(ext)s',
|
||||
'postprocessors': [{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': app.config['YTDLP_QUALITY'],
|
||||
}],
|
||||
'progress_hooks': [progress_hook],
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([youtube_url])
|
||||
|
||||
job.progress = 60
|
||||
db.session.commit()
|
||||
|
||||
# Find downloaded file
|
||||
full_audio_path = temp_path + '.mp3'
|
||||
|
||||
# Trim audio using pydub
|
||||
audio = AudioSegment.from_mp3(full_audio_path)
|
||||
clip = audio[start_time * 1000:end_time * 1000] # pydub uses milliseconds
|
||||
|
||||
job.progress = 80
|
||||
db.session.commit()
|
||||
|
||||
# Save trimmed clip
|
||||
final_filename = generate_audio_filename('mp3')
|
||||
final_path = os.path.join(audio_folder, final_filename)
|
||||
clip.export(final_path, format='mp3', bitrate='192k')
|
||||
|
||||
# Clean up temp file
|
||||
if os.path.exists(full_audio_path):
|
||||
os.remove(full_audio_path)
|
||||
|
||||
# Update question with audio path
|
||||
audio_url = get_audio_path(final_filename)
|
||||
question.audio_path = audio_url
|
||||
|
||||
# Update job status
|
||||
job.status = DownloadJobStatus.COMPLETED
|
||||
job.progress = 100
|
||||
job.completed_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'audio_path': audio_url,
|
||||
'question_id': question_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
job.status = DownloadJobStatus.FAILED
|
||||
job.error_message = str(e)
|
||||
db.session.commit()
|
||||
return {'success': False, 'error': str(e)}
|
||||
Reference in New Issue
Block a user