"""Personal libraries: questions and articles somebody has put aside. A library holds both. It used to hold only questions, so the reading side of the site had a bookmark with nowhere to write — and the two kinds are the same act to whoever is doing it, which is why they share a library rather than each getting their own list. """ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, field_validator from sqlalchemy.orm import Session from app.database import get_db from app.models.article import Article from app.models.collection import (UserCollection, UserCollectionArticle, UserCollectionQuestion) from app.models.question import Question from app.models.user import User from app.services.article_service import readable_articles from app.services.quiz_builder import bank_question_predicate from app.utils.auth import get_current_user router = APIRouter() class CollectionCreate(BaseModel): title: str @field_validator("title") @classmethod def title_shape(cls, value): value = value.strip() if not value or len(value) > 200: raise ValueError("Collection title is required (max 200 characters)") return value class CollectionQuestionIn(BaseModel): question_id: int def _touch(db, collection): """Mark a collection as used now. Opening one counts; so does adding to it.""" collection.last_used_at = datetime.utcnow() def _as_json(db, collection) -> dict: return { "id": collection.id, "title": collection.title, "question_count": db.query(UserCollectionQuestion).filter( UserCollectionQuestion.collection_id == collection.id).count(), "article_count": db.query(UserCollectionArticle).filter( UserCollectionArticle.collection_id == collection.id).count(), "created_at": collection.created_at, "last_used_at": collection.last_used_at, # Every library is one person's. Said plainly rather than assumed, # because the page shows it and a learner should not have to guess # whether saving a question published it. "private": True, } def _own(db, user, collection_id): collection = db.get(UserCollection, collection_id) if not collection: raise HTTPException(404, "Collection not found") if collection.user_id != user.id: raise HTTPException(403, "Not your collection") return collection @router.get("/") def list_collections(db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Every library this learner keeps, most recently used first. A never-opened library sorts by when it was made, beneath everything that has been used — it is newer to the learner than it is to the database. """ rows = db.query(UserCollection).filter(UserCollection.user_id == user.id).all() rows.sort(key=lambda c: (c.last_used_at or c.created_at or datetime.min, c.created_at or datetime.min), reverse=True) return [_as_json(db, c) for c in rows] @router.post("/", status_code=201) def create_collection(data: CollectionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = UserCollection(user_id=user.id, title=data.title) db.add(collection) db.commit() db.refresh(collection) return _as_json(db, collection) @router.patch("/{collection_id}") def rename_collection(collection_id: int, data: CollectionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = _own(db, user, collection_id) collection.title = data.title db.commit() return _as_json(db, collection) @router.delete("/{collection_id}", status_code=204) def delete_collection(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): db.delete(_own(db, user, collection_id)) db.commit() @router.get("/{collection_id}/questions") def collection_questions(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = _own(db, user, collection_id) rows = db.query(Question).join(UserCollectionQuestion, UserCollectionQuestion.question_id == Question.id).filter( UserCollectionQuestion.collection_id == collection.id).all() # Opening a library is using it, which is what the collections page sorts by. _touch(db, collection) db.commit() return [{"id": q.id, "question_text": q.question_text} for q in rows] @router.put("/{collection_id}/questions/{question_id}") def add_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = _own(db, user, collection_id) if not db.query(Question.id).filter(Question.id == question_id, bank_question_predicate(user)).first(): raise HTTPException(404, "Question not found") if db.query(UserCollectionQuestion.id).filter_by(collection_id=collection.id, question_id=question_id).first(): return {"added": False} db.add(UserCollectionQuestion(collection_id=collection.id, question_id=question_id)) _touch(db, collection) db.commit() return {"added": True} @router.delete("/{collection_id}/questions/{question_id}", status_code=204) def remove_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = _own(db, user, collection_id) db.query(UserCollectionQuestion).filter_by(collection_id=collection_id, question_id=question_id).delete( synchronize_session=False) _touch(db, collection) db.commit() @router.get("/{collection_id}/articles") def collection_articles(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = _own(db, user, collection_id) rows = (db.query(Article) .join(UserCollectionArticle, UserCollectionArticle.article_id == Article.id) .filter(UserCollectionArticle.collection_id == collection.id) .order_by(Article.title) .all()) _touch(db, collection) db.commit() # An article saved and later unpublished stays in the library and says so, # rather than vanishing from a list the learner built themselves. return [{"id": a.id, "title": a.title, "slug": a.slug, "status": a.status} for a in rows] @router.put("/{collection_id}/articles/{article_id}") def add_collection_article(collection_id: int, article_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = _own(db, user, collection_id) if not readable_articles(db, user).filter(Article.id == article_id).first(): raise HTTPException(404, "Article not found") if db.query(UserCollectionArticle.id).filter_by( collection_id=collection.id, article_id=article_id).first(): return {"added": False} db.add(UserCollectionArticle(collection_id=collection.id, article_id=article_id)) _touch(db, collection) db.commit() return {"added": True} @router.delete("/{collection_id}/articles/{article_id}", status_code=204) def remove_collection_article(collection_id: int, article_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): collection = _own(db, user, collection_id) db.query(UserCollectionArticle).filter_by( collection_id=collection_id, article_id=article_id).delete(synchronize_session=False) _touch(db, collection) db.commit() @router.get("/for-article/{article_id}") def libraries_holding_article(article_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Which of this learner's libraries already hold this article. The reader needs it to draw the bookmark filled or hollow. Asked as one question rather than by fetching every library's contents, which would also stamp each of them as used and reorder the learner's own list. """ held = {row.collection_id for row in db.query(UserCollectionArticle.collection_id) .join(UserCollection, UserCollection.id == UserCollectionArticle.collection_id) .filter(UserCollection.user_id == user.id, UserCollectionArticle.article_id == article_id).all()} return {"collection_ids": sorted(held)}