485 lines
17 KiB
Python
485 lines
17 KiB
Python
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"]))
|