"""Flashcard decks and cards — generate, browse, edit, delete.""" 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 user_id: int card_count: int created_at: object 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 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 # ── 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") if not current_user.is_admin and section.document.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not your document") 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( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List flashcard decks. Moderators see all; regular users see own.""" q = db.query(FlashcardDeck) if not current_user.is_moderator: q = q.filter(FlashcardDeck.user_id == current_user.id) return q.order_by(FlashcardDeck.created_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.""" deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first() if not deck: raise HTTPException(status_code=404, detail="Deck not found") if not current_user.is_moderator and deck.user_id != current_user.id: 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, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Delete a deck and all its cards.""" deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first() if not deck: raise HTTPException(status_code=404, detail="Deck not found") db.delete(deck) db.commit() # ── 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 ALL flashcards across all decks (like question bank).""" query = db.query(Flashcard) # Restrict non-moderators to own decks if not current_user.is_moderator: own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter( FlashcardDeck.user_id == current_user.id ).all()] if not own_deck_ids: return {"total": 0, "cards": []} query = query.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: cards must have ALL specified tags 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, description="Comma-separated tag IDs (AND filter)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return just IDs for matching cards (for select-all).""" query = db.query(Flashcard.id) if not current_user.is_moderator: own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter( FlashcardDeck.user_id == current_user.id ).all()] if not own_deck_ids: return [] query = query.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(require_moderator), ): """Edit a single flashcard (front, back). Requires moderator.""" card = db.query(Flashcard).filter(Flashcard.id == card_id).first() if not card: raise HTTPException(status_code=404, detail="Card not found") 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, "page_reference": card.page_reference, "image_path": card.image_path, } @router.delete("/cards/{card_id}", status_code=204) def delete_flashcard( card_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Delete a single flashcard. Requires moderator.""" 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() db.delete(card) if deck and deck.card_count > 0: deck.card_count -= 1 db.commit()