"""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()