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], )