From 800de50fc93618718d3eb9bbda93de5ed5f0de15 Mon Sep 17 00:00:00 2001
From: Daniel
Date: Sat, 4 Apr 2026 23:24:39 +0200
Subject: [PATCH] Flashcard ownership, trash, delete cards, fix job status
- Fix: flashcard task used wrong Redis key prefix (job: vs extraction:)
causing progress to stay on "pending" after completion
- Flashcards are now user-scoped: each user sees only their own decks
- Soft-delete decks: DELETE moves to trash, ?permanent=true to destroy
- Trash tab on flashcards page: restore or permanently delete decks
- Delete individual cards with inline confirmation in browse view
- Card edit/delete now allowed for deck owner (not just moderators)
- ExtractionProgress label prop: shows "Generating Flashcards" not
"Extracting Questions" for flashcard jobs
- Added deleted_at column to flashcard_decks
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.gitignore | 1 +
backend/app/main.py | 2 +
backend/app/models/flashcard.py | 1 +
backend/app/routers/flashcards.py | 147 +++++++++++++++-----------
backend/app/tasks/quiz_tasks.py | 14 +--
frontend/src/pages/FlashcardsPage.jsx | 103 ++++++++++++++----
6 files changed, 183 insertions(+), 85 deletions(-)
diff --git a/.gitignore b/.gitignore
index 112382e..0197dcd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,4 @@ dist/
# OS
.DS_Store
*.swp
+backend/.env.save
diff --git a/backend/app/main.py b/backend/app/main.py
index aa6413f..bcc3cad 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -305,6 +305,8 @@ def setup_pgvector():
PRIMARY KEY (flashcard_id, tag_id)
)
"""))
+ # Soft-delete for flashcard decks
+ conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP"))
conn.commit()
diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py
index 3e8416a..e07f619 100644
--- a/backend/app/models/flashcard.py
+++ b/backend/app/models/flashcard.py
@@ -11,6 +11,7 @@ class FlashcardDeck(Base):
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
card_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
+ deleted_at = Column(DateTime, nullable=True)
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
user = relationship("User")
diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py
index 37a74f1..a46f962 100644
--- a/backend/app/routers/flashcards.py
+++ b/backend/app/routers/flashcards.py
@@ -1,4 +1,6 @@
"""Flashcard decks and cards — generate, browse, edit, delete."""
+from datetime import datetime
+
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import or_, text as sa_text
@@ -24,10 +26,11 @@ class FlashcardDeckCreate(BaseModel):
class FlashcardDeckResponse(BaseModel):
id: int
title: str
- section_id: int | None
+ section_id: int | None = None
user_id: int
card_count: int
created_at: object
+ deleted_at: object | None = None
class Config:
from_attributes = True
@@ -49,7 +52,7 @@ class FlashcardCardResponse(BaseModel):
class FlashcardDeckDetail(BaseModel):
id: int
title: str
- section_id: int | None
+ section_id: int | None = None
user_id: int
card_count: int
created_at: object
@@ -64,6 +67,15 @@ class CardEdit(BaseModel):
back: str | None = None
+def _own_deck_or_404(deck_id: int, current_user: User, db: Session) -> FlashcardDeck:
+ deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
+ if not deck:
+ raise HTTPException(status_code=404, detail="Deck not found")
+ if deck.user_id != current_user.id and not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Not your deck")
+ return deck
+
+
# ── Deck endpoints ───────────────────────────────────────────────────
@router.post("/")
@@ -78,8 +90,6 @@ def create_flashcard_deck(
section = db.query(Section).filter(Section.id == data.section_id).first()
if not section:
raise HTTPException(status_code=404, detail="Section not found")
- if not current_user.is_admin and section.document.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not your document")
job_id = str(uuid.uuid4())
@@ -109,16 +119,29 @@ def create_flashcard_deck(
@router.get("/", response_model=list[FlashcardDeckResponse])
def list_flashcard_decks(
+ include_deleted: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """List flashcard decks. Moderators see all; regular users see own."""
- q = db.query(FlashcardDeck)
- if not current_user.is_moderator:
- q = q.filter(FlashcardDeck.user_id == current_user.id)
+ """List flashcard decks belonging to current user."""
+ q = db.query(FlashcardDeck).filter(FlashcardDeck.user_id == current_user.id)
+ if not include_deleted:
+ q = q.filter(FlashcardDeck.deleted_at.is_(None))
return q.order_by(FlashcardDeck.created_at.desc()).all()
+@router.get("/trash", response_model=list[FlashcardDeckResponse])
+def list_trashed_decks(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """List soft-deleted decks for current user's trash."""
+ return db.query(FlashcardDeck).filter(
+ FlashcardDeck.user_id == current_user.id,
+ FlashcardDeck.deleted_at.isnot(None),
+ ).order_by(FlashcardDeck.deleted_at.desc()).all()
+
+
@router.get("/{deck_id}", response_model=FlashcardDeckDetail)
def get_flashcard_deck(
deck_id: int,
@@ -126,28 +149,40 @@ def get_flashcard_deck(
current_user: User = Depends(get_current_user),
):
"""Get a deck with all its cards."""
- 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 and deck.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not your deck")
- return deck
+ return _own_deck_or_404(deck_id, current_user, db)
@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(require_moderator),
+ current_user: User = Depends(get_current_user),
):
- """Delete a deck and all its cards."""
- deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
- if not deck:
- raise HTTPException(status_code=404, detail="Deck not found")
- db.delete(deck)
+ """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}
+
+
# ── Card browse endpoints ────────────────────────────────────────────
@router.get("/cards/browse")
@@ -160,17 +195,16 @@ def browse_flashcards(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """Browse ALL flashcards across all decks (like question bank)."""
- query = db.query(Flashcard)
+ """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": []}
- # Restrict non-moderators to own decks
- if not current_user.is_moderator:
- own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
- FlashcardDeck.user_id == current_user.id
- ).all()]
- if not own_deck_ids:
- return {"total": 0, "cards": []}
- query = query.filter(Flashcard.deck_id.in_(own_deck_ids))
+ query = db.query(Flashcard).filter(Flashcard.deck_id.in_(own_deck_ids))
if deck_id:
query = query.filter(Flashcard.deck_id == deck_id)
@@ -184,7 +218,7 @@ def browse_flashcards(
)
)
- # Tag filter: cards must have ALL specified tags
+ # 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:
@@ -226,33 +260,28 @@ def browse_flashcards(
def browse_flashcard_ids(
q: str | None = Query(None),
deck_id: int | None = Query(None),
- tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
+ 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)."""
- query = db.query(Flashcard.id)
+ 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 []
- if not current_user.is_moderator:
- own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
- FlashcardDeck.user_id == current_user.id
- ).all()]
- if not own_deck_ids:
- return []
- query = query.filter(Flashcard.deck_id.in_(own_deck_ids))
+ 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}%"),
- )
- )
-
+ 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:
@@ -277,39 +306,37 @@ def update_flashcard(
card_id: int,
data: CardEdit,
db: Session = Depends(get_db),
- current_user: User = Depends(require_moderator),
+ current_user: User = Depends(get_current_user),
):
- """Edit a single flashcard (front, back). Requires moderator."""
+ """Edit a single flashcard (front, back). Owner or admin."""
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
+ deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
+ if deck and deck.user_id != current_user.id and not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Not your card")
if data.front is not None:
card.front = data.front
if data.back is not None:
card.back = data.back
db.commit()
db.refresh(card)
- return {
- "id": card.id,
- "deck_id": card.deck_id,
- "front": card.front,
- "back": card.back,
- "page_reference": card.page_reference,
- "image_path": card.image_path,
- }
+ return {"id": card.id, "deck_id": card.deck_id, "front": card.front, "back": card.back}
@router.delete("/cards/{card_id}", status_code=204)
def delete_flashcard(
card_id: int,
db: Session = Depends(get_db),
- current_user: User = Depends(require_moderator),
+ current_user: User = Depends(get_current_user),
):
- """Delete a single flashcard. Requires moderator."""
+ """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
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 7f4272a..e10d5b4 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -504,7 +504,7 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
title: str, model_id: str | None = None):
"""Generate flashcards from a document section using AI."""
r = _redis()
- r.set(f"job:status:{job_id}", "running")
+ r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
try:
from app.models.section import Section
@@ -514,7 +514,7 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
section = db.query(Section).filter(Section.id == section_id).first()
if not section:
- r.set(f"job:status:{job_id}", "failed")
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "Section not found")
return
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
@@ -557,7 +557,7 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
if not all_cards:
- r.set(f"job:status:{job_id}", "failed")
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "No flashcards could be generated")
return
@@ -592,14 +592,14 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
db.add(card)
db.commit()
- r.set(f"job:status:{job_id}", "completed")
- r.set(f"job:result:{job_id}", str(deck.id))
+ r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
+ r.set(f"extraction:deck_id:{job_id}", str(deck.id), ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
except Exception as e:
logger.exception(f"Flashcard generation failed: {e}")
- r.set(f"job:status:{job_id}", "failed")
- r.set(f"job:error:{job_id}", str(e)[:500])
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
try:
db.rollback()
diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx
index 5d6a30c..e1fa60b 100644
--- a/frontend/src/pages/FlashcardsPage.jsx
+++ b/frontend/src/pages/FlashcardsPage.jsx
@@ -4,27 +4,33 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function FlashcardsPage() {
- const [tab, setTab] = useState('decks') // 'decks' | 'browse'
+ const [tab, setTab] = useState('decks') // 'decks' | 'browse' | 'trash'
const [decks, setDecks] = useState([])
+ const [trashedDecks, setTrashedDecks] = useState([])
const [cards, setCards] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [filterDeckId, setFilterDeckId] = useState('')
- const [offset, setOffset] = useState(0)
const [studyCard, setStudyCard] = useState(null)
const [flipped, setFlipped] = useState(false)
+ const [deletingCard, setDeletingCard] = useState(null)
const { user } = useAuth()
const navigate = useNavigate()
- const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const LIMIT = 50
- useEffect(() => {
+ const loadDecks = () => {
api.get('/flashcards/').then(res => {
setDecks(res.data)
setLoading(false)
}).catch(() => setLoading(false))
- }, [])
+ }
+
+ const loadTrash = () => {
+ api.get('/flashcards/trash').then(res => setTrashedDecks(res.data)).catch(() => {})
+ }
+
+ useEffect(() => { loadDecks() }, [])
const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => {
setLoading(true)
@@ -35,12 +41,12 @@ export default function FlashcardsPage() {
const res = await api.get('/flashcards/cards/browse', { params })
setCards(off === 0 ? res.data.cards : prev => [...prev, ...res.data.cards])
setTotal(res.data.total)
- setOffset(off)
} catch { } finally { setLoading(false) }
}
useEffect(() => {
if (tab === 'browse') loadCards()
+ if (tab === 'trash') loadTrash()
}, [tab])
useEffect(() => {
@@ -50,13 +56,36 @@ export default function FlashcardsPage() {
}, [searchQuery, filterDeckId])
const deleteDeck = async (deckId) => {
- if (!window.confirm('Delete this deck and all its cards?')) return
try {
await api.delete(`/flashcards/${deckId}`)
setDecks(prev => prev.filter(d => d.id !== deckId))
} catch { }
}
+ const permanentDeleteDeck = async (deckId) => {
+ try {
+ await api.delete(`/flashcards/${deckId}?permanent=true`)
+ setTrashedDecks(prev => prev.filter(d => d.id !== deckId))
+ } catch { }
+ }
+
+ const restoreDeck = async (deckId) => {
+ try {
+ await api.post(`/flashcards/${deckId}/restore`)
+ setTrashedDecks(prev => prev.filter(d => d.id !== deckId))
+ loadDecks()
+ } catch { }
+ }
+
+ const deleteCard = async (cardId) => {
+ try {
+ await api.delete(`/flashcards/cards/${cardId}`)
+ setCards(prev => prev.filter(c => c.id !== cardId))
+ setTotal(t => t - 1)
+ setDeletingCard(null)
+ } catch { }
+ }
+
if (loading && decks.length === 0 && cards.length === 0) return
return (
@@ -67,6 +96,7 @@ export default function FlashcardsPage() {
+
@@ -90,6 +120,7 @@ export default function FlashcardsPage() {
)}
+ {/* DECKS TAB */}
{tab === 'decks' && (
<>
{decks.length === 0 && !loading && (
@@ -104,9 +135,7 @@ export default function FlashcardsPage() {
Study
- {isModerator && (
-
- )}
+
))}
@@ -114,10 +143,11 @@ export default function FlashcardsPage() {
>
)}
+ {/* BROWSE TAB */}
{tab === 'browse' && (
<>
-
+
setSearchQuery(e.target.value)}
style={{ width: '100%', padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
@@ -128,19 +158,31 @@ export default function FlashcardsPage() {
-
{total} cards total
+
{total} cards
{cards.map(card => (
{ setStudyCard(card); setFlipped(false) }}>
-
-
{card.deck_title}
+ display: 'flex', gap: 10, alignItems: 'flex-start',
+ }}>
+
{ setStudyCard(card); setFlipped(false) }}>
+
{card.deck_title}
+
{card.front}
+
{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}
+
+
+
+ {deletingCard === card.id ? (
+
+
+
+
+ ) : (
+
+ )}
-
{card.front}
-
{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}
))}
@@ -157,6 +199,31 @@ export default function FlashcardsPage() {
)}
>
)}
+
+ {/* TRASH TAB */}
+ {tab === 'trash' && (
+ <>
+ {trashedDecks.length === 0 && (
+
+ )}
+ {trashedDecks.map(deck => (
+
+
+
+
{deck.title}
+
+ {deck.card_count} cards · Deleted {new Date(deck.deleted_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
+ ))}
+ >
+ )}
)
}