Enhance MOD panel API: add ISO date conversion, restore novel retrieval endpoint, and improve recommendation response structure

This commit is contained in:
2026-03-30 14:28:55 +07:00
parent ac5f5db447
commit db5c1f0881
+150 -59
View File
@@ -1,6 +1,7 @@
"""MOD panel API routes — all require MOD or ADMIN role."""
from __future__ import annotations
import datetime as dt
import io
import mimetypes
import os
@@ -35,6 +36,18 @@ router = APIRouter(prefix="/api", tags=["mod"])
MAX_RECOMMENDATIONS_PER_EDITOR = 5
def _to_iso(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, dt.datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=dt.timezone.utc)
return value.isoformat()
if isinstance(value, dt.date):
return dt.datetime.combine(value, dt.time(0, 0, tzinfo=dt.timezone.utc)).isoformat()
return str(value)
def _new_cuid() -> str:
return "c" + uuid.uuid4().hex[:24]
@@ -675,45 +688,6 @@ async def mod_delete_novel(
return {"success": True}
@router.get("/mod/truyen/{novel_id}")
async def mod_get_novel(
novel_id: str,
db: AsyncSession = Depends(get_db_session),
user: dict = Depends(require_mod_user),
):
if _is_admin(user):
result = await db.execute(
text('SELECT * FROM "Novel" WHERE id = :id'), {"id": novel_id}
)
else:
result = await db.execute(
text('SELECT * FROM "Novel" WHERE id = :id AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'),
{"id": novel_id, "uid": user["id"]},
)
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Không tìm thấy truyện")
novel = dict(row)
# Genres
genres_result = await db.execute(
text('SELECT g.id, g.name, g.slug FROM "NovelGenre" ng JOIN "Genre" g ON g.id = ng."genreId" WHERE ng."novelId" = :nid'),
{"nid": novel_id},
)
novel["genres"] = [dict(r) for r in genres_result.mappings()]
# Series
if novel.get("seriesId"):
s_result = await db.execute(
text('SELECT id, name, slug, description FROM "Series" WHERE id = :id LIMIT 1'),
{"id": novel["seriesId"]},
)
novel["series"] = dict(s_result.mappings().first() or {})
return novel
@router.get("/mod/truyen/{novel_id}/trash-words")
async def mod_get_trash_words(
novel_id: str,
@@ -778,12 +752,12 @@ async def mod_bulk_novels(
if _is_admin(user):
result = await db.execute(
text('SELECT id, "coverUrl" FROM "Novel" WHERE id = ANY(:ids::text[])'),
text('SELECT id, "coverUrl" FROM "Novel" WHERE id = ANY(:ids)'),
{"ids": body.ids},
)
else:
result = await db.execute(
text('SELECT id, "coverUrl" FROM "Novel" WHERE id = ANY(:ids::text[]) AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'),
text('SELECT id, "coverUrl" FROM "Novel" WHERE id = ANY(:ids) AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'),
{"ids": body.ids, "uid": user["id"]},
)
accessible = result.mappings().all()
@@ -793,7 +767,7 @@ async def mod_bulk_novels(
await mongo_db.chapters.delete_many({"novelId": {"$in": accessible_ids}})
await db.execute(
text('DELETE FROM "Novel" WHERE id = ANY(:ids::text[])'), {"ids": accessible_ids}
text('DELETE FROM "Novel" WHERE id = ANY(:ids)'), {"ids": accessible_ids}
)
await db.commit()
@@ -845,7 +819,7 @@ async def mod_novels_missing(
),
params,
)
return [dict(r) for r in result.mappings()]
return {"items": [dict(r) for r in result.mappings()]}
class MissingPatchItem(BaseModel):
@@ -872,11 +846,11 @@ async def mod_patch_missing(
ids = [u.id for u in body.updates]
if _is_admin(user):
access_result = await db.execute(
text('SELECT id FROM "Novel" WHERE id = ANY(:ids::text[])'), {"ids": ids}
text('SELECT id FROM "Novel" WHERE id = ANY(:ids)'), {"ids": ids}
)
else:
access_result = await db.execute(
text('SELECT id FROM "Novel" WHERE id = ANY(:ids::text[]) AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'),
text('SELECT id FROM "Novel" WHERE id = ANY(:ids) AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'),
{"ids": ids, "uid": user["id"]},
)
accessible_ids = {r["id"] for r in access_result.mappings()}
@@ -911,6 +885,43 @@ async def mod_patch_missing(
return {"updatedCount": updated}
@router.get("/mod/truyen/{novel_id}")
async def mod_get_novel(
novel_id: str,
db: AsyncSession = Depends(get_db_session),
user: dict = Depends(require_mod_user),
):
if _is_admin(user):
result = await db.execute(
text('SELECT * FROM "Novel" WHERE id = :id'), {"id": novel_id}
)
else:
result = await db.execute(
text('SELECT * FROM "Novel" WHERE id = :id AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'),
{"id": novel_id, "uid": user["id"]},
)
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Không tìm thấy truyện")
novel = dict(row)
genres_result = await db.execute(
text('SELECT g.id, g.name, g.slug FROM "NovelGenre" ng JOIN "Genre" g ON g.id = ng."genreId" WHERE ng."novelId" = :nid'),
{"nid": novel_id},
)
novel["genres"] = [dict(r) for r in genres_result.mappings()]
if novel.get("seriesId"):
s_result = await db.execute(
text('SELECT id, name, slug, description FROM "Series" WHERE id = :id LIMIT 1'),
{"id": novel["seriesId"]},
)
novel["series"] = dict(s_result.mappings().first() or {})
return novel
# ---------------------------------------------------------------------------
# /api/mod/chuong (chapters)
# ---------------------------------------------------------------------------
@@ -1317,7 +1328,10 @@ async def mod_list_recommendations(
novels_map: dict[str, dict] = {}
if novel_ids:
n_result = await db.execute(
text('SELECT id, title, slug, "coverUrl", "authorName", status FROM "Novel" WHERE id = ANY(:ids::text[])'),
text(
'SELECT id, title, slug, "coverUrl", "authorName", status, "totalChapters" '
'FROM "Novel" WHERE id = ANY(:ids)'
),
{"ids": novel_ids},
)
for r in n_result.mappings():
@@ -1326,31 +1340,100 @@ async def mod_list_recommendations(
editors_map: dict[str, dict] = {}
if editor_ids:
e_result = await db.execute(
text('SELECT id, name, email FROM "User" WHERE id = ANY(:ids::text[])'),
text('SELECT id, name, email FROM "User" WHERE id = ANY(:ids)'),
{"ids": editor_ids},
)
for r in e_result.mappings():
editors_map[r["id"]] = dict(r)
recommend_count_map: dict[str, int] = {}
for r in recs:
novel_id = str(r.get("novelId") or "")
if novel_id:
recommend_count_map[novel_id] = recommend_count_map.get(novel_id, 0) + 1
items = []
for r in recs:
items.append({
"id": str(r["_id"]),
"novelId": str(r.get("novelId", "")),
"editorId": str(r.get("editorId", "")),
"novel": novels_map.get(str(r.get("novelId", "")), None),
"editor": editors_map.get(str(r.get("editorId", "")), None),
"createdAt": str(r.get("createdAt", "")),
})
novel_id = str(r.get("novelId", ""))
editor_id = str(r.get("editorId", ""))
novel = novels_map.get(novel_id)
editor = editors_map.get(editor_id)
if not novel or not editor:
continue
items.append(
{
"id": str(r["_id"]),
"createdAt": _to_iso(r.get("createdAt")),
"recommendCount": recommend_count_map.get(novel_id, 0),
"novel": {
"id": novel["id"],
"title": novel["title"],
"slug": novel["slug"],
"authorName": novel.get("authorName") or "",
"coverUrl": novel.get("coverUrl"),
"status": novel.get("status") or "Đang ra",
"totalChapters": int(novel.get("totalChapters") or 0),
},
"editor": {
"id": editor["id"],
"name": editor.get("name") or "Biên tập viên",
},
}
)
summary = []
for novel_id, recommend_count in recommend_count_map.items():
novel = novels_map.get(novel_id)
if not novel:
continue
summary.append(
{
"novel": {
"id": novel["id"],
"title": novel["title"],
"slug": novel["slug"],
"authorName": novel.get("authorName") or "",
"coverUrl": novel.get("coverUrl"),
"status": novel.get("status") or "Đang ra",
"totalChapters": int(novel.get("totalChapters") or 0),
},
"recommendCount": recommend_count,
}
)
summary.sort(key=lambda item: item["recommendCount"], reverse=True)
# Search candidates
candidates = []
if q.strip():
c_result = await db.execute(
text('SELECT id, title, slug FROM "Novel" WHERE title ILIKE :q LIMIT 20'),
text(
'SELECT id, title, slug, "authorName", "coverUrl", status, "totalChapters" '
'FROM "Novel" WHERE title ILIKE :q OR "authorName" ILIKE :q OR slug ILIKE :q LIMIT 20'
),
{"q": f"%{q}%"},
)
candidates = [dict(r) for r in c_result.mappings()]
candidates = []
for r in c_result.mappings():
row = dict(r)
novel_id = row["id"]
already_recommended = any(
str(item.get("novelId")) == novel_id and str(item.get("editorId")) == user["id"]
for item in recs
)
candidates.append(
{
"id": row["id"],
"title": row["title"],
"slug": row["slug"],
"authorName": row.get("authorName") or "",
"coverUrl": row.get("coverUrl"),
"status": row.get("status") or "Đang ra",
"totalChapters": int(row.get("totalChapters") or 0),
"alreadyRecommended": already_recommended,
"recommendCount": int(recommend_count_map.get(novel_id, 0)),
}
)
my_novel_result = await db.execute(
text('SELECT id FROM "Novel" WHERE "uploaderId" = :uid OR "uploaderId" IS NULL'),
@@ -1358,11 +1441,19 @@ async def mod_list_recommendations(
)
my_novel_ids = [r["id"] for r in my_novel_result.mappings()]
current_user_rec_count = sum(1 for item in recs if str(item.get("editorId")) == user["id"])
return {
"items": items,
"summary": summary,
"candidates": candidates,
"myNovelIds": my_novel_ids,
"currentUser": {"id": user["id"], "role": user.get("role")},
"currentUser": {
"id": user["id"],
"role": user.get("role"),
"recommendationCount": current_user_rec_count,
"maxRecommendationCount": MAX_RECOMMENDATIONS_PER_EDITOR,
},
}
@@ -2423,7 +2514,7 @@ async def mod_ai_enrich_batch(
result = await db.execute(
text(
'SELECT id, title, "originalTitle", "authorName", "originalAuthorName", description '
'FROM "Novel" WHERE id = ANY(:ids::text[])'
'FROM "Novel" WHERE id = ANY(:ids)'
),
{"ids": body.novelIds},
)