From eb38b22d9c0782aec0f1f7264f1a2bc994b43717 Mon Sep 17 00:00:00 2001 From: Tobias Burgherr Date: Sun, 20 Sep 2026 23:07:38 +0200 Subject: [PATCH 1/2] Enforce ScheduledCommand.source choices at database level - Add django_admin_runner_scheduledcommand_source_valid CheckConstraint (source__in [code, admin]) to ScheduledCommand.Meta + migration 0011. Fixes the django-extra-checks field-choices-constraint (X060) warning reported by downstream projects. - Sync model definitions with the frozen migration state (label default, help_texts on label/stop_requested/worker_pid) so makemigrations --check stays clean and downstream makemigrations runs no longer emit spurious AlterFields for this app. - Fix _restore_latest in test_migrations.py to migrate to the graph leaf nodes instead of a hardcoded 0006, which left later tests without the 0007+ tables when run in file order. - Add DB-level constraint tests for source and kind. - Refresh uv.lock to 0.2.0 (was still pinned at 0.1.0). --- ...in_runner_scheduledcommand_source_valid.py | 17 +++++++++++ src/django_admin_runner/models.py | 30 ++++++++++++------- tests/test_migrations.py | 2 +- tests/test_schedules.py | 26 ++++++++++++++++ 4 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 src/django_admin_runner/migrations/0011_scheduledcommand_django_admin_runner_scheduledcommand_source_valid.py diff --git a/src/django_admin_runner/migrations/0011_scheduledcommand_django_admin_runner_scheduledcommand_source_valid.py b/src/django_admin_runner/migrations/0011_scheduledcommand_django_admin_runner_scheduledcommand_source_valid.py new file mode 100644 index 0000000..2a3df4e --- /dev/null +++ b/src/django_admin_runner/migrations/0011_scheduledcommand_django_admin_runner_scheduledcommand_source_valid.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_admin_runner", "0010_commandexecution_label"), + ] + + operations = [ + migrations.AddConstraint( + model_name="scheduledcommand", + constraint=models.CheckConstraint( + condition=models.Q(source__in=["code", "admin"]), + name="django_admin_runner_scheduledcommand_source_valid", + ), + ), + ] diff --git a/src/django_admin_runner/models.py b/src/django_admin_runner/models.py index 3561643..0799bc1 100644 --- a/src/django_admin_runner/models.py +++ b/src/django_admin_runner/models.py @@ -31,9 +31,13 @@ class Status(models.TextChoices): TIMEOUT = "TIMEOUT", "Timed out" command_name = models.CharField(max_length=200) - # Optional label: typed on the run form for manual runs, copied from - # the schedule's label for scheduled runs. - label = models.CharField(max_length=200, blank=True) + label = models.CharField( + max_length=200, + blank=True, + default="", + help_text="Optional label: set on the run form (manual runs) " + "or copied from the schedule (scheduled runs).", + ) status = models.CharField( max_length=20, choices=Status.choices, @@ -41,13 +45,15 @@ class Status(models.TextChoices): ) result_html = models.TextField(blank=True) kwargs = models.JSONField(default=dict) - # Set when a (graceful) stop was requested from the admin; the worker - # checks this flag on its output-flush heartbeat and on SIGTERM. - stop_requested = models.BooleanField(default=False) # type: ignore[assignment] - # OS PID of the process running the command, recorded when the - # execution transitions to RUNNING — enables PID-targeted signals - # (django-q2 force kill). - worker_pid = models.IntegerField(null=True, blank=True) + stop_requested = models.BooleanField( # type: ignore[assignment] + default=False, + help_text="Set when a graceful stop was requested from the admin.", + ) + worker_pid = models.IntegerField( + null=True, + blank=True, + help_text="OS PID of the process running the command, recorded at start.", + ) schedule = models.ForeignKey( "ScheduledCommand", null=True, @@ -212,6 +218,10 @@ class Meta: condition=models.Q(kind__in=["cron", "interval", "clocked"]), name="django_admin_runner_scheduledcommand_kind_valid", ), + models.CheckConstraint( + condition=models.Q(source__in=["code", "admin"]), + name="django_admin_runner_scheduledcommand_source_valid", + ), ] def __str__(self) -> str: diff --git a/tests/test_migrations.py b/tests/test_migrations.py index a27d547..6106e39 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -17,7 +17,7 @@ def _restore_latest(self): # Always end at the latest migration so other tests see full state. executor = MigrationExecutor(connection) executor.loader.build_graph() - executor.migrate([(APP, MIG_0006)]) + executor.migrate(executor.loader.graph.leaf_nodes()) def _migrate_to(self, name): executor = MigrationExecutor(connection) diff --git a/tests/test_schedules.py b/tests/test_schedules.py index f606303..433d643 100644 --- a/tests/test_schedules.py +++ b/tests/test_schedules.py @@ -8,6 +8,7 @@ import pytest from django.core.exceptions import ValidationError +from django.db import IntegrityError from django.test import Client, override_settings from django.utils.timezone import localtime @@ -960,3 +961,28 @@ def test_execution_shows_schedule_traceability(self, admin_client, active_comman f"/admin/django_admin_runner/commandexecution/{execution.pk}/change/" ) assert response.status_code == 200 + + +@pytest.mark.django_db(transaction=True) +class TestScheduledCommandConstraints: + """DB-level enforcement of ScheduledCommand field choices.""" + + def make_schedule(self, **kwargs): + return ScheduledCommand.objects.create( + command_name="simple_command", + kind=kwargs.pop("kind", "cron"), + cron=kwargs.pop("cron", "15 4 * * *"), + **kwargs, + ) + + def test_invalid_source_rejected_at_db_level(self, active_command): + with pytest.raises(IntegrityError): + self.make_schedule(source="bogus") + + def test_valid_sources_accepted(self, active_command): + assert self.make_schedule(source="code").source == "code" + assert self.make_schedule(source="admin").source == "admin" + + def test_invalid_kind_rejected_at_db_level(self, active_command): + with pytest.raises(IntegrityError): + self.make_schedule(kind="bogus", cron="") From 255c89c471a3e91405b3c78934f4ef2419ae00d5 Mon Sep 17 00:00:00 2001 From: Tobias Burgherr Date: Mon, 21 Sep 2026 00:05:45 +0200 Subject: [PATCH 2/2] fix: move type-ignore onto the default= line for pyright Pyright suppressions are line-scoped (unlike mypy's statement-scoped ones): after the multi-line reformat of stop_requested, the ignore comment on the BooleanField( line no longer covered the default=False argument one line below, failing the quality gate. --- src/django_admin_runner/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/django_admin_runner/models.py b/src/django_admin_runner/models.py index 0799bc1..2479394 100644 --- a/src/django_admin_runner/models.py +++ b/src/django_admin_runner/models.py @@ -45,8 +45,8 @@ class Status(models.TextChoices): ) result_html = models.TextField(blank=True) kwargs = models.JSONField(default=dict) - stop_requested = models.BooleanField( # type: ignore[assignment] - default=False, + stop_requested = models.BooleanField( + default=False, # type: ignore[assignment] help_text="Set when a graceful stop was requested from the admin.", ) worker_pid = models.IntegerField(