This commit is contained in:
2025-12-22 14:47:25 -05:00
parent d4e859f9a7
commit 00e9eb8986
81 changed files with 13933 additions and 0 deletions

View File

304
backend/routes/admin.py Normal file
View 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
View 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

View 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

View 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
View 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
View 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
View 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