pdf-quiz-generator/backend/app/routers/flashcards.py
Daniel 800de50fc9 Flashcard ownership, trash, delete cards, fix job status
- Fix: flashcard task used wrong Redis key prefix (job: vs extraction:)
  causing progress to stay on "pending" after completion
- Flashcards are now user-scoped: each user sees only their own decks
- Soft-delete decks: DELETE moves to trash, ?permanent=true to destroy
- Trash tab on flashcards page: restore or permanently delete decks
- Delete individual cards with inline confirmation in browse view
- Card edit/delete now allowed for deck owner (not just moderators)
- ExtractionProgress label prop: shows "Generating Flashcards" not
  "Extracting Questions" for flashcard jobs
- Added deleted_at column to flashcard_decks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:24:48 +02:00

343 lines
12 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
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):
section_id: int
title: str
model_id: str | None = None
class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None = None
user_id: int
card_count: int
created_at: object
deleted_at: object | None = None
class Config:
from_attributes = True
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("/{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."""
return _own_deck_or_404(deck_id, current_user, db)
@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}
# ── 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()