diff --git a/backend/app/main.py b/backend/app/main.py index bcc3cad..a3a6800 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -305,8 +305,20 @@ def setup_pgvector(): PRIMARY KEY (flashcard_id, tag_id) ) """)) - # Soft-delete for flashcard decks + # Soft-delete + sharing for flashcard decks conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP")) + conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0")) + # Deck ratings + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS flashcard_deck_ratings ( + id SERIAL PRIMARY KEY, + deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (user_id, deck_id) + ) + """)) conn.commit() diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py index e07f619..51c327a 100644 --- a/backend/app/models/flashcard.py +++ b/backend/app/models/flashcard.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship from app.database import Base @@ -10,11 +10,24 @@ class FlashcardDeck(Base): section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=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 created_at = Column(DateTime, default=datetime.utcnow) deleted_at = Column(DateTime, nullable=True) cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan") + ratings = relationship("FlashcardDeckRating", back_populates="deck", cascade="all, delete-orphan") user = relationship("User") + +class FlashcardDeckRating(Base): + __tablename__ = "flashcard_deck_ratings" + __table_args__ = (UniqueConstraint("user_id", "deck_id", name="uq_deck_rating_user"),) + id = Column(Integer, primary_key=True, index=True) + deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + rating = Column(Integer, nullable=False) # 1-5 + created_at = Column(DateTime, default=datetime.utcnow) + deck = relationship("FlashcardDeck", back_populates="ratings") + class Flashcard(Base): __tablename__ = "flashcards" id = Column(Integer, primary_key=True, index=True) diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index a46f962..6c01389 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -7,7 +7,7 @@ from sqlalchemy import or_, text as sa_text from sqlalchemy.orm import Session from app.database import get_db -from app.models.flashcard import FlashcardDeck, Flashcard +from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating from app.models.section import Section from app.models.user import User from app.utils.auth import get_current_user, require_moderator @@ -29,6 +29,7 @@ class FlashcardDeckResponse(BaseModel): section_id: int | None = None user_id: int card_count: int + is_shared: int = 0 created_at: object deleted_at: object | None = None @@ -36,6 +37,10 @@ class FlashcardDeckResponse(BaseModel): from_attributes = True +class RatingRequest(BaseModel): + rating: int # 1-5 + + class FlashcardCardResponse(BaseModel): id: int deck_id: int @@ -148,8 +153,13 @@ def get_flashcard_deck( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Get a deck with all its cards.""" - return _own_deck_or_404(deck_id, current_user, db) + """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 deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared: + raise HTTPException(status_code=403, detail="Not your deck") + return deck @router.delete("/{deck_id}", status_code=204) @@ -183,6 +193,146 @@ def restore_flashcard_deck( return {"id": deck.id, "title": deck.title, "restored": True} +# ── Sharing & rating ──────────────────────────────────────────────── + +@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 = _own_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.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 + + # Subquery for average rating + 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() + + # Current user's rating per deck + my_ratings = {} + if current_user: + for r in db.query(FlashcardDeckRating).filter(FlashcardDeckRating.user_id == current_user.id).all(): + my_ratings[r.deck_id] = r.rating + + query = db.query(FlashcardDeck).filter( + FlashcardDeck.is_shared == 1, + FlashcardDeck.deleted_at.is_(None), + ) + total = query.count() + + # Join with avg rating for sorting + 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() + + # Get owner names + user_cache: dict[int, str] = {} + result = [] + for deck, avg_r, r_count in decks_with_rating: + if deck.user_id not in user_cache: + owner = db.query(User).filter(User.id == deck.user_id).first() + user_cache[deck.user_id] = owner.name if owner else "Unknown" + result.append({ + "id": deck.id, + "title": deck.title, + "card_count": deck.card_count, + "user_id": deck.user_id, + "owner_name": user_cache[deck.user_id], + "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.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") diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx index e1fa60b..b04e6e2 100644 --- a/frontend/src/pages/FlashcardsPage.jsx +++ b/frontend/src/pages/FlashcardsPage.jsx @@ -3,10 +3,33 @@ import { Link, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' +function StarRating({ rating, onRate, readonly = false }) { + const [hover, setHover] = useState(0) + return ( +
- {deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()} +
+ {deck.card_count} cards �� {new Date(deck.created_at).toLocaleDateString()}
-+ by {deck.owner_name} · {deck.card_count} cards +
+