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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
252
frontend/frontend/src/components/questionbank/ShareDialog.jsx
Normal file
252
frontend/frontend/src/components/questionbank/ShareDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
|
||||
24
migrations/versions/d2113a61fa42_merge_heads.py
Normal file
24
migrations/versions/d2113a61fa42_merge_heads.py
Normal 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
|
||||
@@ -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
24
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user