From c6f81b383251dc9fd0918b4ba040c993444b931e Mon Sep 17 00:00:00 2001 From: siddus Date: Wed, 27 May 2026 18:35:28 -0400 Subject: [PATCH 1/7] Fixed #33185 -- Fixed sqlmigrate crash for RenameModel with a self-referential foreign key. When collecting SQL (e.g. for sqlmigrate), a RenameModel operation's table rename is not executed, so the subsequent field alteration introspected the renamed table before it existed. On MySQL this raised "Table doesn't exist", and on PostgreSQL the missing introspection silently omitted the self-referential foreign key's drop and recreate. The schema editor now records table renames while collecting SQL and redirects constraint-name introspection to the still-existing old table name, which carries the same constraints. Applying migrations is unaffected. --- django/db/backends/base/schema.py | 16 +++++++++++- tests/migrations/test_operations.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index af30f7f7fc00..a99218da92b2 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -156,6 +156,9 @@ def __init__(self, connection, collect_sql=False, atomic=True): self.collect_sql = collect_sql if self.collect_sql: self.collected_sql = [] + # Tables renamed while collecting SQL don't exist under their new + # name in the database, so introspection must target the old name. + self.collected_table_renames = {} self.atomic_migration = self.connection.features.can_rollback_ddl and atomic # State-managing methods @@ -700,6 +703,14 @@ def alter_db_table(self, model, old_db_table, new_db_table): "new_table": self.quote_name(new_db_table), } ) + if self.collect_sql: + # The rename isn't executed, so later introspection of the new + # table name must be redirected to the still-existing old one, + # following any earlier rename of the same table in this batch. + existing_table = self.collected_table_renames.pop( + old_db_table, old_db_table + ) + self.collected_table_renames[new_db_table] = existing_table # Rename all references to the old table name. for sql in self.deferred_sql: if isinstance(sql, Statement): @@ -2022,9 +2033,12 @@ def _constraint_names( ) for name in column_names ] + table_name = model._meta.db_table + if self.collect_sql: + table_name = self.collected_table_renames.get(table_name, table_name) with self.connection.cursor() as cursor: constraints = self.connection.introspection.get_constraints( - cursor, model._meta.db_table + cursor, table_name ) result = [] for name, infodict in constraints.items(): diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index 49bda86d1f8f..7189cef40063 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -938,6 +938,44 @@ def test_rename_model_with_self_referential_fk(self): "test_rmwsrf_rider", ["friend_id"], ("test_rmwsrf_horserider", "id") ) + def test_rename_model_with_self_referential_fk_collect_sql(self): + """ + Collecting SQL (e.g. sqlmigrate) for a RenameModel operation on a model + with a self-referential foreign key doesn't introspect the renamed + table, which doesn't exist yet (#33185). + """ + project_state = self.set_up_test_model("test_rmwsrfcs", related_model=True) + operation = migrations.RenameModel("Rider", "HorseRider") + new_state = project_state.clone() + operation.state_forwards("test_rmwsrfcs", new_state) + # Forwards: only the old table exists, so the renamed table can't be + # introspected. The rename is collected and the self-referential FK is + # handled (rather than silently skipped) using the constraint + # introspected from the still-existing old table. + with connection.schema_editor(collect_sql=True) as editor: + operation.database_forwards( + "test_rmwsrfcs", editor, project_state, new_state + ) + collected_sql = "\n".join(editor.collected_sql) + self.assertIn( + connection.ops.quote_name("test_rmwsrfcs_horserider"), collected_sql + ) + self.assertIn(connection.ops.quote_name("friend_id"), collected_sql) + # Backwards: apply the rename for real so the renamed table exists, + # then collect the reverse SQL. The same redirection must happen, this + # time back to the "horserider" table. + with connection.schema_editor() as editor: + operation.database_forwards( + "test_rmwsrfcs", editor, project_state, new_state + ) + with connection.schema_editor(collect_sql=True) as editor: + operation.database_backwards( + "test_rmwsrfcs", editor, new_state, project_state + ) + collected_sql = "\n".join(editor.collected_sql) + self.assertIn(connection.ops.quote_name("test_rmwsrfcs_rider"), collected_sql) + self.assertIn(connection.ops.quote_name("friend_id"), collected_sql) + def test_rename_model_with_superclass_fk(self): """ Tests the RenameModel operation on a model which has a superclass that From dc4e5461faa390991e6a0f6d3ca47690e8f5c4e1 Mon Sep 17 00:00:00 2001 From: Sezer BOZKIR Date: Tue, 7 Apr 2026 11:20:16 +0300 Subject: [PATCH 2/7] Fixed #36837 -- Skipped backends not implementing (a)get_user() in (a)force_login(). Co-authored-by: Mykhailo Havelia --- django/test/client.py | 7 ++++++- docs/releases/6.2.txt | 4 +++- docs/topics/testing/tools.txt | 12 +++++++++--- tests/test_client/auth_backends.py | 8 +++++++- tests/test_client/tests.py | 19 +++++++++++++++++++ 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/django/test/client.py b/django/test/client.py index 0f986d5a6c81..d4990453635c 100644 --- a/django/test/client.py +++ b/django/test/client.py @@ -865,10 +865,15 @@ async def aforce_login(self, user, backend=None): def _get_backend(self): from django.contrib.auth import load_backend + from django.contrib.auth.backends import BaseBackend + + def overrides(backend, name): + base = getattr(BaseBackend, name) + return getattr(type(backend), name, base) is not base for backend_path in settings.AUTHENTICATION_BACKENDS: backend = load_backend(backend_path) - if hasattr(backend, "get_user"): + if overrides(backend, "get_user") or overrides(backend, "aget_user"): return backend_path def _login(self, user, backend=None): diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index 74ba31f17150..b949b0aa24ae 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -222,7 +222,9 @@ Templates Tests ~~~~~ -* ... +* :meth:`~django.test.Client.force_login` now skips members of + :setting:`AUTHENTICATION_BACKENDS` not implementing ``(a)get_user()``, e.g. + permission-only backends. URLs ~~~~ diff --git a/docs/topics/testing/tools.txt b/docs/topics/testing/tools.txt index da9dd38eeca3..6eeb96817bec 100644 --- a/docs/topics/testing/tools.txt +++ b/docs/topics/testing/tools.txt @@ -479,15 +479,21 @@ Use the ``django.test.Client`` class to make requests. The user will have its ``backend`` attribute set to the value of the ``backend`` argument (which should be a dotted Python path string), or - to ``settings.AUTHENTICATION_BACKENDS[0]`` if a value isn't provided. - The :func:`~django.contrib.auth.authenticate` function called by - :meth:`login` normally annotates the user like this. + if a value isn't provided, the first backend from + :setting:`AUTHENTICATION_BACKENDS` implementing ``get_user()`` or + ``aget_user()``. The :func:`~django.contrib.auth.authenticate` function + called by :meth:`login` normally annotates the user like this. This method is faster than ``login()`` since the expensive password hashing algorithms are bypassed. Also, you can speed up ``login()`` by :ref:`using a weaker hasher while testing `. + .. versionchanged:: 6.2 + + On older versions, backends not implementing ``(a)get_user()`` were + eligible to be selected. + .. method:: Client.logout() .. method:: Client.alogout() diff --git a/tests/test_client/auth_backends.py b/tests/test_client/auth_backends.py index 97a2763aaab3..a20b0be4de5e 100644 --- a/tests/test_client/auth_backends.py +++ b/tests/test_client/auth_backends.py @@ -1,4 +1,4 @@ -from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.backends import BaseBackend, ModelBackend class TestClientBackend(ModelBackend): @@ -7,3 +7,9 @@ class TestClientBackend(ModelBackend): class BackendWithoutGetUserMethod: pass + + +class PermissionOnlyBackend(BaseBackend): + """This class inherits from BaseBackend but does not implement get_user.""" + + pass diff --git a/tests/test_client/tests.py b/tests/test_client/tests.py index bc1472d88bc5..2ba9455d0c16 100644 --- a/tests/test_client/tests.py +++ b/tests/test_client/tests.py @@ -794,6 +794,25 @@ def test_force_login_with_backend_missing_get_user(self): self.client.force_login(self.u1) self.assertEqual(self.u1.backend, "django.contrib.auth.backends.ModelBackend") + @override_settings( + AUTHENTICATION_BACKENDS=[ + "test_client.auth_backends.PermissionOnlyBackend", + "django.contrib.auth.backends.ModelBackend", + ] + ) + def test_force_login_skips_noop_get_user_backend(self): + """force_login() skips auth backends without concrete get_user().""" + self.client.force_login(self.u1) + self.assertEqual(self.u1.backend, "django.contrib.auth.backends.ModelBackend") + + @override_settings( + AUTHENTICATION_BACKENDS=[ + "test_client.auth_backends.PermissionOnlyBackend", + ] + ) + def test_force_login_all_backends_noop(self): + self.assertIsNone(self.client._get_backend()) + @override_settings(SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies") def test_logout_cookie_sessions(self): self.test_logout() From e1df3950b2a3a852dfa9eb70738b7bac28cb0a0e Mon Sep 17 00:00:00 2001 From: Manas225 Date: Mon, 9 Mar 2026 22:54:34 +0530 Subject: [PATCH 3/7] Fixed #36979 -- Made GenericInlineModelAdmin.get_formset() use get_exclude(). --- AUTHORS | 1 + django/contrib/contenttypes/admin.py | 12 ++++++------ tests/generic_inline_admin/tests.py | 11 +++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index 510469182a87..c85a9a23e342 100644 --- a/AUTHORS +++ b/AUTHORS @@ -668,6 +668,7 @@ answer newbie questions, and generally made Django that much better: Mads Jensen Makoto Tsuyuki Malcolm Tredinnick + Manas Madeshiya Manav Agarwal Manuel Saelices Manuzhai diff --git a/django/contrib/contenttypes/admin.py b/django/contrib/contenttypes/admin.py index d324a4f4fe50..c55325ecbec9 100644 --- a/django/contrib/contenttypes/admin.py +++ b/django/contrib/contenttypes/admin.py @@ -100,12 +100,12 @@ def get_formset(self, request, obj=None, **kwargs): fields = kwargs.pop("fields") else: fields = flatten_fieldsets(self.get_fieldsets(request, obj)) - exclude = [*(self.exclude or []), *self.get_readonly_fields(request, obj)] - if ( - self.exclude is None - and hasattr(self.form, "_meta") - and self.form._meta.exclude - ): + excluded = self.get_exclude(request, obj) + exclude = [ + *(excluded or []), + *self.get_readonly_fields(request, obj), + ] + if excluded is None and hasattr(self.form, "_meta") and self.form._meta.exclude: # Take the custom ModelForm's Meta.exclude into account only if the # GenericInlineModelAdmin doesn't define its own. exclude.extend(self.form._meta.exclude) diff --git a/tests/generic_inline_admin/tests.py b/tests/generic_inline_admin/tests.py index b4833e54e797..01346f500eb3 100644 --- a/tests/generic_inline_admin/tests.py +++ b/tests/generic_inline_admin/tests.py @@ -577,3 +577,14 @@ def get_inlines(self, request, obj): request.name = name self.assertEqual(ma.get_inlines(request, None), (inline_class,)) self.assertEqual(type(ma.get_inline_instances(request)[0]), inline_class) + + def test_get_exclude_is_respected(self): + class GetExcludeInline(GenericTabularInline): + model = Media + + def get_exclude(self, request, obj=None): + return ["url"] + + ma = GetExcludeInline(Media, self.site) + formset = ma.get_formset(request) + self.assertNotIn("url", formset.form.base_fields) From 09efda004551319d2956e20020dc0e42e40f151a Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 11 Jun 2026 10:32:51 -0400 Subject: [PATCH 4/7] Refs #24941 -- Doc'd get_exclude() in base features shared by InlineModelAdmin. --- docs/ref/contrib/admin/index.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 875739cffa42..846ac0973b3f 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -2316,6 +2316,7 @@ adds some of its own (the shared features are actually defined in the - :attr:`~ModelAdmin.filter_vertical` - :attr:`~ModelAdmin.ordering` - :attr:`~ModelAdmin.prepopulated_fields` +- :meth:`~ModelAdmin.get_exclude` - :meth:`~ModelAdmin.get_fieldsets` - :meth:`~ModelAdmin.get_queryset` - :attr:`~ModelAdmin.radio_fields` From 1d714484f67777e586fbea49ec743a7e39cba50f Mon Sep 17 00:00:00 2001 From: VIZZARD-X Date: Thu, 11 Jun 2026 20:47:20 +0530 Subject: [PATCH 5/7] Fixed #37129 -- Clarified database cache culling behavior in docs. --- docs/topics/cache.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/topics/cache.txt b/docs/topics/cache.txt index 587a91859e5f..cc2459598ca8 100644 --- a/docs/topics/cache.txt +++ b/docs/topics/cache.txt @@ -253,8 +253,10 @@ In this example, the cache table's name is ``my_cache_table``:: } Unlike other cache backends, the database cache does not support automatic -culling of expired entries at the database level. Instead, expired cache -entries are culled when an ``add()``, ``set()``, or ``touch()`` is called. +culling of expired entries at the database level. Instead, an expired cache +entry is deleted when evaluated by a ``get()`` request. Furthermore, +expired cache entries are culled when ``add()``, ``set()``, or ``touch()`` +is called and the number of entries exceeds ``MAX_ENTRIES``. Since the cull operation can be expensive for a large cache, you may control how often this check occurs by setting ``CULL_PROBABILITY`` to a value between From 443780780bbe88822f0af4b27517d7e8f080a6a2 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Fri, 5 Jun 2026 12:02:26 -0400 Subject: [PATCH 6/7] Doc'd security standards in howto-release-django.txt. --- docs/internals/howto-release-django.txt | 37 +++++++++++++++---------- docs/spelling_wordlist | 1 + 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/internals/howto-release-django.txt b/docs/internals/howto-release-django.txt index 8d95018a0d85..7acad7ac2b19 100644 --- a/docs/internals/howto-release-django.txt +++ b/docs/internals/howto-release-django.txt @@ -85,9 +85,8 @@ permissions. Linux, or ``md5`` and ``shasum`` on macOS) * python -* A GPG key pair. Ensure that the private part of this key is securely stored. - The public part needs to be uploaded to your GitHub account, and also to the - Jenkins server running the "confirm release" job. +* A GPG key pair. Securely store the private part, and protect it with a + passphrase. The public part needs to be uploaded to your GitHub account. .. admonition:: More than one GPG key @@ -96,18 +95,16 @@ permissions. ``you@example.com`` is the email address associated with the key you want to use. -* A clean Python virtual environment (Python 3.9+) to build artifacts, with - these required Python packages installed: - - .. code-block:: shell - - $ python -m pip install build twine +* A clean Python virtual environment (Python 3.10+, pip 26.1+) to build + artifacts. * Access to `Django's project on PyPI `_ to upload binaries, ideally with extra permissions to `yank a release - `_ if necessary. Create a project-scoped token - following the `official documentation `_ - and set up your ``$HOME/.pypirc`` file like this: + `_ if necessary. Ensure your PyPI account + only uses WebAuthn-based authentication factors, not TOTP (one-time codes). + Create an API token following the `official documentation + `_, and set up your ``$HOME/.pypirc`` file + like this: .. code-block:: ini :caption: ``~/.pypirc`` @@ -451,10 +448,15 @@ issuing **multiple releases**, repeat these steps for each release. release date for all releases, if necessary (:commit:`example commit <34a503162fe222033a1cd3249bccad014fcd1d20>`). +#. Regenerate a fresh, dedicated virtual environment for the release tools + using a cooldown: + + .. code-block:: shell + + $ python -m pip install build twine --uploaded-prior-to=P7D + #. A release always begins from a release branch, so you should make sure - you're on an up-to-date stable branch. Also, you should have available a - clean and dedicated virtual environment per version being released. For - example: + you're on an up-to-date stable branch. For example: .. code-block:: shell @@ -642,12 +644,17 @@ Now you're ready to actually put the release out there. To do this: __ https://djangoci.com/job/confirm-release/ +#. Generate a new token for this release on PyPI, and store it in ``.pypirc`` + as shown above. + #. Upload the release packages to PyPI: .. code-block:: shell $ twine upload --repository django dist/* +#. On PyPI, revoke the token you just created. + #. Update the newly created ``Release`` in the admin in ``djangoproject.com`` and enable the ``is_active`` flag. diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 8d548d64398c..61f7a1cb0d31 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -95,6 +95,7 @@ conf config contenttypes contrib +cooldown coroutine coroutines counterintuitive From 14c66825e0088b7e08fe5d108fa293bd31722c39 Mon Sep 17 00:00:00 2001 From: NagaKartheekReddy Date: Thu, 4 Jun 2026 22:41:53 +0200 Subject: [PATCH 7/7] Fixed #37141 -- Added --using option to sendtestemail command. --- AUTHORS | 1 + .../core/management/commands/sendtestemail.py | 17 +++++++-- docs/ref/django-admin.txt | 11 ++++-- docs/releases/6.1.txt | 3 ++ tests/mail/test_sendtestemail.py | 35 ++++++++++++++++++- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index c85a9a23e342..5b06d4ef2fa0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -788,6 +788,7 @@ answer newbie questions, and generally made Django that much better: Mushtaq Ali Mykola Zamkovoi Nadège Michel + Naga Kartheek Reddy Kona Nagy Károly Nasimul Haque Nasir Hussain diff --git a/django/core/management/commands/sendtestemail.py b/django/core/management/commands/sendtestemail.py index fbb2a7856eb9..cfad05f5b8e6 100644 --- a/django/core/management/commands/sendtestemail.py +++ b/django/core/management/commands/sendtestemail.py @@ -28,8 +28,16 @@ def add_arguments(self, parser): action="store_true", help="Send a test email to the addresses specified in settings.ADMINS.", ) + parser.add_argument( + "--using", + default=None, + help=( + "Specify the MAILERS alias to use for sending the test email. " + "Defaults to 'default'." + ), + ) - def handle(self, *args, **kwargs): + def handle(self, *args, using=None, **kwargs): subject = "Test email from %s on %s" % (socket.gethostname(), timezone.now()) send_mail( @@ -37,10 +45,13 @@ def handle(self, *args, **kwargs): message="If you're reading this, it was successful.", from_email=None, recipient_list=kwargs["email"], + using=using, ) if kwargs["managers"]: - mail_managers(subject, "This email was sent to the site managers.") + mail_managers( + subject, "This email was sent to the site managers.", using=using + ) if kwargs["admins"]: - mail_admins(subject, "This email was sent to the site admins.") + mail_admins(subject, "This email was sent to the site admins.", using=using) diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index fc8de971ec99..8e3de60c4f9c 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1069,8 +1069,15 @@ recipient(s) specified. For example: django-admin sendtestemail foo@example.com bar@example.com -There are a couple of options, and you may use any combination of them -together: +The following options may be used in any combination with each other and with +recipient email arguments. + +.. django-admin-option:: --using ALIAS + +.. versionadded:: 6.1 + +Specifies the :setting:`MAILERS` alias to use for sending the test email to +the recipients given as arguments. Defaults to the ``"default"`` mailer. .. django-admin-option:: --managers diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 123486f2429d..adb66a013d31 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -274,6 +274,9 @@ Management Commands :data:`~django.db.models.signals.m2m_changed` signals with ``raw=True`` when loading fixtures. +* The :djadmin:`sendtestemail` command now supports a :option:`--using + ` option to specify the :setting:`MAILERS` alias. + Models ~~~~~~ diff --git a/tests/mail/test_sendtestemail.py b/tests/mail/test_sendtestemail.py index 1bc500237b5d..d07b348fc119 100644 --- a/tests/mail/test_sendtestemail.py +++ b/tests/mail/test_sendtestemail.py @@ -1,4 +1,5 @@ from django.core import mail +from django.core.mail import MailerDoesNotExist from django.core.management import CommandError, call_command from django.test import SimpleTestCase, override_settings @@ -6,7 +7,10 @@ @override_settings( ADMINS=["admin@example.com", "admin_and_manager@example.com"], MANAGERS=["manager@example.com", "admin_and_manager@example.com"], - MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + "notifications": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + }, ) class SendTestEmailManagementCommand(SimpleTestCase): """ @@ -108,3 +112,32 @@ def test_manager_and_admin_receivers(self): "admin_and_manager@example.com", ], ) + + def test_using_option(self): + recipient = "joe@example.com" + call_command("sendtestemail", "--using", "notifications", recipient) + self.assertEqual(len(mail.outbox), 1) + mail_message = mail.outbox[0] + self.assertEqual(mail_message.sent_using, "notifications") + + def test_using_default(self): + recipient = "joe@example.com" + call_command("sendtestemail", recipient) + self.assertEqual(len(mail.outbox), 1) + mail_message = mail.outbox[0] + self.assertEqual(mail_message.sent_using, "default") + + def test_using_option_with_managers(self): + call_command("sendtestemail", "--using", "notifications", "--managers") + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].sent_using, "notifications") + + def test_using_option_with_admins(self): + call_command("sendtestemail", "--using", "notifications", "--admins") + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].sent_using, "notifications") + + def test_using_nonexistent_mailer(self): + msg = "The mailer 'nonexistent' is not configured." + with self.assertRaisesMessage(MailerDoesNotExist, msg): + call_command("sendtestemail", "--using", "nonexistent", "joe@example.com")