feat: flashcard decks share the question and article category tree

Questions and articles both pointed at `question_categories`; decks had no
category at all, so the three content types could not be filtered together and a
topic's cards were unreachable from its category.

- `flashcard_decks.category_id` references the same tree (migration
  t2f3a4b5c697), so one category now spans questions, articles and cards.
- `GET /flashcards/` takes `category_id` and includes descendants, so a parent
  category picks up everything filed beneath it.
- `PATCH /flashcards/{id}` files or unfiles a deck, refusing a category id that
  does not exist rather than storing a dangling reference.
- The cards page shows each deck's category as a selector.

Tests: 5 new backend (all three types resolve to the same id, descendant
filtering, file and unfile, unknown category refused, renaming leaves the
category alone) and 1 new frontend. Full suites green: 101 backend,
136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
This commit is contained in:
Daniel 2026-09-10 01:40:03 +02:00
parent e9c8bfd204
commit 9b51a17507
8 changed files with 205 additions and 4 deletions

View file

@ -0,0 +1,28 @@
"""Put flashcard decks on the same category tree as questions and articles.
Questions and articles already point at `question_categories`; decks had no
category at all, so the three content types could not be filtered together.
Revision ID: t2f3a4b5c697
Revises: s1e2f3a4b586
"""
from alembic import op
revision = "t2f3a4b5c697"
down_revision = "s1e2f3a4b586"
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
ALTER TABLE flashcard_decks
ADD COLUMN IF NOT EXISTS category_id INTEGER
REFERENCES question_categories(id) ON DELETE SET NULL
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_flashcard_decks_category_id ON flashcard_decks(category_id)")
def downgrade():
op.execute("DROP INDEX IF EXISTS ix_flashcard_decks_category_id")
op.execute("ALTER TABLE flashcard_decks DROP COLUMN IF EXISTS category_id")

View file

@ -8,6 +8,8 @@ class FlashcardDeck(Base):
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True)
# Same tree as questions and articles, so one category filters all three.
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
card_count = Column(Integer, default=0)
is_shared = Column(Integer, default=0) # 0 = private, 1 = shared

View file

@ -14,7 +14,9 @@ from app.models.flashcard import (
)
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.quiz_builder import category_descendants
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import get_current_user, require_moderator
@ -33,6 +35,7 @@ class FlashcardDeckCreate(BaseModel):
class FlashcardDeckUpdate(BaseModel):
title: str | None = None
category_id: int | None = None
class FlashcardDeckResponse(BaseModel):
@ -137,13 +140,18 @@ def create_flashcard_deck(
@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."""
"""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()
@ -275,16 +283,21 @@ def update_deck(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update deck metadata (title). Owner or admin only."""
"""Update deck metadata (title, category). Owner or admin only."""
deck = _own_deck_or_404(deck_id, current_user, db)
if data.title is not None:
title = data.title.strip()
if not title:
raise HTTPException(status_code=400, detail="Title cannot be empty")
deck.title = title[:300]
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}
return {"id": deck.id, "title": deck.title, "category_id": deck.category_id}
@router.put("/{deck_id}/share")

View file

@ -21,6 +21,7 @@ class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None = None
category_id: int | None = None
user_id: int
card_count: int
created_at: datetime

View file

@ -0,0 +1,98 @@
"""Questions, articles and flashcard decks all file under one category tree.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
"""
import os
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
import unittest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.article import Article
from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
from app.models.flashcard import FlashcardDeck
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.models.user import User
from app.routers import flashcards
from app.utils.auth import get_current_user
class SharedCategoryTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.user = User(id=1, name="Owner", email="owner@example.test", hashed_password="unused")
self.db.add(self.user)
self.db.add_all([
QuestionCategory(id=1, name="Cardiology", user_id=1),
QuestionCategory(id=2, name="Kawasaki disease", parent_id=1, user_id=1),
QuestionCategory(id=3, name="Neurology", user_id=1),
])
self.db.flush()
# One of each content type filed under the same tree.
self.db.add(Question(id=1, question_category_id=2, user_id=1, is_shared=1,
question_text="A question", question_type="mcq",
options=["yes", "no"], correct_answer="yes"))
self.db.add(Article(id=1, slug="kawasaki", title="Kawasaki disease", sections=[],
category_id=2, user_id=1, status="published"))
self.db.add_all([
FlashcardDeck(id=1, title="Kawasaki cards", user_id=1, card_count=0, category_id=2),
FlashcardDeck(id=2, title="Neuro cards", user_id=1, card_count=0, category_id=3),
FlashcardDeck(id=3, title="Unfiled cards", user_id=1, card_count=0),
])
self.db.commit()
app = FastAPI()
app.include_router(flashcards.router, prefix="/flashcards")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
self.client.close()
self.db.close()
self.engine.dispose()
def test_all_three_content_types_use_the_same_category_ids(self):
question = self.db.get(Question, 1)
article = self.db.get(Article, 1)
deck = self.db.get(FlashcardDeck, 1)
self.assertEqual({question.question_category_id, article.category_id, deck.category_id}, {2})
def test_deck_list_filters_by_category_including_descendants(self):
titles = lambda params: {d["title"] for d in self.client.get("/flashcards/", params=params).json()}
self.assertEqual(titles({"category_id": 2}), {"Kawasaki cards"})
# A parent picks up everything filed beneath it.
self.assertEqual(titles({"category_id": 1}), {"Kawasaki cards"})
self.assertEqual(titles({"category_id": 3}), {"Neuro cards"})
self.assertEqual(titles({}), {"Kawasaki cards", "Neuro cards", "Unfiled cards"})
def test_a_deck_can_be_filed_and_unfiled(self):
response = self.client.patch("/flashcards/3", json={"category_id": 3})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["category_id"], 3)
self.assertEqual(self.db.get(FlashcardDeck, 3).category_id, 3)
self.assertEqual(self.client.patch("/flashcards/3", json={"category_id": None}).json()["category_id"], None)
def test_filing_under_a_category_that_does_not_exist_is_refused(self):
self.assertEqual(self.client.patch("/flashcards/1", json={"category_id": 999}).status_code, 404)
self.assertEqual(self.db.get(FlashcardDeck, 1).category_id, 2)
def test_renaming_a_deck_leaves_its_category_alone(self):
self.client.patch("/flashcards/1", json={"title": "Renamed"})
deck = self.db.get(FlashcardDeck, 1)
self.assertEqual((deck.title, deck.category_id), ("Renamed", 2))
if __name__ == "__main__":
unittest.main()

View file

@ -760,3 +760,12 @@ body {
.cp-adaptive-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); }
.cp-adaptive-label { display: flex; align-items: center; gap: 6px; font-size: .82rem; }
.cp-adaptive-label select { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
/* Deck category — same tree as questions and articles. */
.deck-category { margin-bottom: 8px; }
.deck-category select {
width: 100%; padding: 5px 8px; font-size: .76rem;
border: 1px solid var(--border); border-radius: 6px;
background: var(--input-bg); color: var(--text-muted);
}
.deck-category select:hover { color: var(--text); }

View file

@ -27,6 +27,7 @@ function StarRating({ rating, onRate, readonly = false }) {
export default function FlashcardsPage() {
const [tab, setTab] = useState('decks')
const [decks, setDecks] = useState([])
const [categories, setCategories] = useState([])
const [trashedDecks, setTrashedDecks] = useState([])
const [sharedDecks, setSharedDecks] = useState([])
const [sharedTotal, setSharedTotal] = useState(0)
@ -55,6 +56,9 @@ export default function FlashcardsPage() {
const SHARED_LIMIT = 12
const loadDecks = () => {
api.get('/question-categories/')
.then(res => setCategories(Array.isArray(res.data) ? res.data : []))
.catch(() => setCategories([]))
api.get('/flashcards/').then(res => {
setDecks(res.data)
setLoading(false)
@ -121,6 +125,14 @@ export default function FlashcardsPage() {
} catch { }
}
const setDeckCategory = async (deckId, value) => {
const categoryId = value === '' ? null : Number(value)
try {
await api.patch(`/flashcards/${deckId}`, { category_id: categoryId })
setDecks(prev => prev.map(d => d.id === deckId ? { ...d, category_id: categoryId } : d))
} catch { }
}
const saveTitle = async (deckId, newTitle) => {
const t = (newTitle || '').trim()
if (!t) return
@ -250,6 +262,20 @@ export default function FlashcardsPage() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{decks.map(deck => (
<div key={deck.id} className="card" style={{ padding: 20 }}>
<div className="deck-category">
<label>
<span className="sr-only">Category for {deck.title}</span>
<select value={deck.category_id ?? ''} aria-label={`Category for ${deck.title}`}
onChange={e => setDeckCategory(deck.id, e.target.value)}>
<option value="">Uncategorized</option>
{categories.map(c => (
<option key={c.id} value={c.id}>
{(c.breadcrumbs || []).map(b => b.name).join(' ') || c.name}
</option>
))}
</select>
</label>
</div>
{editingDeckId === deck.id ? (
<div style={{ marginBottom: 8 }}>
<input

View file

@ -21,6 +21,7 @@ beforeEach(() => {
return Promise.resolve({ data: [] })
})
api.put.mockResolvedValue({ data: { linked: true } })
api.patch.mockResolvedValue({ data: {} })
api.delete.mockResolvedValue({ data: null })
})
@ -29,7 +30,30 @@ function renderPage() {
}
describe('cards page', () => {
it('uses the Cards label and honest empty deck state', async () => {
it('files a deck on the shared category tree', async () => {
const base = api.get.getMockImplementation()
api.get.mockImplementation(url => {
if (url === '/flashcards/') return Promise.resolve({ data: [
{ id: 1, title: 'Deck A', user_id: 1, card_count: 3, category_id: null, created_at: '2026-01-01T00:00:00' },
] })
if (url === '/question-categories/') return Promise.resolve({ data: [
{ id: 7, name: 'Cardiology', parent_id: null, breadcrumbs: [{ id: 7, name: 'Cardiology' }] },
] })
return base(url)
})
render(<MemoryRouter><FlashcardsPage /></MemoryRouter>)
const select = await screen.findByLabelText('Category for Deck A')
// The options come from the same tree questions and articles use.
expect([...select.options].map(o => o.text)).toContain('Cardiology')
await userEvent.selectOptions(select, '7')
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/flashcards/1', { category_id: 7 }))
await userEvent.selectOptions(select, '')
await waitFor(() => expect(api.patch).toHaveBeenLastCalledWith('/flashcards/1', { category_id: null }))
})
it('uses the Cards label and honest empty deck state', async () => {
renderPage()
expect(await screen.findByRole('heading', { name: 'Cards' })).toBeInTheDocument()
expect(await screen.findByText('No card decks yet. Create one from a document.')).toBeInTheDocument()