hku-class/backend/app/api/reading.py
2026-05-12 23:10:05 +08:00

355 lines
13 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import (
ensure_class_access,
ensure_class_module_enabled,
get_effective_class_permissions,
require_role,
resolve_class_id_for_user,
)
from app.db.database import get_db
from app.db.models import ReadingBook, ReadingNote, User
from app.schemas.common import PageResponse
from app.schemas.reading import (
ReadingBookCreate,
ReadingBookOut,
ReadingBookUpdate,
ReadingFeedItem,
ReadingNoteCreate,
ReadingNoteOut,
ReadingNoteUpdate,
ReadingRankingItem,
ReadingRankingResponse,
ReadingSummary,
)
from app.services.reading_service import (
create_book,
create_note,
delete_book,
delete_note,
get_book_by_id,
get_note_by_id,
get_ranking_rows,
get_summary,
schedule_cover_fetch,
list_feed_items,
list_books,
list_notes,
update_book,
update_note,
)
router = APIRouter(prefix="/api/reading", tags=["reading"])
def _can_manage(user: User, class_id: int) -> bool:
return "reading_corner_manage" in get_effective_class_permissions(user, class_id)
def _book_to_out(book: ReadingBook) -> ReadingBookOut:
notes = book.notes or []
return ReadingBookOut(
id=book.id,
class_id=book.class_id,
owner_id=book.owner_id,
owner_name=book.owner.name if book.owner else "Unknown",
title=book.title,
author=book.author,
cover_url=book.cover_url,
total_pages=book.total_pages,
current_page=book.current_page,
status=book.status,
started_at=book.started_at,
finished_at=book.finished_at,
personal_note=book.personal_note,
note_count=len(notes),
public_note_count=len([note for note in notes if note.visibility == "class"]),
created_at=book.created_at,
updated_at=book.updated_at,
)
def _note_to_out(note: ReadingNote) -> ReadingNoteOut:
return ReadingNoteOut(
id=note.id,
book_id=note.book_id,
book_title=note.book.title if note.book else "Unknown",
book_author=note.book.author if note.book else None,
author_id=note.author_id,
author_name=note.author.name if note.author else "Unknown",
title=note.title,
content=note.content,
page_ref=note.page_ref,
visibility=note.visibility,
created_at=note.created_at,
updated_at=note.updated_at,
)
@router.get("/summary", response_model=ReadingSummary)
async def get_my_reading_summary(
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return ReadingSummary(
reading_count=0, finished_count=0, total_pages_read=0, month_score=0
)
ensure_class_access(user, effective_class_id)
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
return ReadingSummary(**await get_summary(db, effective_class_id, user.id))
@router.get("/books", response_model=PageResponse[ReadingBookOut])
async def get_books(
page: int = 1,
page_size: int = 20,
status: str | None = None,
owner: str | None = None,
search: str | None = None,
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_access(user, effective_class_id)
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
owner_id = user.id if owner == "me" else None
books, total = await list_books(
db, effective_class_id, page, page_size, status, owner_id, search
)
return PageResponse(
items=[_book_to_out(book) for book in books],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
@router.get("/feed", response_model=PageResponse[ReadingFeedItem])
async def get_feed(
page: int = 1,
page_size: int = 20,
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_access(user, effective_class_id)
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
items, total = await list_feed_items(db, effective_class_id, page, page_size)
return PageResponse(
items=[ReadingFeedItem(**item) for item in items],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
@router.post("/books", response_model=ReadingBookOut)
async def create_new_book(
data: ReadingBookCreate,
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
ensure_class_access(user, effective_class_id)
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
try:
book = await create_book(db, effective_class_id, user.id, data)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
loaded = await get_book_by_id(db, book.id)
if not (loaded or book).cover_url:
schedule_cover_fetch(book.id)
return _book_to_out(loaded or book)
@router.put("/books/{book_id}", response_model=ReadingBookOut)
async def update_existing_book(
book_id: int,
data: ReadingBookUpdate,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
book = await get_book_by_id(db, book_id)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
ensure_class_access(user, book.class_id)
await ensure_class_module_enabled(db, book.class_id, "reading_corner")
if book.owner_id != user.id and not _can_manage(user, book.class_id):
raise HTTPException(status_code=403, detail="Access denied")
try:
updated = await update_book(db, book, data)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
loaded = await get_book_by_id(db, updated.id)
if loaded and not loaded.cover_url:
schedule_cover_fetch(loaded.id)
return _book_to_out(loaded or updated)
@router.delete("/books/{book_id}")
async def delete_existing_book(
book_id: int,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
book = await get_book_by_id(db, book_id)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
ensure_class_access(user, book.class_id)
await ensure_class_module_enabled(db, book.class_id, "reading_corner")
if book.owner_id != user.id and not _can_manage(user, book.class_id):
raise HTTPException(status_code=403, detail="Access denied")
await delete_book(db, book)
return {"message": "Book deleted"}
@router.post("/books/{book_id}/cover/refresh")
async def refresh_book_cover(
book_id: int,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
book = await get_book_by_id(db, book_id)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
ensure_class_access(user, book.class_id)
await ensure_class_module_enabled(db, book.class_id, "reading_corner")
if book.owner_id != user.id and not _can_manage(user, book.class_id):
raise HTTPException(status_code=403, detail="Access denied")
if book.cover_url:
return {"message": "Book already has a cover", "cover_url": book.cover_url}
schedule_cover_fetch(book.id)
return {"message": "Cover refresh scheduled"}
@router.get("/notes", response_model=PageResponse[ReadingNoteOut])
async def get_notes(
page: int = 1,
page_size: int = 20,
book_id: int | None = None,
author_id: int | None = None,
include_private: bool = False,
search: str | None = None,
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_access(user, effective_class_id)
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
notes, total = await list_notes(
db,
effective_class_id,
user.id,
page,
page_size,
book_id,
author_id,
include_private,
search,
)
return PageResponse(
items=[_note_to_out(note) for note in notes],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
@router.post("/books/{book_id}/notes", response_model=ReadingNoteOut)
async def create_new_note(
book_id: int,
data: ReadingNoteCreate,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
book = await get_book_by_id(db, book_id)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
ensure_class_access(user, book.class_id)
await ensure_class_module_enabled(db, book.class_id, "reading_corner")
if book.owner_id != user.id and not _can_manage(user, book.class_id):
raise HTTPException(status_code=403, detail="Access denied")
note = await create_note(db, book, user.id, data)
loaded = await get_note_by_id(db, note.id)
return _note_to_out(loaded or note)
@router.put("/notes/{note_id}", response_model=ReadingNoteOut)
async def update_existing_note(
note_id: int,
data: ReadingNoteUpdate,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
note = await get_note_by_id(db, note_id)
if note is None:
raise HTTPException(status_code=404, detail="Note not found")
ensure_class_access(user, note.book.class_id)
await ensure_class_module_enabled(db, note.book.class_id, "reading_corner")
if note.author_id != user.id and not _can_manage(user, note.book.class_id):
raise HTTPException(status_code=403, detail="Access denied")
updated = await update_note(db, note, data)
loaded = await get_note_by_id(db, updated.id)
return _note_to_out(loaded or updated)
@router.delete("/notes/{note_id}")
async def delete_existing_note(
note_id: int,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
note = await get_note_by_id(db, note_id)
if note is None:
raise HTTPException(status_code=404, detail="Note not found")
ensure_class_access(user, note.book.class_id)
await ensure_class_module_enabled(db, note.book.class_id, "reading_corner")
if note.author_id != user.id and not _can_manage(user, note.book.class_id):
raise HTTPException(status_code=403, detail="Access denied")
await delete_note(db, note)
return {"message": "Note deleted"}
@router.get("/rankings", response_model=ReadingRankingResponse)
async def get_rankings(
period: str = "month",
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
if period not in {"month", "all"}:
raise HTTPException(status_code=400, detail="Period must be month or all")
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return ReadingRankingResponse(period=period, items=[])
ensure_class_access(user, effective_class_id)
await ensure_class_module_enabled(db, effective_class_id, "reading_corner")
rows = await get_ranking_rows(db, effective_class_id, period=period)
return ReadingRankingResponse(
period=period,
items=[ReadingRankingItem(**row) for row in rows],
)