diff --git a/django/conf/locale/en/LC_MESSAGES/django.po b/django/conf/locale/en/LC_MESSAGES/django.po index 0ca6f0060794..faafa8111509 100644 --- a/django/conf/locale/en/LC_MESSAGES/django.po +++ b/django/conf/locale/en/LC_MESSAGES/django.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: Django\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-20 15:53-0400\n" +"POT-Creation-Date: 2026-07-22 16:14-0400\n" "PO-Revision-Date: 2010-05-13 15:35+0200\n" "Last-Translator: Django team\n" "Language-Team: English \n" @@ -589,11 +589,11 @@ msgstr "" msgid "Null characters are not allowed." msgstr "" -#: db/models/base.py:1656 forms/models.py:933 +#: db/models/base.py:1664 forms/models.py:933 msgid "and" msgstr "" -#: db/models/base.py:1658 +#: db/models/base.py:1666 #, python-format msgid "%(model_name)s with this %(field_labels)s already exists." msgstr "" @@ -1043,27 +1043,27 @@ msgid "" "may be ambiguous or it may not exist." msgstr "" -#: forms/widgets.py:553 +#: forms/widgets.py:573 msgid "Clear" msgstr "" -#: forms/widgets.py:554 +#: forms/widgets.py:574 msgid "Currently" msgstr "" -#: forms/widgets.py:555 +#: forms/widgets.py:575 msgid "Change" msgstr "" -#: forms/widgets.py:894 +#: forms/widgets.py:914 msgid "Unknown" msgstr "" -#: forms/widgets.py:895 +#: forms/widgets.py:915 msgid "Yes" msgstr "" -#: forms/widgets.py:896 +#: forms/widgets.py:916 msgid "No" msgstr "" diff --git a/django/contrib/admin/locale/en/LC_MESSAGES/django.po b/django/contrib/admin/locale/en/LC_MESSAGES/django.po index 9c348886f360..1b7f4caaebe2 100644 --- a/django/contrib/admin/locale/en/LC_MESSAGES/django.po +++ b/django/contrib/admin/locale/en/LC_MESSAGES/django.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: Django\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-20 15:53-0400\n" +"POT-Creation-Date: 2026-07-22 16:14-0400\n" "PO-Revision-Date: 2010-05-13 15:35+0200\n" "Last-Translator: Django team\n" "Language-Team: English \n" @@ -24,7 +24,7 @@ msgstr "" msgid "Successfully deleted %(count)d %(items)s." msgstr "" -#: contrib/admin/actions.py:63 contrib/admin/options.py:2528 +#: contrib/admin/actions.py:63 contrib/admin/options.py:2535 #, python-format msgid "Cannot delete %(name)s" msgstr "" @@ -192,7 +192,7 @@ msgstr "" msgid "Added." msgstr "" -#: contrib/admin/models.py:149 contrib/admin/options.py:2785 +#: contrib/admin/models.py:149 contrib/admin/options.py:2818 msgid "and" msgstr "" @@ -215,122 +215,122 @@ msgstr "" msgid "No fields changed." msgstr "" -#: contrib/admin/options.py:293 contrib/admin/options.py:337 +#: contrib/admin/options.py:300 contrib/admin/options.py:344 msgid "None" msgstr "" -#: contrib/admin/options.py:389 +#: contrib/admin/options.py:396 msgid "Hold down “Control”, or “Command” on a Mac, to select more than one." msgstr "" -#: contrib/admin/options.py:1066 +#: contrib/admin/options.py:1073 #, python-brace-format msgid "Select this object for an action - {}" msgstr "" -#: contrib/admin/options.py:1597 +#: contrib/admin/options.py:1604 #, python-format msgid "The app \"%s\" could not be found." msgstr "" -#: contrib/admin/options.py:1639 contrib/admin/options.py:1677 +#: contrib/admin/options.py:1646 contrib/admin/options.py:1684 #, python-brace-format msgid "The {name} “{obj}” was added successfully." msgstr "" -#: contrib/admin/options.py:1641 +#: contrib/admin/options.py:1648 msgid "You may edit it again below." msgstr "" -#: contrib/admin/options.py:1658 +#: contrib/admin/options.py:1665 #, python-brace-format msgid "" "The {name} “{obj}” was added successfully. You may add another {name} below." msgstr "" -#: contrib/admin/options.py:1729 +#: contrib/admin/options.py:1736 #, python-brace-format msgid "" "The {name} “{obj}” was changed successfully. You may edit it again below." msgstr "" -#: contrib/admin/options.py:1749 +#: contrib/admin/options.py:1756 #, python-brace-format msgid "" "The {name} “{obj}” was changed successfully. You may add another {name} " "below." msgstr "" -#: contrib/admin/options.py:1771 +#: contrib/admin/options.py:1778 #, python-brace-format msgid "The {name} “{obj}” was changed successfully." msgstr "" -#: contrib/admin/options.py:1875 contrib/admin/options.py:2359 +#: contrib/admin/options.py:1882 contrib/admin/options.py:2366 msgid "" "Items must be selected in order to perform actions on them. No items have " "been changed." msgstr "" -#: contrib/admin/options.py:1895 +#: contrib/admin/options.py:1902 msgid "No action selected." msgstr "" -#: contrib/admin/options.py:1926 +#: contrib/admin/options.py:1933 #, python-format msgid "The %(name)s “%(obj)s” was deleted successfully." msgstr "" -#: contrib/admin/options.py:2028 +#: contrib/admin/options.py:2035 #, python-format msgid "%(name)s with ID “%(key)s” doesn’t exist. Perhaps it was deleted?" msgstr "" -#: contrib/admin/options.py:2181 +#: contrib/admin/options.py:2188 #, python-format msgid "Add %s" msgstr "" -#: contrib/admin/options.py:2183 +#: contrib/admin/options.py:2190 #, python-format msgid "Change %s" msgstr "" -#: contrib/admin/options.py:2185 +#: contrib/admin/options.py:2192 #, python-format msgid "View %s" msgstr "" -#: contrib/admin/options.py:2288 +#: contrib/admin/options.py:2295 #, python-format msgid "%(count)s %(name)s was changed successfully." msgid_plural "%(count)s %(name)s were changed successfully." msgstr[0] "" msgstr[1] "" -#: contrib/admin/options.py:2321 +#: contrib/admin/options.py:2328 msgid "Database error" msgstr "" -#: contrib/admin/options.py:2435 +#: contrib/admin/options.py:2442 #, python-format msgid "%(total_count)s selected" msgid_plural "All %(total_count)s selected" msgstr[0] "" msgstr[1] "" -#: contrib/admin/options.py:2441 +#: contrib/admin/options.py:2448 #, python-format msgid "0 of %(cnt)s selected" msgstr "" -#: contrib/admin/options.py:2530 +#: contrib/admin/options.py:2537 #: contrib/admin/templates/admin/delete_confirmation.html:18 #: contrib/admin/templates/admin/submit_line.html:14 msgid "Delete" msgstr "" -#: contrib/admin/options.py:2588 +#: contrib/admin/options.py:2595 #, python-format msgid "Change history: %s" msgstr "" @@ -338,12 +338,25 @@ msgstr "" #. Translators: Model verbose name and instance #. representation, suitable to be an item in a #. list. -#: contrib/admin/options.py:2779 +#: contrib/admin/options.py:2794 #, python-format msgid "%(class_name)s %(instance)s" msgstr "" -#: contrib/admin/options.py:2788 +#. Translators: This string is used as a +#. separator between list elements. +#: contrib/admin/options.py:2808 contrib/admin/options.py:2809 +msgid ", " +msgstr "" + +#: contrib/admin/options.py:2811 contrib/admin/templatetags/admin_filters.py:72 +#, python-format +msgid "…and %(count)d more object." +msgid_plural "…and %(count)d more objects." +msgstr[0] "" +msgstr[1] "" + +#: contrib/admin/options.py:2821 #, python-format msgid "" "Deleting %(class_name)s %(instance)s would require deleting the following " @@ -965,13 +978,6 @@ msgstr "" msgid "Reset my password" msgstr "" -#: contrib/admin/templatetags/admin_filters.py:72 -#, python-format -msgid "…and %(count)d more object." -msgid_plural "…and %(count)d more objects." -msgstr[0] "" -msgstr[1] "" - #: contrib/admin/templatetags/admin_list.py:104 msgid "Select all objects on this page for an action" msgstr "" diff --git a/django/contrib/auth/locale/en/LC_MESSAGES/django.po b/django/contrib/auth/locale/en/LC_MESSAGES/django.po index 2e1518e2ad72..e2a34b77b7e1 100644 --- a/django/contrib/auth/locale/en/LC_MESSAGES/django.po +++ b/django/contrib/auth/locale/en/LC_MESSAGES/django.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: Django\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-20 15:53-0400\n" +"POT-Creation-Date: 2026-07-22 16:14-0400\n" "PO-Revision-Date: 2010-05-13 15:35+0200\n" "Last-Translator: Django team\n" "Language-Team: English \n" @@ -149,56 +149,56 @@ msgstr "" msgid "Old password" msgstr "" -#: contrib/auth/hashers.py:357 contrib/auth/hashers.py:450 -#: contrib/auth/hashers.py:541 contrib/auth/hashers.py:636 -#: contrib/auth/hashers.py:689 +#: contrib/auth/hashers.py:355 contrib/auth/hashers.py:448 +#: contrib/auth/hashers.py:539 contrib/auth/hashers.py:634 +#: contrib/auth/hashers.py:685 msgid "algorithm" msgstr "" -#: contrib/auth/hashers.py:358 +#: contrib/auth/hashers.py:356 msgid "iterations" msgstr "" -#: contrib/auth/hashers.py:359 contrib/auth/hashers.py:456 -#: contrib/auth/hashers.py:543 contrib/auth/hashers.py:640 -#: contrib/auth/hashers.py:690 +#: contrib/auth/hashers.py:357 contrib/auth/hashers.py:454 +#: contrib/auth/hashers.py:541 contrib/auth/hashers.py:638 +#: contrib/auth/hashers.py:686 msgid "salt" msgstr "" -#: contrib/auth/hashers.py:360 contrib/auth/hashers.py:457 -#: contrib/auth/hashers.py:641 contrib/auth/hashers.py:691 +#: contrib/auth/hashers.py:358 contrib/auth/hashers.py:455 +#: contrib/auth/hashers.py:639 contrib/auth/hashers.py:687 msgid "hash" msgstr "" -#: contrib/auth/hashers.py:451 +#: contrib/auth/hashers.py:449 msgid "variety" msgstr "" -#: contrib/auth/hashers.py:452 +#: contrib/auth/hashers.py:450 msgid "version" msgstr "" -#: contrib/auth/hashers.py:453 +#: contrib/auth/hashers.py:451 msgid "memory cost" msgstr "" -#: contrib/auth/hashers.py:454 +#: contrib/auth/hashers.py:452 msgid "time cost" msgstr "" -#: contrib/auth/hashers.py:455 contrib/auth/hashers.py:639 +#: contrib/auth/hashers.py:453 contrib/auth/hashers.py:637 msgid "parallelism" msgstr "" -#: contrib/auth/hashers.py:542 contrib/auth/hashers.py:637 +#: contrib/auth/hashers.py:540 contrib/auth/hashers.py:635 msgid "work factor" msgstr "" -#: contrib/auth/hashers.py:544 +#: contrib/auth/hashers.py:542 msgid "checksum" msgstr "" -#: contrib/auth/hashers.py:638 +#: contrib/auth/hashers.py:636 msgid "block size" msgstr "" diff --git a/django/contrib/staticfiles/handlers.py b/django/contrib/staticfiles/handlers.py index 686718a35501..44c1187c8e8e 100644 --- a/django/contrib/staticfiles/handlers.py +++ b/django/contrib/staticfiles/handlers.py @@ -59,7 +59,7 @@ async def get_response_async(self, request): try: return await sync_to_async(self.serve, thread_sensitive=False)(request) except Http404 as e: - return await sync_to_async(response_for_exception, thread_sensitive=False)( + return await sync_to_async(response_for_exception, thread_sensitive=True)( request, e ) diff --git a/django/core/handlers/exception.py b/django/core/handlers/exception.py index 4adc9d7c6af2..559291abfc9b 100644 --- a/django/core/handlers/exception.py +++ b/django/core/handlers/exception.py @@ -43,7 +43,7 @@ async def inner(request): response = await get_response(request) except Exception as exc: response = await sync_to_async( - response_for_exception, thread_sensitive=False + response_for_exception, thread_sensitive=True )(request, exc) return response diff --git a/django/core/management/commands/inspectdb.py b/django/core/management/commands/inspectdb.py index 8bd6c4bc0c21..9aedb0d2474f 100644 --- a/django/core/management/commands/inspectdb.py +++ b/django/core/management/commands/inspectdb.py @@ -123,8 +123,8 @@ def handle_inspection(self, options): cursor, table_name ) except Exception as e: - yield "# Unable to inspect table '%s'" % table_name - yield "# The error was: %s" % e + yield "# Unable to inspect table %r" % table_name + yield "# The error was: %r" % str(e) continue model_name = self.normalize_table_name(table_name) @@ -133,12 +133,9 @@ def handle_inspection(self, options): yield "class %s(models.Model):" % model_name known_models.append(model_name) - if len(primary_key_columns) > 1: - fields = ", ".join([f"'{col}'" for col in primary_key_columns]) - yield f" pk = models.CompositePrimaryKey({fields})" - used_column_names = [] # Holds column names used in the table so far column_to_field_name = {} # Maps column names to names of model fields + field_lines = [] used_relations = set() # Holds foreign relations used in the table. for row in table_description: comment_notes = ( @@ -252,7 +249,16 @@ def handle_inspection(self, options): field_desc += ")" if comment_notes: field_desc += " # " + " ".join(comment_notes) - yield " %s" % field_desc + field_lines.append(" %s" % field_desc) + if len(primary_key_columns) > 1: + fields = ", ".join( + [ + f"{column_to_field_name[col]!r}" + for col in primary_key_columns + ] + ) + yield f" pk = models.CompositePrimaryKey({fields})" + yield from field_lines comment = None if info := table_info.get(table_name): is_view = info.type == "v" diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index d40237d86f32..7fdbad6f665a 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -287,6 +287,12 @@ Miscellaneous * The minimum supported version of ``asgiref`` is increased from 3.9.1 to 3.12.1. +* In the asynchronous request path, error responses (such as those rendered by + ``handler404`` and ``handler500``) are now rendered on the request's + thread-sensitive thread, rather than on a shared thread pool, so that + database connections used during error handling are managed by + ``close_old_connections()``. + .. _deprecated-features-6.2: Features deprecated in 6.2 diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py index 727ce2fa6c91..a303c8146c19 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -30,7 +30,7 @@ from django.utils.http import http_date from django.views.decorators.csrf import csrf_exempt -from .urls import sync_waiter, test_filename +from .urls import permission_denied_threads, sync_waiter, test_filename TEST_STATIC_ROOT = Path(__file__).parent / "project" / "static" TOO_MUCH_DATA_MSG = "Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE." @@ -497,6 +497,30 @@ async def test_request_lifecycle_signals_dispatched_with_thread_sensitive(self): request_started_call["thread"], request_finished_call["thread"] ) + async def test_error_handler_dispatched_with_thread_sensitive(self): + """ + Error responses must be rendered on the request's thread-sensitive + thread, where the request's database connections are usable and remain + subject to close_old_connections(). + """ + permission_denied_threads.clear() + application = get_asgi_application() + scope = self.async_request_factory._base_scope(path="/permission_denied/") + communicator = ApplicationCommunicator(application, scope) + await communicator.send_input({"type": "http.request"}) + response_start = await communicator.receive_output() + self.assertEqual(response_start["type"], "http.response.start") + self.assertEqual(response_start["status"], 403) + response_body = await communicator.receive_output() + self.assertEqual(response_body["body"], b"Error handler content") + # Give response.close() time to finish. + await communicator.wait() + + self.assertEqual( + permission_denied_threads["error_handler"], + permission_denied_threads["view"], + ) + async def test_concurrent_async_uses_multiple_thread_pools(self): sync_waiter.active_threads.clear() diff --git a/tests/asgi/urls.py b/tests/asgi/urls.py index 0311cf3f76b2..a22d97b43196 100644 --- a/tests/asgi/urls.py +++ b/tests/asgi/urls.py @@ -2,6 +2,7 @@ import threading import time +from django.core.exceptions import PermissionDenied from django.http import FileResponse, HttpResponse, StreamingHttpResponse from django.urls import path from django.views.decorators.csrf import csrf_exempt @@ -51,6 +52,20 @@ def post_echo(request): sync_waiter.barrier = threading.Barrier(2) +def permission_denied_view(request): + permission_denied_threads["view"] = threading.current_thread() + raise PermissionDenied + + +def permission_denied_error_handler(request, exception=None): + permission_denied_threads["error_handler"] = threading.current_thread() + return HttpResponse("Error handler content", status=403) + + +permission_denied_threads = {} +handler403 = permission_denied_error_handler + + async def streaming_inner(sleep_time): yield b"first\n" await asyncio.sleep(sleep_time) @@ -73,5 +88,6 @@ async def streaming_view(request): path("post/", post_echo), path("wait/", sync_waiter), path("delayed_hello/", hello_with_delay), + path("permission_denied/", permission_denied_view), path("streaming/", streaming_view), ] diff --git a/tests/gis_tests/geoapp/test_indexes.py b/tests/gis_tests/geoapp/test_indexes.py index 2da0070e30a7..5f0f610e8f0d 100644 --- a/tests/gis_tests/geoapp/test_indexes.py +++ b/tests/gis_tests/geoapp/test_indexes.py @@ -96,6 +96,8 @@ def test_partial_index(self): @skipUnlessDBFeature("supports_tablespaces") def test_tablespace(self): + if not self.has_spatial_indexes(City._meta.db_table): + self.skipTest("Spatial indexes in Meta.indexes are not supported.") index_name = "city_point_partial_tblspce_idx" index = Index( name=index_name, diff --git a/tests/inspectdb/models.py b/tests/inspectdb/models.py index 489704879f35..a4838b508a37 100644 --- a/tests/inspectdb/models.py +++ b/tests/inspectdb/models.py @@ -172,6 +172,12 @@ class CompositePKModel(models.Model): column_2 = models.IntegerField() +class CompositePKModel2(models.Model): + pk = models.CompositePrimaryKey("column_1", "column_2") + column_1 = models.IntegerField(db_column="column-1") + column_2 = models.IntegerField(db_column="column-2") + + class DbOnDeleteModel(models.Model): fk_do_nothing = models.ForeignKey(UniqueTogether, on_delete=models.DO_NOTHING) fk_db_cascade = models.ForeignKey(ColumnTypes, on_delete=models.DB_CASCADE) diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py index 2a7cbc6bac94..b3319dc4ebfe 100644 --- a/tests/inspectdb/tests.py +++ b/tests/inspectdb/tests.py @@ -515,6 +515,26 @@ def test_introspection_errors(self): # The error message depends on the backend self.assertIn("# The error was:", output) + def test_introspection_errors_escape_table_name_and_message(self): + table_name = "table_name\ncontinued" + error_message = "error\ncontinued" + out = StringIO() + with ( + mock.patch( + "django.db.connection.introspection.get_table_list", + return_value=[TableInfo(name=table_name, type="t")], + ), + mock.patch( + "django.db.connection.introspection.get_relations", + side_effect=RuntimeError(error_message), + ), + ): + call_command("inspectdb", stdout=out) + output = out.getvalue() + self.assertIn(f"# Unable to inspect table {table_name!r}", output) + self.assertIn(f"# The error was: {error_message!r}", output) + self.assertNotIn("\ncontinued", output) + def test_same_relations(self): out = StringIO() call_command("inspectdb", "inspectdb_message", stdout=out) @@ -702,6 +722,24 @@ def test_composite_primary_key(self): self.assertIn(f"column_1 = models.{field_type}()", output) self.assertIn(f"column_2 = models.{field_type}()", output) + def test_composite_primary_key_uses_normalized_column_names(self): + out = StringIO() + field_type = connection.features.introspected_field_types["IntegerField"] + call_command("inspectdb", "inspectdb_compositepkmodel2", stdout=out) + output = out.getvalue() + self.assertIn( + "pk = models.CompositePrimaryKey('column_1', 'column_2')", + output, + ) + self.assertIn( + f"column_1 = models.{field_type}(db_column='column-1')", + output, + ) + self.assertIn( + f"column_2 = models.{field_type}(db_column='column-2')", + output, + ) + def test_composite_primary_key_not_unique_together(self): out = StringIO() call_command("inspectdb", "inspectdb_compositepkmodel", stdout=out) diff --git a/tests/staticfiles_tests/test_handlers.py b/tests/staticfiles_tests/test_handlers.py index 5145d187e1a9..51509bfa706c 100644 --- a/tests/staticfiles_tests/test_handlers.py +++ b/tests/staticfiles_tests/test_handlers.py @@ -1,6 +1,11 @@ +import threading + +from asgiref.sync import sync_to_async + from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler from django.core.handlers.asgi import ASGIHandler -from django.test import AsyncRequestFactory +from django.http import HttpResponse +from django.test import AsyncRequestFactory, override_settings from .cases import StaticFilesTestCase @@ -12,6 +17,21 @@ async def __call__(self, scope, receive, send): return "Application called" +error_handler_threads = {} + + +def log_thread(key): + error_handler_threads[key] = threading.current_thread() + + +urlpatterns = [] + + +def handler404(request, exception=None): + log_thread("error_handler") + return HttpResponse("Error handler content", status=404) + + class TestASGIStaticFilesHandler(StaticFilesTestCase): async_request_factory = AsyncRequestFactory() @@ -23,11 +43,20 @@ async def test_get_async_response(self): self.assertEqual(response.status_code, 200) async def test_get_async_response_not_found(self): + error_handler_threads.clear() + await sync_to_async(log_thread)("worker") + request = self.async_request_factory.get("/static/test/not-found.txt") handler = ASGIStaticFilesHandler(ASGIHandler()) - response = await handler.get_response_async(request) + with override_settings(ROOT_URLCONF="staticfiles_tests.test_handlers"): + response = await handler.get_response_async(request) self.assertEqual(response.status_code, 404) + self.assertEqual( + error_handler_threads["error_handler"], + error_handler_threads["worker"], + ) + async def test_non_http_requests_passed_to_the_wrapped_application(self): tests = [ "/static/path.txt",