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 && ( +
Trash is empty.
+ )} + {trashedDecks.map(deck => ( +
+
+
+ {deck.title} +
+ {deck.card_count} cards · Deleted {new Date(deck.deleted_at).toLocaleDateString()} +
+
+
+ + +
+
+
+ ))} + + )}
) }