From 4ee04972e7f9163dbdf5a7c36330e3379187e187 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Fri, 14 Aug 2026 13:49:32 -0400 Subject: [PATCH 1/3] Refs CVE-2026-15307 -- Used a more common lookup in raster lookup tests. This avoids the need to add `@skipUnlessGISLookup("contained")` while modeling equally realistic usage. --- tests/gis_tests/geoapp/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/gis_tests/geoapp/tests.py b/tests/gis_tests/geoapp/tests.py index 1ea87428ce6d..ec7f42054e2d 100644 --- a/tests/gis_tests/geoapp/tests.py +++ b/tests/gis_tests/geoapp/tests.py @@ -808,25 +808,25 @@ def test_lookup_rejects_writing_or_fetching_rasters(self): self.subTest(obj=obj), self.assertRaisesMessage(DisallowedRasterLookup, msg), ): - City.objects.filter(point__contained=obj) + State.objects.filter(poly__intersects=obj) # Strings having nothing to do with rasters raise a more generic error. for obj in str(existing_path), "invalid": msg = "String input unrecognized as WKT EWKT, and HEXEWKB." with self.subTest(obj=obj), self.assertRaisesMessage(ValueError, msg): - City.objects.filter(point__contained=obj) + State.objects.filter(poly__intersects=obj) def test_lookup_allows_writing_raster_from_bytes(self): raster_path = Path(__file__).parent.parent / "data" / "rasters" / "raster.tif" with open(raster_path, "rb") as raster_file: raster_bytes = raster_file.read() # Just get SQL to avoid gating on connection.supports_raster. - City.objects.filter(point__contained=raster_bytes).query + State.objects.filter(poly__intersects=raster_bytes).query def test_lookup_allows_geos_geometry_string(self): geojson = json.dumps({"type": "Point", "coordinates": [2, 49]}) # Just get SQL to avoid gating on connection.supports_raster. - City.objects.filter(point__contained=geojson).query + State.objects.filter(poly__intersects=geojson).query @skipUnlessGISLookup("exact") def test_lookup_against_nested_geometry_collection(self): From 07d4f69c94a0e32c583b3aee5daf48fd81b4cd69 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 8 Aug 2026 23:01:34 +0100 Subject: [PATCH 2/3] Fixed #37260 -- Made alterations between Python on_delete options noops. Support for database-level delete options removed "on_delete" from `Field.non_db_attrs` because changes to or from the new `DB_CASCADE`, `DB_SET_DEFAULT`, and `DB_SET_NULL` options require schema changes. As a consequence, an `AlterField` changing only a Python-level `on_delete` option (such as `CASCADE` to `PROTECT`) performs unnecessary schema changes when it was previously a no-op at the database level. This commit makes `ForeignObject.non_db_attrs` a property that includes `"on_delete"`only when the option is not a database-level one, so that: - Python-level to Python-level changes skip DDL again, - changes to, from, or between database-level options still alter the field. Regression in 0c487aa3a7b2417481bf48c1e5355c855873e210. --- django/db/models/fields/related.py | 8 +++++ docs/releases/6.1.1.txt | 5 +++ tests/migrations/test_operations.py | 27 ++++++++++++++++ tests/schema/tests.py | 49 ++++++++++++++++++++++++++++- 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 56cde9cee327..bce026eae7b2 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -601,6 +601,14 @@ def __copy__(self): obj.__dict__.pop("reverse_path_infos", None) return obj + @property + def non_db_attrs(self): + if isinstance(self.remote_field.on_delete, DatabaseOnDelete): + # Database-level on_delete options are part of the column + # definition. + return super().non_db_attrs + return super().non_db_attrs + ("on_delete",) + def check(self, **kwargs): return [ *super().check(**kwargs), diff --git a/docs/releases/6.1.1.txt b/docs/releases/6.1.1.txt index 4e13e8441ee9..225b3bf07b0f 100644 --- a/docs/releases/6.1.1.txt +++ b/docs/releases/6.1.1.txt @@ -25,3 +25,8 @@ Bugfixes created with :func:`~django.utils.safestring.mark_safe`, used as form media assets were treated as asset paths rather than being rendered verbatim (:ticket:`37262`). + +* Fixed a regression in Django 6.1 that caused ``AlterField`` operations that + changed only the Python-level ``on_delete`` option of ``ForeignKey`` or + ``OneToOneField`` fields to perform unnecessary schema changes + (:ticket:`37260`). diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index 939520d79245..9bbf1249e957 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -2573,6 +2573,33 @@ def test_alter_field_add_db_column_noop(self): operation.database_forwards(app_label, editor, new_state, project_state) self.assertColumnExists(rider_table, "pony_id") + def test_alter_field_python_level_on_delete_noop(self): + """ + AlterField operation is a noop when changing only the Python-level + on_delete option. + """ + app_label = "test_alflodnoop" + project_state = self.set_up_test_model(app_label, related_model=True) + operation = migrations.AlterField( + "Rider", "pony", models.ForeignKey("Pony", models.PROTECT) + ) + new_state = project_state.clone() + operation.state_forwards(app_label, new_state) + self.assertIs( + project_state.models[app_label, "rider"] + .fields["pony"] + .remote_field.on_delete, + models.CASCADE, + ) + self.assertIs( + new_state.models[app_label, "rider"].fields["pony"].remote_field.on_delete, + models.PROTECT, + ) + with connection.schema_editor() as editor, self.assertNumQueries(0): + operation.database_forwards(app_label, editor, project_state, new_state) + with connection.schema_editor() as editor, self.assertNumQueries(0): + operation.database_backwards(app_label, editor, new_state, project_state) + def test_alter_field_foreignobject_noop(self): app_label = "test_alflfo_noop" project_state = self.set_up_test_model(app_label) diff --git a/tests/schema/tests.py b/tests/schema/tests.py index 024e68f2b36d..292df448e7eb 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -635,10 +635,57 @@ def test_fk_alter_on_delete(self): ): editor.alter_field(Book, old_field, new_field) self.assertForeignKeyExists(Book, "author_id", "schema_author") + self.assertGreater(len(ctx.captured_queries), 0) self.assertIs( any("ON DELETE" in query["sql"] for query in ctx.captured_queries), False ) + @skipUnlessDBFeature("supports_foreign_keys", "can_introspect_foreign_keys") + def test_fk_alter_on_delete_python_level_noop(self): + with connection.schema_editor() as editor: + editor.create_model(Author) + editor.create_model(Book) + old_field = Book._meta.get_field("author") + new_field = ForeignKey(Author, PROTECT) + new_field.set_attributes_from_name("author") + # Changing between Python-level on_delete options doesn't require + # database changes. + with connection.schema_editor() as editor, self.assertNumQueries(0): + editor.alter_field(Book, old_field, new_field, strict=True) + with connection.schema_editor() as editor, self.assertNumQueries(0): + editor.alter_field(Book, new_field, old_field, strict=True) + + @isolate_apps("schema") + @skipUnlessDBFeature("supports_foreign_keys", "can_introspect_foreign_keys") + def test_fk_alter_on_delete_db_level(self): + class DBOnDeleteParent(Model): + class Meta: + app_label = "schema" + + class DBOnDeleteChild(Model): + parent = ForeignKey(DBOnDeleteParent, DB_CASCADE, null=True) + + class Meta: + app_label = "schema" + + self.isolated_local_models = [DBOnDeleteChild, DBOnDeleteParent] + with connection.schema_editor() as editor: + editor.create_model(DBOnDeleteParent) + editor.create_model(DBOnDeleteChild) + # Changing between database-level on_delete options requires database + # changes. + old_field = DBOnDeleteChild._meta.get_field("parent") + new_field = ForeignKey(DBOnDeleteParent, DB_SET_NULL, null=True) + new_field.set_attributes_from_name("parent") + with ( + connection.schema_editor() as editor, + CaptureQueriesContext(connection) as ctx, + ): + editor.alter_field(DBOnDeleteChild, old_field, new_field, strict=True) + self.assertIs( + any("SET NULL" in query["sql"] for query in ctx.captured_queries), True + ) + @isolate_apps("schema") @skipUnlessDBFeature("supports_foreign_keys", "can_introspect_foreign_keys") def test_create_model_db_on_delete(self): @@ -4926,7 +4973,7 @@ def test_alter_field_fk_attributes_noop(self): error_messages={"invalid": "error message"}, help_text="help text", limit_choices_to={"limit": "choice"}, - on_delete=CASCADE, + on_delete=PROTECT, related_name="related_name", related_query_name="related_query_name", validators=[lambda x: x], From 3436cf9bce84bb1f6877ad96819637366b27b719 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Wed, 28 Jan 2026 20:34:17 +0000 Subject: [PATCH 3/3] Clarified that FORM_RENDERER applies to the admin. co-authored-by: David Smith --- docs/ref/forms/renderers.txt | 4 +++- docs/ref/settings.txt | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt index 742ed85f59bb..7dceac9c295f 100644 --- a/docs/ref/forms/renderers.txt +++ b/docs/ref/forms/renderers.txt @@ -28,7 +28,9 @@ setting. It defaults to By specifying a custom form renderer and overriding :attr:`~.BaseRenderer.form_template_name` you can adjust the default form -markup across your project from a single place. +markup across your project from a single place. Note that this will include +forms in :doc:`the admin site ` and third-party +packages that use the default renderer. You can also provide a custom renderer per-form or per-widget by setting the :attr:`.Form.default_renderer` attribute or by using the ``renderer`` argument diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 01565844eaea..f629478c9fb1 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1785,7 +1785,7 @@ generate correct URLs when ``FORCE_SCRIPT_NAME`` is provided. Default: ``'``:class:`django.forms.renderers.DjangoTemplates`\ ``'`` -The class that renders forms and form widgets. It must implement +The default class that renders forms and form widgets. It must implement :ref:`the low-level render API `. Included form renderers are: @@ -1793,6 +1793,10 @@ renderers are: * ``'``:class:`django.forms.renderers.Jinja2`\ ``'`` * ``'``:class:`django.forms.renderers.TemplatesSetting`\ ``'`` +Note that changing this setting affects all forms using the default renderer, +including those in :doc:`the admin site ` or +third-party packages. + .. setting:: FORMAT_MODULE_PATH ``FORMAT_MODULE_PATH``