Two removals the user asked for.
No deck from a PDF section. A card should be written from an article —
text a person has read, edited and published — not from whatever
happened to be on pages 40-58 of a source document. POST /flashcards/
and the generate_flashcard_deck task are gone, with the Create Cards
button on the document page. What remains: POST /articles/{id}/ai-cards,
and POST /flashcards/manual for writing a deck by hand.
And no Nextcloud. It was a per-person cloud integration for a corpus one
person loads: a settings panel asking every educator for an app
password, a second tab on the upload page, and three endpoints. The
upload page now has one way in, which is the one anybody used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
809 lines
30 KiB
Python
809 lines
30 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 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
|
|
# Nullable: bank content has no owner, and a schema that promises an
|
|
# int where the column says NULL is a 500 on serialisation.
|
|
user_id: int | None = None
|
|
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
|
|
# Nullable: bank content has no owner, and a schema that promises an
|
|
# int where the column says NULL is a 500 on serialisation.
|
|
user_id: int | None = None
|
|
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 ───────────────────────────────────────────────────
|
|
|
|
class ManualDeckCreate(BaseModel):
|
|
title: str
|
|
category_id: int | None = None
|
|
|
|
|
|
# No deck from a PDF section. Cards are written from an article — the text a
|
|
# person has already read, edited and published — rather than from whatever
|
|
# happened to be on pages 40-58 of a source document. The article path is
|
|
# POST /articles/{id}/ai-cards; a deck by hand is POST /flashcards/manual.
|
|
|
|
|
|
@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
|