pdf-quiz-generator/backend/app/routers/collections.py
Daniel 52ef7acdea feat: articles are collectible, and Summary is a toggle
A library holds both articles and questions now. It held only questions, so the
bookmark on an article had nowhere to write and stood in for the questions
filed under the topic instead — which is not what a reader who saved the
reading asked for, and left a topic with no questions unsaveable. Its own
table rather than a nullable column beside `question_id`: that shape allows a
row with both or neither, and every read then has to say which kind it is
looking at.

Which libraries already hold an article is now asked of the server, as one
question. It was kept on the device because the API could not answer, which was
wrong on the second machine and silently so. Putting one back is the same
control rather than an undo somewhere else.

"Short" is called Summary, because that is what the section is called, and it
is a toggle rather than one tab of three — the whole topic, or the part of it
worth revising, which is a different kind of choice from Long versus Clinical.
It names its own state, so a reader can tell why two thirds of the contents are
not there. The stored variant stays `short`: renaming it would be a data
migration to change a word on a button.

Also: `litellm==1.28.13` has been withdrawn from PyPI, so requirements.txt
could not be edited at all without the pip layer failing to rebuild — which is
what blocked pinning Pillow. Repinned to 1.53.1, the nearest still published;
the three things we use are unchanged in it, and both suites pass on the new
set. Pillow is pinned properly now rather than arriving through PyMuPDF.

One consequence, handled: `litellm.utils.get_valid_models()` now returns
nothing unless a provider's own API key is in the environment, and ours is a
proxy. That branch is only reached when no proxy is configured, and it now says
so instead of answering with an empty list that reads as "this site has no
models".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 14:52:20 +02:00

200 lines
8.4 KiB
Python

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