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) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-04 23:24:39 +02:00
parent 03f53d4190
commit 800de50fc9
6 changed files with 183 additions and 85 deletions

1
.gitignore vendored
View file

@ -21,3 +21,4 @@ dist/
# OS
.DS_Store
*.swp
backend/.env.save

View file

@ -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()

View file

@ -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")

View file

@ -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

View file

@ -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()

View file

@ -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 <div className="loading"><div className="spinner" /></div>
return (
@ -67,6 +96,7 @@ export default function FlashcardsPage() {
<div style={{ display: 'flex', gap: 4 }}>
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>Decks</button>
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse Cards</button>
<button className={`btn btn-sm ${tab === 'trash' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('trash')}>Trash</button>
</div>
</div>
</div>
@ -90,6 +120,7 @@ export default function FlashcardsPage() {
</div>
)}
{/* DECKS TAB */}
{tab === 'decks' && (
<>
{decks.length === 0 && !loading && (
@ -104,9 +135,7 @@ export default function FlashcardsPage() {
</p>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/flashcards/${deck.id}/study`} className="btn btn-primary btn-sm">Study</Link>
{isModerator && (
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
</div>
</div>
))}
@ -114,10 +143,11 @@ export default function FlashcardsPage() {
</>
)}
{/* BROWSE TAB */}
{tab === 'browse' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<input type="text" placeholder="Search cards..." value={searchQuery} onChange={e => 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)' }} />
</div>
@ -128,19 +158,31 @@ export default function FlashcardsPage() {
</select>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} cards total</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} cards</p>
{cards.map(card => (
<div key={card.id} style={{
background: 'var(--card-bg)', border: '1px solid var(--border)',
borderRadius: 'var(--card-radius)', padding: '14px 16px', marginBottom: 8,
cursor: 'pointer',
}} onClick={() => { setStudyCard(card); setFlipped(false) }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>{card.deck_title}</span>
display: 'flex', gap: 10, alignItems: 'flex-start',
}}>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => { setStudyCard(card); setFlipped(false) }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 4 }}>{card.deck_title}</div>
<p style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 4 }}>{card.front}</p>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}>
<button className="btn btn-sm btn-secondary" onClick={() => { setStudyCard(card); setFlipped(false) }}>View</button>
{deletingCard === card.id ? (
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-sm btn-danger" onClick={() => deleteCard(card.id)}>Yes</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(null)}>No</button>
</div>
) : (
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(card.id)}
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Delete</button>
)}
</div>
<p style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 4 }}>{card.front}</p>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}</p>
</div>
))}
@ -157,6 +199,31 @@ export default function FlashcardsPage() {
)}
</>
)}
{/* TRASH TAB */}
{tab === 'trash' && (
<>
{trashedDecks.length === 0 && (
<div className="card"><div className="empty-state">Trash is empty.</div></div>
)}
{trashedDecks.map(deck => (
<div key={deck.id} className="card" style={{ padding: '14px 16px', marginBottom: 8, opacity: 0.7 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<strong>{deck.title}</strong>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
{deck.card_count} cards · Deleted {new Date(deck.deleted_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-primary btn-sm" onClick={() => restoreDeck(deck.id)}>Restore</button>
<button className="btn btn-danger btn-sm" onClick={() => permanentDeleteDeck(deck.id)}>Delete forever</button>
</div>
</div>
</div>
))}
</>
)}
</div>
)
}