Refs #21961 -- Added DatabaseFeatures.supports_on_delete_db_(cascade/null) feature flags.

Needed on MongoDB.

@@ -393,8 +393,10 @@ class BaseDatabaseFeatures:
     # subqueries?
     supports_tuple_comparison_against_subquery = True

-    # Does the backend support DEFAULT as delete option?
+    # Does the backend support CASCADE, DEFAULT, NULL as delete options?
+    supports_on_delete_db_cascade = True
     supports_on_delete_db_default = True
+    supports_on_delete_db_null = True

     # Collation names for use by [NAME_REDACTED]
     test_collations = {

@@ -12,6 +12,7 @@
 from django.db.models.constants import LOOKUP_SEP
 from django.db.models.deletion import (
     CASCADE,
+    DB_CASCADE,
     DB_SET_DEFAULT,
     DB_SET_NULL,
     DO_NOTHING,
@@ -1056,9 +1057,38 @@ def check(self, **kwargs):
             *self._check_unique(),
         ]

+    def _check_on_delete_db_support(self, on_delete, feature_flag, databases):
+        for db in databases:
+            if not router.allow_migrate_model(db, self.model):
+                continue
+            connection = connections[db]
+            if feature_flag in self.model._meta.required_db_features or getattr(
+                connection.features, feature_flag
+            ):
+                continue
+            no_db_option_name = on_delete.__name__.removeprefix("DB_")
+            yield checks.Error(
+                f"{connection.display_name} does not support a {on_delete.__name__}.",
+                hint=f"Change the on_delete rule to {no_db_option_name}.",
+                obj=self,
+                id="fields.E324",
+            )
+
     def _check_on_delete(self, databases):
         on_delete = getattr(self.remote_field, "on_delete", None)
         errors = []
+        if on_delete == DB_CASCADE:
+            errors.extend(
+                self._check_on_delete_db_support(
+                    on_delete, "supports_on_delete_db_cascade", databases
+                )
+            )
+        if on_delete == DB_SET_NULL:
+            errors.extend(
+                self._check_on_delete_db_support(
+                    on_delete, "supports_on_delete_db_null", databases
+                )
+            )
         if on_delete in [DB_SET_NULL, SET_NULL] and not self.null:
             errors.append(
                 checks.Error(
@@ -1092,25 +1122,12 @@ def _check_on_delete(self, databases):
                         id="fields.E322",
                     )
                 )
-            for db in databases:
-                if not router.allow_migrate_model(db, self.model):
-                    continue
-                connection = connections[db]
-                if not (
-                    "supports_on_delete_db_default"
-                    in self.model._meta.required_db_features
-                    or connection.features.supports_on_delete_db_default
-                ):
-                    errors.append(
-                        checks.Error(
-                            f"{connection.display_name} does not support a "
-                            "DB_SET_DEFAULT.",
-                            hint="Change the on_delete rule to SET_DEFAULT.",
-                            obj=self,
-                            id="fields.E324",
-                        ),
-                    )
-        elif not isinstance(self.remote_field.model, str) and on_delete != DO_NOTHING:
+            errors.extend(
+                self._check_on_delete_db_support(
+                    on_delete, "supports_on_delete_db_default", databases
+                )
+            )
+        if not isinstance(self.remote_field.model, str) and on_delete != DO_NOTHING:
             # Database and Python variants cannot be mixed in a chain of
             # model references.
             is_db_on_delete = isinstance(on_delete, DatabaseOnDelete)

@@ -309,7 +309,8 @@ Related fields
   ``db_default`` value.
 * **fields.E323**: Field specifies database/Python-level on_delete variant, but
   referenced model uses python/database-level variant.
-* **fields.E324**: ``<database>`` does not support ``DB_SET_DEFAULT``.
+* **fields.E324**: ``<database>`` does not support
+  ``<database delete option>``.
 * **fields.E330**: ``ManyToManyField``\s cannot be unique.
 * **fields.E331**: Field specifies a many-to-many relation through model
   ``<model>``, which has not been installed.

@@ -55,6 +55,9 @@ class DBCascade(models.Model):
     def __str__(self):
         return str(self.num)

+    class Meta:
+        required_db_features = {"supports_on_delete_db_cascade"}
+

 class Event(models.Model):
     date = models.DateTimeField(auto_now_add=True)

@@ -21,7 +21,7 @@
 from django.contrib.auth.templatetags.auth import render_password_as_hash
 from django.core.validators import EMPTY_VALUES
 from django.db import DEFAULT_DB_ALIAS, models
-from django.test import SimpleTestCase, TestCase, override_settings
+from django.test import SimpleTestCase, TestCase, override_settings, skipUnlessDBFeature
 from django.test.utils import isolate_apps
 from django.utils.formats import localize
 from django.utils.safestring import mark_safe
@@ -115,6 +115,7 @@ def test_relation_on_abstract(self):
         n.collect([Vehicle.objects.first()])

+[USER_REDACTED]("supports_on_delete_db_cascade")
 class DBNestedObjectsTests(NestedObjectsTests):
     """
     [NAME_REDACTED] but with a model that makes use of DB_CASCADE

@@ -49,17 +49,26 @@ class RelatedDbOptionParent(models.Model):
     p = models.ForeignKey(RelatedDbOptionGrandParent, models.DB_CASCADE, null=True)

-class RelatedDbOption(models.Model):
+class CascadeDbModel(models.Model):
     name = models.CharField(max_length=30)
+    db_cascade = models.ForeignKey(
+        RelatedDbOptionParent, models.DB_CASCADE, related_name="db_cascade_set"
+    )
+
+    class Meta:
+        required_db_features = {"supports_on_delete_db_cascade"}
+
+
+class SetNullDbModel(models.Model):
     db_setnull = models.ForeignKey(
         RelatedDbOptionParent,
         models.DB_SET_NULL,
         null=True,
         related_name="db_setnull_set",
     )
-    db_cascade = models.ForeignKey(
-        RelatedDbOptionParent, models.DB_CASCADE, related_name="db_cascade_set"
-    )
+
+    class Meta:
+        required_db_features = {"supports_on_delete_db_null"}

 class SetDefaultDbModel(models.Model):
@@ -159,15 +168,6 @@ def create_a(name):
     return a

-def create_related_db_option(name):
-    a = RelatedDbOption(name=name)
-    for name in ["db_setnull", "db_cascade"]:
-        r = RelatedDbOptionParent.objects.create()
-        setattr(a, name, r)
-    a.save()
-    return a
-
-
 class M(models.Model):
     m2m = models.ManyToManyField(R, related_name="m_set")
     m2m_through = models.ManyToManyField(R, through="MR", related_name="m_through_set")

@@ -15,6 +15,7 @@
     Avatar,
     B,
     Base,
+    CascadeDbModel,
     Child,
     DeleteBottom,
     DeleteTop,
@@ -34,16 +35,15 @@
     RChild,
     RChildChild,
     Referrer,
-    RelatedDbOption,
     RelatedDbOptionGrandParent,
     RelatedDbOptionParent,
     RProxy,
     S,
     SetDefaultDbModel,
+    SetNullDbModel,
     T,
     User,
     create_a,
-    create_related_db_option,
     get_default_r,
 )

@@ -81,10 +81,13 @@ def test_setnull(self):
         a = A.objects.get(pk=a.pk)
         self.assertIsNone(a.setnull)

+    [USER_REDACTED]("supports_on_delete_db_null")
     def test_db_setnull(self):
-        a = create_related_db_option("db_setnull")
+        a = SetNullDbModel.objects.create(
+            db_setnull=RelatedDbOptionParent.objects.create()
+        )
         a.db_setnull.delete()
-        a = RelatedDbOption.objects.get(pk=a.pk)
+        a = SetNullDbModel.objects.get(pk=a.pk)
         self.assertIsNone(a.db_setnull)

     def test_setdefault(self):
@@ -394,20 +397,21 @@ def test_bulk(self):
         self.assertNumQueries(5, s.delete)
         self.assertFalse(S.objects.exists())

+    [USER_REDACTED]("supports_on_delete_db_cascade")
     def test_db_cascade(self):
         related_db_op = RelatedDbOptionParent.objects.create(
             p=RelatedDbOptionGrandParent.objects.create()
         )
-        RelatedDbOption.objects.bulk_create(
+        CascadeDbModel.objects.bulk_create(
             [
-                RelatedDbOption(db_cascade=related_db_op)
+                CascadeDbModel(db_cascade=related_db_op)
                 for _ in range(2 * GET_ITERATOR_CHUNK_SIZE)
             ]
         )
         with self.assertNumQueries(1):
             results = related_db_op.delete()
             self.assertEqual(results, (1, {"delete.RelatedDbOptionParent": 1}))
-        self.assertFalse(RelatedDbOption.objects.exists())
+        self.assertFalse(CascadeDbModel.objects.exists())
         self.assertFalse(RelatedDbOptionParent.objects.exists())

     def test_instance_update(self):

@@ -178,3 +178,9 @@ class DbOnDeleteModel(models.Model):
     fk_set_null = models.ForeignKey(
         DigitsInColumnName, on_delete=models.DB_SET_NULL, null=True
     )
+
+    class Meta:
+        required_db_features = {
+            "supports_on_delete_db_cascade",
+            "supports_on_delete_db_null",
+        }

@@ -301,7 +301,11 @@ def test_foreign_key_to_field(self):
             out.getvalue(),
         )

-    [USER_REDACTED]("can_introspect_foreign_keys")
+    [USER_REDACTED](
+        "can_introspect_foreign_keys",
+        "supports_on_delete_db_cascade",
+        "supports_on_delete_db_null",
+    )
     def test_foreign_key_db_on_delete(self):
         out = StringIO()
         call_command("inspectdb", "inspectdb_dbondeletemodel", stdout=out)

@@ -112,11 +112,20 @@ class Meta:
         required_db_features = {"supports_comments"}

-class DbOnDeleteModel(models.Model):
+class DbOnDeleteCascadeModel(models.Model):
     fk_do_nothing = models.ForeignKey(Country, on_delete=models.DO_NOTHING)
     fk_db_cascade = models.ForeignKey(City, on_delete=models.DB_CASCADE)
+
+    class Meta:
+        required_db_features = {"supports_on_delete_db_cascade"}
+
+
+class DbOnDeleteSetNullModel(models.Model):
     fk_set_null = models.ForeignKey(Reporter, on_delete=models.DB_SET_NULL, null=True)

+    class Meta:
+        required_db_features = {"supports_on_delete_db_null"}
+

 class DbOnDeleteSetDefaultModel(models.Model):
     fk_db_set_default = models.ForeignKey(

@@ -10,8 +10,9 @@
     Comment,
     Country,
     DbCommentModel,
-    DbOnDeleteModel,
+    DbOnDeleteCascadeModel,
     DbOnDeleteSetDefaultModel,
+    DbOnDeleteSetNullModel,
     District,
     Reporter,
     UniqueConstraintConditionModel,
@@ -244,11 +245,11 @@ def test_get_relations(self):
             editor.add_field(Article, body)
         self.assertEqual(relations, expected_relations)

-    [USER_REDACTED]("can_introspect_foreign_keys")
-    def test_get_relations_db_on_delete(self):
+    [USER_REDACTED]("can_introspect_foreign_keys", "supports_on_delete_db_cascade")
+    def test_get_relations_db_on_delete_cascade(self):
         with connection.cursor() as cursor:
             relations = connection.introspection.get_relations(
-                cursor, DbOnDeleteModel._meta.db_table
+                cursor, DbOnDeleteCascadeModel._meta.db_table
             )

         if connection.vendor == "mysql" and connection.mysql_is_mariadb:
@@ -259,6 +260,18 @@ def test_get_relations_db_on_delete(self):
         expected_relations = {
             "fk_db_cascade_id": ("id", City._meta.db_table, DB_CASCADE),
             "fk_do_nothing_id": ("id", Country._meta.db_table, no_db_on_delete),
+        }
+        self.assertEqual(relations, expected_relations)
+
+    [USER_REDACTED]("can_introspect_foreign_keys", "supports_on_delete_db_null")
+    def test_get_relations_db_on_delete_null(self):
+        with connection.cursor() as cursor:
+            relations = connection.introspection.get_relations(
+                cursor, DbOnDeleteSetNullModel._meta.db_table
+            )
+
+        # {field_name: (field_name_other_table, other_table, db_on_delete)}
+        expected_relations = {
             "fk_set_null_id": ("id", Reporter._meta.db_table, DB_SET_NULL),
         }
         self.assertEqual(relations, expected_relations)

@@ -2288,6 +2288,41 @@ class FooBar(models.Model):
 [USER_REDACTED]_apps("invalid_models_tests")
 class DatabaseLevelOnDeleteTests(TestCase):

+    def test_db_cascade_support(self):
+        class Parent(models.Model):
+            pass
+
+        class Child(models.Model):
+            parent = models.ForeignKey(Parent, models.DB_CASCADE)
+
+        field = Child._meta.get_field("parent")
+        expected = (
+            []
+            if connection.features.supports_on_delete_db_cascade
+            else [
+                Error(
+                    f"{connection.display_name} does not support a DB_CASCADE.",
+                    hint="Change the on_delete rule to CASCADE.",
+                    obj=field,
+                    id="fields.E324",
+                )
+            ]
+        )
+        self.assertEqual(field.check(databases=self.databases), expected)
+
+    def test_db_cascade_required_db_features(self):
+        class Parent(models.Model):
+            pass
+
+        class Child(models.Model):
+            parent = models.ForeignKey(Parent, models.DB_CASCADE)
+
+            class Meta:
+                required_db_features = {"supports_on_delete_db_cascade"}
+
+        field = Child._meta.get_field("parent")
+        self.assertEqual(field.check(databases=self.databases), [])
+
     def test_db_set_default_support(self):
         class Parent(models.Model):
             pass
@@ -2349,6 +2384,41 @@ class Child(models.Model):
             ],
         )

+    def test_db_set_null_support(self):
+        class Parent(models.Model):
+            pass
+
+        class Child(models.Model):
+            parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True)
+
+        field = Child._meta.get_field("parent")
+        expected = (
+            []
+            if connection.features.supports_on_delete_db_null
+            else [
+                Error(
+                    f"{connection.display_name} does not support a DB_SET_NULL.",
+                    hint="Change the on_delete rule to SET_NULL.",
+                    obj=field,
+                    id="fields.E324",
+                )
+            ]
+        )
+        self.assertEqual(field.check(databases=self.databases), expected)
+
+    def test_db_set_null_required_db_features(self):
+        class Parent(models.Model):
+            pass
+
+        class Child(models.Model):
+            parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True)
+
+            class Meta:
+                required_db_features = {"supports_on_delete_db_null"}
+
+        field = Child._meta.get_field("parent")
+        self.assertEqual(field.check(databases=self.databases), [])
+
     def test_python_db_chain(self):
         class GrandParent(models.Model):
             pass
@@ -2376,6 +2446,7 @@ class Child(models.Model):
             ],
         )

+    [USER_REDACTED]("supports_on_delete_db_null")
     def test_db_python_chain(self):
         class GrandParent(models.Model):
             pass
@@ -2403,6 +2474,7 @@ class Child(models.Model):
             ],
         )

+    [USER_REDACTED]("supports_on_delete_db_cascade")
     def test_db_python_chain_auto_created(self):
         class GrandParent(models.Model):
             pass
@@ -2430,6 +2502,7 @@ class Child(models.Model):
             ],
         )

+    [USER_REDACTED]("supports_on_delete_db_null")
     def test_db_do_nothing_chain(self):
         class GrandParent(models.Model):
             pass

Looks good to me! I have a couple minor suggestions, but nothing blocking.

Maybe this should be renamed to `CascadeDbModel`?

Agreed, renamed.

```suggestion
            if (
                feature_flag in self.model._meta.required_db_features
                or getattr(connection.features, feature_flag)
            ):
                continue
            no_db_option_name = on_delete.__name__.removeprefix("DB_")
            errors.append(
                checks.Error(
                    f"{connection.display_name} does not support a "
                    f"{on_delete.__name__}.",
                    hint=f"Change the on_delete rule to {no_db_option_name}.",
                    obj=self,
                    id="fields.E324",
                ),
            )
```

We could also make this helper method a generator and `yield` the `Error`s.


Refs #36619 -- Added third-party licenses to accompany vendored eslint configuration files.

Missed in #19899.

Sources:
https://github.com/[USER_REDACTED]nt/eslint/blob/main/LICENSE
https://github.com/[USER_REDACTED]ndresorhus/globals/blob/main/license

@@ -1,5 +1,27 @@
 /* https://github.com/[USER_REDACTED]nt/eslint/blob/v9.36.0/packages/js/src/configs/eslint-recommended.js */

+/*
+[NAME_REDACTED] and other contributors, <www.openjsf.org>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
 /**
  * [USER_REDACTED] Configuration applied when a user configuration extends from
  * eslint:recommended.

@@ -1,5 +1,17 @@
 /* https://github.com/[USER_REDACTED]ndresorhus/globals/blob/v16.4.0/globals.json */

+/*
+MIT License
+
+Copyright (c) [NAME_EMAIL_REDACTED] (https://sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
 "use strict";

This might be optional since we're not packaging these files, but I didn't want to *forget* to package this file later if we add to it.


Refs #36735 -- Supported shift parameter for UUID7 on PostgreSQL.

#### Trac ticket number
ticket-36735

#### Branch description
Expand on #20101 to support the [`shift` parameter for `UUID7` on PostgreSQL](https://www.postgresql.org/docs/current/functions-uuid.html).

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [ ] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [x] I have attached screenshots in both light and dark modes for any UI changes.

@@ -428,6 +428,7 @@ class BaseDatabaseFeatures:

     supports_uuid4_function = False
     supports_uuid7_function = False
+    supports_uuid7_function_shift = False

     def __init__(self, connection):
         self.connection = connection

@@ -85,6 +85,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     def supports_uuid7_function(self):
         return self.is_postgresql_18

+    [USER_REDACTED]_property
+    def supports_uuid7_function_shift(self):
+        return self.is_postgresql_18
+
     [USER_REDACTED]_property
     def django_test_skips(self):
         skips = {

@@ -37,13 +37,30 @@ def as_oracle(self, compiler, connection, **extra_context):

 class UUID7(Func):
     function = "UUIDV7"
-    arity = 0
+    arity = 1
     output_field = UUIDField()

+    def __init__(self, shift=None, **extra):
+        super().__init__(shift, **extra)
+
+    def _parse_expressions(self, *expressions):
+        if expressions[0] is None:
+            expressions = expressions[1:]
+        return super()._parse_expressions(*expressions)
+
     def as_sql(self, compiler, connection, **extra_context):
-        if connection.features.supports_uuid7_function:
-            return super().as_sql(compiler, connection, **extra_context)
-        raise NotSupportedError("UUID7 is not supported on this database backend.")
+        if not connection.features.supports_uuid7_function:
+            raise NotSupportedError("UUID7 is not supported on this database backend.")
+
+        if len(self.source_expressions) == 1:
+            if not connection.features.supports_uuid7_function_shift:
+                msg = (
+                    "The shift argument to UUID7 is not supported "
+                    "on this database backend."
+                )
+                raise NotSupportedError(msg)
+
+        return super().as_sql(compiler, connection, **extra_context)

     def as_postgresql(self, compiler, connection, **extra_context):
         if connection.features.supports_uuid7_function:

@@ -1901,7 +1901,7 @@ generated using a cryptographically secure method.

 .. versionadded:: 6.1

-.. class:: UUID7()
+.. class:: UUID7(shift=None)

 Generates a :rfc:`version 7 UUID <9562#section-5.7>`, which starts with a
 time-based component.
@@ -1911,6 +1911,14 @@ time-based component.
     Available on PostgreSQL version 18 or later, MariaDB version 11.7 or later,
     and SQLite on Python version 3.14 or later.

+.. admonition:: ``shift``
+
+    On PostgreSQL, ``UUID7`` also supports an optional ``shift`` value, which
+    can be used to offset the timestamp of the generated UUID. This should be a
+    :class:`datetime.timedelta` or an expression with an ``output_field`` of
+    :class:`~django.db.models.DurationField`. A positive value will shift the
+    timestamp into the future and a negative value into the past.
+
 Window functions
 ================

@@ -92,6 +92,7 @@ class Migration(migrations.Migration):
             name="UUIDModel",
             fields=[
                 ("uuid", models.UUIDField(null=True)),
+                ("shift", models.DurationField(null=True)),
             ],
         ),
     ]

@@ -59,3 +59,4 @@ class FloatModel(models.Model):

 class UUIDModel(models.Model):
     uuid = models.UUIDField(null=True)
+    shift = models.DurationField(null=True)

@@ -1,4 +1,5 @@
 import uuid
+from datetime import datetime, timedelta, timezone

 from django.db import NotSupportedError, connection
 from django.db.models.functions import UUID4, UUID7
@@ -31,6 +32,24 @@ def test_uuid7(self):
         self.assertEqual(m1.uuid.version, 7)
         self.assertNotEqual(m1.uuid, m2.uuid)

+    [USER_REDACTED]("supports_uuid7_function_shift")
+    def test_uuid7_shift(self):
+        now = datetime.now(timezone.utc)
+        past = datetime(2005, 11, 16, tzinfo=timezone.utc)
+        shift = past - now
+        m = UUIDModel.objects.create(uuid=UUID7(shift))
+        self.assertTrue(str(m.uuid).startswith("0107965e-e40"), m.uuid)
+
+    [USER_REDACTED]("supports_uuid7_function_shift")
+    def test_uuid7_shift_duration_field(self):
+        now = datetime.now(timezone.utc)
+        past = datetime(2005, 11, 16, tzinfo=timezone.utc)
+        shift = past - now
+        m = UUIDModel.objects.create(shift=shift)
+        UUIDModel.objects.update(uuid=UUID7("shift"))
+        m.refresh_from_db()
+        self.assertTrue(str(m.uuid).startswith("0107965e-e40"), m.uuid)
+
     [USER_REDACTED]("supports_uuid4_function")
     def test_uuid4_unsupported(self):
         if connection.vendor == "mysql":
@@ -62,3 +81,11 @@ def test_uuid7_unsupported(self):

         with self.assertRaisesMessage(NotSupportedError, msg):
             UUIDModel.objects.update(uuid=UUID7())
+
+    [USER_REDACTED]("supports_uuid7_function")
+    [USER_REDACTED]("supports_uuid7_function_shift")
+    def test_uuid7_shift_unsupported(self):
+        msg = "The shift argument to UUID7 is not supported on this database backend."
+
+        with self.assertRaisesMessage(NotSupportedError, msg):
+            UUIDModel.objects.update(uuid=UUID7(shift=timedelta(hours=12)))

Thanks [USER_REDACTED] 👍

Thank you 🎉

I don't think setting this to `arity = 1` (as suggested in https://github.com/[USER_REDACTED]ngo/django/pull/20101#discussion_r2608226079) makes sense, so I've just removed it again. The problem is that setting `arity = 1` means that `shift` becomes required, rather than optional. I'm open to suggestions if there's something I've missed here.

Makes sense, I forgot we wouldn't have access to the connection's feature flags to do the switch inside `__init__()`. EDIT: I'll propose something below.

I think we can leverage the order of operations in the `Func` constructor to avoid reimplementing args checking from scratch. This works to get us args checking:

```diff
diff --git a/django/db/models/functions/uuid.py b/django/db/models/functions/uuid.py
index c41f58d11b..7059798ff3 100644
--- a/django/db/models/functions/uuid.py
+++ b/django/db/models/functions/uuid.py
@@ -37,13 +37,16 @@ class UUID4(Func):

 class UUID7(Func):
     function = "UUIDV7"
+    arity = 1
     output_field = UUIDField()

     def __init__(self, shift=None, **extra):
-        if shift is None:
-            super().__init__(**extra)
-        else:
-            super().__init__(shift, **extra)
+        super().__init__(shift, **extra)
+
+    def _parse_expressions(self, *expressions):
+        if expressions[0] is None:
+            expressions = expressions[1:]
+        return super()._parse_expressions(*expressions)

     def as_sql(self, compiler, connection, **extra_context):
         if not connection.features.supports_uuid7_function:
```

I'd probably just add `shift` to the existing model, unless you think that makes the test harder to grok?

I'd chop, we have other save & reload tests.
```suggestion
```

These assertions are failing for me:
```py
System check identified no issues (46 silenced).
FFs
======================================================================
FAIL: test_uuid7_shift (db_functions.test_uuid.TestUUID.test_uuid7_shift)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/django/source/tests/db_functions/test_uuid.py", line 44, in test_uuid7_shift
    self.assertTrue(str(m.uuid).startswith("0107965e-e400"))
AssertionError: False is not true

----------------------------------------------------------------------
(0.005) INSERT INTO "db_functions_uuidmodel" ("uuid")
VALUES (UUIDV7('-7331 days 1:44:20.748468'::interval)) RETURNING "db_functions_uuidmodel"."id",
                                                                 "db_functions_uuidmodel"."uuid"; args=(datetime.timedelta(days=-7331, seconds=6260, microseconds=748468),); alias=default
(0.001) SELECT "db_functions_uuidmodel"."id",
       "db_functions_uuidmodel"."uuid"
FROM "db_functions_uuidmodel"
WHERE "db_functions_uuidmodel"."id" = 5
LIMIT 21; args=(5,); alias=default

======================================================================
FAIL: test_uuid7_shift_duration_field (db_functions.test_uuid.TestUUID.test_uuid7_shift_duration_field)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/django/source/tests/db_functions/test_uuid.py", line 56, in test_uuid7_shift_duration_field
    self.assertTrue(str(m.uuid).startswith("0107965e-e400"))
AssertionError: False is not true

----------------------------------------------------------------------
(0.001) INSERT INTO "db_functions_uuiddurationmodel" ("uuid",
                                              "shift")
VALUES (NULL, '-7331 days 1:44:20.725522'::interval) RETURNING "db_functions_uuiddurationmodel"."id"; args=(None, datetime.timedelta(days=-7331, seconds=6260, microseconds=725522)); alias=default
(0.001) UPDATE "db_functions_uuiddurationmodel"
SET "uuid" = UUIDV7("db_functions_uuiddurationmodel"."shift"); args=(); alias=default
(0.001) SELECT "db_functions_uuiddurationmodel"."id",
       "db_functions_uuiddurationmodel"."uuid",
       "db_functions_uuiddurationmodel"."shift"
FROM "db_functions_uuiddurationmodel"
WHERE "db_functions_uuiddurationmodel"."id" = 5
LIMIT 21; args=(5,); alias=default

----------------------------------------------------------------------
Ran 3 tests in 0.042s

FAILED (failures=2, skipped=1)
```

Backticks around UUID7

I wonder if this is a timezone difference making the calculation a bit off somewhere.

It would be useful to see what the uuid actually starts with - if it's only different in the last hexdigit, I think it may be just a precision/timing difference.

Sure thing. Maybe worth tweaking the assertion so it's always there.

```py
AssertionError: False is not true : 0107965e-e40a-7380-b548-5e3709b94f41
AssertionError: False is not true : 0107965e-e406-72a3-9cf2-ac4f75411952
```

Yeah, looks like it's just a precision issue in the last hexdigit of the timestamp part.


A few scripts to rule them all.

#### Branch description
Unify location of maintainer's scripts and avoid packaging them.

More details: https://forum.djangoproject.com/t/avoid-packaging-scripts-folder/43296

Scripts were taken from:
* https://code.djangoproject.com/wiki/MergerTips
* https://code.djangoproject.com/wiki/ReleaseScript
* https://code.djangoproject.com/wiki/ReleaseTestNewVersion
* https://djangoci.com/job/confirm-release/ (currently not working there but works locally)

#### Checklist
- [X] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.

@@ -11,6 +11,6 @@ graft django
 graft docs
 graft extras
 graft js_tests
-graft scripts
 graft tests
 global-exclude *.py[co]
+prune scripts

@@ -0,0 +1,35 @@
+#!/bin/bash
+
+# Backport helper for Django stable branches.
+
+set -xue
+
+if [ -z $1 ]; then
+    echo "Full hash of commit to backport is required."
+    exit
+fi
+
+BRANCH_NAME=`git branch | sed -n '/\* stable\//s///p'`
+echo $BRANCH_NAME
+
+# Ensure clean working directory
+git reset --hard
+
+REV=$1
+
+TMPFILE=tmplog.tmp
+
+# Cherry-pick the commit
+git cherry-pick ${REV}
+
+# Create new log message by [NAME_REDACTED]
+git log --pretty=format:"[${BRANCH_NAME}] %s%n%n%b%nBackport of ${REV} from main." HEAD^..HEAD \
+    | grep -v '^BP$' > ${TMPFILE}
+
+# Commit new log message
+git commit --amend -F ${TMPFILE}
+
+# Clean up temporary files
+rm -f ${TMPFILE}
+
+git show

@@ -0,0 +1,57 @@
+#! /bin/bash
+
+set -xue
+
+CHECKSUM_FILE="Django-${VERSION}.checksum.txt"
+MEDIA_URL_PREFIX="https://media.djangoproject.com"
+RELEASE_URL_PREFIX="https://www.djangoproject.com/m/releases/"
+DOWNLOAD_PREFIX="https://www.djangoproject.com/download"
+
+if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+(\.[0-9]+|a[0-9]+|b[0-9]+|rc[0-9]+)?$ ]] ; then
+    echo "Not a valid version"
+fi
+
+rm -rf "${VERSION}"
+mkdir "${VERSION}"
+cd "${VERSION}"
+
+function cleanup {
+    cd ..
+    rm -rf "${VERSION}"
+}
+trap cleanup EXIT
+
+echo "Download checksum file ..."
+curl --fail --output "$CHECKSUM_FILE" "${MEDIA_URL_PREFIX}/pgp/${CHECKSUM_FILE}"
+
+echo "Verify checksum file ..."
+if [ -n "${GPG_KEY}" ] ; then
+    gpg --recv-keys "${GPG_KEY}"
+fi
+gpg --verify "${CHECKSUM_FILE}"
+
+echo "Finding release artifacts ..."
+mapfile -t RELEASE_ARTIFACTS < <(grep "${DOWNLOAD_PREFIX}" "${CHECKSUM_FILE}")
+
+echo "Found these release artifacts: "
+for ARTIFACT_URL in "${RELEASE_ARTIFACTS[@]}" ; do
+    echo "- $ARTIFACT_URL"
+done
+
+echo "Downloading artifacts ..."
+for ARTIFACT_URL in "${RELEASE_ARTIFACTS[@]}" ; do
+    ARTIFACT_ACTUAL_URL=$(curl  --head --write-out '%{redirect_url}' --output /dev/null --silent "${ARTIFACT_URL}")
+    curl --location --fail --output "$(basename "${ARTIFACT_ACTUAL_URL}")" "${ARTIFACT_ACTUAL_URL}"
+
+done
+
+echo "Verifying artifact hashes ..."
+# The `2> /dev/null` moves notes like "sha256sum: WARNING: 60 lines are improperly formatted"
+# to /dev/null. That's fine because the return code of the script is still set on error and a
+# wrong checksum will still show up as `FAILED`
+echo "- MD5 checksums"
+md5sum --check "${CHECKSUM_FILE}" 2> /dev/null
+echo "- SHA1 checksums"
+sha1sum --check "${CHECKSUM_FILE}" 2> /dev/null
+echo "- SHA256 checksums"
+sha256sum --check "${CHECKSUM_FILE}" 2> /dev/null

@@ -0,0 +1,226 @@
+#! /usr/bin/env python
+
+"""Helper to build and publish Django artifacts.
+
+Original author: [NAME_REDACTED].
+Other authors: [NAME_REDACTED], [NAME_REDACTED].
+
+"""
+
+import hashlib
+import os
+import re
+import subprocess
+from datetime import date
+
+PGP_KEY_ID = os.getenv("PGP_KEY_ID")
+PGP_KEY_URL = os.getenv("PGP_KEY_URL")
+PGP_EMAIL = os.getenv("PGP_EMAIL")
+DEST_FOLDER = os.path.expanduser(os.getenv("DEST_FOLDER"))
+
+assert (
+    PGP_KEY_ID
+), "Missing PGP_KEY_ID: Set this env var to your PGP key ID (used for signing)."
+assert (
+    PGP_KEY_URL
+), "Missing PGP_KEY_URL: Set this env var to your PGP public key URL (for fetching)."
+assert DEST_FOLDER and os.path.exists(
+    DEST_FOLDER
+), "Missing DEST_FOLDER: Set this env var to the local path to place the artifacts."
+
+
+checksum_file_text = """This file contains MD5, SHA1, and SHA256 checksums for the
+source-code tarball and wheel files of Django {django_version}, released {release_date}.
+
+To use this file, you will need a working install of PGP or other
+compatible public-key encryption software. You will also need to have
+the Django release manager's public key in your keyring. This key has
+the ID ``{pgp_key_id}`` and can be imported from the MIT
+keyserver, for example, if using the open-source GNU [NAME_REDACTED]
+implementation of PGP:
+
+    gpg --keyserver pgp.mit.edu --recv-key {pgp_key_id}
+
+or via the GitHub API:
+
+    curl {pgp_key_url} | gpg --import -
+
+Once the key is imported, verify this file:
+
+    gpg --verify {checksum_file_name}
+
+Once you have verified this file, you can use normal MD5, SHA1, or SHA256
+checksumming applications to generate the checksums of the Django
+package and compare them to the checksums listed below.
+
+Release packages
+================
+
+https://www.djangoproject.com/download/{django_version}/tarball/
+https://www.djangoproject.com/download/{django_version}/wheel/
+
+MD5 checksums
+=============
+
+{md5_tarball}  {tarball_name}
+{md5_wheel}  {wheel_name}
+
+SHA1 checksums
+==============
+
+{sha1_tarball}  {tarball_name}
+{sha1_wheel}  {wheel_name}
+
+SHA256 checksums
+================
+
+{sha256_tarball}  {tarball_name}
+{sha256_wheel}  {wheel_name}
+
+"""
+
+
+def build_artifacts():
+    from build.__main__ import main as build_main
+
+    build_main([])
+
+
+def do_checksum(checksum_algo, release_file):
+    with open(os.path.join(dist_path, release_file), "rb") as f:
+        return checksum_algo(f.read()).hexdigest()
+
+
+# Ensure the working directory is clean.
+subprocess.call(["git", "clean", "-fdx"])
+
+django_repo_path = os.path.abspath(os.path.curdir)
+dist_path = os.path.join(django_repo_path, "dist")
+
+# Build release files.
+build_artifacts()
+release_files = os.listdir(dist_path)
+wheel_name = None
+tarball_name = None
+for f in release_files:
+    if f.endswith(".whl"):
+        wheel_name = f
+    if f.endswith(".tar.gz"):
+        tarball_name = f
+
+assert wheel_name is not None
+assert tarball_name is not None
+
+django_version = wheel_name.split("-")[1]
+django_major_version = ".".join(django_version.split(".")[:2])
+
+artifacts_path = os.path.join(os.path.expanduser(DEST_FOLDER), django_version)
+os.makedirs(artifacts_path, exist_ok=True)
+
+# Chop alpha/beta/rc suffix
+match = re.search("[abrc]", django_major_version)
+if match:
+    django_major_version = django_major_version[: match.start()]
+
+release_date = date.today().strftime("%B %-d, %Y")
+checksum_file_name = f"Django-{django_version}.checksum.txt"
+checksum_file_kwargs = dict(
+    release_date=release_date,
+    pgp_key_id=PGP_KEY_ID,
+    django_version=django_version,
+    pgp_key_url=PGP_KEY_URL,
+    checksum_file_name=checksum_file_name,
+    wheel_name=wheel_name,
+    tarball_name=tarball_name,
+)
+checksums = (
+    ("md5", hashlib.md5),
+    ("sha1", hashlib.sha1),
+    ("sha256", hashlib.sha256),
+)
+for checksum_name, checksum_algo in checksums:
+    checksum_file_kwargs[f"{checksum_name}_tarball"] = do_checksum(
+        checksum_algo, tarball_name
+    )
+    checksum_file_kwargs[f"{checksum_name}_wheel"] = do_checksum(
+        checksum_algo, wheel_name
+    )
+
+# Create the checksum file
+checksum_file_text = checksum_file_text.format(**checksum_file_kwargs)
+checksum_file_path = os.path.join(artifacts_path, checksum_file_name)
+with open(checksum_file_path, "wb") as f:
+    f.write(checksum_file_text.encode("ascii"))
+
+print("\n\nDiffing release with checkout for sanity check.")
+
+# Unzip and diff...
+unzip_command = [
+    "unzip",
+    "-q",
+    os.path.join(dist_path, wheel_name),
+    "-d",
+    os.path.join(dist_path, django_major_version),
+]
+subprocess.run(unzip_command)
+diff_command = [
+    "diff",
+    "-qr",
+    "./django/",
+    os.path.join(dist_path, django_major_version, "django"),
+]
+subprocess.run(diff_command)
+subprocess.run(
+    [
+        "rm",
+        "-rf",
+        os.path.join(dist_path, django_major_version),
+    ]
+)
+
+print("\n\n=> Commands to run NOW:")
+
+# Sign the checksum file, this may prompt for a passphrase.
+pgp_email = f"-u {PGP_EMAIL} " if PGP_EMAIL else ""
+print(f"gpg --clearsign {pgp_email}--digest-algo SHA256 {checksum_file_path}")
+# Create, verify and push tag
+print(f'git tag --sign --message="Tag {django_version}" {django_version}')
+print(f"git tag --verify {django_version}")
+
+# Copy binaries outside the current repo tree to avoid lossing them.
+subprocess.run(["cp", "-r", dist_path, artifacts_path])
+
+# Make the binaries available to the world
+print(
+    "\n\n=> These ONLY 15 MINUTES BEFORE RELEASE TIME (consider new terminal "
+    "session with isolated venv)!"
+)
+
+# Upload the checksum file and release artifacts to the djangoproject admin.
+print(
+    "\n==> ACTION Add tarball, wheel, and checksum files to the Release entry at:"
+    f"https://www.djangoproject.com/admin/releases/release/{django_version}"
+)
+print(
+    f"* Tarball and wheel from {artifacts_path}\n"
+    f"* Signed checksum {checksum_file_path}.asc"
+)
+
+# Test the new version and confirm the signature using Jenkins.
+print("\n==> ACTION Test the release artifacts:")
+print(f"VERSION={django_version} test_new_version.sh")
+
+print("\n==> ACTION Run confirm-release job:")
+print(f"VERSION={django_version} confirm_release.sh")
+
+# Upload to PyPI.
+print("\n==> ACTION Upload to PyPI, ensure your release venv is activated:")
+print(f"cd {artifacts_path}")
+print("pip install -U pip twine")
+print("twine upload --repository django dist/*")
+
+# Push the tags.
+print("\n==> ACTION Push the tags:")
+print("git push --tags")
+
+print("\n\nDONE!!!")

@@ -0,0 +1,48 @@
+#! /bin/bash
+
+# Original author: [NAME_REDACTED].
+
+set -xue
+
+cd /tmp
+
+RELEASE_VERSION="${VERSION}"
+if [[ -z "$RELEASE_VERSION" ]]; then
+    echo "Please set VERSION as env var"
+    exit 1
+fi
+
+PKG_TAR=$(curl -Ls -o /dev/null -w '%{url_effective}' https://www.djangoproject.com/download/$RELEASE_VERSION/tarball/)
+echo $PKG_TAR
+
+PKG_WHL=$(curl -Ls -o /dev/null -w '%{url_effective}' https://www.djangoproject.com/download/$RELEASE_VERSION/wheel/)
+echo $PKG_WHL
+
+python3 -m venv django-pip
+. django-pip/bin/activate
+python -m pip install --no-cache-dir $PKG_TAR
+django-admin startproject test_one
+cd test_one
+./manage.py --help  # Ensure executable bits
+python manage.py migrate
+python manage.py runserver
+
+deactivate
+cd ..
+rm -rf test_one
+rm -rf django-pip
+
+
+python3 -m venv django-pip-wheel
+. django-pip-wheel/bin/activate
+python -m pip install --no-cache-dir $PKG_WHL
+django-admin startproject test_one
+cd test_one
+./manage.py --help  # Ensure executable bits
+python manage.py migrate
+python manage.py runserver
+
+deactivate
+cd ..
+rm -rf test_one
+rm -rf django-pip-wheel

LGTM!

(spotted a typo for "artifacts" in the second commit msg)

I'll look forward to managing improvements over pull requests instead of via the wiki.

You needn't credit me.

What's this?

Good catch, it comes from the script in the wiki and I never paid attention to that part. Will remove it.


Fixed #36741 -- [NAME_REDACTED]() bulk-friendliness discussion from bulk_update() caveats.

#### Trac ticket number
ticket-36741

#### Branch description
This PR adds a link in the `bulk_update()` caveats section of the QuerySet
documentation to the admonition about `Model.save()` being not bulk-friendly
(:ref:`model_save_bulk_friendly`) in `topics/db/models.txt`.

This provides clearer guidance on why `save()` methods and `pre_save`/`post_save`
signals are not triggered during bulk operations, making the relationship between
`bulk_update()` and overridden model methods more explicit.

#### Checklist
- [x] This PR targets the `main` branch.
- [x] The commit message is written in past tense, mentions the ticket number indirectly through the PR/trac reference, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests. *(N/A — docs-only change)*
- [x] I have added or updated relevant docs.
- [ ] I have attached screenshots in both light and dark modes for any UI changes. *(N/A — no UI changes)*

@@ -2432,7 +2432,8 @@ are), and returns created objects as a list, in the same order as provided:

 This has a number of caveats though:

-* The model's ``save()`` method will not be called, and the ``pre_save`` and
+* The model's ``save()`` method :ref:`will not be called
+  <methods-not-called-on-bulk-operations>`, and the ``pre_save`` and
   ``post_save`` signals will not be sent.
 * It does not work with child models in a multi-table inheritance scenario.
 * If the model's primary key is an :class:`~django.db.models.AutoField` or has
@@ -2513,7 +2514,8 @@ than iterating through the list of models and calling ``save()`` on each of
 them, but it has a few caveats:

 * You cannot update the model's primary key.
-* Each model's ``save()`` method isn't called, and the
+* Each model's ``save()`` method :ref:`isn't called
+  <methods-not-called-on-bulk-operations>`, and the
   :attr:`~django.db.models.signals.pre_save` and
   :attr:`~django.db.models.signals.post_save` signals aren't sent.
 * If updating a large number of columns in a large number of rows, the SQL
@@ -2999,7 +3001,8 @@ and calling ``save()``.
     such filter conditions on MySQL.

 Finally, realize that ``update()`` does an update at the SQL level and, thus,
-does not call any ``save()`` methods on your models, nor does it emit the
+method :ref:`does not call<methods-not-called-on-bulk-operations>` any
+``save()`` methods on your models, nor does it emit the
 :attr:`~django.db.models.signals.pre_save` or
 :attr:`~django.db.models.signals.post_save` signals (which are a consequence of
 calling :meth:`Model.save() <django.db.models.Model.save>`). If you want to
@@ -3072,13 +3075,13 @@ This cascade behavior is customizable via the
 :attr:`~django.db.models.ForeignKey.on_delete` argument to the
 :class:`~django.db.models.ForeignKey`.

-The ``delete()`` method does a bulk delete and does not call any ``delete()``
-methods on your models. It does, however, emit the
-:data:`~django.db.models.signals.pre_delete` and
-:data:`~django.db.models.signals.post_delete` signals for all deleted objects
-(including cascaded deletions). Signals won't be sent when ``DB_CASCADE`` is
-used. Also, ``delete()`` doesn't return information about objects deleted from
-database variants (``DB_*``) of the
+The ``delete()`` method does a bulk delete and :ref:`does not call
+<methods-not-called-on-bulk-operations>` any ``delete()`` methods on your
+models. It does, however, emit the :data:`~django.db.models.signals.pre_delete`
+and :data:`~django.db.models.signals.post_delete` signals for all deleted
+objects (including cascaded deletions). Signals won't be sent when
+``DB_CASCADE`` is used. Also, ``delete()`` doesn't return information about
+objects deleted from database variants (``DB_*``) of the
 :attr:`~django.db.models.ForeignKey.on_delete` argument, e.g. ``DB_CASCADE``.

 Django won’t need to fetch objects into memory when deleting them in the

@@ -964,6 +964,8 @@ example::

 See :ref:`ref-models-update-fields` for more details.

+.. _methods-not-called-on-bulk-operations:
+
 .. admonition:: Overridden model methods are not called on bulk operations

     Note that the :meth:`~Model.delete` method for an object is not

Thanks, be sure to build the docs to ensure they build 👍

Thanks for the PR 👍 .

I added the same link in a few analogous places.

Welcome aboard ⛵!

Let's remove this and just make the existing text below a link.

"isn't called" could be a Sphinx link to your new target.

Bulk-friendly was colloquial shorthand from the ticket discussion. Let's try something like `methods-not-called-on-bulk-operations`


Fixed #36305 -- Added documentation indentation guidelines to contributing docs.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36305

#### Fixed
- Corrected directive content indentation in:
  - `docs/ref/checks.txt`
  - `docs/ref/middleware.txt`
- Ensured all directive descriptions, options, and inner content follow
  Sphinx’s indentation rules (4 spaces).
- Wrapped long lines to meet Django’s 79-character documentation guideline.
- Added a guideline explaining directive content indentation to:
  - `docs/internals/contributing/writing-documentation.txt`

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

@@ -420,6 +420,43 @@ documentation:
 * Use :rst:role:`:cve:<cve>` to reference a [NAME_REDACTED] and
   Exposures (CVE) identifier. For example, use ``:cve:`2019-14232```.

+* When documenting Python objects (classes, methods, attributes, etc.) using
+  `Sphinx directives`__ such as ``.. class::``, ``.. method::``, and
+  ``.. attribute::``, all content must be properly indented to ensure correct
+  rendering and to support features like automatic table of contents
+  generation.
+
+  Follow these rules:
+
+  * The directive itself remains flush with the left margin (no indentation).
+  * All descriptive text under the directive must be indented by 4 spaces.
+  * Multi-line descriptions must keep the same indentation level.
+  * Nested directives (for example, methods inside a class) require an
+    additional 4 spaces of indentation to maintain hierarchy.
+  * Field lists (such as ``:param:``, ``:returns:``, etc.) must align with the
+    directive's content level.
+
+  Example:
+
+  .. code-block:: rst
+
+      .. class:: MyClass
+
+          A brief description of the class.
+
+          .. method:: my_method(arg1, arg2)
+
+              Method description.
+
+              :param arg1: Description of the first parameter
+              :param arg2: Description of the second parameter
+
+          .. attribute:: my_attribute
+
+              Attribute description.
+
+__ https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html
+
 Django-specific markup
 ======================

@@ -19,42 +19,42 @@ API reference

 .. class:: CheckMessage(level, msg, hint=None, obj=None, id=None)

-The warnings and errors raised by [NAME_REDACTED] instances of
-``CheckMessage``. An instance encapsulates a single reportable error or
-warning. It also provides context and hints applicable to the message, and a
-unique identifier that is used for filtering purposes.
-
-Constructor arguments are:
-
-``level``
-    The severity of the message. Use one of the predefined values: ``DEBUG``,
-    ``INFO``, ``WARNING``, ``ERROR``, ``CRITICAL``. If the level is greater or
-    equal to ``ERROR``, then Django will prevent management commands from
-    executing. Messages with level lower than ``ERROR`` (i.e. warnings) are
-    reported to the console, but can be silenced.
-
-``msg``
-    A short (less than 80 characters) string describing the problem. The string
-    should *not* contain newlines.
-
-``hint``
-    A single-line string providing a hint for fixing the problem. If no hint
-    can be provided, or the hint is self-evident from the error message, the
-    hint can be omitted, or a value of ``None`` can be used.
-
-``obj``
-    Optional. An object providing context for the message (for example, the
-    model where the problem was discovered). The object should be a model,
-    field, or manager or any other object that defines a ``__str__()`` method.
-    The method is used while reporting all messages and its result precedes the
-    message.
-
-``id``
-    Optional string. A unique identifier for the issue. Identifiers should
-    follow the pattern ``applabel.X001``, where ``X`` is one of the letters
-    ``CEWID``, indicating the message severity (``C`` for criticals, ``E`` for
-    errors and so). The number can be allocated by [NAME_REDACTED], but should
-    be unique within that application.
+    The warnings and errors raised by [NAME_REDACTED] instances of
+    ``CheckMessage``. An instance encapsulates a single reportable error or
+    warning. It also provides context and hints applicable to the message, and
+    a unique identifier that is used for filtering purposes.
+
+    Constructor arguments are:
+
+    ``level``
+       The severity of the message. Use one of the predefined values: ``DEBUG``
+       , ``INFO``, ``WARNING``, ``ERROR``, ``CRITICAL``. If the level is
+       greater or equal to ``ERROR``, then Django will prevent management
+       commands from executing. Messages with level lower than ``ERROR``
+       (i.e. warnings) are reported to the console, but can be silenced.
+
+    ``msg``
+       A short (less than 80 characters) string describing the problem. The
+       string should *not* contain newlines.
+
+    ``hint``
+       A single-line string providing a hint for fixing the problem. If no hint
+       can be provided, or the hint is self-evident from the error message, the
+       hint can be omitted, or a value of ``None`` can be used.
+
+    ``obj``
+       Optional. An object providing context for the message (for example, the
+       model where the problem was discovered). The object should be a model,
+       field, or manager or any other object that defines a ``__str__()``
+       method. The method is used while reporting all messages and its result
+       precedes the message.
+
+    ``id``
+       Optional string. A unique identifier for the issue. Identifiers should
+       follow the pattern ``applabel.X001``, where ``X`` is one of the letters
+       ``CEWID``, indicating the message severity (``C`` for criticals, ``E``
+       for errors and so). The number can be allocated by [NAME_REDACTED], but
+       should be unique within that application.

 There are subclasses to make creating messages with common levels easier. When
 using them you can omit the ``level`` argument because it is implied by the

@@ -147,14 +147,14 @@ Conditional GET middleware

 .. class:: ConditionalGetMiddleware

-Handles conditional GET operations. If the response doesn't have an ``ETag``
-header, the middleware adds one if needed. If the response has an ``ETag`` or
-``Last-Modified`` header, and the request has ``If-None-Match`` or
-``If-Modified-Since``, the response is replaced by an
-:class:`~django.http.HttpResponseNotModified`.
+    Handles conditional GET operations. If the response doesn't have an
+    ``ETag`` header, the middleware adds one if needed. If the response has an
+    ``ETag`` or ``Last-Modified`` header, and the request has ``If-None-Match``
+    or ``If-Modified-Since``, the response is replaced by an
+    :class:`~django.http.HttpResponseNotModified`.

-You can handle conditional GET operations with individual views using the
-:func:`~django.views.decorators.http.conditional_page` decorator.
+    You can handle conditional GET operations with individual views using the
+    :func:`~django.views.decorators.http.conditional_page` decorator.

 Locale middleware
 -----------------
@@ -170,9 +170,9 @@ Locale middleware
         ``LocaleMiddleware`` and override the attribute to customize the
         redirects issued by [NAME_REDACTED]

-Enables language selection based on data from the request. It customizes
-content for each user. See the :doc:`internationalization documentation
-</topics/i18n/translation>`.
+    Enables language selection based on data from the request. It customizes
+    content for each user. See the :doc:`internationalization documentation
+    </topics/i18n/translation>`.

 Message middleware
 ------------------
@@ -182,8 +182,8 @@ Message middleware

 .. class:: MessageMiddleware

-Enables cookie- and session-based message support. See the
-:doc:`messages documentation </ref/contrib/messages>`.
+    Enables cookie- and session-based message support. See the
+    :doc:`messages documentation </ref/contrib/messages>`.

 .. _security-middleware:

@@ -202,19 +202,19 @@ Security middleware

 .. class:: SecurityMiddleware

-The ``django.middleware.security.SecurityMiddleware`` provides several security
-enhancements to the request/response cycle. Each one can be independently
-enabled or disabled with a setting.
+   The ``django.middleware.security.SecurityMiddleware`` provides several
+   security enhancements to the request/response cycle. Each one can be
+   independently enabled or disabled with a setting.

-* :setting:`SECURE_CONTENT_TYPE_NOSNIFF`
-* :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY`
-* :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS`
-* :setting:`SECURE_HSTS_PRELOAD`
-* :setting:`SECURE_HSTS_SECONDS`
-* :setting:`SECURE_REDIRECT_EXEMPT`
-* :setting:`SECURE_REFERRER_POLICY`
-* :setting:`SECURE_SSL_HOST`
-* :setting:`SECURE_SSL_REDIRECT`
+   * :setting:`SECURE_CONTENT_TYPE_NOSNIFF`
+   * :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY`
+   * :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS`
+   * :setting:`SECURE_HSTS_PRELOAD`
+   * :setting:`SECURE_HSTS_SECONDS`
+   * :setting:`SECURE_REDIRECT_EXEMPT`
+   * :setting:`SECURE_REFERRER_POLICY`
+   * :setting:`SECURE_SSL_HOST`
+   * :setting:`SECURE_SSL_REDIRECT`

 .. _http-strict-transport-security:

@@ -339,11 +339,13 @@ this setting are:
     Instructs the browser to send the full URL as the referrer for same-origin
     links, and only the origin for cross-origin links.

-``same-origin`` Instructs the browser to send a full URL, but only for
-same-origin links. No referrer will be sent for cross-origin links.
+``same-origin``
+    Instructs the browser to send a full URL, but only for
+    same-origin links. No referrer will be sent for cross-origin links.

-``strict-origin`` Instructs the browser to send only the origin, not the full
-URL, and to send no referrer when a protocol downgrade occurs.
+``strict-origin``
+    Instructs the browser to send only the origin, not the full
+    URL, and to send no referrer when a protocol downgrade occurs.

 ``strict-origin-when-cross-origin``
     Instructs the browser to send the full URL when the link is same-origin and
@@ -470,8 +472,8 @@ Session middleware

 .. class:: SessionMiddleware

-Enables session support. See the :doc:`session documentation
-</topics/http/sessions>`.
+    Enables session support. See the :doc:`session documentation
+    </topics/http/sessions>`.

 Site middleware
 ---------------
@@ -481,8 +483,8 @@ Site middleware

 .. class:: CurrentSiteMiddleware

-Adds the ``site`` attribute representing the current site to every incoming
-``HttpRequest`` object. See the :ref:`sites documentation <site-middleware>`.
+    Adds the ``site`` attribute representing the current site to every incoming
+    ``HttpRequest`` object. See the :ref:`sites documentation <site-middleware>`.

 Authentication middleware
 -------------------------
@@ -492,9 +494,9 @@ Authentication middleware

 .. class:: AuthenticationMiddleware

-Adds the ``user`` attribute, representing the currently-logged-in user, to
-every incoming ``HttpRequest`` object. See :ref:`Authentication in web requests
-<auth-web-requests>`.
+    Adds the ``user`` attribute, representing the currently-logged-in user, to
+    every incoming ``HttpRequest`` object. See :ref:`Authentication in web
+    requests <auth-web-requests>`.

 .. class:: LoginRequiredMiddleware

@@ -573,14 +575,14 @@ Customize the login URL or field name for authenticated views with the

 .. class:: RemoteUserMiddleware

-Middleware for utilizing web server provided authentication. See
-:doc:`/howto/auth-remote-user` for usage details.
+    Middleware for utilizing web server provided authentication. See
+    :doc:`/howto/auth-remote-user` for usage details.

 .. class:: PersistentRemoteUserMiddleware

-Middleware for utilizing web server provided authentication when enabled only
-on the login page. See :ref:`persistent-remote-user-middleware-howto` for usage
-details.
+    Middleware for utilizing web server provided authentication when enabled
+    only on the login page. See :ref:`persistent-remote-user-middleware-howto`
+    for usage details.

 CSRF protection middleware
 --------------------------
@@ -589,12 +591,12 @@ CSRF protection middleware

 .. class:: CsrfViewMiddleware

-Adds protection against [NAME_REDACTED] Forgeries by [NAME_REDACTED]
-fields to POST forms and checking requests for the correct value. See the
-:doc:`[NAME_REDACTED] Forgery protection documentation </ref/csrf>`.
+    Adds protection against [NAME_REDACTED] Forgeries by [NAME_REDACTED]
+    fields to POST forms and checking requests for the correct value. See the
+    :doc:`[NAME_REDACTED] Forgery protection documentation </ref/csrf>`.

-You can add [NAME_REDACTED] Forgery protection to individual views using the
-:func:`~django.views.decorators.csrf.csrf_protect` decorator.
+    You can add [NAME_REDACTED] Forgery protection to individual views
+    using the :func:`~django.views.decorators.csrf.csrf_protect` decorator.

 ``X-Frame-Options`` middleware
 ------------------------------
@@ -603,8 +605,8 @@ You can add [NAME_REDACTED] Forgery protection to individual views using the

 .. class:: XFrameOptionsMiddleware

-Simple :doc:`clickjacking protection via the X-Frame-Options header
-</ref/clickjacking/>`.
+    Simple :doc:`clickjacking protection via the X-Frame-Options header
+    </ref/clickjacking/>`.

 [NAME_REDACTED] middleware
 ----------------------------------

Thanks 👍

Thanks ⭐

This change isn't necessary.


Fixed #36744 -- Improved scrypt password hasher doc.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36744

#### Branch description

Improved scrypt password hasher documentation.

- Corrected work_factor description and its requirements.
- Added block_size description.
- Changed parallelism description to mention computations, rather than threads (currently it's not multithreaded.)
- For all of the above, added standard scrypt terminology (N, r, p).
- Mentioned that in multithreaded implementations, parallelism also influences the memory requirements.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

@@ -285,15 +285,18 @@ follows:

 scrypt_ has the following attributes that can be customized:

-#. ``work_factor`` controls the number of iterations within the hash.
-#. ``block_size``
-#. ``parallelism`` controls how many threads will run in parallel.
+#. ``work_factor`` controls the number of iterations within the hash and the
+   size of memory for computation (*N*). It must be a power of 2.
+#. ``block_size`` controls the internal block size (*r*), tuning the algorithm
+   to memory latency.
+#. ``parallelism`` controls how many independent computations may run in
+   parallel (*p*).
 #. ``maxmem`` limits the maximum size of memory that can be used during the
    computation of the hash. Defaults to ``0``, which means the default
    limitation from the OpenSSL library.

 We've chosen reasonable defaults, but you may wish to tune it up or down,
-depending on your security needs and available processing power.
+depending on your security needs and available processing power and memory.

 .. admonition:: Estimating memory usage

@@ -304,6 +307,9 @@ depending on your security needs and available processing power.
     so you may need to tweak ``maxmem`` when changing the ``work_factor`` or
     ``block_size`` values.

+    If the underlying implementation of scrypt_ is fully multithreaded, the
+    memory requirement is multiplied by the ``parallelism`` value.
+
 .. _password-upgrades:

 Password upgrading

[USER_REDACTED] Thanks :+1: Welcome aboard :boat:

I think that mentioning `N`, `r`, and `p` is confusing, not helpful, especially that we use ``work_factor``, ``block_size``, and ``parallelism`` in the following explanation. I'd remove them.

TBH _"controls the computational and memory cost parameter"_  doesn't say anything. How it controls them? with the new wording we have more questions and less answers. I would revert this change.
```suggestion
#. ``work_factor`` controls the number of iterations within the hash. It
   must be a power of 2.
```

```suggestion
```

The thing is N, r, p are the standard parameters for scrypt. If you see or request any recommendation for proper scrypt parameters, you always get them in terms of N, r, p. For example, search for "scrypt recommended parameters". Django uses non-standard names for them, so at least documentation should make it easy to map what everyone else uses to Django's world.

It's not easy to explain how it controls them without explaining the whole algorithm. Perhaps, it should say "sets the computational and memory cost"? The original "number of iterations within the hash" is only partially true, since it doesn't explain the memory size which is also set by N.

These are single letter variables :smile: there is nothing "standard" in them, IMO. For example, for me, `n` and `p` are rather parameters for binomial distribution :upside_down_face:

They are standard in the sense that they are defined in the [scrypt paper](https://www.tarsnap.com/scrypt/scrypt.pdf) and [RFC 7912](https://www.rfc-editor.org/rfc/rfc7914#section-2), and the fact that they are used everywhere to define scrypt parameters, e.g. [OWASP recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#scrypt).

> For example, for me, n and p are rather parameters for binomial distribution 🙃

Exactly -- wouldn't you be confused if you'd have to use parameters for binomial distribution, but instead of `n` and `p`, they would be called 𝖅 and ☋, and documentation won't point out what they really mean?

OK, you convinced me.

I've changed it to:

"controls the number of iterations within the hash and the size of memory for computation (*N*); must be a power of 2."

i.e. added "and the size of memory for computation" to the previous description.

We normally try to avoid semicolons in docs. Can you rewrite it a bit?

Sure, changed to ". It must be a power of 2". Sounds fine?


Fixed #26609 -- Extended fields.E004 system check for unordered iterables.

Co-Authored-By: [NAME_REDACTED] <[EMAIL_REDACTED]

ticket-26609

@@ -316,11 +316,13 @@ def _check_choices(self):
         if not self.choices:
             return []

-        if not isinstance(self.choices, Iterable) or isinstance(self.choices, str):
+        if not isinstance(self.choices, Iterable) or isinstance(
+            self.choices, (str, set, frozenset)
+        ):
             return [
                 checks.Error(
-                    "'choices' must be a mapping (e.g. a dictionary) or an iterable "
-                    "(e.g. a list or tuple).",
+                    "'choices' must be a mapping (e.g. a dictionary) or an "
+                    "ordered iterable (e.g. a list or tuple, but not a set).",
                     obj=self,
                     id="fields.E004",
                 )

@@ -167,7 +167,7 @@ Model fields
 * **fields.E003**: ``pk`` is a reserved word that cannot be used as a field
   name.
 * **fields.E004**: ``choices`` must be a mapping (e.g. a dictionary) or an
-  iterable (e.g. a list or tuple).
+  ordered iterable (e.g. a list or tuple, but not a set).
 * **fields.E005**: ``choices`` must be a mapping of actual values to human
   readable names or an iterable containing ``(actual value, human readable
   name)`` tuples.

@@ -199,8 +199,8 @@ class Model(models.Model):
             field.check(),
             [
                 Error(
-                    "'choices' must be a mapping (e.g. a dictionary) or an iterable "
-                    "(e.g. a list or tuple).",
+                    "'choices' must be a mapping (e.g. a dictionary) or an "
+                    "ordered iterable (e.g. a list or tuple, but not a set).",
                     obj=field,
                     id="fields.E004",
                 ),
@@ -906,8 +906,42 @@ class Model(models.Model):
             field.check(),
             [
                 Error(
-                    "'choices' must be a mapping (e.g. a dictionary) or an iterable "
-                    "(e.g. a list or tuple).",
+                    "'choices' must be a mapping (e.g. a dictionary) or an "
+                    "ordered iterable (e.g. a list or tuple, but not a set).",
+                    obj=field,
+                    id="fields.E004",
+                ),
+            ],
+        )
+
+    def test_unordered_choices_set(self):
+        class Model(models.Model):
+            field = models.IntegerField(choices={1, 2, 3})
+
+        field = Model._meta.get_field("field")
+        self.assertEqual(
+            field.check(),
+            [
+                Error(
+                    "'choices' must be a mapping (e.g. a dictionary) or an "
+                    "ordered iterable (e.g. a list or tuple, but not a set).",
+                    obj=field,
+                    id="fields.E004",
+                ),
+            ],
+        )
+
+    def test_unordered_choices_frozenset(self):
+        class Model(models.Model):
+            field = models.IntegerField(choices=frozenset({1, 2, 3}))
+
+        field = Model._meta.get_field("field")
+        self.assertEqual(
+            field.check(),
+            [
+                Error(
+                    "'choices' must be a mapping (e.g. a dictionary) or an "
+                    "ordered iterable (e.g. a list or tuple, but not a set).",
                     obj=field,
                     id="fields.E004",
                 ),

Ahh I always assumed `frozenset` was subclass of `set` but it makes sense that it isn't (e.g. it doesn't have an `add` method).

> Ahh I always assumed `frozenset` was subclass of `set` ....

![giphy](https://github.com/[USER_REDACTED]nts/assets/f0d9f81c-df72-45f9-bbb7-422b18a4f081)

:sweat_smile:

But they are both subclasses of [`collections.abc.Set`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Set).


Fixed #36708 -- Initialized formset to None in ChangeList.__init__().

#### Trac ticket number

ticket-36708

#### Branch description

This PR adds a default formset = None attribute to the ChangeList class.
The Ticket describes how when Django instantiates ChangeList through ModelAdmin, it assigns formset dynamically.
However, when developers create their own custom ChangeList instances, formset may not be set, leading to an AttributeError in templates.
Defining formset as a class-level field (attribute) makes sure that behaviour is a bit more consistent

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

@@ -2050,11 +2050,6 @@ def changelist_view(self, request, extra_context=None):
             # me back" button on the action confirmation page.
             return HttpResponseRedirect(request.get_full_path())

-        # If we're allowing changelist editing, we need to construct a formset
-        # for the changelist given all the fields to be edited. Then we'll
-        # use the formset to validate/process POSTed data.
-        formset = cl.formset = None
-
         # Handle POSTed bulk-edit data.
         if request.method == "POST" and cl.list_editable and "_save" in request.POST:
             if not self.has_change_permission(request):
@@ -2063,13 +2058,11 @@ def changelist_view(self, request, extra_context=None):
             modified_objects = self._get_list_editable_queryset(
                 request, FormSet.get_default_prefix()
             )
-            formset = cl.formset = FormSet(
-                request.POST, request.FILES, queryset=modified_objects
-            )
-            if formset.is_valid():
+            cl.formset = FormSet(request.POST, request.FILES, queryset=modified_objects)
+            if cl.formset.is_valid():
                 changecount = 0
                 with transaction.atomic(using=router.db_for_write(self.model)):
-                    for form in formset.forms:
+                    for form in cl.formset.forms:
                         if form.has_changed():
                             obj = self.save_form(request, form, change=True)
                             self.save_model(request, obj, form, change=True)
@@ -2095,11 +2088,11 @@ def changelist_view(self, request, extra_context=None):
         # Handle GET -- construct a formset for display.
         elif cl.list_editable and self.has_change_permission(request):
             FormSet = self.get_changelist_formset(request)
-            formset = cl.formset = FormSet(queryset=cl.result_list)
+            cl.formset = FormSet(queryset=cl.result_list)

         # Build the list of media to be used by [NAME_REDACTED]
-        if formset:
-            media = self.media + formset.media
+        if cl.formset:
+            media = self.media + cl.formset.media
         else:
             media = self.media

@@ -101,7 +101,7 @@ def __init__(
         self.preserved_filters = model_admin.get_preserved_filters(request)
         self.sortable_by = sortable_by
         self.search_help_text = search_help_text
-
+        self.formset = None
         # Get search parameters from the query string.
         _search_form = self.search_form_class(request.GET)
         if not _search_form.is_valid():

@@ -240,7 +240,6 @@ def test_result_list_empty_changelist_value(self):
         request.user = self.superuser
         m = ChildAdmin(Child, custom_site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -285,7 +284,6 @@ def test_result_list_set_empty_value_display_on_admin_site(self):
         admin.site.empty_value_display = "???"
         m = ChildAdmin(Child, admin.site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -310,7 +308,6 @@ def test_result_list_set_empty_value_display_in_model_admin(self):
         request.user = self.superuser
         m = EmptyValueChildAdmin(Child, admin.site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -341,7 +338,6 @@ def test_result_list_html(self):
         request.user = self.superuser
         m = ChildAdmin(Child, custom_site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )
@@ -370,7 +366,6 @@ def test_action_checkbox_for_model_with_dunder_html(self):
         request = self._mocked_authenticated_request("/grandchild/", self.superuser)
         m = GrandChildAdmin(GrandChild, custom_site)
         cl = m.get_changelist_instance(request)
-        cl.formset = None
         template = Template(
             "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}"
         )

Thank you for working on this [USER_REDACTED] ⭐

As Ben mentioned in the ticket, by [NAME_REDACTED] `formset` attribute on `ChangeList`, it seems we’ll need to update the parts in the `changelist_view` method where `formset` was being assigned dynamically.

```diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 6c202c8e61..62856cebfb 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -2050,11 +2050,6 @@ class ModelAdmin(BaseModelAdmin):
             # me back" button on the action confirmation page.
             return HttpResponseRedirect(request.get_full_path())

-        # If we're allowing changelist editing, we need to construct a formset
-        # for the changelist given all the fields to be edited. Then we'll
-        # use the formset to validate/process POSTed data.
-        formset = cl.formset = None
-
         # Handle POSTed bulk-edit data.
         if request.method == "POST" and cl.list_editable and "_save" in request.POST:
             if not self.has_change_permission(request):
@@ -2063,13 +2058,13 @@ class ModelAdmin(BaseModelAdmin):
             modified_objects = self._get_list_editable_queryset(
                 request, FormSet.get_default_prefix()
             )
-            formset = cl.formset = FormSet(
+            cl.formset = FormSet(
                 request.POST, request.FILES, queryset=modified_objects
             )
-            if formset.is_valid():
+            if cl.formset.is_valid():
                 changecount = 0
                 with transaction.atomic(using=router.db_for_write(self.model)):
-                    for form in formset.forms:
+                    for form in cl.formset.forms:
                         if form.has_changed():
                             obj = self.save_form(request, form, change=True)
                             self.save_model(request, obj, form, change=True)
@@ -2095,11 +2090,11 @@ class ModelAdmin(BaseModelAdmin):
         # Handle GET -- construct a formset for display.
         elif cl.list_editable and self.has_change_permission(request):
             FormSet = self.get_changelist_formset(request)
-            formset = cl.formset = FormSet(queryset=cl.result_list)
+            cl.formset = FormSet(queryset=cl.result_list)

         # Build the list of media to be used by [NAME_REDACTED]
-        if formset:
-            media = self.media + formset.media
+        if cl.formset:
+            media = self.media + cl.formset.media
         else:
             media = self.media

```

Thank you for update [USER_REDACTED] ⭐

I left small comments :)

Thanks 👍

Thanks [USER_REDACTED] ⭐

It looks like the lint issue is occurring in this part.
Removing that line should resolve it!

It seems that `noqa` won’t be necessary 🤔

oh boy! that was a stupid oversight sorry 😅
i mistook options for main ig

yep, i thought that snakeoil fix (#type:ignore) would work here
i have removed this ✅
it passes the test now

I could see value in leaving `cl.formset = None` here to just avoid any pollution from prior calls. [USER_REDACTED] wdyt?

Can we make this an instance attribute instead?

Never mind -- `cl` is an instance. `cl` meaning "changelist", who knew? 😄

I have made this change ✅


Fixed #36737 -- Escaped C1 control characters in escapejs.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-[#36737](https://code.djangoproject.com/ticket/36737)

#### Branch description
In Ticket 36737 there was a discussion on C0 control characters of ASCII value and it was a relatively an easy issue to solve I felt that I could tackle the given problem. which was simply solved by [NAME_REDACTED] loop
I would like to apologize to any problem or error I have caused.

#### Checklist
- [ ] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [ ] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

@@ -7,6 +7,7 @@
 from collections import deque
 from collections.abc import Mapping
 from html.parser import HTMLParser
+from itertools import chain
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit, urlunsplit

 from django.conf import settings
@@ -76,8 +77,11 @@ def escape(text):
     ord("\u2029"): "\\u2029",
 }

-# Escape every ASCII character with a value less than 32.
-_js_escapes.update((ord("%c" % z), "\\u%04X" % z) for z in range(32))
+# Escape every ASCII character with a value less than 32 (C0), 127(C0),
+# or 128-159(C1).
+_js_escapes.update(
+    (ord("%c" % z), "\\u%04X" % z) for z in chain(range(32), range(0x7F, 0xA0))
+)

 [USER_REDACTED]_lazy(SafeString)

@@ -244,6 +244,9 @@ def test_escapejs(self):
                 "paragraph separator:\\u2029and line separator:\\u2028",
             ),
             ("`", "\\u0060"),
+            ("\u007f", "\\u007F"),
+            ("\u0080", "\\u0080"),
+            ("\u009f", "\\u009F"),
         )
         for value, output in items:
             with self.subTest(value=value, output=output):

Thanks for the PR 👍

Please add two tests to `TestUtilsHtml.test_escapejs` cases:
- U+007F (DELETE)
- another C1 control character

I don't see the requested tests anywhere.

Thanks, almost there! 👍

Thanks for the PR!

Please revert.

So sorry I was a bit confused and went down a rabbit hole I will revert and work thanks

I made a major mistake went down some rabbit hole which led to this
thanks

Please keep the period at the end.

Nit: Suggest keeping the comment as prose
```suggestion
# Escape every ASCII character with a value less than 32 (C0) and 127-159(C1).
```

```suggestion
            ("\u009F", "\u009F"),
```

```suggestion
            ("\u007F", "\u007F"),
```

These should all be escaped
```suggestion
            ("\u007f", "\\u007F"),
            ("\u0080", "\\u0080"),
            ("\u009f", "\\u009F"),
```

Major doubt I have checked my code works but during the implementation of test cases throws an ` Asserstion error` that similar test executed separately runs perfectly
I have been stuck with this for a while

The test passes for me when I correct the expected results.

You can just do this once if you `from itertools import chain`. Also please don't include 127 with the C1 range, it's C0.
```suggestion
# Escape every ASCII character with a value less than 32 (C0), 127 (C0),
# or 128-159(C1).
_js_escapes.update(
    (ord("%c" % z), "\\u%04X" % z) for z in chain(range(32), range(0x7F, 0xA0))
)
```

```suggestion
# Escape every ASCII character with a value less than 32 (C0), 127(C0)
```

```suggestion
# Escape every ASCII character with a value less than 32 (C0), 127(C0),
# or 128-159(C1).
```