From 2595850fa43e23fa4890a711cf48323846035af6 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Wed, 21 Oct 2026 20:23:26 -0700 Subject: [PATCH] chore(migrations): fold the post-v2.5.5 migrations into one v2.5.5 shipped through 0057, so 0058 through 0060 have never reached a release and no install in the wild has applied them. Fold them into 0058_post_v2_5_5_schema, as #909 did for the post-v2.3.3 chain, so an upgrading install applies one migration. Operations keep their order: the series fields, the series filter columns, the FTS drop and recreate with series_summary, then the metadata_imported_at backfill. The FTS SQL and the backfill were copied verbatim from the old files. Verified on scratch databases: a fresh migrate of the fold and of the old chain give identical sqlite_master schemas, as does an upgrade from 0057. Rolling the fold back gives the 0057 schema. makemigrations --check reports no changes. tests/test_lazy_import_marker.py imported the backfill by module path and is repointed at the folded module. A dev database that already applied the old 0058-0060 needs to be rebuilt or `migrate codex 0058_post_v2_5_5_schema --fake`. A renamed migration is one the recorder has never seen, so it would re-apply and fail on a duplicate column. Co-Authored-By: Claude Opus 5.5 --- codex/migrations/0058_post_v2_5_5_schema.py | 148 ++++++++++++++++++ codex/migrations/0058_series_fields.py | 55 ------- .../0059_series_filters_and_fts_summary.py | 72 --------- .../0060_backfill_metadata_imported_at.py | 44 ------ tests/test_lazy_import_marker.py | 10 +- 5 files changed, 152 insertions(+), 177 deletions(-) create mode 100644 codex/migrations/0058_post_v2_5_5_schema.py delete mode 100644 codex/migrations/0058_series_fields.py delete mode 100644 codex/migrations/0059_series_filters_and_fts_summary.py delete mode 100644 codex/migrations/0060_backfill_metadata_imported_at.py diff --git a/codex/migrations/0058_post_v2_5_5_schema.py b/codex/migrations/0058_post_v2_5_5_schema.py new file mode 100644 index 000000000..646e3bde8 --- /dev/null +++ b/codex/migrations/0058_post_v2_5_5_schema.py @@ -0,0 +1,148 @@ +""" +Everything the schema gained after v2.5.5, as one migration. + +v2.5.5 shipped through 0057, so nothing here has reached a release. The three +migrations that landed on develop since are folded together: an upgrading +install applies one migration instead of a chain of three. + +In order: + +- **Series get a year range, a publication status and a summary.** Comicbox's + series carries them (status is its SeriesStatusEnum, from Metron) and the + importer merges them per series. The years are nullable and the text columns + default empty, so existing rows stay empty until their comics are + re-imported. +- **The browser filters gain series start year, end year and status**, so the + per-user filter settings get a column for each. +- **The search index gains ``series_summary``.** ``ComicFTS`` is unmanaged, so + makemigrations never sees it: the column list lives in SQL and has to be + dropped and recreated by hand, as 0049 did for ``alternate_series``. The + table comes back empty; the librarian's regular ``SearchIndexSyncTask`` + repopulates it. +- **Comics scanned with tag import on are marked import-complete.** Before + this release only a forced or lazy import stamped + ``Comic.metadata_imported_at``. A library scan with the import-tags flag on + read every comic but stamped none, so a comic with no embedded metadata file + read as un-imported forever: hovering its tag button re-imported it, purged + and rebuilt its cover and reloaded the browser. 0043 backfilled the rows + that existed then; this backfills the ones scanned since, with the same + seed. Only when the import-tags flag is on: a scan with it off read no tags, + and its NULL rows are what the lazy importer is for. A missing flag row + counts as on, its default. + +Every step reverses except the backfill, whose reverse is a no-op. +""" + +from django.db import migrations, models +from django.db.models.functions import Coalesce + +import codex.models.fields + +# 0049's column list, for the reverse migration. +_OLD_FTS_SQL = ( + "CREATE VIRTUAL TABLE codex_comicfts USING fts5(" + "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, " + "publisher, imprint, series, name, collection_title, " + "age_rating_tagged, age_rating_metron, country, language, " + "original_format, review, scan_info, summary, tagger, " + "alternate_series, characters, " + "credits, genres, locations, roles, series_groups, stories, " + "story_arcs, tags, teams, universes, sources" + ")" +) +# 0049's column list plus ``series_summary``. +_NEW_FTS_SQL = ( + "CREATE VIRTUAL TABLE codex_comicfts USING fts5(" + "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, " + "publisher, imprint, series, name, collection_title, " + "age_rating_tagged, age_rating_metron, country, language, " + "original_format, review, scan_info, summary, series_summary, tagger, " + "alternate_series, characters, " + "credits, genres, locations, roles, series_groups, stories, " + "story_arcs, tags, teams, universes, sources" + ")" +) +_DROP_FTS_SQL = "DROP TABLE IF EXISTS codex_comicfts;" + +# AdminFlagChoices.IMPORT_METADATA, frozen at this migration's point in time. +_IMPORT_METADATA_KEY = "IM" + + +def _backfill_metadata_imported_at(apps, _schema_editor) -> None: + """Stamp un-stamped comics if library scans import tags.""" + admin_flag_model = apps.get_model("codex", "adminflag") + flag = admin_flag_model.objects.filter(key=_IMPORT_METADATA_KEY).first() + if flag is not None and not flag.on: + return + comic_model = apps.get_model("codex", "comic") + comic_model.objects.filter(metadata_imported_at__isnull=True).update( + metadata_imported_at=Coalesce("metadata_mtime", "updated_at") + ) + + +class Migration(migrations.Migration): + """Series fields and filters, FTS series_summary, imported-at backfill.""" + + dependencies = [ + ("codex", "0057_remove_pinned_settings"), + ] + + operations = [ + # Series year range, status and summary. + migrations.AddField( + model_name="series", + name="end_year", + field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True), + ), + migrations.AddField( + model_name="series", + name="start_year", + field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True), + ), + migrations.AddField( + model_name="series", + name="status", + field=models.CharField( + blank=True, + choices=[ + ("Ongoing", "Ongoing"), + ("Cancelled", "Cancelled"), + ("Completed", "Completed"), + ("Hiatus", "Hiatus"), + ], + default="", + max_length=9, + ), + ), + migrations.AddField( + model_name="series", + name="summary", + field=codex.models.fields.CleaningTextField( + blank=True, db_collation="nocase", default="" + ), + ), + # Series filter columns, then the FTS series_summary column. + migrations.AddField( + model_name="settingsbrowserfilters", + name="series_end_year", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="settingsbrowserfilters", + name="series_start_year", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="settingsbrowserfilters", + name="series_status", + field=models.JSONField(default=list), + ), + # ComicFTS is unmanaged; its Django state tracks only (comic, + # created_at, updated_at). The FTS columns are a SQL-level concern — + # no state_operations are needed, only a raw DROP + CREATE. Unlike + # 0049, the reverse restores the previous table rather than none. + migrations.RunSQL(sql=_DROP_FTS_SQL, reverse_sql=_OLD_FTS_SQL), + migrations.RunSQL(sql=_NEW_FTS_SQL, reverse_sql=_DROP_FTS_SQL), + # The metadata_imported_at backfill. + migrations.RunPython(_backfill_metadata_imported_at, migrations.RunPython.noop), + ] diff --git a/codex/migrations/0058_series_fields.py b/codex/migrations/0058_series_fields.py deleted file mode 100644 index f91744035..000000000 --- a/codex/migrations/0058_series_fields.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Generated by Django 6.1.2 on 2026-10-20 01:44.""" - -from django.db import migrations, models - -import codex.models.fields - - -class Migration(migrations.Migration): - """ - Give series a year range, a publication status and a summary. - - Comicbox's series carries them (status is its SeriesStatusEnum, from - Metron) and the importer merges them per series. Additive and - reversible: the years are nullable and the text columns default empty. - Existing rows stay empty until their comics are re-imported. - """ - - dependencies = [ - ("codex", "0057_remove_pinned_settings"), - ] - - operations = [ - migrations.AddField( - model_name="series", - name="end_year", - field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True), - ), - migrations.AddField( - model_name="series", - name="start_year", - field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True), - ), - migrations.AddField( - model_name="series", - name="status", - field=models.CharField( - blank=True, - choices=[ - ("Ongoing", "Ongoing"), - ("Cancelled", "Cancelled"), - ("Completed", "Completed"), - ("Hiatus", "Hiatus"), - ], - default="", - max_length=9, - ), - ), - migrations.AddField( - model_name="series", - name="summary", - field=codex.models.fields.CleaningTextField( - blank=True, db_collation="nocase", default="" - ), - ), - ] diff --git a/codex/migrations/0059_series_filters_and_fts_summary.py b/codex/migrations/0059_series_filters_and_fts_summary.py deleted file mode 100644 index f3401e492..000000000 --- a/codex/migrations/0059_series_filters_and_fts_summary.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -Series filters and a searchable series summary. - -The browser filters gain the series start year, end year and status, so -the per-user filter settings get a column for each. - -The FTS5 rebuild adds the ``series_summary`` column. ``ComicFTS`` is -unmanaged, so makemigrations never sees it — the column list lives in -SQL and has to be dropped and recreated by hand, as 0049 did for -``alternate_series``. The table comes back empty; the librarian's -regular ``SearchIndexSyncTask`` repopulates it. -""" - -from django.db import migrations, models - -# 0049's column list, for the reverse migration. -_OLD_FTS_SQL = ( - "CREATE VIRTUAL TABLE codex_comicfts USING fts5(" - "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, " - "publisher, imprint, series, name, collection_title, " - "age_rating_tagged, age_rating_metron, country, language, " - "original_format, review, scan_info, summary, tagger, " - "alternate_series, characters, " - "credits, genres, locations, roles, series_groups, stories, " - "story_arcs, tags, teams, universes, sources" - ")" -) -# 0049's column list plus ``series_summary``. -_NEW_FTS_SQL = ( - "CREATE VIRTUAL TABLE codex_comicfts USING fts5(" - "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, " - "publisher, imprint, series, name, collection_title, " - "age_rating_tagged, age_rating_metron, country, language, " - "original_format, review, scan_info, summary, series_summary, tagger, " - "alternate_series, characters, " - "credits, genres, locations, roles, series_groups, stories, " - "story_arcs, tags, teams, universes, sources" - ")" -) -_DROP_FTS_SQL = "DROP TABLE IF EXISTS codex_comicfts;" - - -class Migration(migrations.Migration): - """Series filter columns and FTS series_summary.""" - - dependencies = [ - ("codex", "0058_series_fields"), - ] - - operations = [ - migrations.AddField( - model_name="settingsbrowserfilters", - name="series_end_year", - field=models.JSONField(default=list), - ), - migrations.AddField( - model_name="settingsbrowserfilters", - name="series_start_year", - field=models.JSONField(default=list), - ), - migrations.AddField( - model_name="settingsbrowserfilters", - name="series_status", - field=models.JSONField(default=list), - ), - # ComicFTS is unmanaged; its Django state tracks only (comic, - # created_at, updated_at). The FTS columns are a SQL-level concern — - # no state_operations are needed, only a raw DROP + CREATE. Unlike - # 0049, the reverse restores the previous table rather than none. - migrations.RunSQL(sql=_DROP_FTS_SQL, reverse_sql=_OLD_FTS_SQL), - migrations.RunSQL(sql=_NEW_FTS_SQL, reverse_sql=_DROP_FTS_SQL), - ] diff --git a/codex/migrations/0060_backfill_metadata_imported_at.py b/codex/migrations/0060_backfill_metadata_imported_at.py deleted file mode 100644 index 756a71791..000000000 --- a/codex/migrations/0060_backfill_metadata_imported_at.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Mark comics scanned with tag import on as import-complete. - -Before this release, only a forced or lazy import stamped -``Comic.metadata_imported_at``. A library scan with the import-tags flag on -read every comic but stamped none, so a comic with no embedded metadata file -read as un-imported forever. Hovering its tag button re-imported it, purged -and rebuilt its cover and reloaded the browser. 0043 backfilled the rows that -existed then; this backfills the ones scanned since, with the same seed. - -Only when the import-tags flag is on: a scan with it off read no tags, and -its NULL rows are what the lazy importer is for. A missing flag row counts -as on, its default. -""" - -from django.db import migrations -from django.db.models.functions import Coalesce - -# AdminFlagChoices.IMPORT_METADATA, frozen at this migration's point in time. -_IMPORT_METADATA_KEY = "IM" - - -def _backfill_metadata_imported_at(apps, _schema_editor) -> None: - """Stamp un-stamped comics if library scans import tags.""" - admin_flag_model = apps.get_model("codex", "adminflag") - flag = admin_flag_model.objects.filter(key=_IMPORT_METADATA_KEY).first() - if flag is not None and not flag.on: - return - comic_model = apps.get_model("codex", "comic") - comic_model.objects.filter(metadata_imported_at__isnull=True).update( - metadata_imported_at=Coalesce("metadata_mtime", "updated_at") - ) - - -class Migration(migrations.Migration): - """Backfill Comic.metadata_imported_at for comics scanned since 0043.""" - - dependencies = [ - ("codex", "0059_series_filters_and_fts_summary"), - ] - - operations = [ - migrations.RunPython(_backfill_metadata_imported_at, migrations.RunPython.noop), - ] diff --git a/tests/test_lazy_import_marker.py b/tests/test_lazy_import_marker.py index a34508818..51b29a1b3 100644 --- a/tests/test_lazy_import_marker.py +++ b/tests/test_lazy_import_marker.py @@ -14,7 +14,7 @@ column NULL for the lazy importer. * ``LazyImporter.lazy_import`` gates on ``metadata_imported_at__isnull=True``, so a comic is imported once and then excluded from further hover-triggered passes. -* Migration 0060 stamps the rows scans left NULL before scans stamped them. +* Migration 0058 stamps the rows scans left NULL before scans stamped them. """ import importlib @@ -39,9 +39,7 @@ from codex.models.admin import AdminFlag from tests.tmp_dirs import tmp_dir -_MIGRATION = importlib.import_module( - "codex.migrations.0060_backfill_metadata_imported_at" -) +_MIGRATION = importlib.import_module("codex.migrations.0058_post_v2_5_5_schema") _backfill = _MIGRATION._backfill_metadata_imported_at # noqa: SLF001 _LIB_PATH: Final = tmp_dir(Path(__file__).stem) @@ -163,7 +161,7 @@ def test_scan_finish_leaves_marker_null_with_import_tags_off(self) -> None: assert self.comic.metadata_imported_at is None def test_backfill_stamps_with_import_tags_on(self) -> None: - """0060 stamps the rows a tag-reading scan left NULL.""" + """0058 stamps the rows a tag-reading scan left NULL.""" _backfill(apps, None) self.comic.refresh_from_db() assert self.comic.metadata_imported_at == self.comic.updated_at @@ -176,7 +174,7 @@ def test_backfill_stamps_with_no_flag_row(self) -> None: assert self.comic.metadata_imported_at is not None def test_backfill_skips_with_import_tags_off(self) -> None: - """0060 leaves the rows of a scan that read no tags to the lazy importer.""" + """0058 leaves the rows of a scan that read no tags to the lazy importer.""" _set_import_metadata_flag(on=False) _backfill(apps, None) self.comic.refresh_from_db()