diff --git a/CHANGELOG.md b/CHANGELOG.md index c6230c6..22f8df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-05-31 + +### Changed + +- **`0001_initial` is now idempotent and swappable-user-model safe.** + The migration builds its tables through the schema editor + (`create_model`), guarded to run only when `dynamic_columns_*` are + absent, instead of a plain `CreateModel`. Two payoffs: + - **Pre-existing schema no longer collides.** Projects that predate + the package extraction (notably BPP, whose in-tree `dynamic_columns` + app created these tables years ago) can apply `0001_initial` onto a + database that already carries the tables — it detects them and + no-ops. This makes the dedicated `MIGRATION_MODULES` override such + projects used to ship unnecessary; they can drop it and consume the + package's migrations directly. + - **Custom user models resolve correctly on a from-empty build.** The + `user` FK is materialised by the schema editor against + `settings.AUTH_USER_MODEL`, so a fresh `migrate` (or + `migrate --create-db` / baseline rebuild) on a project with a + swapped user model points the FK at the right table instead of a + hard-coded `auth_user`. + + The migration state is unchanged (a `CreateModel` pair plus the + existing constraints, now under `SeparateDatabaseAndState`), so + databases that already recorded `0001_initial` as applied are + unaffected and `makemigrations` detects no drift. + ## [0.4.5] - 2026-05-12 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index b3afcfa..39d49cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "django-dynamic-admin-columns" -version = "0.4.5" +version = "0.5.0" description = "User-controllable, in-database list_display for Django admin: enable, disable and reorder columns at runtime." readme = "README.md" requires-python = ">=3.11,<3.15" diff --git a/src/dynamic_admin_columns/migrations/0001_initial.py b/src/dynamic_admin_columns/migrations/0001_initial.py index 67de30d..cbc10f9 100644 --- a/src/dynamic_admin_columns/migrations/0001_initial.py +++ b/src/dynamic_admin_columns/migrations/0001_initial.py @@ -1,18 +1,63 @@ """Initial migration for ``dynamic_admin_columns``. -This is a plain Django ``CreateModel`` migration. Fresh installs run -it as-is; downstream projects that come from the pre-extraction -in-tree ``dynamic_columns`` app (notably BPP) are expected to ship a -companion migration that runs *before* this one and ``--fake``-marks -this initial as already applied. See -``bpp.0416_rename_dynamic_columns_to_admin`` in the BPP repository -for the reference implementation. +The schema is built through the schema editor (``create_model``) rather +than a plain ``CreateModel`` operation, guarded so it only runs when the +tables are absent. Two scenarios share this single migration: + +* **Fresh install** -- the ``dynamic_columns_*`` tables do not exist, so + the guard creates them. The ``user`` foreign key is materialised by + the schema editor against ``settings.AUTH_USER_MODEL``, so it resolves + to whatever user table the project uses (``auth_user`` by default, + ``bpp_bppuser`` under a swapped user model, ...). Nothing is + hard-coded. +* **Pre-existing schema** -- downstream projects that predate the + package extraction (notably BPP, whose in-tree ``dynamic_columns`` app + created these tables years ago) already carry them. The guard detects + the tables and no-ops, so re-applying the migration never collides. + +The model side of the migration lives in ``state_operations`` (a +``CreateModel`` pair plus the constraints), keeping Django's migration +state identical to a conventional ``CreateModel`` migration. The +database side is the guarded builder below. """ import django.db.models.deletion from django.conf import settings from django.db import migrations, models +_MODELS = ("ModelAdmin", "ModelAdminColumn") + + +def _tables(schema_editor): + return schema_editor.connection.introspection.table_names() + + +def create_schema_if_absent(apps, schema_editor): + """Create the two tables via the schema editor unless they exist. + + Uses the historical models so the ``user`` FK resolves through + ``settings.AUTH_USER_MODEL`` -- swappable-safe by construction, with + no literal user-table name baked into the SQL. + """ + model_admin = apps.get_model("dynamic_admin_columns", "ModelAdmin") + if model_admin._meta.db_table in _tables(schema_editor): + return # legacy / baseline-loaded DB already carries the tables + for model_name in _MODELS: + schema_editor.create_model( + apps.get_model("dynamic_admin_columns", model_name) + ) + + +def drop_schema_if_present(apps, schema_editor): + """Reverse of :func:`create_schema_if_absent`.""" + model_admin = apps.get_model("dynamic_admin_columns", "ModelAdmin") + if model_admin._meta.db_table not in _tables(schema_editor): + return + for model_name in reversed(_MODELS): + schema_editor.delete_model( + apps.get_model("dynamic_admin_columns", model_name) + ) + class Migration(migrations.Migration): @@ -24,106 +69,122 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name="ModelAdmin", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("class_name", models.TextField()), - ( - "model_ref", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="contenttypes.contenttype", - ), + # State only: declare the models (and their constraints) in + # Django's migration state without emitting any DDL. The + # database side is handled by the guarded RunPython below, which + # then sees these models in the accumulated migration state. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name="ModelAdmin", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("class_name", models.TextField()), + ( + "model_ref", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + help_text=( + "If set, this is a personal column " + "configuration owned by that user. NULL " + "rows are global defaults." + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Model admin", + "verbose_name_plural": "Model admins", + "db_table": "dynamic_columns_modeladmin", + "ordering": ("class_name", "user_id"), + }, ), - ( - "user", - models.ForeignKey( - blank=True, - help_text=( - "If set, this is a personal column configuration " - "owned by that user. NULL rows are global defaults." + migrations.CreateModel( + name="ModelAdminColumn", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), ), - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - verbose_name="User", - ), + ( + "col_name", + models.CharField( + max_length=255, verbose_name="Column name" + ), + ), + ( + "enabled", + models.BooleanField(default=True, verbose_name="Enabled"), + ), + ( + "ordering", + models.PositiveSmallIntegerField(verbose_name="Ordering"), + ), + ( + "parent", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="dynamic_admin_columns.modeladmin", + verbose_name="Parent", + ), + ), + ], + options={ + "verbose_name": "Model admin column", + "verbose_name_plural": "Model admin columns", + "db_table": "dynamic_columns_modeladmincolumn", + "ordering": ("parent", "ordering"), + }, ), - ], - options={ - "verbose_name": "Model admin", - "verbose_name_plural": "Model admins", - "db_table": "dynamic_columns_modeladmin", - "ordering": ("class_name", "user_id"), - }, - ), - migrations.CreateModel( - name="ModelAdminColumn", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + migrations.AddConstraint( + model_name="modeladmin", + constraint=models.UniqueConstraint( + condition=models.Q(("user__isnull", True)), + fields=("class_name", "model_ref"), + name="dyncol_unique_global_modeladmin", ), ), - ( - "col_name", - models.CharField(max_length=255, verbose_name="Column name"), - ), - ( - "enabled", - models.BooleanField(default=True, verbose_name="Enabled"), - ), - ( - "ordering", - models.PositiveSmallIntegerField(verbose_name="Ordering"), - ), - ( - "parent", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="dynamic_admin_columns.modeladmin", - verbose_name="Parent", + migrations.AddConstraint( + model_name="modeladmin", + constraint=models.UniqueConstraint( + condition=models.Q(("user__isnull", False)), + fields=("user", "class_name", "model_ref"), + name="dyncol_unique_user_modeladmin", ), ), + migrations.AlterUniqueTogether( + name="modeladmincolumn", + unique_together={("parent", "col_name")}, + ), ], - options={ - "verbose_name": "Model admin column", - "verbose_name_plural": "Model admin columns", - "db_table": "dynamic_columns_modeladmincolumn", - "ordering": ("parent", "ordering"), - }, - ), - migrations.AddConstraint( - model_name="modeladmin", - constraint=models.UniqueConstraint( - condition=models.Q(("user__isnull", True)), - fields=("class_name", "model_ref"), - name="dyncol_unique_global_modeladmin", - ), - ), - migrations.AddConstraint( - model_name="modeladmin", - constraint=models.UniqueConstraint( - condition=models.Q(("user__isnull", False)), - fields=("user", "class_name", "model_ref"), - name="dyncol_unique_user_modeladmin", - ), + database_operations=[], ), - migrations.AlterUniqueTogether( - name="modeladmincolumn", - unique_together={("parent", "col_name")}, + migrations.RunPython( + create_schema_if_absent, + reverse_code=drop_schema_if_present, ), ] diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..217f123 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,39 @@ +"""The initial migration must tolerate a pre-existing schema. + +Downstream projects that predate the package extraction re-apply +``0001_initial`` onto a database that already carries the +``dynamic_columns_*`` tables. The canonical example is BPP, whose +in-tree ``dynamic_columns`` app created those tables years before the +code was split out into this package. A plain ``CreateModel`` collides +with the existing tables; the migration must detect them and no-op, +while still building the schema from scratch on a fresh install. +""" + +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor + +APP = "dynamic_admin_columns" +TABLES = ("dynamic_columns_modeladmin", "dynamic_columns_modeladmincolumn") + + +@pytest.mark.django_db(transaction=True) +def test_initial_migration_is_idempotent_against_preexisting_tables(): + # Roll the app back to an empty schema, then recreate the tables by + # hand -- this stands in for a legacy / baseline-loaded database that + # already holds them before the migration is applied. + MigrationExecutor(connection).migrate([(APP, None)]) + + executor = MigrationExecutor(connection) + state = executor.loader.project_state((APP, "0001_initial")) + with connection.schema_editor() as schema_editor: + schema_editor.create_model(state.apps.get_model(APP, "ModelAdmin")) + schema_editor.create_model(state.apps.get_model(APP, "ModelAdminColumn")) + + # Re-applying the initial migration onto the existing tables must not + # raise "table already exists". + MigrationExecutor(connection).migrate([(APP, "0001_initial")]) + + existing = connection.introspection.table_names() + for table in TABLES: + assert table in existing