512 lines
17 KiB
Python
512 lines
17 KiB
Python
"""Flashcard decks and cards — generate, browse, edit, delete."""
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import or_, text as sa_text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating
|
|
from app.models.section import Section
|
|
from app.models.user import User
|
|
from app.utils.auth import get_current_user, require_moderator
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Schemas ──────────────────────────────────────────────────────────
|
|
|
|
class FlashcardDeckCreate(BaseModel):
|
|
model_config = {"protected_namespaces": ()}
|
|
|
|
section_id: int
|
|
title: str
|
|
model_id: str | None = None
|
|
|
|
|
|
class FlashcardDeckUpdate(BaseModel):
|
|
title: str | None = None
|
|
|
|
|
|
class FlashcardDeckResponse(BaseModel):
|
|
id: int
|
|
title: str
|
|
section_id: int | None = None
|
|
user_id: int
|
|
card_count: int
|
|
is_shared: int = 0
|
|
created_at: object
|
|
deleted_at: object | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class RatingRequest(BaseModel):
|
|
rating: int # 1-5
|
|
|
|
|
|
class FlashcardCardResponse(BaseModel):
|
|
id: int
|
|
deck_id: int
|
|
front: str
|
|
back: str
|
|
page_reference: int | None = None
|
|
image_path: str | None = None
|
|
created_at: object
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class FlashcardDeckDetail(BaseModel):
|
|
id: int
|
|
title: str
|
|
section_id: int | None = None
|
|
user_id: int
|
|
card_count: int
|
|
created_at: object
|
|
cards: list[FlashcardCardResponse]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class CardEdit(BaseModel):
|
|
front: str | None = None
|
|
back: str | None = None
|
|
|
|
|
|
def _own_deck_or_404(deck_id: int, current_user: User, db: Session) -> FlashcardDeck:
|
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Deck not found")
|
|
if deck.user_id != current_user.id and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not your deck")
|
|
return deck
|
|
|
|
|
|
# ── Deck endpoints ───────────────────────────────────────────────────
|
|
|
|
@router.post("/")
|
|
def create_flashcard_deck(
|
|
data: FlashcardDeckCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator),
|
|
):
|
|
"""Start async flashcard generation from a section. Returns {job_id} immediately."""
|
|
import uuid
|
|
|
|
section = db.query(Section).filter(Section.id == data.section_id).first()
|
|
if not section:
|
|
raise HTTPException(status_code=404, detail="Section not found")
|
|
|
|
job_id = str(uuid.uuid4())
|
|
|
|
try:
|
|
from app.tasks.quiz_tasks import generate_flashcard_deck
|
|
import redis as redis_lib
|
|
from app.config import settings
|
|
|
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
|
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
|
|
r.lpush(f"extraction:user_jobs:{current_user.id}", job_id)
|
|
r.expire(f"extraction:user_jobs:{current_user.id}", 86400)
|
|
r.set(f"extraction:job_title:{job_id}", data.title, ex=3600)
|
|
|
|
generate_flashcard_deck.delay(
|
|
job_id=job_id,
|
|
user_id=current_user.id,
|
|
section_id=data.section_id,
|
|
title=data.title,
|
|
model_id=data.model_id,
|
|
)
|
|
except Exception:
|
|
raise HTTPException(status_code=503, detail="Task queue unavailable")
|
|
|
|
return {"job_id": job_id, "status": "pending"}
|
|
|
|
|
|
@router.get("/", response_model=list[FlashcardDeckResponse])
|
|
def list_flashcard_decks(
|
|
include_deleted: bool = Query(False),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""List flashcard decks belonging to current user."""
|
|
q = db.query(FlashcardDeck).filter(FlashcardDeck.user_id == current_user.id)
|
|
if not include_deleted:
|
|
q = q.filter(FlashcardDeck.deleted_at.is_(None))
|
|
return q.order_by(FlashcardDeck.created_at.desc()).all()
|
|
|
|
|
|
@router.get("/trash", response_model=list[FlashcardDeckResponse])
|
|
def list_trashed_decks(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""List soft-deleted decks for current user's trash."""
|
|
return db.query(FlashcardDeck).filter(
|
|
FlashcardDeck.user_id == current_user.id,
|
|
FlashcardDeck.deleted_at.isnot(None),
|
|
).order_by(FlashcardDeck.deleted_at.desc()).all()
|
|
|
|
|
|
@router.get("/shared")
|
|
def list_shared_decks(
|
|
limit: int = Query(20, le=100),
|
|
offset: int = Query(0),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""List shared decks sorted by average rating (desc), with pagination."""
|
|
from sqlalchemy import func
|
|
|
|
avg_rating = db.query(
|
|
FlashcardDeckRating.deck_id,
|
|
func.avg(FlashcardDeckRating.rating).label("avg_rating"),
|
|
func.count(FlashcardDeckRating.id).label("rating_count"),
|
|
).group_by(FlashcardDeckRating.deck_id).subquery()
|
|
|
|
my_ratings = {}
|
|
for r in db.query(FlashcardDeckRating).filter(FlashcardDeckRating.user_id == current_user.id).all():
|
|
my_ratings[r.deck_id] = r.rating
|
|
|
|
total = db.query(FlashcardDeck).filter(
|
|
FlashcardDeck.is_shared == 1,
|
|
FlashcardDeck.deleted_at.is_(None),
|
|
).count()
|
|
|
|
decks_with_rating = db.query(
|
|
FlashcardDeck,
|
|
func.coalesce(avg_rating.c.avg_rating, 0).label("avg_rating"),
|
|
func.coalesce(avg_rating.c.rating_count, 0).label("rating_count"),
|
|
).outerjoin(
|
|
avg_rating, FlashcardDeck.id == avg_rating.c.deck_id
|
|
).filter(
|
|
FlashcardDeck.is_shared == 1,
|
|
FlashcardDeck.deleted_at.is_(None),
|
|
).order_by(
|
|
func.coalesce(avg_rating.c.avg_rating, 0).desc(),
|
|
FlashcardDeck.created_at.desc(),
|
|
).offset(offset).limit(limit).all()
|
|
|
|
user_cache: dict[int, str] = {}
|
|
result = []
|
|
for deck, avg_r, r_count in decks_with_rating:
|
|
if deck.user_id not in user_cache:
|
|
owner = db.query(User).filter(User.id == deck.user_id).first()
|
|
user_cache[deck.user_id] = owner.name if owner else "Unknown"
|
|
result.append({
|
|
"id": deck.id,
|
|
"title": deck.title,
|
|
"card_count": deck.card_count,
|
|
"user_id": deck.user_id,
|
|
"owner_name": user_cache[deck.user_id],
|
|
"is_shared": deck.is_shared,
|
|
"created_at": deck.created_at,
|
|
"avg_rating": round(float(avg_r), 1),
|
|
"rating_count": int(r_count),
|
|
"my_rating": my_ratings.get(deck.id),
|
|
})
|
|
|
|
return {"total": total, "decks": result}
|
|
|
|
|
|
@router.get("/{deck_id}", response_model=FlashcardDeckDetail)
|
|
def get_flashcard_deck(
|
|
deck_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Get a deck with all its cards. Accessible if owned or shared."""
|
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Deck not found")
|
|
if deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared:
|
|
raise HTTPException(status_code=403, detail="Not your deck")
|
|
return deck
|
|
|
|
|
|
@router.delete("/{deck_id}", status_code=204)
|
|
def delete_flashcard_deck(
|
|
deck_id: int,
|
|
permanent: bool = Query(False),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Soft-delete a deck (moves to trash). Use ?permanent=true to permanently delete."""
|
|
deck = _own_deck_or_404(deck_id, current_user, db)
|
|
if permanent:
|
|
db.delete(deck)
|
|
else:
|
|
deck.deleted_at = datetime.utcnow()
|
|
db.commit()
|
|
|
|
|
|
@router.post("/{deck_id}/restore", status_code=200)
|
|
def restore_flashcard_deck(
|
|
deck_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Restore a soft-deleted deck from trash."""
|
|
deck = _own_deck_or_404(deck_id, current_user, db)
|
|
if not deck.deleted_at:
|
|
raise HTTPException(status_code=400, detail="Deck is not in trash")
|
|
deck.deleted_at = None
|
|
db.commit()
|
|
return {"id": deck.id, "title": deck.title, "restored": True}
|
|
|
|
|
|
# ── Sharing & rating ────────────────────────────────────────────────
|
|
|
|
@router.patch("/{deck_id}")
|
|
def update_deck(
|
|
deck_id: int,
|
|
data: FlashcardDeckUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Update deck metadata (title). Owner or admin only."""
|
|
deck = _own_deck_or_404(deck_id, current_user, db)
|
|
if data.title is not None:
|
|
title = data.title.strip()
|
|
if not title:
|
|
raise HTTPException(status_code=400, detail="Title cannot be empty")
|
|
deck.title = title[:300]
|
|
db.commit()
|
|
db.refresh(deck)
|
|
return {"id": deck.id, "title": deck.title}
|
|
|
|
|
|
@router.put("/{deck_id}/share")
|
|
def toggle_share_deck(
|
|
deck_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Toggle sharing on a deck. Owner or admin."""
|
|
deck = _own_deck_or_404(deck_id, current_user, db)
|
|
deck.is_shared = 0 if deck.is_shared else 1
|
|
db.commit()
|
|
return {"id": deck.id, "is_shared": deck.is_shared}
|
|
|
|
|
|
@router.delete("/{deck_id}/share", status_code=204)
|
|
def admin_unshare_deck(
|
|
deck_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Admin: force-unshare any deck."""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Deck not found")
|
|
deck.is_shared = 0
|
|
db.commit()
|
|
|
|
|
|
@router.post("/{deck_id}/rate")
|
|
def rate_deck(
|
|
deck_id: int,
|
|
data: RatingRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Rate a shared deck (1-5). Updates if already rated."""
|
|
if data.rating < 1 or data.rating > 5:
|
|
raise HTTPException(status_code=400, detail="Rating must be 1-5")
|
|
|
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Deck not found")
|
|
if not deck.is_shared:
|
|
raise HTTPException(status_code=400, detail="Deck is not shared")
|
|
|
|
existing = db.query(FlashcardDeckRating).filter(
|
|
FlashcardDeckRating.deck_id == deck_id,
|
|
FlashcardDeckRating.user_id == current_user.id,
|
|
).first()
|
|
|
|
if existing:
|
|
existing.rating = data.rating
|
|
else:
|
|
db.add(FlashcardDeckRating(
|
|
deck_id=deck_id,
|
|
user_id=current_user.id,
|
|
rating=data.rating,
|
|
))
|
|
db.commit()
|
|
|
|
# Return updated average
|
|
from sqlalchemy import func
|
|
avg = db.query(func.avg(FlashcardDeckRating.rating)).filter(
|
|
FlashcardDeckRating.deck_id == deck_id
|
|
).scalar()
|
|
count = db.query(FlashcardDeckRating).filter(FlashcardDeckRating.deck_id == deck_id).count()
|
|
|
|
return {"deck_id": deck_id, "my_rating": data.rating, "avg_rating": round(float(avg), 1), "rating_count": count}
|
|
|
|
|
|
# ── Card browse endpoints ────────────────────────────────────────────
|
|
|
|
@router.get("/cards/browse")
|
|
def browse_flashcards(
|
|
q: str | None = Query(None),
|
|
deck_id: int | None = Query(None),
|
|
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
|
limit: int = Query(50, le=200),
|
|
offset: int = Query(0),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Browse flashcards across user's own decks."""
|
|
# Get user's active (non-deleted) deck IDs
|
|
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
|
|
FlashcardDeck.user_id == current_user.id,
|
|
FlashcardDeck.deleted_at.is_(None),
|
|
).all()]
|
|
if not own_deck_ids:
|
|
return {"total": 0, "cards": []}
|
|
|
|
query = db.query(Flashcard).filter(Flashcard.deck_id.in_(own_deck_ids))
|
|
|
|
if deck_id:
|
|
query = query.filter(Flashcard.deck_id == deck_id)
|
|
|
|
if q and q.strip():
|
|
phrase = q.strip()
|
|
query = query.filter(
|
|
or_(
|
|
Flashcard.front.ilike(f"%{phrase}%"),
|
|
Flashcard.back.ilike(f"%{phrase}%"),
|
|
)
|
|
)
|
|
|
|
# Tag filter
|
|
if tag_ids:
|
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
matching_ids = list(db.execute(sa_text("""
|
|
SELECT flashcard_id FROM flashcard_tag_links
|
|
WHERE tag_id = ANY(:tag_ids)
|
|
GROUP BY flashcard_id
|
|
HAVING COUNT(DISTINCT tag_id) = :cnt
|
|
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
|
|
if matching_ids:
|
|
query = query.filter(Flashcard.id.in_(matching_ids))
|
|
else:
|
|
return {"total": 0, "cards": []}
|
|
|
|
total = query.count()
|
|
cards = query.order_by(Flashcard.deck_id, Flashcard.id).offset(offset).limit(limit).all()
|
|
|
|
deck_cache: dict[int, str] = {}
|
|
result = []
|
|
for card in cards:
|
|
if card.deck_id not in deck_cache:
|
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
|
|
deck_cache[card.deck_id] = deck.title if deck else f"Deck {card.deck_id}"
|
|
result.append({
|
|
"id": card.id,
|
|
"deck_id": card.deck_id,
|
|
"deck_title": deck_cache[card.deck_id],
|
|
"front": card.front,
|
|
"back": card.back,
|
|
"page_reference": card.page_reference,
|
|
"image_path": card.image_path,
|
|
"created_at": card.created_at,
|
|
})
|
|
|
|
return {"total": total, "cards": result}
|
|
|
|
|
|
@router.get("/cards/browse/ids")
|
|
def browse_flashcard_ids(
|
|
q: str | None = Query(None),
|
|
deck_id: int | None = Query(None),
|
|
tag_ids: str | None = Query(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return just IDs for matching cards (for select-all)."""
|
|
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
|
|
FlashcardDeck.user_id == current_user.id,
|
|
FlashcardDeck.deleted_at.is_(None),
|
|
).all()]
|
|
if not own_deck_ids:
|
|
return []
|
|
|
|
query = db.query(Flashcard.id).filter(Flashcard.deck_id.in_(own_deck_ids))
|
|
|
|
if deck_id:
|
|
query = query.filter(Flashcard.deck_id == deck_id)
|
|
if q and q.strip():
|
|
phrase = q.strip()
|
|
query = query.filter(or_(
|
|
Flashcard.front.ilike(f"%{phrase}%"),
|
|
Flashcard.back.ilike(f"%{phrase}%"),
|
|
))
|
|
if tag_ids:
|
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
matching_ids = list(db.execute(sa_text("""
|
|
SELECT flashcard_id FROM flashcard_tag_links
|
|
WHERE tag_id = ANY(:tag_ids)
|
|
GROUP BY flashcard_id
|
|
HAVING COUNT(DISTINCT tag_id) = :cnt
|
|
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
|
|
if matching_ids:
|
|
query = query.filter(Flashcard.id.in_(matching_ids))
|
|
else:
|
|
return []
|
|
|
|
return [row[0] for row in query.all()]
|
|
|
|
|
|
# ── Single card endpoints ────────────────────────────────────────────
|
|
|
|
@router.put("/cards/{card_id}")
|
|
def update_flashcard(
|
|
card_id: int,
|
|
data: CardEdit,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Edit a single flashcard (front, back). Owner or admin."""
|
|
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
|
|
if deck and deck.user_id != current_user.id and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not your card")
|
|
if data.front is not None:
|
|
card.front = data.front
|
|
if data.back is not None:
|
|
card.back = data.back
|
|
db.commit()
|
|
db.refresh(card)
|
|
return {"id": card.id, "deck_id": card.deck_id, "front": card.front, "back": card.back}
|
|
|
|
|
|
@router.delete("/cards/{card_id}", status_code=204)
|
|
def delete_flashcard(
|
|
card_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Delete a single flashcard. Owner or admin."""
|
|
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
|
|
if deck and deck.user_id != current_user.id and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not your card")
|
|
db.delete(card)
|
|
if deck and deck.card_count > 0:
|
|
deck.card_count -= 1
|
|
db.commit()
|