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
200 lines
8.4 KiB
Python
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)}
|