import asyncio import logging from datetime import date, datetime import httpx from sqlalchemy import case, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings from app.db.database import async_session from app.db.models import ClassMembership, ReadingBook, ReadingNote, User from app.schemas.reading import ReadingBookCreate, ReadingBookUpdate, ReadingNoteCreate, ReadingNoteUpdate from app.services.cos_service import upload_image logger = logging.getLogger(__name__) GOOGLE_BOOKS_API = "https://www.googleapis.com/books/v1/volumes" GOOGLE_BOOKS_HEADERS = {"User-Agent": "HKU-ICB-ClassHub/1.0"} def _month_start() -> datetime: today = date.today() return datetime(today.year, today.month, 1) def validate_book_pages(total_pages: int, current_page: int): if total_pages < 0: raise ValueError("Total pages cannot be negative") if current_page < 0: raise ValueError("Current page cannot be negative") if total_pages and current_page > total_pages: raise ValueError("Current page cannot exceed total pages") async def create_book( db: AsyncSession, class_id: int, owner_id: int, data: ReadingBookCreate ) -> ReadingBook: validate_book_pages(data.total_pages, data.current_page) book = ReadingBook(class_id=class_id, owner_id=owner_id, **data.model_dump()) db.add(book) await db.commit() await db.refresh(book) return book async def update_book(db: AsyncSession, book: ReadingBook, data: ReadingBookUpdate) -> ReadingBook: values = data.model_dump(exclude_unset=True) total_pages = values.get("total_pages", book.total_pages) current_page = values.get("current_page", book.current_page) if "current_page" in values and current_page < book.current_page: raise ValueError("Current page can only increase") validate_book_pages(total_pages, current_page) for field, value in values.items(): setattr(book, field, value) await db.commit() await db.refresh(book) return book def schedule_cover_fetch(book_id: int): asyncio.create_task(_safe_fetch_and_store_cover(book_id)) async def _safe_fetch_and_store_cover(book_id: int): try: await fetch_and_store_cover(book_id) except Exception as exc: logger.warning("Failed to fetch reading book cover for %s: %s", book_id, exc) async def fetch_and_store_cover(book_id: int): if not (settings.cos_bucket and settings.cos_base_url): logger.info("Skip reading cover fetch because COS is not configured") return async with async_session() as db: book = await get_book_by_id(db, book_id) if book is None or book.cover_url: return source_url = await find_cover_url(book.title, book.author) if not source_url: return data, content_type = await download_cover(source_url) cos_url = await asyncio.to_thread( upload_image, f"reading-covers/{book.class_id}/{book.id}", "cover.jpg", data, content_type, ) await db.execute( update(ReadingBook) .where( ReadingBook.id == book.id, or_(ReadingBook.cover_url == None, ReadingBook.cover_url == ""), ) .values(cover_url=cos_url) ) await db.commit() async def find_cover_url(title: str, author: str | None = None) -> str | None: query_parts = [f"intitle:{title}"] if author: query_parts.append(f"inauthor:{author}") async with httpx.AsyncClient(timeout=12, follow_redirects=True) as client: params = { "q": " ".join(query_parts), "maxResults": "5", "printType": "books", } if settings.google_books_api_key: params["key"] = settings.google_books_api_key try: response = await client.get( GOOGLE_BOOKS_API, params=params, headers=GOOGLE_BOOKS_HEADERS, ) response.raise_for_status() except httpx.HTTPError as exc: logger.info("Google Books cover lookup failed for %s: %s", title, exc) return None data = response.json() for item in data.get("items", []): image_links = item.get("volumeInfo", {}).get("imageLinks", {}) cover_url = image_links.get("thumbnail") or image_links.get("smallThumbnail") if cover_url: return str(cover_url).replace("http://", "https://") return None async def download_cover(url: str) -> tuple[bytes, str]: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: response = await client.get(url, headers=GOOGLE_BOOKS_HEADERS) response.raise_for_status() content_type = response.headers.get("content-type", "image/jpeg").split(";")[0] if content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: content_type = "image/jpeg" return response.content, content_type async def delete_book(db: AsyncSession, book: ReadingBook): await db.delete(book) await db.commit() async def get_book_by_id(db: AsyncSession, book_id: int) -> ReadingBook | None: result = await db.execute( select(ReadingBook) .options( selectinload(ReadingBook.owner), selectinload(ReadingBook.notes), ) .where(ReadingBook.id == book_id) ) return result.scalar_one_or_none() async def list_books( db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20, status: str | None = None, owner_id: int | None = None, search: str | None = None, ) -> tuple[list[ReadingBook], int]: filters = [ReadingBook.class_id == class_id] if status and status != "all": filters.append(ReadingBook.status == status) if owner_id: filters.append(ReadingBook.owner_id == owner_id) if search: pattern = f"%{search}%" filters.append(or_(ReadingBook.title.ilike(pattern), ReadingBook.author.ilike(pattern))) count_result = await db.execute(select(func.count(ReadingBook.id)).where(*filters)) total = count_result.scalar() or 0 result = await db.execute( select(ReadingBook) .options( selectinload(ReadingBook.owner), selectinload(ReadingBook.notes), ) .where(*filters) .order_by(ReadingBook.updated_at.desc(), ReadingBook.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) return list(result.scalars().all()), total async def create_note( db: AsyncSession, book: ReadingBook, author_id: int, data: ReadingNoteCreate ) -> ReadingNote: note = ReadingNote(book_id=book.id, author_id=author_id, **data.model_dump()) db.add(note) await db.commit() await db.refresh(note) return note async def update_note(db: AsyncSession, note: ReadingNote, data: ReadingNoteUpdate) -> ReadingNote: for field, value in data.model_dump(exclude_unset=True).items(): setattr(note, field, value) await db.commit() await db.refresh(note) return note async def delete_note(db: AsyncSession, note: ReadingNote): await db.delete(note) await db.commit() async def get_note_by_id(db: AsyncSession, note_id: int) -> ReadingNote | None: result = await db.execute( select(ReadingNote) .options( selectinload(ReadingNote.author), selectinload(ReadingNote.book).selectinload(ReadingBook.owner), ) .where(ReadingNote.id == note_id) ) return result.scalar_one_or_none() async def list_notes( db: AsyncSession, class_id: int, viewer_id: int, 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, ) -> tuple[list[ReadingNote], int]: filters = [ReadingBook.class_id == class_id] if include_private: filters.append(or_(ReadingNote.visibility == "class", ReadingNote.author_id == viewer_id)) else: filters.append(ReadingNote.visibility == "class") if book_id: filters.append(ReadingNote.book_id == book_id) if author_id: filters.append(ReadingNote.author_id == author_id) if search: pattern = f"%{search}%" filters.append(or_(ReadingBook.title.ilike(pattern), ReadingNote.title.ilike(pattern))) base = select(ReadingNote).join(ReadingBook).where(*filters) count_result = await db.execute( select(func.count(ReadingNote.id)).join(ReadingBook).where(*filters) ) total = count_result.scalar() or 0 result = await db.execute( base.options( selectinload(ReadingNote.author), selectinload(ReadingNote.book), ) .order_by(ReadingNote.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) return list(result.scalars().all()), total async def get_summary(db: AsyncSession, class_id: int, user_id: int) -> dict: books_result = await db.execute( select( func.coalesce(func.sum(ReadingBook.current_page), 0), func.sum(case((ReadingBook.status == "reading", 1), else_=0)), func.sum(case((ReadingBook.status == "finished", 1), else_=0)), ).where(ReadingBook.class_id == class_id, ReadingBook.owner_id == user_id) ) total_pages, reading_count, finished_count = books_result.one() score_rows = await get_ranking_rows(db, class_id, period="month", user_id=user_id) month_score = score_rows[0]["score"] if score_rows else 0 return { "reading_count": int(reading_count or 0), "finished_count": int(finished_count or 0), "total_pages_read": int(total_pages or 0), "month_score": int(month_score or 0), } def _book_progress(book: ReadingBook) -> int: if not book.total_pages: return 100 if book.current_page > 0 else 0 return min(100, round((book.current_page / book.total_pages) * 100)) def _note_excerpt(content: str, limit: int = 140) -> str: normalized = " ".join(content.split()) return normalized if len(normalized) <= limit else f"{normalized[:limit]}..." async def list_feed_items( db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 ) -> tuple[list[dict], int]: book_result = await db.execute( select(ReadingBook) .options(selectinload(ReadingBook.owner)) .where(ReadingBook.class_id == class_id) .order_by(ReadingBook.updated_at.desc()) ) note_result = await db.execute( select(ReadingNote) .join(ReadingBook) .options( selectinload(ReadingNote.author), selectinload(ReadingNote.book), ) .where( ReadingBook.class_id == class_id, ReadingNote.visibility == "class", ) .order_by(ReadingNote.created_at.desc()) ) items: list[dict] = [] for book in book_result.scalars().all(): event_type = "finished" if book.status == "finished" else "progress" items.append( { "id": f"{event_type}:{book.id}", "type": event_type, "class_id": book.class_id, "user_id": book.owner_id, "user_name": book.owner.name if book.owner else "Unknown", "book_id": book.id, "book_title": book.title, "book_author": book.author, "book_cover_url": book.cover_url, "status": book.status, "current_page": book.current_page, "total_pages": book.total_pages, "progress": _book_progress(book), "note_id": None, "note_title": None, "note_excerpt": None, "page_ref": None, "created_at": book.updated_at, } ) for note in note_result.scalars().all(): book = note.book items.append( { "id": f"note:{note.id}", "type": "note", "class_id": book.class_id, "user_id": note.author_id, "user_name": note.author.name if note.author else "Unknown", "book_id": note.book_id, "book_title": book.title, "book_author": book.author, "book_cover_url": book.cover_url, "status": book.status, "current_page": book.current_page, "total_pages": book.total_pages, "progress": _book_progress(book), "note_id": note.id, "note_title": note.title, "note_excerpt": _note_excerpt(note.content), "page_ref": note.page_ref, "created_at": note.created_at, } ) items.sort(key=lambda item: item["created_at"], reverse=True) total = len(items) start = (page - 1) * page_size end = start + page_size return items[start:end], total async def get_ranking_rows( db: AsyncSession, class_id: int, period: str = "month", user_id: int | None = None ) -> list[dict]: book_filters = [ReadingBook.class_id == class_id] note_filters = [ReadingBook.class_id == class_id] if period == "month": since = _month_start() book_filters.append(ReadingBook.updated_at >= since) note_filters.append(ReadingNote.created_at >= since) if user_id: book_filters.append(ReadingBook.owner_id == user_id) note_filters.append(ReadingNote.author_id == user_id) book_result = await db.execute( select( ReadingBook.owner_id.label("user_id"), func.coalesce(func.sum(ReadingBook.current_page), 0).label("pages_read"), func.sum(case((ReadingBook.status == "finished", 1), else_=0)).label("finished_books"), ) .where(*book_filters) .group_by(ReadingBook.owner_id) ) note_result = await db.execute( select( ReadingNote.author_id.label("user_id"), func.sum(case((ReadingNote.visibility == "class", 1), else_=0)).label("public_notes"), func.sum(case((ReadingNote.visibility == "private", 1), else_=0)).label("private_notes"), ) .join(ReadingBook) .where(*note_filters) .group_by(ReadingNote.author_id) ) member_result = await db.execute( select(User.id, User.name) .join(ClassMembership, ClassMembership.user_id == User.id) .where(ClassMembership.class_id == class_id, User.status == "approved") ) rows: dict[int, dict] = {} for member_id, member_name in member_result.all(): if user_id and member_id != user_id: continue rows[member_id] = { "user_id": member_id, "user_name": member_name, "pages_read": 0, "finished_books": 0, "public_notes": 0, "private_notes": 0, } for row in book_result.mappings(): item = rows.setdefault(row["user_id"], { "user_id": row["user_id"], "user_name": "Unknown", "pages_read": 0, "finished_books": 0, "public_notes": 0, "private_notes": 0, }) item["pages_read"] = int(row["pages_read"] or 0) item["finished_books"] = int(row["finished_books"] or 0) for row in note_result.mappings(): item = rows.setdefault(row["user_id"], { "user_id": row["user_id"], "user_name": "Unknown", "pages_read": 0, "finished_books": 0, "public_notes": 0, "private_notes": 0, }) item["public_notes"] = int(row["public_notes"] or 0) item["private_notes"] = int(row["private_notes"] or 0) missing_name_ids = [ item["user_id"] for item in rows.values() if item["user_name"] == "Unknown" ] if missing_name_ids: user_result = await db.execute( select(User.id, User.name).where(User.id.in_(missing_name_ids)) ) name_by_id = {user_id: name for user_id, name in user_result.all()} for item in rows.values(): if item["user_name"] == "Unknown": item["user_name"] = name_by_id.get(item["user_id"], f"用户{item['user_id']}") for item in rows.values(): item["score"] = ( item["pages_read"] + item["finished_books"] * 50 + item["public_notes"] * 20 + item["private_notes"] * 10 ) return sorted(rows.values(), key=lambda x: (-x["score"], -x["pages_read"], x["user_name"]))