Add question ownership and sharing

Questions now have a created_by field linking to the user who created them.
Users only see questions they own or that have been shared with them.
Includes share dialog, user search, bulk sharing, and export/import
respects ownership.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 09:43:04 -04:00
parent 02fcbad9ba
commit 69992f1be9
15 changed files with 836 additions and 70 deletions

View File

@@ -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'

View File

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

View File

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

View File

@@ -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('/<int:question_id>', 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('/<int:question_id>', 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('/<int:question_id>', 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('/<int:question_id>/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('/<int:question_id>/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('/<int:question_id>/share/<int:user_id>', 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

View File

@@ -9,17 +9,29 @@ 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
# 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()
@@ -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

View File

@@ -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() {
</select>
</div>
{/* Owner Filter */}
<div>
<select
value={filterOwner}
onChange={(e) => setFilterOwner(e.target.value)}
style={{
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
minWidth: "130px",
}}
>
<option value="">All Questions</option>
<option value="mine">My Questions</option>
<option value="shared">Shared with Me</option>
</select>
</div>
{/* Clear Filters */}
{(searchTerm || filterCategory || filterType || sortBy !== "created_at" || sortOrder !== "desc") && (
{(searchTerm || filterCategory || filterType || filterOwner || sortBy !== "created_at" || sortOrder !== "desc") && (
<button
onClick={() => {
setSearchTerm("");
setFilterCategory("");
setFilterType("");
setFilterOwner("");
setSortBy("created_at");
setSortOrder("desc");
}}
@@ -742,6 +767,19 @@ export default function QuestionBankView() {
>
Assign Category
</button>
<button
onClick={() => setShareTarget(selectedQuestions)}
style={{
padding: "0.5rem 1rem",
background: "#9C27B0",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Share Selected
</button>
<button
onClick={handleBulkDelete}
style={{
@@ -880,12 +918,21 @@ export default function QuestionBankView() {
Answer
<SortIndicator column="answer" />
</th>
<th
style={{
...sortableHeaderStyle,
width: "100px",
cursor: "default",
}}
>
Creator
</th>
<th
style={{
padding: "0.75rem",
textAlign: "center",
borderBottom: "2px solid #ddd",
width: "150px",
width: "200px",
}}
>
Actions
@@ -954,6 +1001,16 @@ export default function QuestionBankView() {
<td style={{ padding: "0.75rem", fontWeight: "bold" }}>
{q.answer}
</td>
<td style={{ padding: "0.75rem" }}>
<span
style={{
fontSize: "0.8rem",
color: q.created_by === dbUser?.id ? "#1976d2" : "#666",
}}
>
{q.created_by === dbUser?.id ? "You" : (q.creator_name || "Unknown")}
</span>
</td>
<td style={{ padding: "0.75rem", textAlign: "center" }}>
<div
style={{
@@ -962,6 +1019,8 @@ export default function QuestionBankView() {
justifyContent: "center",
}}
>
{q.created_by === dbUser?.id && (
<>
<button
onClick={() => handleEdit(q)}
style={{
@@ -976,6 +1035,20 @@ export default function QuestionBankView() {
>
Edit
</button>
<button
onClick={() => setShareTarget(q)}
style={{
padding: "0.4rem 0.8rem",
background: "#9C27B0",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.9rem",
}}
>
Share
</button>
<button
onClick={() => handleDelete(q.id)}
style={{
@@ -990,6 +1063,8 @@ export default function QuestionBankView() {
>
Delete
</button>
</>
)}
</div>
</td>
</tr>
@@ -1045,6 +1120,13 @@ export default function QuestionBankView() {
</div>
)}
</div>
{shareTarget && (
<ShareDialog
target={shareTarget}
onClose={() => setShareTarget(null)}
/>
)}
</>
);
}

View File

@@ -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 (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h3 style={{ margin: 0 }}>
{isBulk ? `Share ${target.length} Question(s)` : "Share Question"}
</h3>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
fontSize: "1.5rem",
cursor: "pointer",
color: "#666",
}}
>
x
</button>
</div>
{!isBulk && (
<p style={{ fontSize: "0.9rem", color: "#666", margin: "0 0 1rem 0" }}>
{target.question_content?.substring(0, 80)}{target.question_content?.length > 80 ? "..." : ""}
</p>
)}
{/* User search */}
<div style={{ marginBottom: "1rem" }}>
<input
type="text"
placeholder="Search users by name, username, or email..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
style={{
width: "100%",
padding: "0.6rem",
border: "1px solid #ddd",
borderRadius: "6px",
fontSize: "0.95rem",
boxSizing: "border-box",
}}
autoFocus
/>
</div>
{/* Search results */}
{searchResults.length > 0 && (
<div style={{ marginBottom: "1rem", border: "1px solid #eee", borderRadius: "6px", overflow: "hidden" }}>
{searchResults.map((user) => {
const alreadyShared = currentShares.some(s => s.shared_with_user_id === user.id);
return (
<div
key={user.id}
style={{
padding: "0.6rem 0.8rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderBottom: "1px solid #f0f0f0",
}}
>
<div>
<div style={{ fontWeight: 500 }}>{user.name || user.username || "Unknown"}</div>
{user.email && (
<div style={{ fontSize: "0.8rem", color: "#888" }}>{user.email}</div>
)}
</div>
{alreadyShared ? (
<span style={{ fontSize: "0.85rem", color: "#4CAF50" }}>Shared</span>
) : (
<button
onClick={() => handleShare(user.id)}
style={{
padding: "0.3rem 0.8rem",
background: "#9C27B0",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.85rem",
}}
>
Share
</button>
)}
</div>
);
})}
</div>
)}
{isSearching && (
<p style={{ fontSize: "0.9rem", color: "#888", textAlign: "center" }}>Searching...</p>
)}
{/* Current shares (single question only) */}
{!isBulk && currentShares.length > 0 && (
<div>
<h4 style={{ margin: "0 0 0.5rem 0", fontSize: "0.95rem" }}>Shared with</h4>
{currentShares.map((share) => (
<div
key={share.id}
style={{
padding: "0.5rem 0.8rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "#f9f9f9",
borderRadius: "6px",
marginBottom: "0.4rem",
}}
>
<span>{share.shared_with_user_name || `User ${share.shared_with_user_id}`}</span>
<button
onClick={() => handleUnshare(share.shared_with_user_id)}
style={{
padding: "0.2rem 0.6rem",
background: "#f44336",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.8rem",
}}
>
Remove
</button>
</div>
))}
</div>
)}
{!isBulk && currentShares.length === 0 && searchResults.length === 0 && !isSearching && (
<p style={{ fontSize: "0.9rem", color: "#888", textAlign: "center" }}>
Not shared with anyone yet. Search for a user above.
</p>
)}
{message && (
<p style={{ fontSize: "0.9rem", color: "#4CAF50", textAlign: "center", margin: "0.5rem 0 0 0" }}>
{message}
</p>
)}
</div>
</div>
);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 ###

View File

@@ -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 ###

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ dependencies = [
"authlib>=1.3.0",
"cryptography>=42.0.0",
"requests>=2.31.0",
"beautifulsoup4>=4.14.3",
]
[project.optional-dependencies]

24
uv.lock generated
View File

@@ -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" },