From 611213ae5af79551176900cbe91a98285af4a9ea Mon Sep 17 00:00:00 2001 From: virtus Date: Tue, 12 May 2026 14:37:32 +0700 Subject: [PATCH] refactor: remove Series table and related fields from Novel model - Dropped the Series table and removed the seriesId field from the Novel model. - Updated database migration scripts to reflect these changes. - Adjusted relevant queries and data handling to ensure consistency without the Series relationship. --- app/main.py | 274 ++++++++++------------------------- prisma/schema.prisma | 14 -- scripts/drop_series_table.py | 4 +- 3 files changed, 82 insertions(+), 210 deletions(-) diff --git a/app/main.py b/app/main.py index fd8bc2b..2004389 100644 --- a/app/main.py +++ b/app/main.py @@ -35,7 +35,7 @@ from app.config import settings from app.database import get_db_session from app.storage import storage -# Giới hạn an toàn khi tách chương EPUB (đồng bộ với batch import trên web). +# Giới hạn chương EPUB chỉ khi client gửi `enforceMaxChapters=true` (import nhiều / batch). MOD_EPUB_MAX_CHAPTERS = 4000 @@ -109,6 +109,18 @@ async def _ensure_migration_tables() -> None: continue raise + migration_ddls = [ + 'ALTER TABLE "Novel" DROP CONSTRAINT IF EXISTS "Novel_seriesId_fkey"', + 'ALTER TABLE "Novel" DROP COLUMN IF EXISTS "seriesId"', + 'DROP TABLE IF EXISTS "Series" CASCADE', + ] + async with engine.begin() as conn: + for ddl in migration_ddls: + try: + await conn.execute(text(ddl)) + except Exception: + pass + app = FastAPI(title=settings.app_name, lifespan=lifespan) @@ -147,25 +159,6 @@ def _shuffle_rows[T](rows: list[T]) -> list[T]: return copied -def _collapse_series_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - picked_series: set[str] = set() - output: list[dict[str, Any]] = [] - - for row in rows: - series_id = row.get("seriesId") - if not series_id: - output.append(row) - continue - - if series_id in picked_series: - continue - - picked_series.add(series_id) - output.append(row) - - return output - - def _fill_unique_rows(rows: list[dict[str, Any]], fallback: list[dict[str, Any]], target: int) -> list[dict[str, Any]]: picked: set[str] = set() output: list[dict[str, Any]] = [] @@ -196,7 +189,6 @@ def _home_novel_from_row(row: dict[str, Any]) -> dict[str, Any]: "status": row.get("status") or "Đang ra", "description": row.get("description") or "", "bookmarkCount": int(row.get("bookmarkCount") or 0), - "seriesId": row.get("seriesId"), "updatedAt": _iso(row.get("updatedAt")), } @@ -217,7 +209,7 @@ async def _fetch_home_ranking_rows( text( 'SELECT n.id, n.slug, n.title, n."authorName", n."coverColor", n."coverUrl", ' 'n.rating, n.views, n."totalChapters", n.status, n.description, n."bookmarkCount", ' - 'n."seriesId", n."updatedAt", COALESCE(SUM(v.views), 0)::int AS aggregated_views ' + 'n."updatedAt", COALESCE(SUM(v.views), 0)::int AS aggregated_views ' 'FROM "NovelViewDaily" v ' 'JOIN "Novel" n ON n.id = v."novelId" ' f'{where_sql} ' @@ -232,7 +224,6 @@ async def _fetch_home_ranking_rows( return [ { "id": row["id"], - "seriesId": row["seriesId"], "aggregatedViews": int(row.get("aggregated_views") or 0), "novel": _home_novel_from_row(dict(row)), } @@ -245,7 +236,7 @@ async def _fetch_home_popular_fallback(db: AsyncSession, *, take: int = 400) -> await db.execute( text( 'SELECT id, slug, title, "authorName", "coverColor", "coverUrl", rating, views, ' - '"totalChapters", status, description, "bookmarkCount", "seriesId", "updatedAt" ' + '"totalChapters", status, description, "bookmarkCount", "updatedAt" ' 'FROM "Novel" ' 'ORDER BY views DESC, "updatedAt" DESC ' 'LIMIT :take' @@ -257,7 +248,6 @@ async def _fetch_home_popular_fallback(db: AsyncSession, *, take: int = 400) -> return [ { "id": row["id"], - "seriesId": row["seriesId"], "aggregatedViews": int(row.get("views") or 0), "novel": _home_novel_from_row(dict(row)), } @@ -270,7 +260,7 @@ async def _fetch_home_random_pool(db: AsyncSession, *, take: int = 420) -> list[ await db.execute( text( 'SELECT id, slug, title, "authorName", "coverColor", "coverUrl", rating, views, ' - '"totalChapters", status, description, "bookmarkCount", "seriesId", "updatedAt" ' + '"totalChapters", status, description, "bookmarkCount", "updatedAt" ' 'FROM "Novel" ' 'ORDER BY "updatedAt" DESC ' 'LIMIT :take' @@ -453,7 +443,7 @@ async def _fetch_home_latest_novels(db: AsyncSession, *, take: int = 5) -> list[ await db.execute( text( 'SELECT id, slug, title, "authorName", "coverColor", "coverUrl", rating, views, ' - '"totalChapters", status, description, "bookmarkCount", "seriesId", "updatedAt" ' + '"totalChapters", status, description, "bookmarkCount", "updatedAt" ' 'FROM "Novel" ' 'WHERE id = ANY(:novel_ids)' ), @@ -463,12 +453,12 @@ async def _fetch_home_latest_novels(db: AsyncSession, *, take: int = 5) -> list[ novel_map = {row["id"]: _home_novel_from_row(dict(row)) for row in novel_rows} ordered = [novel_map[novel_id] for novel_id in latest_novel_ids if novel_id in novel_map] - collapsed = _collapse_series_rows(ordered)[:take] + picked = ordered[:take] - for novel in collapsed: + for novel in picked: novel["latestChapter"] = latest_chapter_map.get(novel["id"]) - return collapsed + return picked def _to_hot_slide(row: dict[str, Any], source: str) -> dict[str, Any]: @@ -843,18 +833,6 @@ async def _ensure_unique_slug(db: AsyncSession, *, table: str, slug: str, curren candidate = f"{base}-{idx}" -async def _resolve_series_id( - db: AsyncSession, - *, - series_id: str | None, - series_name: str | None, -) -> str | None: - _ = db - _ = series_name - sid = str(series_id or "").strip() - return sid or None - - async def _set_novel_genres(db: AsyncSession, novel_id: str, genre_ids: list[str]) -> None: clean_ids = [str(g).strip() for g in (genre_ids or []) if str(g).strip()] await db.execute(text('DELETE FROM "NovelGenre" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) @@ -939,8 +917,7 @@ async def mod_list_novels( rows = ( await db.execute( text( - 'SELECT n.id, n.title, n.slug, n."authorName", n.status, n."totalChapters", n."coverUrl", ' - 'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug ' + 'SELECT n.id, n.title, n.slug, n."authorName", n.status, n."totalChapters", n."coverUrl" ' 'FROM "Novel" n ' 'ORDER BY n."updatedAt" DESC, n."createdAt" DESC' ) @@ -955,11 +932,6 @@ async def mod_list_novels( "status": r.get("status") or "Đang ra", "totalChapters": int(r.get("totalChapters") or 0), "coverUrl": r.get("coverUrl"), - "series": ( - {"id": r["series_id"], "name": r["series_name"], "slug": r["series_slug"]} - if r.get("series_id") - else None - ), } for r in rows ] @@ -1007,8 +979,7 @@ async def mod_get_novel_detail( await db.execute( text( 'SELECT n.id, n.title, n.slug, n."authorName", n."originalTitle", n."originalAuthorName", ' - 'n.description, n."coverUrl", n.status, n."totalChapters", ' - 'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug ' + 'n.description, n."coverUrl", n.status, n."totalChapters" ' 'FROM "Novel" n WHERE n.id = :id LIMIT 1' ), {"id": novel_id}, @@ -1033,11 +1004,6 @@ async def mod_get_novel_detail( "coverUrl": row.get("coverUrl"), "status": row.get("status") or "Đang ra", "totalChapters": int(row.get("totalChapters") or 0), - "series": ( - {"id": row["series_id"], "name": row["series_name"], "slug": row["series_slug"]} - if row.get("series_id") - else None - ), "genres": [dict(g) for g in genre_rows], } @@ -1057,14 +1023,13 @@ async def mod_create_novel( slug_base = _norm_title(title).replace(" ", "-")[:120] or _new_id("n_") slug = await _ensure_unique_slug(db, table="Novel", slug=slug_base) - resolved_series_id = await _resolve_series_id(db, series_id=payload.seriesId, series_name=payload.seriesName) novel_id = _new_id("n_") row = ( await db.execute( text( - 'INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "seriesId", "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") ' - 'VALUES (:id,:title,:slug,:author,:original_title,:original_author,:description,:cover_url,:status,:series_id,0,0,0,0,0,NOW(),NOW()) ' + 'INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") ' + 'VALUES (:id,:title,:slug,:author,:original_title,:original_author,:description,:cover_url,:status,0,0,0,0,0,NOW(),NOW()) ' 'RETURNING id, title, slug, "authorName", status, "totalChapters", "coverUrl"' ), { @@ -1077,7 +1042,6 @@ async def mod_create_novel( "description": (payload.description or "").strip(), "cover_url": (payload.coverUrl or "").strip() or None, "status": (payload.status or "Đang ra").strip() or "Đang ra", - "series_id": resolved_series_id, }, ) ).mappings().first() @@ -1100,7 +1064,7 @@ async def mod_update_novel( raise HTTPException(status_code=400, detail="id là bắt buộc") current = ( - await db.execute(text('SELECT id, title, slug, "seriesId" FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id}) + await db.execute(text('SELECT id, title, slug FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id}) ).mappings().first() if not current: raise HTTPException(status_code=404, detail="Novel not found") @@ -1115,14 +1079,6 @@ async def mod_update_novel( slug_base = _norm_title(next_title).replace(" ", "-")[:120] or str(current.get("slug") or _new_id("n_")) next_slug = await _ensure_unique_slug(db, table="Novel", slug=slug_base, current_id=novel_id) - use_series_name = parsed.seriesName is not None and str(parsed.seriesName).strip() != "" - if use_series_name: - next_series_id = await _resolve_series_id(db, series_id=None, series_name=parsed.seriesName) - elif parsed.seriesId is not None: - next_series_id = await _resolve_series_id(db, series_id=parsed.seriesId, series_name=None) - else: - next_series_id = current.get("seriesId") - row = ( await db.execute( text( @@ -1131,7 +1087,7 @@ async def mod_update_novel( '"authorName" = COALESCE(:author_name, "authorName"), ' '"originalTitle" = :original_title, "originalAuthorName" = :original_author, ' 'description = :description, "coverUrl" = :cover_url, ' - 'status = COALESCE(:status, status), "seriesId" = :series_id, "updatedAt" = NOW() ' + 'status = COALESCE(:status, status), "updatedAt" = NOW() ' 'WHERE id = :id ' 'RETURNING id, title, slug, "authorName", status, "totalChapters", "coverUrl"' ), @@ -1145,7 +1101,6 @@ async def mod_update_novel( "description": (parsed.description or "").strip(), "cover_url": (parsed.coverUrl or "").strip() or None, "status": (parsed.status or "").strip() or None, - "series_id": next_series_id, }, ) ).mappings().first() @@ -1230,8 +1185,7 @@ async def mod_list_missing_novels( rows = ( await db.execute( text( - 'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.description, n."totalChapters", n."updatedAt", ' - 'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug ' + 'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.description, n."totalChapters", n."updatedAt" ' 'FROM "Novel" n ' f'{where_sql} ' 'ORDER BY n."updatedAt" DESC, n.title ASC LIMIT 2000' @@ -1269,11 +1223,6 @@ async def mod_list_missing_novels( "description": r.get("description") or "", "totalChapters": int(r.get("totalChapters") or 0), "updatedAt": _iso(r.get("updatedAt")), - "series": ( - {"id": r["series_id"], "name": r["series_name"], "slug": r["series_slug"]} - if r.get("series_id") - else None - ), "genres": genres, "missing": { "author": author_blank, @@ -1350,12 +1299,10 @@ async def mod_overview( novel_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Novel"'))).scalar_one() total_views = (await db.execute(text('SELECT COALESCE(SUM(views),0)::int FROM "Novel"'))).scalar_one() comment_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Comment"'))).scalar_one() - series_count = 0 return { "novelCount": int(novel_count or 0), "totalViews": int(total_views or 0), "commentCount": int(comment_count or 0), - "seriesCount": int(series_count or 0), } @@ -1926,11 +1873,9 @@ async def mod_epub_upload( description: str | None = Form(default=None), genreIds: str | None = Form(default=None), status: str | None = Form(default=None), - seriesMode: str | None = Form(default=None), - seriesId: str | None = Form(default=None), - seriesName: str | None = Form(default=None), replaceExisting: str | None = Form(default=None), appendTargetNovelId: str | None = Form(default=None), + enforceMaxChapters: str | None = Form(default=None), db: AsyncSession = Depends(get_db_session), user: dict = Depends(require_current_user), ): @@ -1966,9 +1911,19 @@ async def mod_epub_upload( parsed_genre_ids = [g.strip() for g in str(genreIds or "").split(",") if g.strip()] n_chapters = len(chapters) - if n_chapters > MOD_EPUB_MAX_CHAPTERS: + enforce_cap = str(enforceMaxChapters or "").lower() in {"1", "true", "yes", "on"} + if enforce_cap and n_chapters > MOD_EPUB_MAX_CHAPTERS: if str(preview or "").lower() == "true": n_ch = n_chapters + # Vẫn trả mẫu chương để mod xác nhận tách TOC/regex đúng; chỉ chặn import do chính sách. + cover_blocked = _extract_epub_cover(tmp_path) or _extract_epub_cover_from_zip(tmp_path) + has_cover_b = bool(cover_blocked) + cover_data_url_b: str | None = None + if cover_blocked: + cover_bytes_b, cover_ext_b = cover_blocked + cover_ext_b = _guess_image_extension(cover_bytes_b) + mime_b = _mime_from_extension(cover_ext_b) + cover_data_url_b = f"data:{mime_b};base64,{base64.b64encode(cover_bytes_b).decode('ascii')}" return { "preview": True, "importBlocked": True, @@ -1979,8 +1934,8 @@ async def mod_epub_upload( "fileName": file.filename or "upload.epub", "splitMode": mode, "detectedStructureType": "standard", - "hasCoverFromEpub": False, - "coverPreviewDataUrl": None, + "hasCoverFromEpub": has_cover_b, + "coverPreviewDataUrl": cover_data_url_b, "parserInfo": { "splitMode": mode, "chapterRegexUsed": pattern, @@ -1989,9 +1944,9 @@ async def mod_epub_upload( "sectionsDroppedByFilter": max(0, len(source_sections) - len(sections_after_filter)), "chaptersDetected": n_ch, "chaptersFinal": n_ch, - "insertedMissingChapters": 0, - "detectedMaxChapterNumber": 0, - "detectedNumberAssignments": 0, + "insertedMissingChapters": len([c for c in chapters if c.get("is_placeholder")]), + "detectedMaxChapterNumber": max([int(c.get("number") or 0) for c in chapters], default=0), + "detectedNumberAssignments": len([c for c in chapters if int(c.get("number") or 0) > 0]), "policySkippedHeavyScan": True, }, "novel": { @@ -2001,8 +1956,19 @@ async def mod_epub_upload( "detectedGenres": inferred_genres, "totalChapters": n_ch, }, - "chaptersPreview": [], - "sample": [], + "chaptersPreview": [ + { + "number": int(c.get("number") or 0), + "title": str(c.get("title") or ""), + "isPlaceholder": bool(c.get("is_placeholder") or False), + "volumeNumber": None, + "volumeTitle": None, + "volumeChapterNumber": None, + "excerpt": str(c.get("txt") or "")[:200], + } + for c in chapters[:30] + ], + "sample": _chapter_preview_samples(chapters, sample_size=10), } raise HTTPException( status_code=400, @@ -2127,25 +2093,17 @@ async def mod_epub_upload( media_type="application/json", ) - target_series_id: str | None = None - sm = str(seriesMode or "none").lower() - if sm == "existing": - target_series_id = await _resolve_series_id(db, series_id=seriesId, series_name=None) - elif sm == "new": - target_series_id = await _resolve_series_id(db, series_id=None, series_name=seriesName) - if existing_by_title and should_replace: novel_id = str(existing_by_title["id"]) await db.execute(text('DELETE FROM "ChapterContentRef" WHERE "chapterId" IN (SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id)'), {"novel_id": novel_id}) await db.execute(text('DELETE FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) await db.execute( - text('UPDATE "Novel" SET "authorName" = :author, description = :desc, "coverUrl" = COALESCE(:cover, "coverUrl"), "seriesId" = :series_id, "updatedAt" = NOW() WHERE id = :id'), + text('UPDATE "Novel" SET "authorName" = :author, description = :desc, "coverUrl" = COALESCE(:cover, "coverUrl"), "updatedAt" = NOW() WHERE id = :id'), { "id": novel_id, "author": base_author, "desc": base_desc, "cover": uploaded_cover_url, - "series_id": target_series_id, }, ) await db.execute( @@ -2163,7 +2121,7 @@ async def mod_epub_upload( novel_id = _new_id("n_") slug = await _ensure_unique_slug(db, table="Novel", slug=_norm_title(base_title).replace(" ", "-")[:120] or novel_id) await db.execute( - text('INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "seriesId", "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,:author,:original_title,:original_author,:desc,:cover,:status,:series_id,0,0,0,0,0,NOW(),NOW())'), + text('INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,:author,:original_title,:original_author,:desc,:cover,:status,0,0,0,0,0,NOW(),NOW())'), { "id": novel_id, "title": base_title, @@ -2174,7 +2132,6 @@ async def mod_epub_upload( "desc": base_desc, "cover": uploaded_cover_url, "status": base_status, - "series_id": target_series_id, }, ) if parsed_genre_ids: @@ -2228,16 +2185,9 @@ async def browse_novels( sort: str = "latest", page: int = Query(default=1, ge=1), limit: int = Query(default=20, ge=1, le=500), - collapse_series: bool = Query(default=False), db: AsyncSession = Depends(get_db_session), ): skip = (page - 1) * limit - outer_order_clause = { - "popular": 'views DESC', - "rating": 'rating DESC', - "name": 'title ASC', - "latest": '"updatedAt" DESC', - }.get(sort, '"updatedAt" DESC') inner_order_clause = { "popular": 'n.views DESC', "rating": 'n.rating DESC', @@ -2271,55 +2221,30 @@ async def browse_novels( base_select = ( 'n.id, n.title, n.slug, n."originalTitle", n."authorName", n."coverUrl", n."coverColor", ' 'n.status, n."totalChapters", n.views, n.rating, n."ratingCount", n."bookmarkCount", ' - 'n."seriesId", NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug, n."updatedAt"' + 'n."updatedAt"' ) base_from = ( 'FROM "Novel" n ' ) - if collapse_series: - # DISTINCT ON picks the best novel per series (most recent), then outer query sorts+paginates - inner_sql = ( - f'SELECT DISTINCT ON (COALESCE(n."seriesId", n.id)) {base_select} ' - f'{base_from}' - f'{where_sql} ' - f'ORDER BY COALESCE(n."seriesId", n.id), n."updatedAt" DESC' + total_count = ( + await db.execute( + text(f'SELECT COUNT(*)::int {base_from}{where_sql}'), + params, ) - total_count = ( - await db.execute( - text(f'SELECT COUNT(*)::int FROM ({inner_sql}) AS _c'), - params, - ) - ).scalar_one() - rows = ( - await db.execute( - text( - f'SELECT * FROM ({inner_sql}) AS collapsed ' - f'ORDER BY {outer_order_clause} ' - f'OFFSET :skip LIMIT :limit' - ), - params, - ) - ).mappings().all() - else: - total_count = ( - await db.execute( - text(f'SELECT COUNT(*)::int {base_from}{where_sql}'), - params, - ) - ).scalar_one() - rows = ( - await db.execute( - text( - f'SELECT {base_select} ' - f'{base_from}' - f'{where_sql} ' - f'ORDER BY {inner_order_clause} ' - f'OFFSET :skip LIMIT :limit' - ), - params, - ) - ).mappings().all() + ).scalar_one() + rows = ( + await db.execute( + text( + f'SELECT {base_select} ' + f'{base_from}' + f'{where_sql} ' + f'ORDER BY {inner_order_clause} ' + f'OFFSET :skip LIMIT :limit' + ), + params, + ) + ).mappings().all() novel_ids = [row["id"] for row in rows] genre_map: dict[str, list[dict[str, str]]] = {novel_id: [] for novel_id in novel_ids} @@ -2360,16 +2285,6 @@ async def browse_novels( "rating": float(row["rating"] or 0), "ratingCount": row["ratingCount"], "bookmarkCount": row["bookmarkCount"], - "seriesId": row["seriesId"], - "series": ( - { - "id": row["series_id"], - "name": row["series_name"], - "slug": row["series_slug"], - } - if row["series_id"] - else None - ), "genres": genre_map.get(row["id"], []), "updatedAt": _iso(row["updatedAt"]), "latestChapter": chapter_map.get(row["id"]), @@ -2392,8 +2307,7 @@ async def get_novel_detail(id_or_slug: str, db: AsyncSession = Depends(get_db_se text( 'SELECT n.id, n.title, n.slug, n."originalTitle", n."authorName", n."originalAuthorName", ' 'n.description, n."coverUrl", n."coverColor", n.status, n."totalChapters", n.views, n.rating, ' - 'n."ratingCount", n."bookmarkCount", n."seriesId", n."createdAt", n."updatedAt", ' - 'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug ' + 'n."ratingCount", n."bookmarkCount", n."createdAt", n."updatedAt" ' 'FROM "Novel" n ' 'WHERE n.id = :value OR n.slug = :value ' 'LIMIT 1' @@ -2418,27 +2332,6 @@ async def get_novel_detail(id_or_slug: str, db: AsyncSession = Depends(get_db_se ) ).mappings().all() - series = None - if row["seriesId"]: - series_novels = ( - await db.execute( - text( - 'SELECT id, title, slug, "totalChapters", status, "coverUrl" ' - 'FROM "Novel" ' - 'WHERE "seriesId" = :series_id ' - 'ORDER BY title ASC' - ), - {"series_id": row["seriesId"]}, - ) - ).mappings().all() - - series = { - "id": row["series_id"], - "name": row["series_name"], - "slug": row["series_slug"], - "novels": [dict(item) for item in series_novels], - } - return { "id": row["id"], "title": row["title"], @@ -2455,8 +2348,6 @@ async def get_novel_detail(id_or_slug: str, db: AsyncSession = Depends(get_db_se "rating": float(row["rating"] or 0), "ratingCount": row["ratingCount"], "bookmarkCount": row["bookmarkCount"], - "seriesId": row["seriesId"], - "series": series, "genres": [dict(item) for item in genres], "createdAt": _iso(row["createdAt"]), "updatedAt": _iso(row["updatedAt"]), @@ -2604,7 +2495,7 @@ async def suggest_novels(q: str = "", db: AsyncSession = Depends(get_db_session) rows = ( await db.execute( text( - 'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", NULL::text AS series_id, NULL::text AS series_name ' + 'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl" ' 'FROM "Novel" n ' 'WHERE n.title ILIKE :q OR n."authorName" ILIKE :q ' 'ORDER BY n.views DESC, n."updatedAt" DESC ' @@ -2621,11 +2512,6 @@ async def suggest_novels(q: str = "", db: AsyncSession = Depends(get_db_session) "slug": row["slug"], "authorName": row["authorName"], "coverUrl": row["coverUrl"], - "series": ( - {"id": row["series_id"], "name": row["series_name"]} - if row["series_id"] - else None - ), } for row in rows ] @@ -2657,8 +2543,6 @@ class ModNovelPayload(BaseModel): coverUrl: str | None = None status: str | None = None genreIds: list[str] | None = None - seriesId: str | None = None - seriesName: str | None = None class ModNovelBulkPayload(BaseModel): diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da313f2..73c350d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,8 +82,6 @@ model Novel { title String originalTitle String? slug String @unique - seriesId String? - series Series? @relation(fields: [seriesId], references: [id], onDelete: SetNull) authorName String // Tên tác giả nguyên bản của truyện originalAuthorName String? uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload @@ -123,18 +121,6 @@ model NovelViewDaily { @@index([day]) } -model Series { - id String @id @default(cuid()) - name String - slug String @unique - description String? @db.Text - - novels Novel[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - model Genre { id String @id @default(cuid()) name String @unique diff --git a/scripts/drop_series_table.py b/scripts/drop_series_table.py index cbaa264..34bd2f9 100644 --- a/scripts/drop_series_table.py +++ b/scripts/drop_series_table.py @@ -19,9 +19,11 @@ def main() -> None: engine = create_engine(_normalize_database_url(database_url)) with engine.begin() as conn: + conn.execute(text('ALTER TABLE "Novel" DROP CONSTRAINT IF EXISTS "Novel_seriesId_fkey"')) + conn.execute(text('ALTER TABLE "Novel" DROP COLUMN IF EXISTS "seriesId"')) conn.execute(text('DROP TABLE IF EXISTS "Series" CASCADE')) - print("Dropped Series table") + print('Dropped "Series" table and "Novel"."seriesId" column (if present).') if __name__ == "__main__":