pdf-quiz-generator/backend/app/routers/flashcards.py
Daniel b6cfcaa1e9
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
feat: the bank belongs to a role, not to a person
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +02:00

846 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 _deck_or_404(deck_id: int, current_user: User, db: Session) -> FlashcardDeck:
"""A deck, for somebody who works on decks.
A deck used to be its creator's, and access turned on that. Decks are
study material the whole site reads, so they belong to the bank: an
educator or an admin reaches any of them, and nobody else reaches one
this way. Studying a shared deck goes through the shared routes.
"""
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:
raise HTTPException(status_code=404, detail="Deck not found")
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 _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),
):
"""The bank's decks, optionally by category. Educators only.
There is no "my decks" any more — a deck is not anybody's. A learner
studies what has been shared with them, which is the /shared listing.
"""
if not current_user.is_moderator:
return []
q = db.query(FlashcardDeck)
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),
):
"""Decks in the bin. Editors' business, like the decks themselves."""
if not current_user.is_moderator:
return []
return db.query(FlashcardDeck).filter(
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()
result = []
for deck, avg_r, r_count in decks_with_rating:
result.append({
"id": deck.id,
"title": deck.title,
"card_count": deck.card_count,
# No owner to name. A shared deck is the library's.
"user_id": None,
"owner_name": "PedsHub",
"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 not deck.is_shared and not current_user.is_moderator:
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 not deck.is_shared and not current_user.is_moderator:
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 not deck.is_shared and not current_user.is_moderator:
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.is_shared or current_user.is_moderator]
@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 = _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 = _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 = _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 the cards in the bank's decks. Educators; learners study shared."""
if not current_user.is_moderator:
return {"total": 0, "cards": []}
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
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)."""
if not current_user.is_moderator:
return []
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
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 not current_user.is_moderator:
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 not current_user.is_moderator:
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 not current_user.is_moderator:
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 not deck.is_shared and not current_user.is_moderator:
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 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