342 lines
12 KiB
Python
342 lines
12 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.deps import (
|
|
ensure_class_access,
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
rows = await get_ranking_rows(db, effective_class_id, period=period)
|
|
return ReadingRankingResponse(
|
|
period=period,
|
|
items=[ReadingRankingItem(**row) for row in rows],
|
|
)
|