diff --git a/AUTHORS b/AUTHORS index 510469182a87..5b06d4ef2fa0 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 @@ -787,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/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/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/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/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/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/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` 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/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/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 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 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/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) 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") 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 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()