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
+149 -58
View File
@@ -1,6 +1,7 @@
"""MOD panel API routes — all require MOD or ADMIN role.""" """MOD panel API routes — all require MOD or ADMIN role."""
from __future__ import annotations from __future__ import annotations
import datetime as dt
import io import io
import mimetypes import mimetypes
import os import os
@@ -35,6 +36,18 @@ router = APIRouter(prefix="/api", tags=["mod"])
MAX_RECOMMENDATIONS_PER_EDITOR = 5 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: def _new_cuid() -> str:
return "c" + uuid.uuid4().hex[:24] return "c" + uuid.uuid4().hex[:24]
@@ -675,45 +688,6 @@ async def mod_delete_novel(
return {"success": True} 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") @router.get("/mod/truyen/{novel_id}/trash-words")
async def mod_get_trash_words( async def mod_get_trash_words(
novel_id: str, novel_id: str,
@@ -778,12 +752,12 @@ async def mod_bulk_novels(
if _is_admin(user): if _is_admin(user):
result = await db.execute( 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}, {"ids": body.ids},
) )
else: else:
result = await db.execute( 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"]}, {"ids": body.ids, "uid": user["id"]},
) )
accessible = result.mappings().all() accessible = result.mappings().all()
@@ -793,7 +767,7 @@ async def mod_bulk_novels(
await mongo_db.chapters.delete_many({"novelId": {"$in": accessible_ids}}) await mongo_db.chapters.delete_many({"novelId": {"$in": accessible_ids}})
await db.execute( 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() await db.commit()
@@ -845,7 +819,7 @@ async def mod_novels_missing(
), ),
params, params,
) )
return [dict(r) for r in result.mappings()] return {"items": [dict(r) for r in result.mappings()]}
class MissingPatchItem(BaseModel): class MissingPatchItem(BaseModel):
@@ -872,11 +846,11 @@ async def mod_patch_missing(
ids = [u.id for u in body.updates] ids = [u.id for u in body.updates]
if _is_admin(user): if _is_admin(user):
access_result = await db.execute( 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: else:
access_result = await db.execute( 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"]}, {"ids": ids, "uid": user["id"]},
) )
accessible_ids = {r["id"] for r in access_result.mappings()} accessible_ids = {r["id"] for r in access_result.mappings()}
@@ -911,6 +885,43 @@ async def mod_patch_missing(
return {"updatedCount": updated} 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) # /api/mod/chuong (chapters)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1317,7 +1328,10 @@ async def mod_list_recommendations(
novels_map: dict[str, dict] = {} novels_map: dict[str, dict] = {}
if novel_ids: if novel_ids:
n_result = await db.execute( 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}, {"ids": novel_ids},
) )
for r in n_result.mappings(): for r in n_result.mappings():
@@ -1326,31 +1340,100 @@ async def mod_list_recommendations(
editors_map: dict[str, dict] = {} editors_map: dict[str, dict] = {}
if editor_ids: if editor_ids:
e_result = await db.execute( 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}, {"ids": editor_ids},
) )
for r in e_result.mappings(): for r in e_result.mappings():
editors_map[r["id"]] = dict(r) 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 = [] items = []
for r in recs: for r in recs:
items.append({ 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"]), "id": str(r["_id"]),
"novelId": str(r.get("novelId", "")), "createdAt": _to_iso(r.get("createdAt")),
"editorId": str(r.get("editorId", "")), "recommendCount": recommend_count_map.get(novel_id, 0),
"novel": novels_map.get(str(r.get("novelId", "")), None), "novel": {
"editor": editors_map.get(str(r.get("editorId", "")), None), "id": novel["id"],
"createdAt": str(r.get("createdAt", "")), "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 # Search candidates
candidates = [] candidates = []
if q.strip(): if q.strip():
c_result = await db.execute( 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}%"}, {"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( my_novel_result = await db.execute(
text('SELECT id FROM "Novel" WHERE "uploaderId" = :uid OR "uploaderId" IS NULL'), 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()] 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 { return {
"items": items, "items": items,
"summary": summary,
"candidates": candidates, "candidates": candidates,
"myNovelIds": my_novel_ids, "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( result = await db.execute(
text( text(
'SELECT id, title, "originalTitle", "authorName", "originalAuthorName", description ' 'SELECT id, title, "originalTitle", "authorName", "originalAuthorName", description '
'FROM "Novel" WHERE id = ANY(:ids::text[])' 'FROM "Novel" WHERE id = ANY(:ids)'
), ),
{"ids": body.novelIds}, {"ids": body.novelIds},
) )