Four things a deck got wrong. The category. Every deck written from an article is filed where the article is filed — and the list drew all of them "Uncategorized", because the router defines its own FlashcardDeckResponse that shadows the one in schemas/, and that one has no category_id. So the field was set on the row, returned by nothing, and an educator refiled by hand what the system had already filed correctly. The shared schema was imported by no module at all, so it is gone rather than left as a second definition to read past next time. The size. Fifteen cards is the per-chunk default, and an article is one chunk however long it is — a ten-section piece and a two-paragraph stub both asked for fifteen. Now roughly a card per 150 words, floored at 12 so a short article still makes a deck and capped at 30 so one call stays inside the model's output. The card. Set at list-item size inside a frame that fills the window, so a two-line question sat in the middle of an acre of white. The face scales with the window and stops at a comfortable measure; the back is set smaller than the front, as prose rather than a headline. And the contract snapshot, which still owed the jobs endpoint from the last commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
834 lines
31 KiB
Python
834 lines
31 KiB
Python
"""Flashcard decks and cards — generate, browse, edit, delete, and schedule."""
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
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.article import Article
|
|
from app.models.flashcard import (
|
|
FlashcardDeck, Flashcard, FlashcardDeckRating,
|
|
FlashcardQuestionLink, FlashcardArticleLink,
|
|
)
|
|
from app.models.question import Question
|
|
from app.models.section import Section
|
|
from app.models.question_category import QuestionCategory
|
|
from app.models.user import User
|
|
from app.services import card_review
|
|
from app.services.quiz_builder import category_descendants
|
|
from app.services.search_service import hybrid_ids
|
|
from app.services.quiz_builder import bank_question_predicate
|
|
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
|
|
category_id: int | None = None
|
|
|
|
|
|
class FlashcardDeckResponse(BaseModel):
|
|
id: int
|
|
title: str
|
|
section_id: int | None = None
|
|
# Where the deck is filed. It was set on every deck written from an
|
|
# article — and left out of this schema, so the list drew every one of
|
|
# them as "Uncategorized" and an educator refiled a deck the system had
|
|
# already filed correctly.
|
|
category_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
|
|
#: A picture on the card. Empty string clears it, which is why this is
|
|
#: `| None` for "not mentioned" rather than for "remove".
|
|
image_path: 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
|
|
|
|
|
|
def _own_deck_to_edit(deck_id: int, current_user: User, db: Session) -> FlashcardDeck:
|
|
"""Yours to change, and you are somebody who writes decks.
|
|
|
|
Cards are written by educators. Ownership alone is not enough: if a learner
|
|
ever comes to own a deck — inherited, imported, granted — that must not by
|
|
itself make them an author of study material other people may end up
|
|
seeing.
|
|
"""
|
|
if not current_user.is_moderator:
|
|
raise HTTPException(status_code=403, detail="Writing cards needs educator access")
|
|
return _own_deck_or_404(deck_id, current_user, db)
|
|
|
|
|
|
# ── 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"}
|
|
|
|
|
|
class ManualDeckCreate(BaseModel):
|
|
title: str
|
|
category_id: int | None = None
|
|
|
|
|
|
@router.post("/manual")
|
|
def create_deck_manually(
|
|
data: ManualDeckCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator),
|
|
):
|
|
"""An empty deck, to write cards into by hand.
|
|
|
|
Every deck until now came out of a model — generated from a document
|
|
section or from an article — so an educator who simply wanted to write six
|
|
cards had nowhere to put them. The single-card route already existed and
|
|
could only add to a deck that did not exist yet.
|
|
|
|
Unshared, like every other new deck: sharing is a second, deliberate act.
|
|
"""
|
|
title = (data.title or "").strip()
|
|
if not title:
|
|
raise HTTPException(status_code=400, detail="A deck needs a title")
|
|
deck = FlashcardDeck(title=title[:200], user_id=current_user.id,
|
|
category_id=data.category_id, card_count=0, is_shared=0)
|
|
db.add(deck)
|
|
db.commit()
|
|
db.refresh(deck)
|
|
return {"id": deck.id, "title": deck.title, "card_count": 0, "is_shared": 0}
|
|
|
|
|
|
@router.get("/", response_model=list[FlashcardDeckResponse])
|
|
def list_flashcard_decks(
|
|
include_deleted: bool = Query(False),
|
|
category_id: int | None = Query(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""List flashcard decks belonging to current user, optionally by category."""
|
|
q = db.query(FlashcardDeck).filter(FlashcardDeck.user_id == current_user.id)
|
|
if not include_deleted:
|
|
q = q.filter(FlashcardDeck.deleted_at.is_(None))
|
|
if category_id is not None:
|
|
# A parent category includes everything filed beneath it.
|
|
wanted = category_descendants(db.query(QuestionCategory).all(), [category_id])
|
|
q = q.filter(FlashcardDeck.category_id.in_(wanted))
|
|
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
|
|
|
|
|
|
class CardVerdict(BaseModel):
|
|
"""What a learner said about a card. Two answers, deliberately."""
|
|
|
|
outcome: Literal["known", "again"]
|
|
|
|
|
|
@router.get("/{deck_id}/study")
|
|
def study_deck(deck_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""The deck in the order it should be sat, with what is due said out loud.
|
|
|
|
Ordering happens here rather than in the page because it needs this
|
|
learner's history, and a page that fetched the whole review log to sort
|
|
twenty cards would be downloading a year of answers to draw one screen.
|
|
"""
|
|
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")
|
|
|
|
cards = db.query(Flashcard).filter(Flashcard.deck_id == deck_id).order_by(Flashcard.id).all()
|
|
ordered, counts = card_review.study_order(db, current_user.id, cards)
|
|
return {
|
|
"deck": {"id": deck.id, "title": deck.title, "card_count": len(cards)},
|
|
**counts,
|
|
"cards": [{"id": card.id, "front": card.front, "back": card.back,
|
|
"image_path": card.image_path} for card in ordered],
|
|
}
|
|
|
|
|
|
@router.post("/cards/{card_id}/review")
|
|
def review_card(card_id: int, data: CardVerdict, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Record how a card went, so the deck can come back at the right time."""
|
|
card = db.get(Flashcard, card_id)
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
deck = db.get(FlashcardDeck, card.deck_id)
|
|
if deck and 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")
|
|
card_review.record(db, current_user.id, card_id, data.outcome)
|
|
return {"card_id": card_id, "outcome": data.outcome}
|
|
|
|
|
|
@router.get("/questions/{question_id}/cards")
|
|
def cards_for_question(question_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""The cards an educator tied to this question.
|
|
|
|
The link is read from the question's end only. A card that listed the
|
|
questions it belongs to would hand a learner revising the deck the shape of
|
|
the exam — and the answer, since a card's back is an answer.
|
|
"""
|
|
rows = db.query(Flashcard, FlashcardDeck).join(
|
|
FlashcardQuestionLink, FlashcardQuestionLink.flashcard_id == Flashcard.id).join(
|
|
FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
|
|
FlashcardQuestionLink.question_id == question_id,
|
|
FlashcardDeck.deleted_at.is_(None)).all()
|
|
return [{"card_id": card.id, "deck_id": deck.id, "deck_title": deck.title,
|
|
"front": card.front}
|
|
for card, deck in rows
|
|
if deck.user_id == current_user.id or deck.is_shared or current_user.is_admin]
|
|
|
|
|
|
@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, category). Owner or admin only."""
|
|
deck = _own_deck_to_edit(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]
|
|
if "category_id" in data.model_fields_set:
|
|
# Same tree as questions and articles, so the id must exist in it.
|
|
if data.category_id is not None and not db.get(QuestionCategory, data.category_id):
|
|
raise HTTPException(status_code=404, detail="Category not found")
|
|
deck.category_id = data.category_id
|
|
db.commit()
|
|
db.refresh(deck)
|
|
return {"id": deck.id, "title": deck.title, "category_id": deck.category_id}
|
|
|
|
|
|
@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():
|
|
# Hybrid retrieval, same as questions and articles.
|
|
ranked, _ = hybrid_ids(db, q.strip(), "flashcard", limit=500)
|
|
if not ranked:
|
|
return {"total": 0, "cards": []}
|
|
query = query.filter(Flashcard.id.in_(ranked))
|
|
|
|
# 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
|
|
if data.image_path is not None:
|
|
card.image_path = data.image_path.strip() or None
|
|
db.commit()
|
|
db.refresh(card)
|
|
return {"id": card.id, "deck_id": card.deck_id, "front": card.front,
|
|
"back": card.back, "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(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()
|
|
|
|
|
|
# ── Manual cards and question/article associations ────────────────────
|
|
|
|
class CardCreate(BaseModel):
|
|
front: str
|
|
back: str
|
|
image_path: str | None = None
|
|
|
|
|
|
class CardQuestionLinkIn(BaseModel):
|
|
question_id: int
|
|
|
|
|
|
class CardArticleLinkIn(BaseModel):
|
|
article_id: int
|
|
article_section_id: str | None = None
|
|
|
|
|
|
@router.post("/decks/{deck_id}/cards")
|
|
def create_flashcard_manually(
|
|
deck_id: int,
|
|
data: CardCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Add a single card to an existing deck. Owner or admin."""
|
|
deck = _own_deck_to_edit(deck_id, current_user, db)
|
|
if not data.front.strip() or not data.back.strip():
|
|
raise HTTPException(status_code=400, detail="Both sides are required")
|
|
card = Flashcard(deck_id=deck.id, front=data.front.strip(), back=data.back.strip(),
|
|
image_path=(data.image_path or None) or None)
|
|
db.add(card)
|
|
deck.card_count = (deck.card_count or 0) + 1
|
|
db.commit()
|
|
db.refresh(card)
|
|
return {"id": card.id, "deck_id": deck.id, "front": card.front, "back": card.back, "image_path": card.image_path}
|
|
|
|
|
|
def _own_card_or_404(card_id: int, current_user: User, db: Session) -> Flashcard:
|
|
card = db.get(Flashcard, card_id)
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
deck = db.get(FlashcardDeck, card.deck_id)
|
|
if deck and deck.user_id != current_user.id and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not your card")
|
|
return card
|
|
|
|
|
|
@router.get("/cards/{card_id}/links")
|
|
def list_card_links(
|
|
card_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Questions and articles associated with a card (access follows the deck)."""
|
|
card = db.get(Flashcard, card_id)
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
deck = db.get(FlashcardDeck, card.deck_id)
|
|
if deck and deck.user_id != current_user.id and not deck.is_shared and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Not your card")
|
|
questions = db.query(Question.id, Question.question_text)
|
|
if not current_user.is_moderator:
|
|
questions = questions.filter(bank_question_predicate(current_user))
|
|
questions = questions.join(
|
|
FlashcardQuestionLink, FlashcardQuestionLink.question_id == Question.id,
|
|
).filter(FlashcardQuestionLink.flashcard_id == card_id).all()
|
|
articles = db.query(Article.id, Article.title, Article.status, FlashcardArticleLink.article_section_id)
|
|
if not current_user.is_moderator:
|
|
articles = articles.filter(Article.status == 'published')
|
|
articles = articles.join(
|
|
FlashcardArticleLink, FlashcardArticleLink.article_id == Article.id,
|
|
).filter(FlashcardArticleLink.flashcard_id == card_id).all()
|
|
return {
|
|
"questions": [{"id": qid, "question_text": text} for qid, text in questions],
|
|
"articles": [{"id": aid, "title": title, "status": status, "article_section_id": sid}
|
|
for aid, title, status, sid in articles],
|
|
}
|
|
|
|
|
|
@router.put("/cards/{card_id}/links/question")
|
|
def link_card_question(
|
|
card_id: int,
|
|
data: CardQuestionLinkIn,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
_own_card_or_404(card_id, current_user, db)
|
|
if not db.query(Question.id).filter(Question.id == data.question_id, bank_question_predicate(current_user)).first():
|
|
raise HTTPException(status_code=404, detail="Question not found")
|
|
if db.query(FlashcardQuestionLink.id).filter_by(flashcard_id=card_id, question_id=data.question_id).first():
|
|
return {"linked": False}
|
|
db.add(FlashcardQuestionLink(flashcard_id=card_id, question_id=data.question_id))
|
|
db.commit()
|
|
return {"linked": True}
|
|
|
|
|
|
@router.delete("/cards/{card_id}/links/question/{question_id}", status_code=204)
|
|
def unlink_card_question(
|
|
card_id: int,
|
|
question_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
_own_card_or_404(card_id, current_user, db)
|
|
db.query(FlashcardQuestionLink).filter_by(flashcard_id=card_id, question_id=question_id).delete(synchronize_session=False)
|
|
db.commit()
|
|
|
|
|
|
@router.put("/cards/{card_id}/links/article")
|
|
def link_card_article(
|
|
card_id: int,
|
|
data: CardArticleLinkIn,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
_own_card_or_404(card_id, current_user, db)
|
|
article = db.get(Article, data.article_id)
|
|
if not article:
|
|
raise HTTPException(status_code=404, detail="Article not found")
|
|
if data.article_section_id is not None and data.article_section_id not in {
|
|
section["id"] for section in (article.sections or [])}:
|
|
raise HTTPException(status_code=400, detail="Section not found in this article")
|
|
if db.query(FlashcardArticleLink.id).filter_by(
|
|
flashcard_id=card_id, article_id=data.article_id, article_section_id=data.article_section_id).first():
|
|
return {"linked": False}
|
|
db.add(FlashcardArticleLink(flashcard_id=card_id, article_id=data.article_id,
|
|
article_section_id=data.article_section_id))
|
|
db.commit()
|
|
return {"linked": True}
|
|
|
|
|
|
@router.delete("/cards/{card_id}/links/article/{article_id}", status_code=204)
|
|
def unlink_card_article(
|
|
card_id: int,
|
|
article_id: int,
|
|
article_section_id: str | None = Query(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
_own_card_or_404(card_id, current_user, db)
|
|
db.query(FlashcardArticleLink).filter_by(
|
|
flashcard_id=card_id, article_id=article_id, article_section_id=article_section_id,
|
|
).delete(synchronize_session=False)
|
|
db.commit()
|
|
|
|
|
|
@router.get("/cards/linked")
|
|
def cards_for_target(
|
|
question_id: int | None = Query(None),
|
|
article_id: int | None = Query(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Cards linked to a question or article; results follow deck sharing/ownership."""
|
|
if (question_id is None) == (article_id is None):
|
|
raise HTTPException(400, "Provide exactly one of question_id or article_id")
|
|
if question_id is not None and not db.query(Question.id).filter(
|
|
Question.id == question_id, bank_question_predicate(current_user)).first():
|
|
raise HTTPException(404, "Question not found")
|
|
query = db.query(Flashcard, FlashcardDeck).join(
|
|
FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id,
|
|
).filter(FlashcardDeck.deleted_at.is_(None))
|
|
if question_id is not None:
|
|
query = query.join(FlashcardQuestionLink, FlashcardQuestionLink.flashcard_id == Flashcard.id).filter(
|
|
FlashcardQuestionLink.question_id == question_id)
|
|
else:
|
|
article = db.get(Article, article_id)
|
|
if not article:
|
|
raise HTTPException(404, "Article not found")
|
|
if article.status != "published" and not current_user.is_moderator:
|
|
raise HTTPException(404, "Article not found")
|
|
query = query.join(FlashcardArticleLink, FlashcardArticleLink.flashcard_id == Flashcard.id).filter(
|
|
FlashcardArticleLink.article_id == article_id)
|
|
cards = []
|
|
for card, deck in query.all():
|
|
if not current_user.is_moderator and deck.user_id != current_user.id and not deck.is_shared:
|
|
continue
|
|
cards.append({"id": card.id, "deck_id": deck.id, "deck_title": deck.title,
|
|
"front": card.front, "back": card.back, "image_path": card.image_path})
|
|
return cards
|