Enhance MOD panel API: add ISO date conversion, restore novel retrieval endpoint, and improve recommendation response structure
This commit is contained in:
+149
-58
@@ -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},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user