diff --git a/backend/models.py b/backend/models.py index 1399a3c..866abed 100644 --- a/backend/models.py +++ b/backend/models.py @@ -80,9 +80,22 @@ class Question(db.Model): end_time = db.Column(db.Integer, nullable=True) # End time in seconds created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Relationships + creator = db.relationship('User', backref='questions') game_questions = db.relationship('GameQuestion', back_populates='question', cascade='all, delete-orphan') + shares = db.relationship('QuestionShare', backref='question', cascade='all, delete-orphan') + + def is_visible_to(self, user): + """Check if this question is visible to the given user""" + if self.created_by == user.id: + return True + return any(s.shared_with_user_id == user.id for s in self.shares) + + def is_owned_by(self, user): + """Check if this question is owned by the given user""" + return self.created_by == user.id def to_dict(self, include_answer=False): """Convert question to dictionary""" @@ -96,13 +109,43 @@ class Question(db.Model): '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 + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'created_by': self.created_by, + 'creator_name': (self.creator.name or self.creator.preferred_username) if self.creator else None, } if include_answer: data['answer'] = self.answer return data +class QuestionShare(db.Model): + """Junction table for sharing questions between users""" + __tablename__ = 'question_shares' + + id = db.Column(db.Integer, primary_key=True) + question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False) + shared_with_user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + shared_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + shared_with_user = db.relationship('User', foreign_keys=[shared_with_user_id], backref='shared_questions') + shared_by_user = db.relationship('User', foreign_keys=[shared_by_user_id]) + + __table_args__ = ( + db.UniqueConstraint('question_id', 'shared_with_user_id', name='unique_question_share'), + ) + + def to_dict(self): + return { + 'id': self.id, + 'question_id': self.question_id, + 'shared_with_user_id': self.shared_with_user_id, + 'shared_with_user_name': (self.shared_with_user.name or self.shared_with_user.preferred_username) if self.shared_with_user else None, + 'shared_by_user_id': self.shared_by_user_id, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + class Game(db.Model): """Game model representing a trivia game session""" __tablename__ = 'games' diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 2eb6b9e..03a74c6 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -122,3 +122,34 @@ def logout(): def get_current_user(): """Get current user info (requires auth)""" return jsonify(g.current_user.to_dict()), 200 + + +@bp.route('/users/search') +@require_auth +def search_users(): + """Search users by name, username, or email for sharing + + Query parameters: + - q: Search query (required, min 1 character) + """ + query = request.args.get('q', '').strip() + if not query: + return jsonify([]), 200 + + search_pattern = f'%{query}%' + users = User.query.filter( + User.id != g.current_user.id, + User.is_active == True, + db.or_( + User.name.ilike(search_pattern), + User.preferred_username.ilike(search_pattern), + User.email.ilike(search_pattern) + ) + ).limit(10).all() + + return jsonify([{ + 'id': u.id, + 'name': u.name, + 'username': u.preferred_username, + 'email': u.email + } for u in users]), 200 diff --git a/backend/routes/export_import.py b/backend/routes/export_import.py index d2d3780..b1bafa3 100644 --- a/backend/routes/export_import.py +++ b/backend/routes/export_import.py @@ -1,7 +1,7 @@ """Routes for exporting and importing trivia questions""" import os import tempfile -from flask import Blueprint, jsonify, Response, request +from flask import Blueprint, jsonify, Response, request, g from werkzeug.utils import secure_filename from backend.auth.middleware import require_auth @@ -23,8 +23,8 @@ def export_data(): ZIP file containing manifest.json and all media files """ try: - # Generate export ZIP - zip_bytes, zip_filename = export_questions_to_zip() + # Generate export ZIP (only questions visible to current user) + zip_bytes, zip_filename = export_questions_to_zip(user_id=g.current_user.id) # Create response with ZIP file response = Response( @@ -82,8 +82,8 @@ def import_data(): file_size = os.path.getsize(temp_path) print(f"Saved file size: {file_size} bytes") - # Import from ZIP - result = import_questions_from_zip(temp_path) + # Import from ZIP (set ownership to current user) + result = import_questions_from_zip(temp_path, user_id=g.current_user.id) return jsonify(result), 200 diff --git a/backend/routes/questions.py b/backend/routes/questions.py index 3bc1481..2a51aa5 100644 --- a/backend/routes/questions.py +++ b/backend/routes/questions.py @@ -1,13 +1,28 @@ -from flask import Blueprint, request, jsonify, current_app -from backend.models import db, Question, QuestionType +from flask import Blueprint, request, jsonify, current_app, g +from backend.models import db, Question, QuestionType, QuestionShare, User from backend.services.image_service import save_image, delete_image +from backend.auth.middleware import require_auth bp = Blueprint('questions', __name__, url_prefix='/api/questions') +def _visible_questions_query(): + """Return a base query filtered to questions visible to the current user.""" + return Question.query.filter( + db.or_( + Question.created_by == g.current_user.id, + Question.id.in_( + db.session.query(QuestionShare.question_id) + .filter(QuestionShare.shared_with_user_id == g.current_user.id) + ) + ) + ) + + @bp.route('', methods=['GET']) +@require_auth def list_questions(): - """Get all questions with optional filtering and sorting + """Get all questions visible to the current user with optional filtering and sorting Query parameters: - search: Search in question content and answer (case-insensitive) @@ -15,8 +30,16 @@ def list_questions(): - type: Filter by question type (text, image, youtube_audio) - sort_by: Field to sort by (created_at, category, type, question_content, answer) - sort_order: Sort direction (asc, desc) - default: desc + - owner: Filter by ownership ('mine', 'shared', or omit for all visible) """ - query = Question.query + query = _visible_questions_query() + + # Owner filter + owner = request.args.get('owner', '').strip() + if owner == 'mine': + query = query.filter(Question.created_by == g.current_user.id) + elif owner == 'shared': + query = query.filter(Question.created_by != g.current_user.id) # Search filter search = request.args.get('search', '').strip() @@ -70,13 +93,17 @@ def list_questions(): @bp.route('/', methods=['GET']) +@require_auth def get_question(question_id): - """Get a single question by ID""" + """Get a single question by ID (must be visible to current user)""" question = Question.query.get_or_404(question_id) + if not question.is_visible_to(g.current_user): + return jsonify({'error': 'Question not found'}), 404 return jsonify(question.to_dict(include_answer=True)), 200 @bp.route('', methods=['POST']) +@require_auth def create_question(): """Create a new question""" try: @@ -171,7 +198,8 @@ def create_question(): audio_path=audio_path, # Will be None initially for YouTube start_time=start_time, end_time=end_time, - category=category if category else None + category=category if category else None, + created_by=g.current_user.id ) db.session.add(question) @@ -207,10 +235,14 @@ def create_question(): @bp.route('/', methods=['PUT']) +@require_auth def update_question(question_id): - """Update an existing question""" + """Update an existing question (owner only)""" question = Question.query.get_or_404(question_id) + if not question.is_owned_by(g.current_user): + return jsonify({'error': 'You can only edit questions you created'}), 403 + try: # Handle multipart form data or JSON if request.content_type and 'multipart/form-data' in request.content_type: @@ -268,10 +300,14 @@ def update_question(question_id): @bp.route('/', methods=['DELETE']) +@require_auth def delete_question(question_id): - """Delete a question""" + """Delete a question (owner only)""" question = Question.query.get_or_404(question_id) + if not question.is_owned_by(g.current_user): + return jsonify({'error': 'You can only delete questions you created'}), 403 + try: # Delete associated image if exists if question.image_path: @@ -293,8 +329,9 @@ def delete_question(question_id): @bp.route('/random', methods=['GET']) +@require_auth def get_random_questions(): - """Get random questions by category + """Get random questions by category (only from visible questions) Query parameters: - category: Category name (required) @@ -312,8 +349,8 @@ def get_random_questions(): 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() + # Get visible questions for the category + questions = _visible_questions_query().filter_by(category=category).all() if not questions: return jsonify({'error': f'No questions found for category: {category}'}), 404 @@ -334,6 +371,7 @@ def get_random_questions(): @bp.route('/bulk', methods=['POST']) +@require_auth def bulk_create_questions(): """Bulk create questions @@ -377,7 +415,8 @@ def bulk_create_questions(): 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 + image_path=None, # Bulk import doesn't support images + created_by=g.current_user.id ) db.session.add(question) @@ -398,3 +437,139 @@ def bulk_create_questions(): except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 + + +# --- Sharing endpoints --- + +@bp.route('//shares', methods=['GET']) +@require_auth +def get_shares(question_id): + """Get list of users a question is shared with (owner only)""" + question = Question.query.get_or_404(question_id) + + if not question.is_owned_by(g.current_user): + return jsonify({'error': 'Only the question owner can view shares'}), 403 + + return jsonify([s.to_dict() for s in question.shares]), 200 + + +@bp.route('//share', methods=['POST']) +@require_auth +def share_question(question_id): + """Share a question with another user (owner only) + + Expected JSON: { "user_id": int } + """ + question = Question.query.get_or_404(question_id) + + if not question.is_owned_by(g.current_user): + return jsonify({'error': 'Only the question owner can share it'}), 403 + + data = request.get_json() + if not data or 'user_id' not in data: + return jsonify({'error': 'user_id is required'}), 400 + + target_user_id = data['user_id'] + + if target_user_id == g.current_user.id: + return jsonify({'error': 'Cannot share a question with yourself'}), 400 + + target_user = User.query.get(target_user_id) + if not target_user: + return jsonify({'error': 'User not found'}), 404 + + # Check if already shared + existing = QuestionShare.query.filter_by( + question_id=question_id, + shared_with_user_id=target_user_id + ).first() + + if existing: + return jsonify({'error': 'Question already shared with this user'}), 409 + + share = QuestionShare( + question_id=question_id, + shared_with_user_id=target_user_id, + shared_by_user_id=g.current_user.id + ) + db.session.add(share) + db.session.commit() + + return jsonify(share.to_dict()), 201 + + +@bp.route('//share/', methods=['DELETE']) +@require_auth +def unshare_question(question_id, user_id): + """Remove sharing of a question with a user (owner only)""" + question = Question.query.get_or_404(question_id) + + if not question.is_owned_by(g.current_user): + return jsonify({'error': 'Only the question owner can manage shares'}), 403 + + share = QuestionShare.query.filter_by( + question_id=question_id, + shared_with_user_id=user_id + ).first() + + if not share: + return jsonify({'error': 'Share not found'}), 404 + + db.session.delete(share) + db.session.commit() + + return jsonify({'message': 'Share removed'}), 200 + + +@bp.route('/bulk-share', methods=['POST']) +@require_auth +def bulk_share_questions(): + """Share multiple questions with a user (owner only for each) + + Expected JSON: { "question_ids": [int, ...], "user_id": int } + """ + data = request.get_json() + if not data or 'question_ids' not in data or 'user_id' not in data: + return jsonify({'error': 'question_ids and user_id are required'}), 400 + + target_user_id = data['user_id'] + question_ids = data['question_ids'] + + if target_user_id == g.current_user.id: + return jsonify({'error': 'Cannot share questions with yourself'}), 400 + + target_user = User.query.get(target_user_id) + if not target_user: + return jsonify({'error': 'User not found'}), 404 + + shared = [] + skipped = [] + + for qid in question_ids: + question = Question.query.get(qid) + if not question or not question.is_owned_by(g.current_user): + skipped.append({'id': qid, 'reason': 'not found or not owned'}) + continue + + existing = QuestionShare.query.filter_by( + question_id=qid, + shared_with_user_id=target_user_id + ).first() + if existing: + skipped.append({'id': qid, 'reason': 'already shared'}) + continue + + share = QuestionShare( + question_id=qid, + shared_with_user_id=target_user_id, + shared_by_user_id=g.current_user.id + ) + db.session.add(share) + shared.append(qid) + + db.session.commit() + + return jsonify({ + 'shared': shared, + 'skipped': skipped + }), 200 diff --git a/backend/services/export_import_service.py b/backend/services/export_import_service.py index ce5ff30..e8a5404 100644 --- a/backend/services/export_import_service.py +++ b/backend/services/export_import_service.py @@ -9,18 +9,30 @@ from pathlib import Path from typing import Tuple, Optional from flask import current_app -from backend.models import db, Question, Category, QuestionType, Score, Team, GameQuestion, Game +from backend.models import db, Question, Category, QuestionType, Score, Team, GameQuestion, Game, QuestionShare -def export_questions_to_zip() -> Tuple[bytes, str]: +def export_questions_to_zip(user_id=None) -> Tuple[bytes, str]: """ - Export all questions and categories to a ZIP file with images and audio. + Export questions and categories to a ZIP file with images and audio. + If user_id is provided, only exports questions owned by or shared with that user. Returns: Tuple of (zip_bytes, filename) """ - # Query all data - questions = Question.query.order_by(Question.created_at).all() + # Query data filtered by visibility + if user_id is not None: + questions = Question.query.filter( + db.or_( + Question.created_by == user_id, + Question.id.in_( + db.session.query(QuestionShare.question_id) + .filter(QuestionShare.shared_with_user_id == user_id) + ) + ) + ).order_by(Question.created_at).all() + else: + questions = Question.query.order_by(Question.created_at).all() categories = Category.query.order_by(Category.name).all() # Create temporary directory @@ -279,12 +291,13 @@ def clear_all_media_files(): print(f"Warning: Failed to delete {file_path}: {e}") -def import_questions_from_zip(zip_path: str) -> dict: +def import_questions_from_zip(zip_path: str, user_id=None) -> dict: """ Import questions and media from a ZIP file. Args: zip_path: Path to ZIP file to import + user_id: If provided, set created_by on all imported questions Returns: Dict with import summary @@ -373,7 +386,8 @@ def import_questions_from_zip(zip_path: str) -> dict: audio_path=audio_path, youtube_url=q_data.get('youtube_url'), start_time=q_data.get('start_time'), - end_time=q_data.get('end_time') + end_time=q_data.get('end_time'), + created_by=user_id ) # Preserve created_at if provided diff --git a/frontend/frontend/src/components/questionbank/QuestionBankView.jsx b/frontend/frontend/src/components/questionbank/QuestionBankView.jsx index 0508820..a16603b 100644 --- a/frontend/frontend/src/components/questionbank/QuestionBankView.jsx +++ b/frontend/frontend/src/components/questionbank/QuestionBankView.jsx @@ -6,8 +6,11 @@ import { downloadJobsAPI, } from "../../services/api"; import AdminNavbar from "../common/AdminNavbar"; +import ShareDialog from "./ShareDialog"; +import { useAuth } from "../../contexts/AuthContext"; export default function QuestionBankView() { + const { dbUser } = useAuth(); const [questions, setQuestions] = useState([]); const [categories, setCategories] = useState([]); const [showForm, setShowForm] = useState(false); @@ -28,18 +31,20 @@ export default function QuestionBankView() { const [bulkCategory, setBulkCategory] = useState(""); const [downloadJob, setDownloadJob] = useState(null); const [downloadProgress, setDownloadProgress] = useState(0); + const [shareTarget, setShareTarget] = useState(null); // question or array of ids for sharing // Filter and sort state const [searchTerm, setSearchTerm] = useState(""); const [filterCategory, setFilterCategory] = useState(""); const [filterType, setFilterType] = useState(""); + const [filterOwner, setFilterOwner] = useState(""); const [sortBy, setSortBy] = useState("created_at"); const [sortOrder, setSortOrder] = useState("desc"); useEffect(() => { loadQuestions(); loadCategories(); - }, [searchTerm, filterCategory, filterType, sortBy, sortOrder]); + }, [searchTerm, filterCategory, filterType, filterOwner, sortBy, sortOrder]); const loadQuestions = async () => { try { @@ -50,6 +55,7 @@ export default function QuestionBankView() { if (searchTerm) params.search = searchTerm; if (filterCategory) params.category = filterCategory; if (filterType) params.type = filterType; + if (filterOwner) params.owner = filterOwner; const response = await questionsAPI.getAll(params); setQuestions(response.data); @@ -688,13 +694,32 @@ export default function QuestionBankView() { + {/* Owner Filter */} +
+ +
+ {/* Clear Filters */} - {(searchTerm || filterCategory || filterType || sortBy !== "created_at" || sortOrder !== "desc") && ( + {(searchTerm || filterCategory || filterType || filterOwner || sortBy !== "created_at" || sortOrder !== "desc") && ( + - + {q.created_by === dbUser?.id && ( + <> + + + + + )} @@ -1045,6 +1120,13 @@ export default function QuestionBankView() { )} + + {shareTarget && ( + setShareTarget(null)} + /> + )} ); } diff --git a/frontend/frontend/src/components/questionbank/ShareDialog.jsx b/frontend/frontend/src/components/questionbank/ShareDialog.jsx new file mode 100644 index 0000000..8082237 --- /dev/null +++ b/frontend/frontend/src/components/questionbank/ShareDialog.jsx @@ -0,0 +1,252 @@ +import { useState, useEffect, useRef } from "react"; +import { questionsAPI, usersAPI } from "../../services/api"; + +export default function ShareDialog({ target, onClose }) { + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [currentShares, setCurrentShares] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [message, setMessage] = useState(null); + const searchTimeout = useRef(null); + + // Determine if this is a single question or bulk share + const isBulk = Array.isArray(target); + const questionId = isBulk ? null : target.id; + + // Load current shares for single question + useEffect(() => { + if (!isBulk && questionId) { + loadShares(); + } + }, [questionId, isBulk]); + + const loadShares = async () => { + try { + const response = await questionsAPI.getShares(questionId); + setCurrentShares(response.data); + } catch (error) { + console.error("Error loading shares:", error); + } + }; + + const handleSearchChange = (value) => { + setSearchQuery(value); + if (searchTimeout.current) clearTimeout(searchTimeout.current); + + if (value.trim().length === 0) { + setSearchResults([]); + return; + } + + searchTimeout.current = setTimeout(async () => { + setIsSearching(true); + try { + const response = await usersAPI.search(value.trim()); + setSearchResults(response.data); + } catch (error) { + console.error("Error searching users:", error); + } + setIsSearching(false); + }, 300); + }; + + const handleShare = async (userId) => { + try { + if (isBulk) { + const response = await questionsAPI.bulkShare(target, userId); + const { shared, skipped } = response.data; + setMessage(`Shared ${shared.length} question(s). ${skipped.length > 0 ? `${skipped.length} skipped.` : ""}`); + } else { + await questionsAPI.share(questionId, userId); + setMessage("Question shared successfully."); + loadShares(); + } + setSearchQuery(""); + setSearchResults([]); + } catch (error) { + const msg = error.response?.data?.error || "Error sharing question"; + setMessage(msg); + } + }; + + const handleUnshare = async (userId) => { + try { + await questionsAPI.unshare(questionId, userId); + loadShares(); + setMessage("Share removed."); + } catch (error) { + console.error("Error removing share:", error); + } + }; + + const overlayStyle = { + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + background: "rgba(0,0,0,0.5)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 2000, + }; + + const dialogStyle = { + background: "white", + borderRadius: "12px", + padding: "1.5rem", + width: "450px", + maxHeight: "80vh", + overflowY: "auto", + boxShadow: "0 8px 32px rgba(0,0,0,0.2)", + }; + + return ( +
+
e.stopPropagation()}> +
+

+ {isBulk ? `Share ${target.length} Question(s)` : "Share Question"} +

+ +
+ + {!isBulk && ( +

+ {target.question_content?.substring(0, 80)}{target.question_content?.length > 80 ? "..." : ""} +

+ )} + + {/* User search */} +
+ handleSearchChange(e.target.value)} + style={{ + width: "100%", + padding: "0.6rem", + border: "1px solid #ddd", + borderRadius: "6px", + fontSize: "0.95rem", + boxSizing: "border-box", + }} + autoFocus + /> +
+ + {/* Search results */} + {searchResults.length > 0 && ( +
+ {searchResults.map((user) => { + const alreadyShared = currentShares.some(s => s.shared_with_user_id === user.id); + return ( +
+
+
{user.name || user.username || "Unknown"}
+ {user.email && ( +
{user.email}
+ )} +
+ {alreadyShared ? ( + Shared + ) : ( + + )} +
+ ); + })} +
+ )} + + {isSearching && ( +

Searching...

+ )} + + {/* Current shares (single question only) */} + {!isBulk && currentShares.length > 0 && ( +
+

Shared with

+ {currentShares.map((share) => ( +
+ {share.shared_with_user_name || `User ${share.shared_with_user_id}`} + +
+ ))} +
+ )} + + {!isBulk && currentShares.length === 0 && searchResults.length === 0 && !isSearching && ( +

+ Not shared with anyone yet. Search for a user above. +

+ )} + + {message && ( +

+ {message} +

+ )} +
+
+ ); +} diff --git a/frontend/frontend/src/contexts/AuthContext.jsx b/frontend/frontend/src/contexts/AuthContext.jsx index 4d3dc1c..e59d34a 100644 --- a/frontend/frontend/src/contexts/AuthContext.jsx +++ b/frontend/frontend/src/contexts/AuthContext.jsx @@ -1,14 +1,30 @@ -import { createContext, useContext, useState, useEffect } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { jwtDecode } from 'jwt-decode'; const AuthContext = createContext(null); export function AuthProvider({ children }) { const [user, setUser] = useState(null); + const [dbUser, setDbUser] = useState(null); // User record from DB (has .id for ownership checks) const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [accessToken, setAccessToken] = useState(null); + // Fetch the DB user record (includes numeric id for ownership comparisons) + const fetchDbUser = useCallback(async (idToken) => { + try { + const response = await fetch('/api/auth/me', { + headers: { 'Authorization': `Bearer ${idToken}` } + }); + if (response.ok) { + const data = await response.json(); + setDbUser(data); + } + } catch (error) { + console.error('Failed to fetch DB user:', error); + } + }, []); + useEffect(() => { // Check if user is already logged in (token in localStorage) const storedToken = localStorage.getItem('access_token'); @@ -28,6 +44,7 @@ export function AuthProvider({ children }) { setAccessToken(storedToken); setUser({ profile: decoded }); setIsAuthenticated(true); + fetchDbUser(storedIdToken); } else { // Token expired, clear storage console.log('AuthContext: Tokens expired'); @@ -43,7 +60,7 @@ export function AuthProvider({ children }) { console.log('AuthContext: No tokens found in storage'); } setIsLoading(false); - }, []); + }, [fetchDbUser]); const login = () => { // Redirect to backend login endpoint @@ -84,6 +101,7 @@ export function AuthProvider({ children }) { setAccessToken(access_token); setUser({ profile: decoded }); setIsAuthenticated(true); + fetchDbUser(id_token); console.log('handleCallback: Auth state updated, isAuthenticated=true'); @@ -104,6 +122,7 @@ export function AuthProvider({ children }) { setAccessToken(null); setUser(null); + setDbUser(null); setIsAuthenticated(false); // Redirect to backend logout to clear cookies and get Authelia logout URL @@ -138,6 +157,7 @@ export function AuthProvider({ children }) { const value = { user, + dbUser, isAuthenticated, isLoading, accessToken, diff --git a/frontend/frontend/src/services/api.js b/frontend/frontend/src/services/api.js index d5e5415..67748eb 100644 --- a/frontend/frontend/src/services/api.js +++ b/frontend/frontend/src/services/api.js @@ -66,6 +66,12 @@ export const questionsAPI = { headers: { "Content-Type": "multipart/form-data" }, }), delete: (id) => api.delete(`/questions/${id}`), + // Sharing + getShares: (id) => api.get(`/questions/${id}/shares`), + share: (id, userId) => api.post(`/questions/${id}/share`, { user_id: userId }), + unshare: (id, userId) => api.delete(`/questions/${id}/share/${userId}`), + bulkShare: (questionIds, userId) => + api.post("/questions/bulk-share", { question_ids: questionIds, user_id: userId }), }; // Games API @@ -135,4 +141,9 @@ export const audioControlAPI = { api.post(`/admin/game/${gameId}/audio/seek`, { position }), }; +// Users API (for sharing) +export const usersAPI = { + search: (query) => api.get("/auth/users/search", { params: { q: query } }), +}; + export default api; diff --git a/migrations/versions/2937635f309a_add_turn_tracking_fields_to_game_model.py b/migrations/versions/2937635f309a_add_turn_tracking_fields_to_game_model.py new file mode 100644 index 0000000..064d5d5 --- /dev/null +++ b/migrations/versions/2937635f309a_add_turn_tracking_fields_to_game_model.py @@ -0,0 +1,38 @@ +"""Add turn tracking fields to Game model + +Revision ID: 2937635f309a +Revises: 90b81e097444 +Create Date: 2026-01-13 08:56:51.426585 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2937635f309a' +down_revision = '90b81e097444' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('games', schema=None) as batch_op: + batch_op.add_column(sa.Column('current_turn_team_id', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('turn_order', sa.JSON(), nullable=True)) + batch_op.add_column(sa.Column('is_steal_mode', sa.Boolean(), nullable=True)) + batch_op.create_foreign_key('fk_games_current_turn_team', 'teams', ['current_turn_team_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('games', schema=None) as batch_op: + batch_op.drop_constraint('fk_games_current_turn_team', type_='foreignkey') + batch_op.drop_column('is_steal_mode') + batch_op.drop_column('turn_order') + batch_op.drop_column('current_turn_team_id') + + # ### end Alembic commands ### diff --git a/migrations/versions/9a119272b516_add_question_ownership_and_sharing.py b/migrations/versions/9a119272b516_add_question_ownership_and_sharing.py new file mode 100644 index 0000000..bfe3f99 --- /dev/null +++ b/migrations/versions/9a119272b516_add_question_ownership_and_sharing.py @@ -0,0 +1,59 @@ +"""Add question ownership and sharing + +Revision ID: 9a119272b516 +Revises: d2113a61fa42 +Create Date: 2026-04-03 09:32:53.890510 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision = '9a119272b516' +down_revision = 'd2113a61fa42' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('question_shares', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('question_id', sa.Integer(), nullable=False), + sa.Column('shared_with_user_id', sa.Integer(), nullable=False), + sa.Column('shared_by_user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), + sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('question_id', 'shared_with_user_id', name='unique_question_share') + ) + with op.batch_alter_table('games', schema=None) as batch_op: + batch_op.add_column(sa.Column('completed_at', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('winners', sa.JSON(), nullable=True)) + batch_op.drop_column('turn_order') + batch_op.drop_column('is_steal_mode') + + with op.batch_alter_table('questions', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_by', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_questions_created_by', 'users', ['created_by'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('questions', schema=None) as batch_op: + batch_op.drop_constraint('fk_questions_created_by', type_='foreignkey') + batch_op.drop_column('created_by') + + with op.batch_alter_table('games', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_steal_mode', sa.BOOLEAN(), nullable=True)) + batch_op.add_column(sa.Column('turn_order', sqlite.JSON(), nullable=True)) + batch_op.drop_column('winners') + batch_op.drop_column('completed_at') + + op.drop_table('question_shares') + # ### end Alembic commands ### diff --git a/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py b/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py index c001383..987afed 100644 --- a/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py +++ b/migrations/versions/a1b2c3d4e5f6_add_current_turn_team_id_to_games.py @@ -17,18 +17,10 @@ depends_on = None def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('games', schema=None) as batch_op: - batch_op.add_column(sa.Column('current_turn_team_id', sa.Integer(), nullable=True)) - batch_op.create_foreign_key('fk_games_current_turn_team_id', 'teams', ['current_turn_team_id'], ['id']) - - # ### end Alembic commands ### + # No-op: columns already added by 2937635f309a + pass def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('games', schema=None) as batch_op: - batch_op.drop_constraint('fk_games_current_turn_team_id', type_='foreignkey') - batch_op.drop_column('current_turn_team_id') - - # ### end Alembic commands ### + # No-op: columns managed by 2937635f309a + pass diff --git a/migrations/versions/d2113a61fa42_merge_heads.py b/migrations/versions/d2113a61fa42_merge_heads.py new file mode 100644 index 0000000..3e37c6d --- /dev/null +++ b/migrations/versions/d2113a61fa42_merge_heads.py @@ -0,0 +1,24 @@ +"""Merge heads + +Revision ID: d2113a61fa42 +Revises: 2937635f309a, a1b2c3d4e5f6 +Create Date: 2026-04-03 09:32:19.765953 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd2113a61fa42' +down_revision = ('2937635f309a', 'a1b2c3d4e5f6') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/pyproject.toml b/pyproject.toml index 896ac91..02bf644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "authlib>=1.3.0", "cryptography>=42.0.0", "requests>=2.31.0", + "beautifulsoup4>=4.14.3", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index ceb3f71..effd804 100644 --- a/uv.lock +++ b/uv.lock @@ -40,6 +40,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "bidict" version = "0.23.1" @@ -853,6 +866,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.44" @@ -888,6 +910,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "authlib" }, + { name = "beautifulsoup4" }, { name = "celery" }, { name = "cryptography" }, { name = "eventlet" }, @@ -915,6 +938,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.3.0" }, + { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "celery", specifier = ">=5.3.0" }, { name = "cryptography", specifier = ">=42.0.0" }, { name = "eventlet", specifier = ">=0.36.0" },