Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
259 changes: 160 additions & 99 deletions src/dynamic_admin_columns/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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):

Expand All @@ -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,
),
]
39 changes: 39 additions & 0 deletions tests/test_migrations.py
Original file line number Diff line number Diff line change
@@ -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
Loading