Merge branch 'main' into ticket_36321

This commit is contained in:
Stephen kihuni 2025-11-17 21:26:49 +03:00 committed by GitHub
commit 7032a5124a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 1338 additions and 686 deletions

View file

@ -24,8 +24,9 @@ jobs:
echo "prefix=[$VERSION]" >> $GITHUB_OUTPUT
- name: Check PR title prefix
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
TITLE="${{ github.event.pull_request.title }}"
PREFIX="${{ steps.vars.outputs.prefix }}"
if [[ "$TITLE" != "$PREFIX"* ]]; then
echo "❌ PR title must start with the required prefix: $PREFIX"

View file

@ -98,7 +98,7 @@ jobs:
- name: Run Selenium tests
working-directory: ./tests/
run: |
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_sqlite --parallel 2
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_sqlite --parallel 1
selenium-postgresql:
runs-on: ubuntu-latest
@ -136,7 +136,7 @@ jobs:
- name: Run Selenium tests
working-directory: ./tests/
run: |
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_postgres --parallel 2
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_postgres --parallel 1
postgresql:
strategy:

View file

@ -33,7 +33,7 @@ jobs:
- name: Run Selenium tests with screenshots
working-directory: ./tests/
run: python -Wall runtests.py --verbosity=2 --noinput --selenium=chrome --headless --screenshots --settings=test_sqlite --parallel=2
run: python -Wall runtests.py --verbosity=2 --noinput --selenium=chrome --headless --screenshots --settings=test_sqlite --parallel=1
- name: Cache oxipng
uses: actions/cache@v4

View file

@ -35,7 +35,7 @@ jobs:
- name: Run Selenium tests
working-directory: ./tests/
run: |
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_sqlite --parallel 2
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_sqlite --parallel 1
selenium-postgresql:
if: contains(github.event.pull_request.labels.*.name, 'selenium')
@ -74,4 +74,4 @@ jobs:
- name: Run Selenium tests
working-directory: ./tests/
run: |
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_postgres --parallel 2
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --settings=test_postgres --parallel 1

View file

@ -159,6 +159,7 @@ answer newbie questions, and generally made Django that much better:
Ben Slavin <benjamin.slavin@gmail.com>
Ben Sturmfels <ben@sturm.com.au>
Bendegúz Csirmaz <csirmazbendeguz@gmail.com>
Benedict Etzel <developer@beheh.de>
Berker Peksag <berker.peksag@gmail.com>
Bernd Schlapsi
Bernhard Essl <me@bernhardessl.com>

View file

@ -416,29 +416,27 @@ class AdminSite:
"""
Display the login form for the given HttpRequest.
"""
if request.method == "GET" and self.has_permission(request):
# Already logged-in, redirect to admin index
index_path = reverse("admin:index", current_app=self.name)
return HttpResponseRedirect(index_path)
# Since this module gets imported in the application's root package,
# it cannot import models from other applications at the module level,
# and django.contrib.admin.forms eventually imports User.
from django.contrib.admin.forms import AdminAuthenticationForm
from django.contrib.auth.views import LoginView
redirect_url = LoginView().get_redirect_url(request) or reverse(
"admin:index", current_app=self.name
)
if request.method == "GET" and self.has_permission(request):
# Already logged-in, redirect accordingly.
return HttpResponseRedirect(redirect_url)
context = {
**self.each_context(request),
"title": _("Log in"),
"subtitle": None,
"app_path": request.get_full_path(),
"username": request.user.get_username(),
REDIRECT_FIELD_NAME: redirect_url,
}
if (
REDIRECT_FIELD_NAME not in request.GET
and REDIRECT_FIELD_NAME not in request.POST
):
context[REDIRECT_FIELD_NAME] = reverse("admin:index", current_app=self.name)
context.update(extra_context or {})
defaults = {

View file

@ -18,23 +18,10 @@ Requires core.js and SelectBox.js.
from_box.setAttribute('aria-labelledby', field_id + '_from_label');
from_box.setAttribute('aria-describedby', `${field_id}_helptext ${field_id}_choose_helptext`);
for (const p of from_box.parentNode.getElementsByTagName('p')) {
if (p.classList.contains("info")) {
// Remove <p class="info">, because it just gets in the way.
from_box.parentNode.removeChild(p);
} else if (p.classList.contains("help")) {
// Move help text up to the top so it isn't below the select
// boxes or wrapped off on the side to the right of the add
// button:
from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild);
}
}
// <div class="selector"> or <div class="selector stacked">
const selector_div = quickElement('div', from_box.parentNode);
// Make sure the selector div is at the beginning so that the
// add link would be displayed to the right of the widget.
from_box.parentNode.prepend(selector_div);
// Make sure the selector div appears between the label and the add link.
from_box.parentNode.insertBefore(selector_div, from_box.nextSibling);
selector_div.className = is_stacked ? 'selector stacked' : 'selector';
// <div class="selector-available">

View file

@ -1,5 +1,4 @@
from itertools import chain
from types import MethodType
from django.apps import apps
from django.conf import settings
@ -98,7 +97,7 @@ def check_user_model(app_configs, **kwargs):
)
)
if isinstance(cls().is_anonymous, MethodType):
if callable(cls().is_anonymous):
errors.append(
checks.Critical(
"%s.is_anonymous must be an attribute or property rather than "
@ -108,7 +107,7 @@ def check_user_model(app_configs, **kwargs):
id="auth.C009",
)
)
if isinstance(cls().is_authenticated, MethodType):
if callable(cls().is_authenticated):
errors.append(
checks.Critical(
"%s.is_authenticated must be an attribute or property rather "

View file

@ -40,20 +40,28 @@ class RedirectURLMixin:
def get_success_url(self):
return self.get_redirect_url() or self.get_default_redirect_url()
def get_redirect_url(self):
"""Return the user-originating redirect URL if it's safe."""
redirect_to = self.request.POST.get(
self.redirect_field_name, self.request.GET.get(self.redirect_field_name)
def get_redirect_url(self, request=None):
"""Return the user-originating redirect URL if it's safe.
Optionally takes a request argument, allowing use outside class-based
views.
"""
if request is None:
request = self.request
redirect_to = request.POST.get(
self.redirect_field_name, request.GET.get(self.redirect_field_name)
)
url_is_safe = url_has_allowed_host_and_scheme(
url=redirect_to,
allowed_hosts=self.get_success_url_allowed_hosts(),
require_https=self.request.is_secure(),
allowed_hosts=self.get_success_url_allowed_hosts(request),
require_https=request.is_secure(),
)
return redirect_to if url_is_safe else ""
def get_success_url_allowed_hosts(self):
return {self.request.get_host(), *self.success_url_allowed_hosts}
def get_success_url_allowed_hosts(self, request=None):
if request is None:
request = self.request
return {request.get_host(), *self.success_url_allowed_hosts}
def get_default_redirect_url(self):
"""Return the default redirect URL."""

View file

@ -65,6 +65,11 @@ class PostgresConfig(AppConfig):
3910: "django.contrib.postgres.fields.DateTimeRangeField",
3912: "django.contrib.postgres.fields.DateRangeField",
3926: "django.contrib.postgres.fields.BigIntegerRangeField",
# PostgreSQL OIDs may vary depending on the
# installation, especially for datatypes from
# extensions, e.g. "hstore". In such cases, the
# type_display attribute (psycopg 3.2+) should be used.
"hstore": "django.contrib.postgres.fields.HStoreField",
}
)
if conn.connection is not None:

View file

@ -70,7 +70,7 @@ class ArrayField(CheckPostgresInstalledMixin, CheckFieldDefaultMixin, Field):
)
)
else:
base_checks = self.base_field.check()
base_checks = self.base_field.check(**kwargs)
if base_checks:
error_messages = "\n ".join(
"%s (%s)" % (base_check.msg, base_check.id)

View file

@ -1,6 +1,7 @@
from collections.abc import Iterable
from itertools import chain
from django.db import connections
from django.utils.inspect import func_accepts_kwargs
@ -84,6 +85,14 @@ class CheckRegistry:
if tags is not None:
checks = [check for check in checks if not set(check.tags).isdisjoint(tags)]
elif not databases:
# By default, 'database'-tagged checks are not run if an alias
# is not explicitly specified as they do more than mere static
# code analysis.
checks = [check for check in checks if Tags.database not in check.tags]
if databases is None:
databases = list(connections)
for check in checks:
new_errors = check(app_configs=app_configs, databases=databases)

View file

@ -3,7 +3,7 @@ import logging
import sys
import tempfile
import traceback
from contextlib import aclosing
from contextlib import aclosing, closing
from asgiref.sync import ThreadSensitiveContext, sync_to_async
@ -174,65 +174,41 @@ class ASGIHandler(base.BaseHandler):
body_file = await self.read_body(receive)
except RequestAborted:
return
# Request is complete and can be served.
set_script_prefix(get_script_prefix(scope))
await signals.request_started.asend(sender=self.__class__, scope=scope)
# Get the request and check for basic issues.
request, error_response = self.create_request(scope, body_file)
if request is None:
body_file.close()
await self.send_response(error_response, send)
await sync_to_async(error_response.close)()
return
async def process_request(request, send):
response = await self.run_get_response(request)
try:
await self.send_response(response, send)
except asyncio.CancelledError:
# Client disconnected during send_response (ignore exception).
with closing(body_file):
# Request is complete and can be served.
set_script_prefix(get_script_prefix(scope))
await signals.request_started.asend(sender=self.__class__, scope=scope)
# Get the request and check for basic issues.
request, error_response = self.create_request(scope, body_file)
if request is None:
body_file.close()
await self.send_response(error_response, send)
await sync_to_async(error_response.close)()
return
class RequestProcessed(Exception):
pass
return response
# Try to catch a disconnect while getting response.
tasks = [
# Check the status of these tasks and (optionally) terminate them
# in this order. The listen_for_disconnect() task goes first
# because it should not raise unexpected errors that would prevent
# us from cancelling process_request().
asyncio.create_task(self.listen_for_disconnect(receive)),
asyncio.create_task(process_request(request, send)),
]
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
# Now wait on both tasks (they may have both finished by now).
for task in tasks:
if task.done():
response = None
try:
try:
task.result()
except RequestAborted:
# Ignore client disconnects.
async with asyncio.TaskGroup() as tg:
tg.create_task(self.listen_for_disconnect(receive))
response = await self.run_get_response(request)
await self.send_response(response, send)
raise RequestProcessed
except* (RequestProcessed, RequestAborted):
pass
except AssertionError:
body_file.close()
raise
except BaseExceptionGroup as exception_group:
if len(exception_group.exceptions) == 1:
raise exception_group.exceptions[0]
raise
if response is None:
await signals.request_finished.asend(sender=self.__class__)
else:
# Allow views to handle cancellation.
task.cancel()
try:
await task
except asyncio.CancelledError:
# Task re-raised the CancelledError as expected.
pass
try:
response = tasks[1].result()
except asyncio.CancelledError:
await signals.request_finished.asend(sender=self.__class__)
else:
await sync_to_async(response.close)()
body_file.close()
await sync_to_async(response.close)()
async def listen_for_disconnect(self, receive):
"""Listen for disconnect from the client."""

View file

@ -4,6 +4,7 @@ import re
from django.core.management.base import BaseCommand, CommandError
from django.db import DEFAULT_DB_ALIAS, connections
from django.db.models.constants import LOOKUP_SEP
from django.db.models.deletion import DatabaseOnDelete
class Command(BaseCommand):
@ -163,7 +164,9 @@ class Command(BaseCommand):
extra_params["unique"] = True
if is_relation:
ref_db_column, ref_db_table = relations[column_name]
ref_db_column, ref_db_table, db_on_delete = relations[
column_name
]
if extra_params.pop("unique", False) or extra_params.get(
"primary_key"
):
@ -191,6 +194,8 @@ class Command(BaseCommand):
model_name.lower(),
att_name,
)
if db_on_delete and isinstance(db_on_delete, DatabaseOnDelete):
extra_params["on_delete"] = f"models.{db_on_delete}"
used_relations.add(rel_to)
else:
# Calling `get_field_type` to get the field type string
@ -227,8 +232,12 @@ class Command(BaseCommand):
"" if "." in field_type else "models.",
field_type,
)
on_delete_qualname = extra_params.pop("on_delete", None)
if field_type.startswith(("ForeignKey(", "OneToOneField(")):
field_desc += ", models.DO_NOTHING"
if on_delete_qualname:
field_desc += f", {on_delete_qualname}"
else:
field_desc += ", models.DO_NOTHING"
# Add comment.
if connection.features.supports_comments and row.comment:
@ -350,21 +359,15 @@ class Command(BaseCommand):
if field_type in {"CharField", "TextField"} and row.collation:
field_params["db_collation"] = row.collation
if field_type == "DecimalField":
if row.precision is None or row.scale is None:
field_notes.append(
"max_digits and decimal_places have been guessed, as this "
"database handles decimal fields as float"
)
field_params["max_digits"] = (
row.precision if row.precision is not None else 10
)
field_params["decimal_places"] = (
row.scale if row.scale is not None else 5
)
else:
field_params["max_digits"] = row.precision
field_params["decimal_places"] = row.scale
if field_type == "DecimalField" and (
# This can generate DecimalFields with only one of max_digits and
# decimal_fields specified. This configuration would be incorrect,
# but nothing more correct could be generated.
row.precision is not None
or row.scale is not None
):
field_params["max_digits"] = row.precision
field_params["decimal_places"] = row.scale
return field_type, field_params, field_notes

View file

@ -383,6 +383,9 @@ class BaseDatabaseFeatures:
# Does the backend support unlimited character columns?
supports_unlimited_charfield = False
# Does the backend support numeric columns with no precision?
supports_no_precision_decimalfield = False
# Does the backend support native tuple lookups (=, >, <, IN)?
supports_tuple_lookups = True

View file

@ -1,5 +1,7 @@
from collections import namedtuple
from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING
# Structure returned by DatabaseIntrospection.get_table_list()
TableInfo = namedtuple("TableInfo", ["name", "type"])
@ -15,6 +17,13 @@ class BaseDatabaseIntrospection:
"""Encapsulate backend-specific introspection utilities."""
data_types_reverse = {}
on_delete_types = {
"CASCADE": DB_CASCADE,
"NO ACTION": DO_NOTHING,
"SET DEFAULT": DB_SET_DEFAULT,
"SET NULL": DB_SET_NULL,
# DB_RESTRICT - "RESTRICT" is not supported.
}
def __init__(self, connection):
self.connection = connection
@ -169,8 +178,11 @@ class BaseDatabaseIntrospection:
def get_relations(self, cursor, table_name):
"""
Return a dictionary of {field_name: (field_name_other_table,
other_table)} representing all foreign keys in the given table.
Return a dictionary of
{
field_name: (field_name_other_table, other_table, db_on_delete)
}
representing all foreign keys in the given table.
"""
raise NotImplementedError(
"subclasses of BaseDatabaseIntrospection may require a "

View file

@ -334,6 +334,7 @@ class DatabaseWrapper(BaseDatabaseWrapper):
for column_name, (
referenced_column_name,
referenced_table_name,
_,
) in relations.items():
cursor.execute(
"""

View file

@ -196,24 +196,36 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
def get_relations(self, cursor, table_name):
"""
Return a dictionary of {field_name: (field_name_other_table,
other_table)} representing all foreign keys in the given table.
Return a dictionary of
{
field_name: (field_name_other_table, other_table, db_on_delete)
}
representing all foreign keys in the given table.
"""
cursor.execute(
"""
SELECT column_name, referenced_column_name, referenced_table_name
FROM information_schema.key_column_usage
WHERE table_name = %s
AND table_schema = DATABASE()
AND referenced_table_schema = DATABASE()
AND referenced_table_name IS NOT NULL
AND referenced_column_name IS NOT NULL
SELECT
kcu.column_name,
kcu.referenced_column_name,
kcu.referenced_table_name,
rc.delete_rule
FROM
information_schema.key_column_usage kcu
JOIN
information_schema.referential_constraints rc
ON rc.constraint_name = kcu.constraint_name
AND rc.constraint_schema = kcu.constraint_schema
WHERE kcu.table_name = %s
AND kcu.table_schema = DATABASE()
AND kcu.referenced_table_schema = DATABASE()
AND kcu.referenced_table_name IS NOT NULL
AND kcu.referenced_column_name IS NOT NULL
""",
[table_name],
)
return {
field_name: (other_field, other_table)
for field_name, other_field, other_table in cursor.fetchall()
field_name: (other_field, other_table, self.on_delete_types.get(on_delete))
for field_name, other_field, other_table, on_delete in cursor.fetchall()
}
def get_storage_engine(self, cursor, table_name):

View file

@ -106,6 +106,12 @@ class _UninitializedOperatorsDescriptor:
return instance.__dict__["operators"]
def _get_decimal_column(data):
if data["max_digits"] is None and data["decimal_places"] is None:
return "NUMBER"
return "NUMBER(%(max_digits)s, %(decimal_places)s)" % data
class DatabaseWrapper(BaseDatabaseWrapper):
vendor = "oracle"
display_name = "Oracle"
@ -125,7 +131,7 @@ class DatabaseWrapper(BaseDatabaseWrapper):
"CharField": "NVARCHAR2(%(max_length)s)",
"DateField": "DATE",
"DateTimeField": "TIMESTAMP",
"DecimalField": "NUMBER(%(max_digits)s, %(decimal_places)s)",
"DecimalField": _get_decimal_column,
"DurationField": "INTERVAL DAY(9) TO SECOND(6)",
"FileField": "NVARCHAR2(%(max_length)s)",
"FilePathField": "NVARCHAR2(%(max_length)s)",

View file

@ -79,6 +79,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_json_negative_indexing = False
supports_collation_on_textfield = False
supports_on_delete_db_default = False
supports_no_precision_decimalfield = True
test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
django_test_expected_failures = {
# A bug in Django/oracledb with respect to string handling (#23843).

View file

@ -194,14 +194,21 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
comment,
) = field_map[name]
name %= {} # oracledb, for some reason, doubles percent signs.
if desc[1] == oracledb.NUMBER and desc[5] == -127 and desc[4] == 0:
# DecimalField with no precision.
precision = None
scale = None
else:
precision = desc[4] or 0
scale = desc[5] or 0
description.append(
FieldInfo(
self.identifier_converter(name),
desc[1],
display_size,
desc[3],
desc[4] or 0,
desc[5] or 0,
precision,
scale,
*desc[6:],
default,
collation,
@ -254,13 +261,16 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
def get_relations(self, cursor, table_name):
"""
Return a dictionary of {field_name: (field_name_other_table,
other_table)} representing all foreign keys in the given table.
Return a dictionary of
{
field_name: (field_name_other_table, other_table, db_on_delete)
}
representing all foreign keys in the given table.
"""
table_name = table_name.upper()
cursor.execute(
"""
SELECT ca.column_name, cb.table_name, cb.column_name
SELECT ca.column_name, cb.table_name, cb.column_name, user_constraints.delete_rule
FROM user_constraints, USER_CONS_COLUMNS ca, USER_CONS_COLUMNS cb
WHERE user_constraints.table_name = %s AND
user_constraints.constraint_name = ca.constraint_name AND
@ -273,8 +283,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
self.identifier_converter(field_name): (
self.identifier_converter(rel_field_name),
self.identifier_converter(rel_table_name),
self.on_delete_types.get(on_delete),
)
for field_name, rel_table_name, rel_field_name in cursor.fetchall()
for (
field_name,
rel_table_name,
rel_field_name,
on_delete,
) in cursor.fetchall()
}
def get_primary_key_columns(self, cursor, table_name):

View file

@ -8,6 +8,7 @@ import asyncio
import threading
import warnings
from contextlib import contextmanager
from functools import lru_cache
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@ -29,6 +30,7 @@ except ImportError:
raise ImproperlyConfigured("Error loading psycopg2 or psycopg module")
@lru_cache
def psycopg_version():
version = Database.__version__.split(" ", 1)[0]
return get_version_tuple(version)
@ -87,6 +89,12 @@ def _get_varchar_column(data):
return "varchar(%(max_length)s)" % data
def _get_decimal_column(data):
if data["max_digits"] is None and data["decimal_places"] is None:
return "numeric"
return "numeric(%(max_digits)s, %(decimal_places)s)" % data
class DatabaseWrapper(BaseDatabaseWrapper):
vendor = "postgresql"
display_name = "PostgreSQL"
@ -103,7 +111,7 @@ class DatabaseWrapper(BaseDatabaseWrapper):
"CharField": _get_varchar_column,
"DateField": "date",
"DateTimeField": "timestamp with time zone",
"DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)",
"DecimalField": _get_decimal_column,
"DurationField": "interval",
"FileField": "varchar(%(max_length)s)",
"FilePathField": "varchar(%(max_length)s)",

View file

@ -68,6 +68,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_covering_indexes = True
supports_stored_generated_columns = True
supports_nulls_distinct_unique_constraints = True
supports_no_precision_decimalfield = True
can_rename_index = True
test_collations = {
"deterministic": "C",

View file

@ -3,7 +3,8 @@ from collections import namedtuple
from django.db.backends.base.introspection import BaseDatabaseIntrospection
from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo
from django.db.backends.base.introspection import TableInfo as BaseTableInfo
from django.db.models import Index
from django.db.backends.postgresql.base import psycopg_version
from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING, Index
FieldInfo = namedtuple("FieldInfo", [*BaseFieldInfo._fields, "is_autofield", "comment"])
TableInfo = namedtuple("TableInfo", [*BaseTableInfo._fields, "comment"])
@ -38,6 +39,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
ignored_tables = []
on_delete_types = {
"a": DO_NOTHING,
"c": DB_CASCADE,
"d": DB_SET_DEFAULT,
"n": DB_SET_NULL,
# DB_RESTRICT - "r" is not supported.
}
def get_field_type(self, data_type, description):
field_type = super().get_field_type(data_type, description)
if description.is_autofield or (
@ -112,15 +121,26 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
cursor.execute(
"SELECT * FROM %s LIMIT 1" % self.connection.ops.quote_name(table_name)
)
# PostgreSQL OIDs may vary depending on the installation, especially
# for datatypes from extensions, e.g. "hstore". In such cases, the
# type_display attribute (psycopg 3.2+) should be used.
type_display_available = psycopg_version() >= (3, 2)
return [
FieldInfo(
line.name,
line.type_code,
(
line.type_display
if type_display_available and line.type_display == "hstore"
else line.type_code
),
# display_size is always None on psycopg2.
line.internal_size if line.display_size is None else line.display_size,
line.internal_size,
line.precision,
line.scale,
# precision and scale are always 2^16 - 1 on psycopg2 for
# DecimalFields with no precision.
None if line.precision == 2**16 - 1 else line.precision,
None if line.scale == 2**16 - 1 else line.scale,
*field_map[line.name],
)
for line in cursor.description
@ -154,12 +174,15 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
def get_relations(self, cursor, table_name):
"""
Return a dictionary of {field_name: (field_name_other_table,
other_table)} representing all foreign keys in the given table.
Return a dictionary of
{
field_name: (field_name_other_table, other_table, db_on_delete)
}
representing all foreign keys in the given table.
"""
cursor.execute(
"""
SELECT a1.attname, c2.relname, a2.attname
SELECT a1.attname, c2.relname, a2.attname, con.confdeltype
FROM pg_constraint con
LEFT JOIN pg_class c1 ON con.conrelid = c1.oid
LEFT JOIN pg_class c2 ON con.confrelid = c2.oid
@ -175,7 +198,10 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
""",
[table_name],
)
return {row[0]: (row[2], row[1]) for row in cursor.fetchall()}
return {
row[0]: (row[2], row[1], self.on_delete_types.get(row[3]))
for row in cursor.fetchall()
}
def get_constraints(self, cursor, table_name):
"""

View file

@ -55,6 +55,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
insert_test_table_with_defaults = 'INSERT INTO {} ("null") VALUES (1)'
supports_default_keyword_in_insert = False
supports_unlimited_charfield = True
supports_no_precision_decimalfield = True
can_return_columns_from_insert = True
can_return_rows_from_bulk_insert = True
can_return_rows_from_update = True

View file

@ -153,20 +153,27 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
def get_relations(self, cursor, table_name):
"""
Return a dictionary of {column_name: (ref_column_name, ref_table_name)}
Return a dictionary of
{column_name: (ref_column_name, ref_table_name, db_on_delete)}
representing all foreign keys in the given table.
"""
cursor.execute(
"PRAGMA foreign_key_list(%s)" % self.connection.ops.quote_name(table_name)
)
return {
column_name: (ref_column_name, ref_table_name)
column_name: (
ref_column_name,
ref_table_name,
self.on_delete_types.get(on_delete),
)
for (
_,
_,
ref_table_name,
column_name,
ref_column_name,
_,
on_delete,
*_,
) in cursor.fetchall()
}
@ -407,7 +414,10 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
"check": False,
"index": False,
}
for index, (column_name, (ref_column_name, ref_table_name)) in relations
for index, (
column_name,
(ref_column_name, ref_table_name, _),
) in relations
}
)
return constraints

View file

@ -1,5 +1,6 @@
import datetime
import decimal
import sqlite3
import uuid
from functools import lru_cache
from itertools import chain
@ -143,16 +144,15 @@ class DatabaseOperations(BaseDatabaseOperations):
"""
Only for last_executed_query! Don't use this to execute SQL queries!
"""
# This function is limited both by SQLITE_LIMIT_VARIABLE_NUMBER (the
# number of parameters, default = 999) and SQLITE_MAX_COLUMN (the
# number of return values, default = 2000). Since Python's sqlite3
# module doesn't expose the get_limit() C API, assume the default
# limits are in effect and split the work in batches if needed.
BATCH_SIZE = 999
if len(params) > BATCH_SIZE:
connection = self.connection.connection
variable_limit = self.connection.features.max_query_params
column_limit = connection.getlimit(sqlite3.SQLITE_LIMIT_COLUMN)
batch_size = min(variable_limit, column_limit)
if len(params) > batch_size:
results = ()
for index in range(0, len(params), BATCH_SIZE):
chunk = params[index : index + BATCH_SIZE]
for index in range(0, len(params), batch_size):
chunk = params[index : index + batch_size]
results += self._quote_params_for_last_executed_query(chunk)
return results

View file

@ -109,11 +109,11 @@ class MigrationLoader:
if was_loaded:
reload(module)
self.migrated_apps.add(app_config.label)
migration_names = {
migration_names = [
name
for _, name, is_pkg in pkgutil.iter_modules(module.__path__)
if not is_pkg and name[0] not in "_~"
}
]
# Load migrations
for migration_name in migration_names:
migration_path = "%s.%s" % (module_name, migration_name)

View file

@ -222,6 +222,17 @@ class FunctoolsPartialSerializer(BaseSerializer):
)
class GenericAliasSerializer(BaseSerializer):
def serialize(self):
imports = set()
# Avoid iterating self.value, because it returns itself.
# https://github.com/python/cpython/issues/103450
for item in self.value.__args__:
_, item_imports = serializer_factory(item).serialize()
imports.update(item_imports)
return repr(self.value), imports
class IterableSerializer(BaseSerializer):
def serialize(self):
imports = set()
@ -364,6 +375,7 @@ class Serializer:
decimal.Decimal: DecimalSerializer,
(functools.partial, functools.partialmethod): FunctoolsPartialSerializer,
FUNCTION_TYPES: FunctionTypeSerializer,
types.GenericAlias: GenericAliasSerializer,
collections.abc.Iterable: IterableSerializer,
(COMPILED_REGEX_TYPE, RegexObject): RegexSerializer,
uuid.UUID: UUIDSerializer,

View file

@ -1801,7 +1801,7 @@ class Model(AltersData, metaclass=ModelBase):
meta = cls._meta
pk = meta.pk
if not isinstance(pk, CompositePrimaryKey):
if meta.proxy or not isinstance(pk, CompositePrimaryKey):
return errors
seen_columns = defaultdict(list)

View file

@ -51,9 +51,12 @@ class BaseConstraint:
def _expression_refs_exclude(cls, model, expression, exclude):
get_field = model._meta.get_field
for field_name, *__ in model._get_expr_references(expression):
if field_name in exclude:
if field_name == "pk":
field = model._meta.pk
else:
field = get_field(field_name)
if field_name in exclude or field.name in exclude:
return True
field = get_field(field_name)
if field.generated and cls._expression_refs_exclude(
model, field.expression, exclude
):

View file

@ -1725,54 +1725,84 @@ class DecimalField(Field):
return errors
def _check_decimal_places(self):
try:
decimal_places = int(self.decimal_places)
if decimal_places < 0:
raise ValueError()
except TypeError:
return [
checks.Error(
"DecimalFields must define a 'decimal_places' attribute.",
obj=self,
id="fields.E130",
)
]
except ValueError:
return [
checks.Error(
"'decimal_places' must be a non-negative integer.",
obj=self,
id="fields.E131",
)
]
if self.decimal_places is None:
if (
not connection.features.supports_no_precision_decimalfield
and "supports_no_precision_decimalfield"
not in self.model._meta.required_db_features
):
return [
checks.Error(
"DecimalFields must define a 'decimal_places' attribute.",
obj=self,
id="fields.E130",
)
]
elif self.max_digits is not None:
return [
checks.Error(
"DecimalFields max_digits and decimal_places must both "
"be defined or both omitted.",
obj=self,
id="fields.E135",
),
]
else:
return []
try:
decimal_places = int(self.decimal_places)
if decimal_places < 0:
raise ValueError()
except ValueError:
return [
checks.Error(
"'decimal_places' must be a non-negative integer.",
obj=self,
id="fields.E131",
)
]
return []
def _check_max_digits(self):
try:
max_digits = int(self.max_digits)
if max_digits <= 0:
raise ValueError()
except TypeError:
return [
checks.Error(
"DecimalFields must define a 'max_digits' attribute.",
obj=self,
id="fields.E132",
)
]
except ValueError:
return [
checks.Error(
"'max_digits' must be a positive integer.",
obj=self,
id="fields.E133",
)
]
if self.max_digits is None:
if (
not connection.features.supports_no_precision_decimalfield
and "supports_no_precision_decimalfield"
not in self.model._meta.required_db_features
):
return [
checks.Error(
"DecimalFields must define a 'max_digits' attribute.",
obj=self,
id="fields.E132",
)
]
elif self.decimal_places is not None:
return [
checks.Error(
"DecimalFields max_digits and decimal_places must both "
"be defined or both omitted.",
obj=self,
id="fields.E135",
),
]
else:
return []
try:
max_digits = int(self.max_digits)
if max_digits <= 0:
raise ValueError()
except ValueError:
return [
checks.Error(
"'max_digits' must be a positive integer.",
obj=self,
id="fields.E133",
)
]
return []
def _check_decimal_places_and_max_digits(self, **kwargs):
if self.decimal_places is None or self.max_digits is None:
return []
if int(self.decimal_places) > int(self.max_digits):
return [
checks.Error(

View file

@ -12,6 +12,7 @@ from django.db.models.lookups import (
PostgresOperatorLookup,
Transform,
)
from django.utils.deconstruct import deconstructible
from django.utils.deprecation import RemovedInDjango70Warning, django_file_prefixes
from django.utils.translation import gettext_lazy as _
@ -150,6 +151,7 @@ class JSONField(CheckFieldDefaultMixin, Field):
)
@deconstructible(path="django.db.models.JSONNull")
class JSONNull(expressions.Value):
"""Represent JSON `null` primitive."""

View file

@ -46,6 +46,8 @@ MAX_GET_RESULTS = 21
# The maximum number of items to display in a QuerySet.__repr__
REPR_OUTPUT_SIZE = 20
PROHIBITED_FILTER_KWARGS = frozenset(["_connector", "_negated"])
class BaseIterable:
def __init__(
@ -840,8 +842,7 @@ class QuerySet(AltersData):
)
for obj_with_pk, results in zip(objs_with_pk, returned_columns):
for result, field in zip(results, opts.db_returning_fields):
if field != opts.pk:
setattr(obj_with_pk, field.attname, result)
setattr(obj_with_pk, field.attname, result)
for obj_with_pk in objs_with_pk:
obj_with_pk._state.adding = False
obj_with_pk._state.db = self.db
@ -1645,6 +1646,9 @@ class QuerySet(AltersData):
return clone
def _filter_or_exclude_inplace(self, negate, args, kwargs):
if invalid_kwargs := PROHIBITED_FILTER_KWARGS.intersection(kwargs):
invalid_kwargs_str = ", ".join(f"'{k}'" for k in sorted(invalid_kwargs))
raise TypeError(f"The following kwargs are invalid: {invalid_kwargs_str}")
if negate:
self._query.add_q(~Q(*args, **kwargs))
else:

View file

@ -48,8 +48,12 @@ class Q(tree.Node):
XOR = "XOR"
default = AND
conditional = True
connectors = (None, AND, OR, XOR)
def __init__(self, *args, _connector=None, _negated=False, **kwargs):
if _connector not in self.connectors:
connector_reprs = ", ".join(f"{conn!r}" for conn in self.connectors[1:])
raise ValueError(f"_connector must be one of {connector_reprs}, or None.")
super().__init__(
children=[*args, *sorted(kwargs.items())],
connector=_connector,

View file

@ -22,6 +22,28 @@ NONE_ID = _make_id(None)
NO_RECEIVERS = object()
async def _gather(*coros):
if len(coros) == 0:
return []
if len(coros) == 1:
return [await coros[0]]
async def run(i, coro):
results[i] = await coro
try:
async with asyncio.TaskGroup() as tg:
results = [None] * len(coros)
for i, coro in enumerate(coros):
tg.create_task(run(i, coro))
return results
except BaseExceptionGroup as exception_group:
if len(exception_group.exceptions) == 1:
raise exception_group.exceptions[0]
raise
class Signal:
"""
Base class for all signals
@ -186,7 +208,7 @@ class Signal:
If any receivers are asynchronous, they are called after all the
synchronous receivers via a single call to async_to_sync(). They are
also executed concurrently with asyncio.gather().
also executed concurrently with asyncio.TaskGroup().
Arguments:
@ -211,7 +233,7 @@ class Signal:
if async_receivers:
async def asend():
async_responses = await asyncio.gather(
async_responses = await _gather(
*(
receiver(signal=self, sender=sender, **named)
for receiver in async_receivers
@ -235,7 +257,7 @@ class Signal:
sync_to_async() adaption before executing any asynchronous receivers.
If any receivers are asynchronous, they are grouped and executed
concurrently with asyncio.gather().
concurrently with asyncio.TaskGroup().
Arguments:
@ -268,9 +290,9 @@ class Signal:
async def sync_send():
return []
responses, async_responses = await asyncio.gather(
responses, async_responses = await _gather(
sync_send(),
asyncio.gather(
_gather(
*(
receiver(signal=self, sender=sender, **named)
for receiver in async_receivers
@ -294,7 +316,7 @@ class Signal:
If any receivers are asynchronous, they are called after all the
synchronous receivers via a single call to async_to_sync(). They are
also executed concurrently with asyncio.gather().
also executed concurrently with asyncio.TaskGroup().
Arguments:
@ -340,7 +362,7 @@ class Signal:
return response
async def asend():
async_responses = await asyncio.gather(
async_responses = await _gather(
*(
asend_and_wrap_exception(receiver)
for receiver in async_receivers
@ -359,7 +381,7 @@ class Signal:
sync_to_async() adaption before executing any asynchronous receivers.
If any receivers are asynchronous, they are grouped and executed
concurrently with asyncio.gather.
concurrently with asyncio.TaskGroup.
Arguments:
@ -414,9 +436,9 @@ class Signal:
return err
return response
responses, async_responses = await asyncio.gather(
responses, async_responses = await _gather(
sync_send(),
asyncio.gather(
_gather(
*(asend_and_wrap_exception(receiver) for receiver in async_receivers),
),
)

View file

@ -192,7 +192,9 @@ class BoundField(RenderableFieldMixin):
if id_:
id_for_label = widget.id_for_label(id_)
if id_for_label:
attrs = {**(attrs or {}), "for": id_for_label}
attrs = attrs or {}
if tag != "legend":
attrs = {**attrs, "for": id_for_label}
if self.field.required and hasattr(self.form, "required_css_class"):
attrs = attrs or {}
if "class" in attrs:

View file

@ -22,7 +22,7 @@ from django.utils import timezone
from django.utils.datastructures import CaseInsensitiveMapping
from django.utils.encoding import iri_to_uri
from django.utils.functional import cached_property
from django.utils.http import content_disposition_header, http_date
from django.utils.http import MAX_URL_LENGTH, content_disposition_header, http_date
from django.utils.regex_helper import _lazy_re_compile
_charset_from_content_type_re = _lazy_re_compile(
@ -631,7 +631,12 @@ class HttpResponseRedirectBase(HttpResponse):
def __init__(self, redirect_to, preserve_request=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self["Location"] = iri_to_uri(redirect_to)
parsed = urlsplit(str(redirect_to))
redirect_to_str = str(redirect_to)
if len(redirect_to_str) > MAX_URL_LENGTH:
raise DisallowedRedirect(
f"Unsafe redirect exceeding {MAX_URL_LENGTH} characters"
)
parsed = urlsplit(redirect_to_str)
if preserve_request:
self.status_code = self.status_code_preserve_request
if parsed.scheme and parsed.scheme not in self.allowed_schemes:

View file

@ -4,6 +4,7 @@ import html
import json
import re
import warnings
from collections import deque
from collections.abc import Mapping
from html.parser import HTMLParser
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit, urlunsplit
@ -297,6 +298,7 @@ class Urlizer:
simple_url_re = _lazy_re_compile(r"^https?://\[?\w", re.IGNORECASE)
simple_url_2_re = _lazy_re_compile(
rf"^www\.|^(?!http)(?:{DomainNameValidator.hostname_re})"
rf"(?:{DomainNameValidator.domain_re})"
r"\.(com|edu|gov|int|mil|net|org)($|/.*)$",
re.IGNORECASE,
)
@ -428,7 +430,7 @@ class Urlizer:
# Strip all opening wrapping punctuation.
middle = word.lstrip(self.wrapping_punctuation_openings)
lead = word[: len(word) - len(middle)]
trail = ""
trail = deque()
# Continue trimming until middle remains unchanged.
trimmed_something = True
@ -441,7 +443,7 @@ class Urlizer:
rstripped = middle.rstrip(closing)
if rstripped != middle:
strip = counts[closing] - counts[opening]
trail = middle[-strip:]
trail.appendleft(middle[-strip:])
middle = middle[:-strip]
trimmed_something = True
counts[closing] -= strip
@ -452,7 +454,7 @@ class Urlizer:
else:
rstripped = middle.rstrip(self.trailing_punctuation_chars_no_semicolon)
if rstripped != middle:
trail = middle[len(rstripped) :] + trail
trail.appendleft(middle[len(rstripped) :])
middle = rstripped
trimmed_something = True
@ -469,13 +471,14 @@ class Urlizer:
# entity.
recent_semicolon = middle[trail_start:].index(";")
middle_semicolon_index = recent_semicolon + trail_start + 1
trail = middle[middle_semicolon_index:] + trail
trail.appendleft(middle[middle_semicolon_index:])
middle = rstripped + middle[trail_start:middle_semicolon_index]
else:
trail = middle[trail_start:] + trail
trail.appendleft(middle[trail_start:])
middle = rstripped
trimmed_something = True
trail = "".join(trail)
return lead, middle, trail
@staticmethod

View file

@ -169,11 +169,11 @@ def int_to_base36(i):
raise ValueError("Negative base36 conversion input.")
if i < 36:
return char_set[i]
b36 = ""
b36_parts = []
while i != 0:
i, n = divmod(i, 36)
b36 = char_set[n] + b36
return b36
b36_parts.append(char_set[n])
return "".join(reversed(b36_parts))
def urlsafe_base64_encode(s):

View file

@ -1,10 +1,24 @@
import functools
import inspect
from django.utils.version import PY314
if PY314:
import annotationlib
@functools.lru_cache(maxsize=512)
def _get_func_parameters(func, remove_first):
parameters = tuple(inspect.signature(func).parameters.values())
# As the annotations are not used in any case, inspect the signature with
# FORWARDREF to leave any deferred annotations unevaluated.
if PY314:
signature = inspect.signature(
func, annotation_format=annotationlib.Format.FORWARDREF
)
else:
signature = inspect.signature(func)
parameters = tuple(signature.parameters.values())
if remove_first:
parameters = parameters[1:]
return parameters

View file

@ -48,6 +48,9 @@ def format(
if abs(number) < cutoff:
number = Decimal("0")
if not number.is_finite():
return str(number)
# Format values with more than 200 digits (an arbitrary cutoff) using
# scientific notation to avoid high memory usage in {:f}'.format().
_, digits, exponent = number.as_tuple()
@ -91,15 +94,15 @@ def format(
# grouping is a single value
intervals = [grouping, 0]
active_interval = intervals.pop(0)
int_part_gd = ""
int_part_gd = []
cnt = 0
for digit in int_part[::-1]:
if cnt and cnt == active_interval:
if intervals:
active_interval = intervals.pop(0) or active_interval
int_part_gd += thousand_sep[::-1]
int_part_gd.append(thousand_sep[::-1])
cnt = 0
int_part_gd += digit
int_part_gd.append(digit)
cnt += 1
int_part = int_part_gd[::-1]
int_part = "".join(int_part_gd)[::-1]
return sign + int_part + dec_part

View file

@ -103,7 +103,7 @@ class TruncateHTMLParser(HTMLParser):
def __init__(self, *, length, replacement, convert_charrefs=True):
super().__init__(convert_charrefs=convert_charrefs)
self.tags = deque()
self.output = ""
self.output = []
self.remaining = length
self.replacement = replacement
@ -119,13 +119,13 @@ class TruncateHTMLParser(HTMLParser):
self.handle_endtag(tag)
def handle_starttag(self, tag, attrs):
self.output += self.get_starttag_text()
self.output.append(self.get_starttag_text())
if tag not in self.void_elements:
self.tags.appendleft(tag)
def handle_endtag(self, tag):
if tag not in self.void_elements:
self.output += f"</{tag}>"
self.output.append(f"</{tag}>")
try:
self.tags.remove(tag)
except ValueError:
@ -136,16 +136,16 @@ class TruncateHTMLParser(HTMLParser):
data_len = len(data)
if self.remaining < data_len:
self.remaining = 0
self.output += add_truncation_text(output, self.replacement)
self.output.append(add_truncation_text(output, self.replacement))
raise self.TruncationCompleted
self.remaining -= data_len
self.output += output
self.output.append(output)
def feed(self, data):
try:
super().feed(data)
except self.TruncationCompleted:
self.output += "".join([f"</{tag}>" for tag in self.tags])
self.output.extend([f"</{tag}>" for tag in self.tags])
self.tags.clear()
self.reset()
else:
@ -166,9 +166,9 @@ class TruncateCharsHTMLParser(TruncateHTMLParser):
def process(self, data):
self.processed_chars += len(data)
if (self.processed_chars == self.length) and (
len(self.output) + len(data) == len(self.rawdata)
sum(len(p) for p in self.output) + len(data) == len(self.rawdata)
):
self.output += data
self.output.append(data)
raise self.TruncationCompleted
output = escape("".join(data[: self.remaining]))
return data, output
@ -213,7 +213,7 @@ class Truncator(SimpleLazyObject):
parser = TruncateCharsHTMLParser(length=length, replacement=truncate)
parser.feed(text)
parser.close()
return parser.output
return "".join(parser.output)
return self._text_chars(length, truncate, text)
def _text_chars(self, length, truncate, text):
@ -250,7 +250,7 @@ class Truncator(SimpleLazyObject):
parser = TruncateWordsHTMLParser(length=length, replacement=truncate)
parser.feed(self._wrapped)
parser.close()
return parser.output
return "".join(parser.output)
return self._text_words(length, truncate)
def _text_words(self, length, truncate):

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 224 KiB

View file

@ -1,282 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xl="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="59.5 134.5 385 491.5" width="385" height="491.5">
<defs>
<filter id="Shadow" filterUnits="userSpaceOnUse" x="21" y="85.35">
<feGaussianBlur in="SourceAlpha" result="blur" stdDeviation="1.308"/>
<feOffset in="blur" result="offset" dx="0" dy="2"/>
<feFlood flood-color="black" flood-opacity=".5" result="flood"/>
<feComposite in="flood" in2="offset" operator="in"/>
</filter>
<marker orient="auto" overflow="visible" markerUnits="strokeWidth" id="FilledArrow_Marker" stroke-linejoin="miter" stroke-miterlimit="10" viewBox="-1 -3 7 6" markerWidth="7" markerHeight="6" color="#008f00">
<g>
<path d="M 4.8 0 L 0 -1.8 L 0 1.8 Z" fill="currentColor" stroke="currentColor" stroke-width="1"/>
</g>
</marker>
<marker orient="auto" overflow="visible" markerUnits="strokeWidth" id="FilledArrow_Marker_2" stroke-linejoin="miter" stroke-miterlimit="10" viewBox="-1 -3 7 6" markerWidth="7" markerHeight="6" color="#005493">
<g>
<path d="M 4.8 0 L 0 -1.8 L 0 1.8 Z" fill="currentColor" stroke="currentColor" stroke-width="1"/>
</g>
</marker>
</defs>
<g id="Canevas_1" stroke="none" fill="none" fill-opacity="1" stroke-dasharray="none" stroke-opacity="1">
<title>Canevas 1</title>
<rect fill="white" x="59.5" y="134.5" width="385" height="491.5"/>
<g id="Canevas_1_Calque_1">
<title>Calque 1</title>
<g id="Graphic_88_shadow" filter="url(#Shadow)">
<rect x="72" y="576" width="162" height="36" fill="white"/>
</g>
<g id="Graphic_126_shadow" filter="url(#Shadow)">
<rect x="270" y="576" width="162" height="36" fill="white"/>
</g>
<g id="Graphic_32_shadow" filter="url(#Shadow)">
<rect x="288" y="216" width="144" height="288" fill="white"/>
<rect x="288" y="216" width="144" height="288" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_99_shadow" filter="url(#Shadow)">
<rect x="72" y="216" width="144" height="288" fill="white"/>
<rect x="72" y="216" width="144" height="288" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_10_shadow" filter="url(#Shadow)">
<path d="M 95 450 L 193 450 C 195.76142 450 198 452.2386 198 455 L 198 481 C 198 483.7614 195.76142 486 193 486 L 95 486 C 92.23858 486 90 483.7614 90 481 L 90 455 C 90 452.2386 92.23858 450 95 450 Z" fill="#0096ff" fill-opacity=".3"/>
<path d="M 95 450 L 193 450 C 195.76142 450 198 452.2386 198 455 L 198 481 C 198 483.7614 195.76142 486 193 486 L 95 486 C 92.23858 486 90 483.7614 90 481 L 90 455 C 90 452.2386 92.23858 450 95 450 Z" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_11_shadow" filter="url(#Shadow)">
<path d="M 95 360 L 193 360 C 195.76142 360 198 362.23858 198 365 L 198 391 C 198 393.76142 195.76142 396 193 396 L 95 396 C 92.23858 396 90 393.76142 90 391 L 90 365 C 90 362.23858 92.23858 360 95 360 Z" fill="#0096ff" fill-opacity=".3"/>
<path d="M 95 360 L 193 360 C 195.76142 360 198 362.23858 198 365 L 198 391 C 198 393.76142 195.76142 396 193 396 L 95 396 C 92.23858 396 90 393.76142 90 391 L 90 365 C 90 362.23858 92.23858 360 95 360 Z" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_12_shadow" filter="url(#Shadow)">
<path d="M 95 270 L 193 270 C 195.76142 270 198 272.23858 198 275 L 198 301 C 198 303.76142 195.76142 306 193 306 L 95 306 C 92.23858 306 90 303.76142 90 301 L 90 275 C 90 272.23858 92.23858 270 95 270 Z" fill="#0096ff" fill-opacity=".3"/>
<path d="M 95 270 L 193 270 C 195.76142 270 198 272.23858 198 275 L 198 301 C 198 303.76142 195.76142 306 193 306 L 95 306 C 92.23858 306 90 303.76142 90 301 L 90 275 C 90 272.23858 92.23858 270 95 270 Z" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_123_shadow" filter="url(#Shadow)">
<rect x="315" y="279" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="279" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_130_shadow" filter="url(#Shadow)">
<rect x="315" y="459" width="90" height="18" fill="#008f00" fill-opacity=".3"/>
<rect x="315" y="459" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_139_shadow" filter="url(#Shadow)">
<rect x="315" y="351" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="351" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_137_shadow" filter="url(#Shadow)">
<rect x="315" y="387" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="387" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_135_shadow" filter="url(#Shadow)">
<rect x="315" y="423" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="423" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_132_shadow" filter="url(#Shadow)">
<rect x="315" y="315" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="315" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_88">
<rect x="72" y="576" width="162" height="36" fill="white"/>
</g>
<g id="Graphic_126">
<rect x="270" y="576" width="162" height="36" fill="white"/>
</g>
<g id="Graphic_32">
<rect x="288" y="216" width="144" height="288" fill="white"/>
<rect x="288" y="216" width="144" height="288" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(293 221)" fill="black">
<tspan font-family="Helvetica" font-size="14" fill="black" x="23.427734" y="14">Closed tickets</tspan>
<tspan font-family="Helvetica" font-size="12" fill="black" x="40.984375" y="35">resolution</tspan>
</text>
</g>
<g id="Graphic_99">
<rect x="72" y="216" width="144" height="288" fill="white"/>
<rect x="72" y="216" width="144" height="288" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(77 221)" fill="black">
<tspan font-family="Helvetica" font-size="14" fill="black" x="28.093262" y="14">Open tickets</tspan>
<tspan font-family="Helvetica" font-size="12" fill="black" x="37.316406" y="35">triage state</tspan>
</text>
</g>
<g id="Graphic_10">
<path d="M 95 450 L 193 450 C 195.76142 450 198 452.2386 198 455 L 198 481 C 198 483.7614 195.76142 486 193 486 L 95 486 C 92.23858 486 90 483.7614 90 481 L 90 455 C 90 452.2386 92.23858 450 95 450 Z" fill="#0096ff" fill-opacity=".3"/>
<path d="M 95 450 L 193 450 C 195.76142 450 198 452.2386 198 455 L 198 481 C 198 483.7614 195.76142 486 193 486 L 95 486 C 92.23858 486 90 483.7614 90 481 L 90 455 C 90 452.2386 92.23858 450 95 450 Z" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(95 454)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="22.987305" y="11">Ready for </tspan>
<tspan font-family="Helvetica" font-size="12" fill="black" x="27.323242" y="25">Checkin</tspan>
</text>
</g>
<g id="Graphic_11">
<path d="M 95 360 L 193 360 C 195.76142 360 198 362.23858 198 365 L 198 391 C 198 393.76142 195.76142 396 193 396 L 95 396 C 92.23858 396 90 393.76142 90 391 L 90 365 C 90 362.23858 92.23858 360 95 360 Z" fill="#0096ff" fill-opacity=".3"/>
<path d="M 95 360 L 193 360 C 195.76142 360 198 362.23858 198 365 L 198 391 C 198 393.76142 195.76142 396 193 396 L 95 396 C 92.23858 396 90 393.76142 90 391 L 90 365 C 90 362.23858 92.23858 360 95 360 Z" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(95 371)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="23.983398" y="11">Accepted</tspan>
</text>
</g>
<g id="Graphic_12">
<path d="M 95 270 L 193 270 C 195.76142 270 198 272.23858 198 275 L 198 301 C 198 303.76142 195.76142 306 193 306 L 95 306 C 92.23858 306 90 303.76142 90 301 L 90 275 C 90 272.23858 92.23858 270 95 270 Z" fill="#0096ff" fill-opacity=".3"/>
<path d="M 95 270 L 193 270 C 195.76142 270 198 272.23858 198 275 L 198 301 C 198 303.76142 195.76142 306 193 306 L 95 306 C 92.23858 306 90 303.76142 90 301 L 90 275 C 90 272.23858 92.23858 270 95 270 Z" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(95 281)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="17.31836" y="11">Unreviewed</tspan>
</text>
</g>
<g id="Graphic_123">
<rect x="315" y="279" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="279" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(320 281)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="15.982422" y="11">duplicate</tspan>
</text>
</g>
<g id="Graphic_130">
<rect x="315" y="459" width="90" height="18" fill="#008f00" fill-opacity=".3"/>
<rect x="315" y="459" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(320 461)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="27.326172" y="11">fixed</tspan>
</text>
</g>
<g id="Graphic_139">
<rect x="315" y="351" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="351" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(320 353)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="22.990234" y="11">invalid</tspan>
</text>
</g>
<g id="Graphic_137">
<rect x="315" y="387" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="387" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(320 389)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="13.978516" y="11">needsinfo</tspan>
</text>
</g>
<g id="Graphic_135">
<rect x="315" y="423" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="423" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(320 425)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="8.995117" y="11">worksforme</tspan>
</text>
</g>
<g id="Graphic_132">
<rect x="315" y="315" width="90" height="18" fill="#ff2600" fill-opacity=".3"/>
<rect x="315" y="315" width="90" height="18" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(320 317)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="21.326172" y="11">wontfix</tspan>
</text>
</g>
<g id="Line_33">
<line x1="72" y1="243" x2="216" y2="243" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Line_36">
<line x1="288" y1="243" x2="432" y2="243" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"/>
</g>
<g id="Graphic_127">
<rect x="351" y="594" width="81" height="18" fill="#008f00" fill-opacity=".3"/>
<text transform="translate(356 596)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="7.817383" y="11">completed</tspan>
</text>
</g>
<g id="Graphic_125">
<rect x="351" y="576" width="81" height="18" fill="#ff2600" fill-opacity=".3"/>
<text transform="translate(356 578)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="14.148438" y="11">stopped</tspan>
</text>
</g>
<g id="Graphic_129">
<rect x="270" y="594" width="81" height="18" fill="#0096ff" fill-opacity=".3"/>
<text transform="translate(275 596)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="5.819336" y="11">in progress</tspan>
</text>
</g>
<g id="Line_42">
<line x1="183.6" y1="585" x2="208.5" y2="585" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Graphic_43">
<text transform="translate(72 578)" fill="#008f00">
<tspan font-family="Helvetica" font-size="12" fill="#008f00" x="17.173828" y="11">Ticket triagers </tspan>
</text>
</g>
<g id="Line_44">
<line x1="183.6" y1="603" x2="208.5" y2="603" marker-end="url(#FilledArrow_Marker_2)" stroke="#005493" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Graphic_45">
<text transform="translate(72 596)" fill="#005493">
<tspan font-family="Helvetica" font-size="12" fill="#005493" x="52.3125" y="11">Mergers</tspan>
</text>
</g>
<g id="Graphic_128">
<rect x="270" y="576" width="81" height="18" fill="white"/>
<text transform="translate(275 578)" fill="black">
<tspan font-family="Helvetica" font-size="12" fill="black" x="19.492188" y="11">status</tspan>
</text>
</g>
<g id="Line_124">
<line x1="252" y1="288" x2="302.1" y2="288" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Line_140">
<line x1="252" y1="288" x2="306.5053" y2="350.29176" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Line_138">
<line x1="252" y1="288" x2="308.50006" y2="384.85725" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Line_136">
<line x1="252" y1="288" x2="309.82944" y2="420.18157" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Line_131">
<line x1="198" y1="468" x2="302.1" y2="468" marker-end="url(#FilledArrow_Marker_2)" stroke="#005493" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Line_54">
<line x1="144" y1="306" x2="144" y2="347.1" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Line_82">
<line x1="198" y1="288" x2="252" y2="288" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Line_60">
<line x1="144" y1="396" x2="144" y2="437.1" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
<g id="Graphic_89">
<rect x="189" y="144" width="243" height="54" fill="white"/>
<path d="M 432 198 L 189 198 L 189 144 L 432 144 Z" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
<text transform="translate(193 150)" fill="#595959">
<tspan font-family="Helvetica" font-size="12" fill="#595959" x="19.789062" y="11">The ticket was already reported, was </tspan>
<tspan font-family="Helvetica" font-size="12" fill="#595959" x=".8017578" y="25">already rejected, isn&apos;t a bug, doesn&apos;t contain </tspan>
<tspan font-family="Helvetica" font-size="12" fill="#595959" x="1.2792969" y="39">enough information, or can&apos;t be reproduced.</tspan>
</text>
</g>
<g id="Line_90">
<line x1="252" y1="278.5" x2="252" y2="198" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
</g>
<g id="Graphic_91">
<path d="M 258.36395 281.63605 C 261.8787 285.15076 261.8787 290.84924 258.36395 294.36395 C 254.84924 297.8787 249.15076 297.8787 245.63605 294.36395 C 242.1213 290.84924 242.1213 285.15076 245.63605 281.63605 C 249.15076 278.1213 254.84924 278.1213 258.36395 281.63605" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
</g>
<g id="Graphic_96">
<rect x="72" y="144" width="99" height="54" fill="white"/>
<path d="M 171 198 L 72 198 L 72 144 L 171 144 Z" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
<text transform="translate(76 150)" fill="#595959">
<tspan font-family="Helvetica" font-size="12" fill="#595959" x="8.486328" y="11">The ticket is a </tspan>
<tspan font-family="Helvetica" font-size="12" fill="#595959" x="4.463867" y="25">bug and should </tspan>
<tspan font-family="Helvetica" font-size="12" fill="#595959" x="22.81836" y="39">be fixed.</tspan>
</text>
</g>
<g id="Graphic_97">
<path d="M 150.36395 317.63605 C 153.87869 321.15076 153.87869 326.84924 150.36395 330.36395 C 146.84924 333.8787 141.15076 333.8787 137.63605 330.36395 C 134.12131 326.84924 134.12131 321.15076 137.63605 317.63605 C 141.15076 314.1213 146.84924 314.1213 150.36395 317.63605" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
</g>
<g id="Line_98">
<path d="M 134.5 324 L 81 324 L 81 198" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
</g>
<g id="Graphic_102">
<rect x="72" y="522" width="342" height="36" fill="white"/>
<path d="M 414 558 L 72 558 L 72 522 L 414 522 Z" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
<text transform="translate(76 526)" fill="#595959">
<tspan font-family="Helvetica" font-size="12" fill="#595959" x="7.241211" y="11">The ticket has a patch which applies cleanly and includes all </tspan>
<tspan font-family="Helvetica" font-size="12" fill="#595959" x="26.591797" y="25">needed tests and docs. A merger can commit it as is.</tspan>
</text>
</g>
<g id="Graphic_103">
<path d="M 150.36395 407.63605 C 153.87869 411.15076 153.87869 416.84924 150.36395 420.36395 C 146.84924 423.8787 141.15076 423.8787 137.63605 420.36395 C 134.12131 416.84924 134.12131 411.15076 137.63605 407.63605 C 141.15076 404.1213 146.84924 404.1213 150.36395 407.63605" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
</g>
<g id="Line_104">
<path d="M 134.5 414 L 81 414 L 81 522" stroke="#595959" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.0,4.0" stroke-width="1"/>
</g>
<g id="Line_151">
<line x1="252" y1="288" x2="303.79966" y2="317.5998" marker-end="url(#FilledArrow_Marker)" stroke="#008f00" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -28,7 +28,7 @@ If an `unreviewed ticket`_ reports a bug, try and reproduce it. If you can
reproduce it and it seems valid, make a note that you confirmed the bug and
accept the ticket. Make sure the ticket is filed under the correct component
area. Consider writing a patch that adds a test for the bug's behavior, even if
you don't fix the bug itself. See more at :ref:`how-can-i-help-with-triaging`
you don't fix the bug itself. See more at :ref:`how-can-i-help-with-triaging`.
Review patches of accepted tickets
----------------------------------

View file

@ -49,47 +49,58 @@ attribute easily tells us what and who each ticket is waiting on.
Since a picture is worth a thousand words, let's start there:
.. image:: /internals/_images/triage_process.*
:height: 750
:width: 600
.. image:: /internals/_images/contribution_process.*
:alt: Django's ticket triage workflow
We've got two roles in this diagram:
We have four roles in this diagram. Maintainers (also known as Fellows) usually
take part in all of them, but anyone in the Django community can participate in
any role except merger. The :ref:`merger role <mergers-team>` is granted by a
vote of the :ref:`Steering Council <steering-council>`.
* Mergers: people with commit access who are responsible for making the
final decision to merge a change.
* Triagers: anyone can take on this role by checking whether a ticket describes
a real issue and keeping the tracker organized.
* Ticket triagers: anyone in the Django community who chooses to
become involved in Django's development process. Our Trac installation
is intentionally left open to the public, and anyone can triage tickets.
Django is a community project, and we encourage :ref:`triage by the
community<how-can-i-help-with-triaging>`.
* Bug fixers: anyone can contribute by opening a pull request and working on a
solution for a ticket.
By way of example, here we see the lifecycle of an average ticket:
* Reviewers: anyone can review pull requests and suggest improvements.
* Alice creates a ticket and sends an incomplete pull request (no tests,
* Mergers: people with commit access who make the final decision to merge a
change.
Our Trac system is intentionally open to the public, and anyone can help by
working on tickets. Django is a community project, and we encourage
:ref:`triage and collaboration by the community
<how-can-i-help-with-triaging>`. This could be you!
For example, here's the typical lifecycle of a ticket:
* Alice creates a ticket and opens an incomplete pull request (missing tests,
incorrect implementation).
* Bob reviews the pull request, marks the ticket as "Accepted", "needs tests",
and "patch needs improvement", and leaves a comment telling Alice how the
patch could be improved.
* Bob reviews the pull request, marks the ticket as "Accepted", sets the
flags "needs tests" and "patch needs improvement", and leaves a comment
explaining how Alice can improve the patch. This puts the ticket
automatically into the "waiting on author" queue within the "accepted" stage.
* Alice updates the pull request, adding tests (but not changing the
implementation). She removes the two flags.
* Alice updates the pull request, adding tests (but not yet fixing the
implementation), and removes the two flags. The ticket moves into the "needs
PR review" queue.
* Charlie reviews the pull request and resets the "patch needs improvement"
flag with another comment about improving the implementation.
* Charlie reviews the pull request, sets the "patch needs improvement" flag
again, and leaves another comment suggesting changes to the implementation.
The ticket moves back to the "waiting on author" queue.
* Alice updates the pull request, fixing the implementation. She removes the
"patch needs improvement" flag.
* Alice updates the pull request again, this time fixing the implementation,
and removes the "patch needs improvement" flag. The ticket moves once more
into the "needs PR review" queue.
* Daisy reviews the pull request and marks the ticket as "Ready for checkin".
* Jacob, a :ref:`merger <mergers-team>`, reviews the pull request and merges
it.
* Jacob, a :ref:`merger <mergers-team>`, reviews and merges the pull request.
Some tickets require much less feedback than this, but then again some tickets
require much much more.
Some tickets move through these steps quickly, while others take more time and
discussion. Each contribution helps Django improve.
.. _triage-stages:
@ -104,16 +115,15 @@ Unreviewed
The ticket has not been reviewed by anyone who felt qualified to make a
judgment about whether the ticket contained a valid issue or ought to be closed
for any of the various reasons.
for any reasons. Unreviewed tickets appear in the "triage" queue.
Accepted
--------
The big gray area! The absolute meaning of "accepted" is that the issue
described in the ticket is valid and is in some stage of being worked on.
Beyond that there are several considerations:
The absolute meaning of "accepted" is that the issue described in the ticket is
valid and actionable. It is broken out into three queues:
* **Accepted + No Flags**
* **Needs Patch** (Accepted + No Flags)
The ticket is valid, but no one has submitted a patch for it yet. Often this
means you could safely start writing a fix for it. This is generally more
@ -126,14 +136,14 @@ Beyond that there are several considerations:
<requesting-features>` and received community and :ref:`Steering Council
<steering-council>` approval, or been accepted in a DEP.
* **Accepted + Has Patch**
* **Needs PR Review** (Accepted + Has Patch)
The ticket is waiting for people to review the supplied solution. This means
downloading the patch and trying it out, verifying that it contains tests
and docs, running the test suite with the included patch, and leaving
feedback on the ticket.
* **Accepted + Has Patch + Needs ...**
* **Waiting On Author** (Accepted + Has Patch + Needs fixes)
This means the ticket has been reviewed, and has been found to need further
work. "Needs tests" and "Needs documentation" are self-explanatory. "Patch
@ -339,10 +349,10 @@ bring the issue to the `Django Forum`_ instead.
.. _how-can-i-help-with-triaging:
How can I help with triaging?
=============================
How can I help with development?
================================
The triage process is primarily driven by community members. Really,
The development process is primarily driven by community members. Really,
**ANYONE** can help.
To get involved, start by `creating an account on Trac`_. If you have an

View file

@ -196,6 +196,8 @@ Model fields
* **fields.E133**: ``max_digits`` must be a positive integer.
* **fields.E134**: ``max_digits`` must be greater or equal to
``decimal_places``.
* **fields.E135**: ``DecimalField``s ``max_digits`` and ``decimal_places``
must both be defined or both omitted.
* **fields.E140**: ``FilePathField``\s must have either ``allow_files`` or
``allow_folders`` set to True.
* **fields.E150**: ``GenericIPAddressField``\s cannot have ``blank=True`` if

View file

@ -1223,7 +1223,7 @@ Subclassing the built-in database backends
==========================================
Django comes with built-in database backends. You may subclass an existing
database backends to modify its behavior, features, or configuration.
database backend to modify its behavior, features, or configuration.
Consider, for example, that you need to change a single database feature.
First, you have to create a new directory with a ``base`` module in it. For

View file

@ -257,3 +257,12 @@ The ``Storage`` class
Returns the URL where the contents of the file referenced by ``name``
can be accessed. For storage systems that don't support access by URL
this will raise ``NotImplementedError`` instead.
.. admonition:: There are community-maintained solutions too!
Django has a vibrant ecosystem. There are storage backends
highlighted on the `Community Ecosystem`_ page. The Django Packages
`Storage Backends grid`_ has even more options for you!
.. _Community Ecosystem: https://www.djangoproject.com/community/ecosystem/#storage-and-static-files
.. _Storage Backends grid: https://djangopackages.org/grids/g/storage-backends/

View file

@ -862,16 +862,22 @@ A fixed-precision decimal number, represented in Python by a
:class:`~decimal.Decimal` instance. It validates the input using
:class:`~django.core.validators.DecimalValidator`.
Has the following **required** arguments:
Has the following arguments:
.. attribute:: DecimalField.max_digits
The maximum number of digits allowed in the number. Note that this number
must be greater than or equal to ``decimal_places``.
must be greater than or equal to ``decimal_places``. It's always required
on MySQL because this database doesn't support numeric fields with no
precision. It's also required for all database backends when
:attr:`~DecimalField.decimal_places` is provided.
.. attribute:: DecimalField.decimal_places
The number of decimal places to store with the number.
The number of decimal places to store with the number. It's always required
on MySQL because this database doesn't support numeric fields with no
precision. It's also required for all database backends when
:attr:`~DecimalField.max_digits` is provided.
For example, to store numbers up to ``999.99`` with a resolution of 2 decimal
places, you'd use::
@ -895,6 +901,11 @@ when :attr:`~django.forms.Field.localize` is ``False`` or
should also be aware of :ref:`SQLite limitations <sqlite-decimal-handling>`
of decimal fields.
.. versionchanged:: 6.1
Support for ``DecimalField`` with no precision was added on Oracle,
PostgreSQL, and SQLite.
``DurationField``
-----------------
@ -2523,8 +2534,8 @@ Field API reference
.. method:: get_db_prep_value(value, connection, prepared=False)
Converts ``value`` to a backend-specific value. By default it returns
``value`` if ``prepared=True`` and :meth:`~Field.get_prep_value` if is
``False``.
``value`` if ``prepared=True``, and :meth:`get_prep_value(value)
<Field.get_prep_value>` otherwise.
See :ref:`converting-query-values-to-database-values` for usage.

View file

@ -312,6 +312,12 @@ not be looking at your Django code. For example::
ordering = [F("author").asc(nulls_last=True)]
.. admonition:: Default ordering and GROUP BY
In :ref:`GROUP BY queries <aggregation-ordering-interaction>` (for example,
those using :meth:`~.QuerySet.values` and :meth:`~.QuerySet.annotate`), the
default ordering is not applied.
.. warning::
Ordering is not a free operation. Each field you add to the ordering

View file

@ -133,8 +133,8 @@ to, or in lieu of custom ``field.clean()`` methods.
:param code: If not ``None``, overrides :attr:`code`.
:param allowlist: If not ``None``, overrides :attr:`allowlist`.
An :class:`EmailValidator` ensures that a value looks like an email, and
raises a :exc:`~django.core.exceptions.ValidationError` with
An :class:`EmailValidator` ensures that a value looks like an email
address, and raises a :exc:`~django.core.exceptions.ValidationError` with
:attr:`message` and :attr:`code` if it doesn't. Values longer than 320
characters are always considered invalid.

View file

@ -7,4 +7,19 @@ Django 4.2.26 release notes
Django 4.2.26 fixes one security issue with severity "high" and one security
issue with severity "moderate" in 4.2.25.
...
CVE-2025-64458: Potential denial-of-service vulnerability in ``HttpResponseRedirect`` and ``HttpResponsePermanentRedirect`` on Windows
======================================================================================================================================
Python's :func:`NFKC normalization <python:unicodedata.normalize>` is slow on
Windows. As a consequence, :class:`~django.http.HttpResponseRedirect`,
:class:`~django.http.HttpResponsePermanentRedirect`, and the shortcut
:func:`redirect() <django.shortcuts.redirect>` were subject to a potential
denial-of-service attack via certain inputs with a very large number of Unicode
characters (follow up to :cve:`2025-27556`).
CVE-2025-64459: Potential SQL injection via ``_connector`` keyword argument
===========================================================================
:meth:`.QuerySet.filter`, :meth:`~.QuerySet.exclude`, :meth:`~.QuerySet.get`,
and :class:`~.Q` were subject to SQL injection using a suitably crafted
dictionary, with dictionary expansion, as the ``_connector`` argument.

View file

@ -7,4 +7,19 @@ Django 5.1.14 release notes
Django 5.1.14 fixes one security issue with severity "high" and one security
issue with severity "moderate" in 5.1.13.
...
CVE-2025-64458: Potential denial-of-service vulnerability in ``HttpResponseRedirect`` and ``HttpResponsePermanentRedirect`` on Windows
======================================================================================================================================
Python's :func:`NFKC normalization <python:unicodedata.normalize>` is slow on
Windows. As a consequence, :class:`~django.http.HttpResponseRedirect`,
:class:`~django.http.HttpResponsePermanentRedirect`, and the shortcut
:func:`redirect() <django.shortcuts.redirect>` were subject to a potential
denial-of-service attack via certain inputs with a very large number of Unicode
characters (follow up to :cve:`2025-27556`).
CVE-2025-64459: Potential SQL injection via ``_connector`` keyword argument
===========================================================================
:meth:`.QuerySet.filter`, :meth:`~.QuerySet.exclude`, :meth:`~.QuerySet.get`,
and :class:`~.Q` were subject to SQL injection using a suitably crafted
dictionary, with dictionary expansion, as the ``_connector`` argument.

View file

@ -8,6 +8,23 @@ Django 5.2.8 fixes one security issue with severity "high", one security issue
with severity "moderate", and several bugs in 5.2.7. It also adds compatibility
with Python 3.14.
CVE-2025-64458: Potential denial-of-service vulnerability in ``HttpResponseRedirect`` and ``HttpResponsePermanentRedirect`` on Windows
======================================================================================================================================
Python's :func:`NFKC normalization <python:unicodedata.normalize>` is slow on
Windows. As a consequence, :class:`~django.http.HttpResponseRedirect`,
:class:`~django.http.HttpResponsePermanentRedirect`, and the shortcut
:func:`redirect() <django.shortcuts.redirect>` were subject to a potential
denial-of-service attack via certain inputs with a very large number of Unicode
characters (follow up to :cve:`2025-27556`).
CVE-2025-64459: Potential SQL injection via ``_connector`` keyword argument
===========================================================================
:meth:`.QuerySet.filter`, :meth:`~.QuerySet.exclude`, :meth:`~.QuerySet.get`,
and :class:`~.Q` were subject to SQL injection using a suitably crafted
dictionary, with dictionary expansion, as the ``_connector`` argument.
Bugfixes
========
@ -16,3 +33,6 @@ Bugfixes
* Fixed a bug in Django 5.2 where ``QuerySet.first()`` and ``QuerySet.last()``
raised an error on querysets performing aggregation that selected all fields
of a composite primary key.
* Fixed a bug in Django 5.2 where proxy models having a ``CompositePrimaryKey``
incorrectly raised a ``models.E042`` system check error.

12
docs/releases/5.2.9.txt Normal file
View file

@ -0,0 +1,12 @@
==========================
Django 5.2.9 release notes
==========================
*Expected December 2, 2025*
Django 5.2.9 fixes several bugs in 5.2.8.
Bugfixes
========
* ...

View file

@ -92,7 +92,8 @@ Minor features
:mod:`django.contrib.admin`
~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ...
* The admin site login view now redirects authenticated users to the next URL,
if available, instead of always redirecting to the admin index page.
:mod:`django.contrib.admindocs`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -125,7 +126,9 @@ Minor features
:mod:`django.contrib.postgres`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ...
* :djadmin:`inspectdb` now introspects
:class:`~django.contrib.postgres.fields.HStoreField` when ``psycopg`` 3.2+ is
installed and ``django.contrib.postgres`` is in :setting:`INSTALLED_APPS`.
:mod:`django.contrib.redirects`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -246,6 +249,11 @@ Models
top-level or nested JSON ``null`` values. See
:ref:`storing-and-querying-for-none` for usage examples and some caveats.
* :attr:`DecimalField.max_digits <django.db.models.DecimalField.max_digits>`
and :attr:`DecimalField.decimal_places
<django.db.models.DecimalField.decimal_places>` are no longer required to be
set on Oracle, PostgreSQL, and SQLite.
Pagination
~~~~~~~~~~
@ -316,6 +324,11 @@ backends.
database has native support for ``DurationField``, override this method to
simply return the value.
* The ``DatabaseIntrospection.get_relations()`` should now return a dictionary
with 3-tuples containing (``field_name_other_table``, ``other_table``,
``db_on_delete``) as values. ``db_on_delete`` is one of the database-level
delete options e.g. :attr:`~django.db.models.DB_CASCADE`.
:mod:`django.contrib.gis`
-------------------------

View file

@ -39,6 +39,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
5.2.9
5.2.8
5.2.7
5.2.6

View file

@ -36,6 +36,30 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
November 5, 2025 - :cve:`2025-64458`
------------------------------------
Potential denial-of-service vulnerability in ``HttpResponseRedirect`` and
``HttpResponsePermanentRedirect`` on Windows. `Full description
<https://www.djangoproject.com/weblog/2025/nov/05/security-releases/>`__
* Django 6.0 :commit:`(patch) <6e13348436fccf8f22982921d6a3a3e65c956a9f>`
* Django 5.2 :commit:`(patch) <4f5d904b63751dea9ffc3b0e046404a7fa5881ac>`
* Django 5.1 :commit:`(patch) <3790593781d26168e7306b5b2f8ea0309de16242>`
* Django 4.2 :commit:`(patch) <770eea38d7a0e9ba9455140b5a9a9e33618226a7>`
November 5, 2025 - :cve:`2025-64459`
------------------------------------
Potential SQL injection via ``_connector`` keyword argument in ``QuerySet`` and
``Q`` objects. `Full description
<https://www.djangoproject.com/weblog/2025/nov/05/security-releases/>`__
* Django 6.0 :commit:`(patch) <06dd38324ac3d60d83d9f3adabf0dcdf423d2a85>`
* Django 5.2 :commit:`(patch) <6703f364d767e949c5b0e4016433ef75063b4f9b>`
* Django 5.1 :commit:`(patch) <72d2c87431f2ae0431d65d0ec792047f078c8241>`
* Django 4.2 :commit:`(patch) <59ae82e67053d281ff4562a24bbba21299f0a7d4>`
October 1, 2025 - :cve:`2025-59681`
-----------------------------------

View file

@ -627,8 +627,15 @@ fields you also select in a ``values()`` call.
You might reasonably ask why Django doesn't remove the extraneous columns
for you. The main reason is consistency with ``distinct()`` and other
places: Django **never** removes ordering constraints that you have
specified (and we can't change those other methods' behavior, as that
would violate our :doc:`/misc/api-stability` policy).
specified *explicitly with* ``order_by()`` (and we can't change those
other methods' behavior, as that would violate our
:doc:`/misc/api-stability` policy).
.. admonition:: Default ordering not applied to GROUP BY
``GROUP BY`` queries (for example, those using ``.values()`` and
``.annotate()``) don't use the model's default ordering.
Use ``order_by()`` explicitly when a given order is needed.
Aggregating annotations
-----------------------

View file

@ -773,6 +773,16 @@ specify this backend, put the following in your settings::
This backend is not intended for use in production -- it is provided as a
convenience that can be used during development.
.. admonition:: There are community-maintained solutions too!
Django has a vibrant ecosystem. There are email backends
highlighted on the `Community Ecosystem`_ page. The Django Packages
`Email grid`_ has even more options for you!
.. _Community Ecosystem: https://www.djangoproject.com/community/ecosystem/#email-and-notifications
.. _Email grid: https://djangopackages.org/grids/g/email/
.. _topic-custom-email-backend:
Defining a custom email backend

View file

@ -426,6 +426,12 @@ Django is compatible with versions of PyPy corresponding to the supported
Python versions, but you will need to check the compatibility of other
libraries you rely on.
That said, a lot of a web framework's work is done by concatenating
strings, and PyPy has an issue with that (see
`this PyPy blog
<https://pypy.org/posts/2023/01/string-concatenation-quadratic.html>`_).
This may cause performance issues, depending on your use.
C implementations of Python libraries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -323,11 +323,15 @@ in order to reduce the number of sync/async calling-style switches within a
they are async before being called. This means that an asynchronous receiver
registered before a synchronous receiver may be executed after the synchronous
receiver. In addition, async receivers are executed concurrently using
``asyncio.gather()``.
:class:`asyncio.TaskGroup`.
All built-in signals, except those in the async request-response cycle, are
dispatched using :meth:`Signal.send`.
.. versionchanged:: 6.1
In older versions, async receivers were executed via ``asyncio.gather()``.
Disconnecting signals
=====================

View file

@ -10,8 +10,11 @@ QUnit.test('init', function(assert) {
$('<div class="helptext">This is helpful.</div>').appendTo('#test');
$('<select id="id"><option value="0">A</option></select>').appendTo('#test');
SelectFilter.init('id', 'things', 0);
assert.equal($('#test').children().first().prop("tagName"), "DIV");
assert.equal($('#test').children().first().attr("class"), "selector");
assert.deepEqual(
Array.from($('#test')[0].children).map(child => child.tagName),
["LABEL", "DIV", "DIV"]
);
assert.equal($('.helptext')[0].nextSibling.getAttribute("class"), "selector");
assert.equal($('.selector-available label').text().trim(), "Available things");
assert.equal($('.selector-available label').attr("id"), "id_from_label");
assert.equal($('.selector-chosen label').text().trim(), "Chosen things");

View file

@ -33,7 +33,6 @@ from django.core.management.base import LabelCommand, SystemCheckError
from django.core.management.commands.loaddata import Command as LoaddataCommand
from django.core.management.commands.runserver import Command as RunserverCommand
from django.core.management.commands.testserver import Command as TestserverCommand
from django.core.management.utils import find_formatters
from django.db import ConnectionHandler, connection
from django.db.migrations.recorder import MigrationRecorder
from django.test import LiveServerTestCase, SimpleTestCase, TestCase, override_settings
@ -49,6 +48,8 @@ custom_templates_dir = os.path.join(os.path.dirname(__file__), "custom_templates
SYSTEM_CHECK_MSG = "System check identified no issues"
HAS_BLACK = shutil.which("black")
class AdminScriptTestCase(SimpleTestCase):
def setUp(self):
@ -112,20 +113,7 @@ class AdminScriptTestCase(SimpleTestCase):
paths.append(os.path.dirname(backend_dir))
return paths
@cached_property
def path_without_formatters(self):
return os.pathsep.join(
[
path_component
for path_component in os.environ.get("PATH", "").split(os.pathsep)
for formatter_path in find_formatters().values()
if os.path.commonpath([path_component, formatter_path]) == os.sep
]
)
def run_test(
self, args, settings_file=None, apps=None, umask=-1, discover_formatters=False
):
def run_test(self, args, settings_file=None, apps=None, umask=-1):
base_dir = os.path.dirname(self.test_dir)
# The base dir for Django's tests is one level up.
tests_dir = os.path.dirname(os.path.dirname(__file__))
@ -147,8 +135,6 @@ class AdminScriptTestCase(SimpleTestCase):
python_path.extend(ext_backend_base_dirs)
test_environ["PYTHONPATH"] = os.pathsep.join(python_path)
test_environ["PYTHONWARNINGS"] = ""
if not discover_formatters:
test_environ["PATH"] = self.path_without_formatters
p = subprocess.run(
[sys.executable, *args],
@ -160,19 +146,10 @@ class AdminScriptTestCase(SimpleTestCase):
)
return p.stdout, p.stderr
def run_django_admin(
self, args, settings_file=None, umask=-1, discover_formatters=False
):
return self.run_test(
["-m", "django", *args],
settings_file,
umask=umask,
discover_formatters=discover_formatters,
)
def run_django_admin(self, args, settings_file=None, umask=-1):
return self.run_test(["-m", "django", *args], settings_file, umask=umask)
def run_manage(
self, args, settings_file=None, manage_py=None, discover_formatters=False
):
def run_manage(self, args, settings_file=None, manage_py=None):
template_manage_py = (
os.path.join(os.path.dirname(__file__), manage_py)
if manage_py
@ -191,11 +168,17 @@ class AdminScriptTestCase(SimpleTestCase):
with open(test_manage_py, "w") as fp:
fp.write(manage_py_contents)
return self.run_test(
["./manage.py", *args],
settings_file,
discover_formatters=discover_formatters,
)
return self.run_test(["./manage.py", *args], settings_file)
def assertInAfterFormatting(self, member, container, msg=None):
if HAS_BLACK:
import black
# Black does not have a stable API, but this is still less fragile
# than attempting to filter out all paths where it is available.
member = black.format_str(member, mode=black.FileMode())
self.assertIn(member, container, msg=msg)
def assertNoOutput(self, stream):
"Utility assertion: assert that the given stream is empty"
@ -772,7 +755,7 @@ class DjangoAdminSettingsDirectory(AdminScriptTestCase):
with open(os.path.join(app_path, "apps.py")) as f:
content = f.read()
self.assertIn("class SettingsTestConfig(AppConfig)", content)
self.assertIn("name = 'settings_test'", content)
self.assertInAfterFormatting("name = 'settings_test'", content)
def test_setup_environ_custom_template(self):
"""
@ -797,7 +780,7 @@ class DjangoAdminSettingsDirectory(AdminScriptTestCase):
with open(os.path.join(app_path, "apps.py"), encoding="utf8") as f:
content = f.read()
self.assertIn("class こんにちはConfig(AppConfig)", content)
self.assertIn("name = 'こんにちは'", content)
self.assertInAfterFormatting("name = 'こんにちは'", content)
def test_builtin_command(self):
"""
@ -1959,7 +1942,7 @@ class CommandTypes(AdminScriptTestCase):
def test_version(self):
"version is handled as a special case"
args = ["version"]
out, err = self.run_manage(args, discover_formatters=True)
out, err = self.run_manage(args)
self.assertNoOutput(err)
self.assertOutput(out, get_version())
@ -2719,7 +2702,7 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase):
args = ["startproject", "--template", template_path, "customtestproject"]
testproject_dir = os.path.join(self.test_dir, "customtestproject")
_, err = self.run_django_admin(args, discover_formatters=True)
_, err = self.run_django_admin(args)
self.assertNoOutput(err)
with open(
os.path.join(template_path, "additional_dir", "requirements.in")
@ -2814,7 +2797,7 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase):
f"{self.live_server_url}/user_agent_check/project_template.tgz"
)
args = ["startproject", "--template", template_url, "urltestproject"]
_, err = self.run_django_admin(args, discover_formatters=True)
_, err = self.run_django_admin(args)
self.assertNoOutput(err)
self.assertIn("Django/%s" % get_version(), user_agent)
@ -2885,8 +2868,10 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase):
test_manage_py = os.path.join(testproject_dir, "manage.py")
with open(test_manage_py) as fp:
content = fp.read()
self.assertIn('project_name = "another_project"', content)
self.assertIn('project_directory = "%s"' % testproject_dir, content)
self.assertInAfterFormatting('project_name = "another_project"', content)
self.assertInAfterFormatting(
'project_directory = "%s"' % testproject_dir, content
)
def test_no_escaping_of_project_variables(self):
"Make sure template context variables are not html escaped"
@ -2996,7 +2981,7 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase):
self.assertNoOutput(err)
render_py_path = os.path.join(testproject_dir, ".hidden", "render.py")
with open(render_py_path) as fp:
self.assertIn(
self.assertInAfterFormatting(
f"# The {project_name} should be rendered.",
fp.read(),
)
@ -3156,7 +3141,7 @@ class StartApp(AdminScriptTestCase):
with open(os.path.join(app_path, "apps.py")) as f:
content = f.read()
self.assertIn("class NewAppConfig(AppConfig)", content)
self.assertIn("name = 'new_app'", content)
self.assertInAfterFormatting("name = 'new_app'", content)
def test_creates_directory_when_custom_app_destination_missing(self):
args = [

View file

@ -2413,6 +2413,32 @@ class AdminViewPermissionsTest(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context[REDIRECT_FIELD_NAME], reverse("admin:index"))
def test_login_redirect_when_logged_in(self):
self.client.force_login(self.superuser)
response = self.client.get(reverse("admin:login"))
self.assertRedirects(response, reverse("admin:index"))
def test_login_redirect_to_next_url_when_logged_in(self):
self.client.force_login(self.superuser)
next_url = reverse("admin:admin_views_article_add")
response = self.client.get(
reverse("admin:login"),
query_params={REDIRECT_FIELD_NAME: next_url},
)
self.assertRedirects(response, next_url)
def test_login_redirect_unsafe_next_url_when_logged_in(self):
self.client.force_login(self.superuser)
response = self.client.get(
reverse("admin:login"),
query_params={
REDIRECT_FIELD_NAME: "https://example.com/bad",
},
)
self.assertRedirects(
response, reverse("admin:index"), fetch_redirect_response=False
)
def test_login_has_permission(self):
# Regular User should not be able to login.
response = self.client.get(reverse("has_permission_admin:index"))

View file

@ -206,6 +206,45 @@ class UserModelChecksTests(SimpleTestCase):
],
)
@override_settings(AUTH_USER_MODEL="auth_tests.VulnerableStaticUser")
def test_is_anonymous_authenticated_static_methods(self):
"""
<User Model>.is_anonymous/is_authenticated must not be static methods.
"""
class VulnerableStaticUser(AbstractBaseUser):
username = models.CharField(max_length=30, unique=True)
USERNAME_FIELD = "username"
@staticmethod
def is_anonymous():
return False
@staticmethod
def is_authenticated():
return False
errors = checks.run_checks(app_configs=self.apps.get_app_configs())
self.assertEqual(
errors,
[
checks.Critical(
"%s.is_anonymous must be an attribute or property rather than "
"a method. Ignoring this is a security issue as anonymous "
"users will be treated as authenticated!" % VulnerableStaticUser,
obj=VulnerableStaticUser,
id="auth.C009",
),
checks.Critical(
"%s.is_authenticated must be an attribute or property rather "
"than a method. Ignoring this is a security issue as anonymous "
"users will be treated as authenticated!" % VulnerableStaticUser,
obj=VulnerableStaticUser,
id="auth.C010",
),
],
)
@isolate_apps("auth_tests", attr_name="apps")
@override_system_checks([check_models_permissions])

View file

@ -517,8 +517,11 @@ class Tests(TestCase):
def test_correct_extraction_psycopg_version(self):
from django.db.backends.postgresql.base import Database, psycopg_version
psycopg_version.cache_clear()
with mock.patch.object(Database, "__version__", "4.2.1 (dt dec pq3 ext lo64)"):
self.addCleanup(psycopg_version.cache_clear)
self.assertEqual(psycopg_version(), (4, 2, 1))
psycopg_version.cache_clear()
with mock.patch.object(
Database, "__version__", "4.2b0.dev1 (dt dec pq3 ext lo64)"
):

View file

@ -1,5 +1,6 @@
import os
import re
import sqlite3
import tempfile
import threading
import unittest
@ -215,15 +216,28 @@ class LastExecutedQueryTest(TestCase):
substituted = "SELECT '\"''\\'"
self.assertEqual(connection.queries[-1]["sql"], substituted)
def test_large_number_of_parameters(self):
# If SQLITE_MAX_VARIABLE_NUMBER (default = 999) has been changed to be
# greater than SQLITE_MAX_COLUMN (default = 2000), last_executed_query
# can hit the SQLITE_MAX_COLUMN limit (#26063).
with connection.cursor() as cursor:
sql = "SELECT MAX(%s)" % ", ".join(["%s"] * 2001)
params = list(range(2001))
# This should not raise an exception.
cursor.db.ops.last_executed_query(cursor.cursor, sql, params)
def test_parameter_count_exceeds_variable_or_column_limit(self):
sql = "SELECT MAX(%s)" % ", ".join(["%s"] * 1001)
params = list(range(1001))
for label, limit, current_limit in [
(
"variable",
sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER,
connection.features.max_query_params,
),
(
"column",
sqlite3.SQLITE_LIMIT_COLUMN,
connection.connection.getlimit(sqlite3.SQLITE_LIMIT_COLUMN),
),
]:
with self.subTest(limit=label):
connection.connection.setlimit(limit, 1000)
self.addCleanup(connection.connection.setlimit, limit, current_limit)
with connection.cursor() as cursor:
# This should not raise an exception.
cursor.db.ops.last_executed_query(cursor.cursor, sql, params)
connection.connection.setlimit(limit, current_limit)
@unittest.skipUnless(connection.vendor == "sqlite", "SQLite tests")

View file

@ -884,6 +884,15 @@ class BulkCreateTests(TestCase):
(obj,) = DbDefaultPrimaryKey.objects.bulk_create([DbDefaultPrimaryKey()])
self.assertIsInstance(obj.id, datetime)
@skipUnlessDBFeature(
"can_return_rows_from_bulk_insert", "supports_expression_defaults"
)
def test_db_expression_primary_key(self):
(obj,) = DbDefaultPrimaryKey.objects.bulk_create(
[DbDefaultPrimaryKey(id=Now())]
)
self.assertIsInstance(obj.id, datetime)
@skipUnlessDBFeature("supports_transactions", "has_bulk_insert")
class BulkCreateTransactionTests(TransactionTestCase):

View file

@ -1,11 +1,11 @@
import multiprocessing
import sys
from io import StringIO
from unittest import skipIf
from unittest import mock, skipIf
from django.apps import apps
from django.core import checks
from django.core.checks import Error, Warning
from django.core.checks import Error, Tags, Warning
from django.core.checks.messages import CheckMessage
from django.core.checks.registry import CheckRegistry
from django.core.management import call_command
@ -92,6 +92,21 @@ class SystemCheckFrameworkTests(SimpleTestCase):
with self.assertRaisesMessage(TypeError, msg):
registry.run_checks()
def test_run_checks_database_exclusion(self):
registry = CheckRegistry()
database_errors = [checks.Warning("Database Check")]
@registry.register(Tags.database)
def database_system_check(**kwargs):
return database_errors
errors = registry.run_checks()
self.assertEqual(errors, [])
errors = registry.run_checks(databases=["default"])
self.assertEqual(errors, database_errors)
class MessageTests(SimpleTestCase):
def test_printing(self):
@ -190,10 +205,12 @@ class CheckCommandTests(SimpleTestCase):
def test_simple_call(self):
call_command("check")
self.assertEqual(
simple_system_check.kwargs, {"app_configs": None, "databases": None}
simple_system_check.kwargs,
{"app_configs": None, "databases": ["default", "other"]},
)
self.assertEqual(
tagged_system_check.kwargs, {"app_configs": None, "databases": None}
tagged_system_check.kwargs,
{"app_configs": None, "databases": ["default", "other"]},
)
@override_system_checks([simple_system_check, tagged_system_check])
@ -203,11 +220,17 @@ class CheckCommandTests(SimpleTestCase):
admin_config = apps.get_app_config("admin")
self.assertEqual(
simple_system_check.kwargs,
{"app_configs": [auth_config, admin_config], "databases": None},
{
"app_configs": [auth_config, admin_config],
"databases": ["default", "other"],
},
)
self.assertEqual(
tagged_system_check.kwargs,
{"app_configs": [auth_config, admin_config], "databases": None},
{
"app_configs": [auth_config, admin_config],
"databases": ["default", "other"],
},
)
@override_system_checks([simple_system_check, tagged_system_check])
@ -215,7 +238,8 @@ class CheckCommandTests(SimpleTestCase):
call_command("check", tags=["simpletag"])
self.assertIsNone(simple_system_check.kwargs)
self.assertEqual(
tagged_system_check.kwargs, {"app_configs": None, "databases": None}
tagged_system_check.kwargs,
{"app_configs": None, "databases": ["default", "other"]},
)
@override_system_checks([simple_system_check, tagged_system_check])
@ -268,6 +292,17 @@ class CheckCommandTests(SimpleTestCase):
with self.assertRaises(CommandError):
call_command("check", fail_level="WARNING")
def test_database_system_checks(self):
database_check = mock.Mock(return_value=[], tags=[Tags.database])
with override_system_checks([database_check]):
call_command("check")
database_check.assert_not_called()
call_command("check", databases=["default"])
database_check.assert_called_once_with(
app_configs=None, databases=["default"]
)
def custom_error_system_check(app_configs, **kwargs):
return [Error("Error", id="myerrorcheck.E001")]

View file

@ -268,3 +268,38 @@ class CompositePKChecksTests(TestCase):
),
],
)
def test_proxy_model_can_subclass_model_with_composite_pk(self):
class Foo(models.Model):
pk = models.CompositePrimaryKey("a", "b")
a = models.SmallIntegerField()
b = models.SmallIntegerField()
class Bar(Foo):
class Meta:
proxy = True
self.assertEqual(Foo.check(databases=self.databases), [])
self.assertEqual(Bar.check(databases=self.databases), [])
def test_proxy_model_does_not_check_superclass_composite_pk_errors(self):
class Foo(models.Model):
pk = models.CompositePrimaryKey("a", "b")
a = models.SmallIntegerField()
class Bar(Foo):
class Meta:
proxy = True
self.assertEqual(
Foo.check(databases=self.databases),
[
checks.Error(
"'b' cannot be included in the composite primary key.",
hint="'b' is not a valid field.",
obj=Foo,
id="models.E042",
),
],
)
self.assertEqual(Bar.check(databases=self.databases), [])

View file

@ -3913,7 +3913,7 @@ aria-describedby="id_age_error"></td></tr>""",
)
self.assertHTMLEqual(
f["field"].legend_tag(),
'<legend for="id_field" class="required">Field:</legend>',
'<legend class="required">Field:</legend>',
)
self.assertHTMLEqual(
f["field"].label_tag(attrs={"class": "foo"}),
@ -3921,14 +3921,14 @@ aria-describedby="id_age_error"></td></tr>""",
)
self.assertHTMLEqual(
f["field"].legend_tag(attrs={"class": "foo"}),
'<legend for="id_field" class="foo required">Field:</legend>',
'<legend class="foo required">Field:</legend>',
)
self.assertHTMLEqual(
f["field2"].label_tag(), '<label for="id_field2">Field2:</label>'
)
self.assertHTMLEqual(
f["field2"].legend_tag(),
'<legend for="id_field2">Field2:</legend>',
"<legend>Field2:</legend>",
)
def test_label_split_datetime_not_displayed(self):
@ -4190,31 +4190,47 @@ aria-describedby="id_age_error"></td></tr>""",
boundfield = SomeForm()["field"]
testcases = [ # (args, kwargs, expected)
# without anything: just print the <label>
((), {}, '<%(tag)s for="id_field">Field:</%(tag)s>'),
testcases = [ # (args, kwargs, expected_label, expected_legend)
# without anything: just print the <label>/<legend>
((), {}, '<label for="id_field">Field:</label>', "<legend>Field:</legend>"),
# passing just one argument: overrides the field's label
(("custom",), {}, '<%(tag)s for="id_field">custom:</%(tag)s>'),
(
("custom",),
{},
'<label for="id_field">custom:</label>',
"<legend>custom:</legend>",
),
# the overridden label is escaped
(("custom&",), {}, '<%(tag)s for="id_field">custom&amp;:</%(tag)s>'),
((mark_safe("custom&"),), {}, '<%(tag)s for="id_field">custom&:</%(tag)s>'),
# Passing attrs to add extra attributes on the <label>
(
("custom&",),
{},
'<label for="id_field">custom&amp;:</label>',
"<legend>custom&amp;:</legend>",
),
(
(mark_safe("custom&"),),
{},
'<label for="id_field">custom&:</label>',
"<legend>custom&:</legend>",
),
# Passing attrs to add extra attributes on the <label>/<legend>
(
(),
{"attrs": {"class": "pretty"}},
'<%(tag)s for="id_field" class="pretty">Field:</%(tag)s>',
'<label for="id_field" class="pretty">Field:</label>',
'<legend class="pretty">Field:</legend>',
),
]
for args, kwargs, expected in testcases:
for args, kwargs, expected_label, expected_legend in testcases:
with self.subTest(args=args, kwargs=kwargs):
self.assertHTMLEqual(
boundfield.label_tag(*args, **kwargs),
expected % {"tag": "label"},
expected_label,
)
self.assertHTMLEqual(
boundfield.legend_tag(*args, **kwargs),
expected % {"tag": "legend"},
expected_legend,
)
def test_boundfield_label_tag_no_id(self):
@ -4252,7 +4268,7 @@ aria-describedby="id_age_error"></td></tr>""",
)
self.assertHTMLEqual(
form["custom"].legend_tag(),
'<legend for="custom_id_custom">Custom:</legend>',
"<legend>Custom:</legend>",
)
self.assertHTMLEqual(form["empty"].label_tag(), "<label>Empty:</label>")
self.assertHTMLEqual(form["empty"].legend_tag(), "<legend>Empty:</legend>")
@ -4266,7 +4282,7 @@ aria-describedby="id_age_error"></td></tr>""",
self.assertHTMLEqual(boundfield.label_tag(), '<label for="id_field"></label>')
self.assertHTMLEqual(
boundfield.legend_tag(),
'<legend for="id_field"></legend>',
"<legend></legend>",
)
def test_boundfield_id_for_label(self):
@ -4339,7 +4355,7 @@ aria-describedby="id_age_error"></td></tr>""",
)
self.assertHTMLEqual(
boundfield.legend_tag(label_suffix="$"),
'<legend for="id_field">Field$</legend>',
"<legend>Field$</legend>",
)
def test_error_dict(self):
@ -4879,7 +4895,7 @@ aria-describedby="id_age_error"></td></tr>""",
)
self.assertEqual(
field.legend_tag(),
'<legend for="id_first_name">First name:</legend>',
"<legend>First name:</legend>",
)
@override_settings(USE_THOUSAND_SEPARATOR=True)
@ -4892,7 +4908,7 @@ aria-describedby="id_age_error"></td></tr>""",
)
self.assertHTMLEqual(
field.legend_tag(attrs={"number": 9999}),
'<legend number="9999" for="id_first_name">First name:</legend>',
'<legend number="9999">First name:</legend>',
)
def test_remove_cached_field(self):
@ -5204,12 +5220,12 @@ class TemplateTests(SimpleTestCase):
self.assertHTMLEqual(
t.render(Context({"form": f})),
"<form>"
'<p><legend for="id_username">Username:</legend>'
"<p><legend>Username:</legend>"
'<input id="id_username" type="text" name="username" maxlength="10" '
'aria-describedby="id_username_helptext" required></p>'
'<p><legend for="id_password1">Password1:</legend>'
"<p><legend>Password1:</legend>"
'<input type="password" name="password1" id="id_password1" required></p>'
'<p><legend for="id_password2">Password2:</legend>'
"<p><legend>Password2:</legend>"
'<input type="password" name="password2" id="id_password2" required></p>'
'<input type="submit" required>'
"</form>",

View file

@ -59,14 +59,14 @@ class FormsI18nTests(SimpleTestCase):
)
self.assertHTMLEqual(
f["field_1"].legend_tag(),
'<legend for="id_field_1">field_1:</legend>',
"<legend>field_1:</legend>",
)
self.assertHTMLEqual(
f["field_2"].label_tag(), '<label for="field_2_id">field_2:</label>'
)
self.assertHTMLEqual(
f["field_2"].legend_tag(),
'<legend for="field_2_id">field_2:</legend>',
"<legend>field_2:</legend>",
)
def test_non_ascii_choices(self):

View file

@ -248,12 +248,12 @@ class ClearableFileInputTest(WidgetTest):
form = TestForm()
self.assertIs(self.widget.use_fieldset, True)
self.assertHTMLEqual(
'<div><fieldset><legend for="id_field">Field:</legend>'
"<div><fieldset><legend>Field:</legend>"
'<input id="id_field" name="field" type="file" required></fieldset></div>'
'<div><fieldset><legend for="id_with_file">With file:</legend>Currently: '
"<div><fieldset><legend>With file:</legend>Currently: "
'<a href="something">something</a><br>Change:<input type="file" '
'name="with_file" id="id_with_file"></fieldset></div>'
'<div><fieldset><legend for="id_clearable_file">Clearable file:</legend>'
"<div><fieldset><legend>Clearable file:</legend>"
'Currently: <a href="something">something</a><input '
'type="checkbox" name="clearable_file-clear" id="clearable_file-clear_id">'
'<label for="clearable_file-clear_id">Clear</label><br>Change:'

View file

@ -718,7 +718,7 @@ class SelectDateWidgetTest(WidgetTest):
form = TestForm()
self.assertIs(self.widget.use_fieldset, True)
self.assertHTMLEqual(
'<div><fieldset><legend for="id_field_month">Field:</legend>'
"<div><fieldset><legend>Field:</legend>"
'<select name="field_month" required id="id_field_month">'
'<option value="1">January</option><option value="2">February</option>'
'<option value="3">March</option><option value="4">April</option>'

View file

@ -24,6 +24,7 @@ from django.http import (
)
from django.test import SimpleTestCase
from django.utils.functional import lazystr
from django.utils.http import MAX_URL_LENGTH
class QueryDictTests(SimpleTestCase):
@ -490,6 +491,7 @@ class HttpResponseTests(SimpleTestCase):
'data:text/html,<script>window.alert("xss")</script>',
"mailto:test@example.com",
"file:///etc/passwd",
"é" * (MAX_URL_LENGTH + 1),
]
for url in bad_urls:
with self.assertRaises(DisallowedRedirect):

View file

@ -153,6 +153,9 @@ class HumanizeTests(SimpleTestCase):
"-1234567.1234567",
Decimal("1234567.1234567"),
Decimal("-1234567.1234567"),
Decimal("Infinity"),
Decimal("-Infinity"),
Decimal("NaN"),
None,
"",
"-",
@ -193,6 +196,9 @@ class HumanizeTests(SimpleTestCase):
"-1,234,567.1234567",
"1,234,567.1234567",
"-1,234,567.1234567",
"Infinity",
"-Infinity",
"NaN",
None,
"1,234,567",
"-1,234,567",

View file

@ -121,6 +121,15 @@ class CharFieldUnlimited(models.Model):
required_db_features = {"supports_unlimited_charfield"}
class DecimalFieldNoPrec(models.Model):
decimal_field_no_precision = models.DecimalField(
max_digits=None, decimal_places=None
)
class Meta:
required_db_features = {"supports_no_precision_decimalfield"}
class UniqueTogether(models.Model):
field1 = models.IntegerField()
field2 = models.CharField(max_length=10)
@ -161,3 +170,11 @@ class CompositePKModel(models.Model):
pk = models.CompositePrimaryKey("column_1", "column_2")
column_1 = models.IntegerField()
column_2 = models.IntegerField()
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)
fk_set_null = models.ForeignKey(
DigitsInColumnName, on_delete=models.DB_SET_NULL, null=True
)

View file

@ -202,6 +202,13 @@ class InspectDBTestCase(TestCase):
output = out.getvalue()
self.assertIn("char_field = models.CharField()", output)
@skipUnlessDBFeature("supports_no_precision_decimalfield")
def test_decimal_field_no_precision(self):
out = StringIO()
call_command("inspectdb", "inspectdb_decimalfieldnoprec", stdout=out)
output = out.getvalue()
self.assertIn("decimal_field_no_precision = models.DecimalField()", output)
def test_number_field_types(self):
"""Test introspection of various Django field types"""
assertFieldType = self.make_field_type_asserter()
@ -228,13 +235,8 @@ class InspectDBTestCase(TestCase):
assertFieldType(
"decimal_field", "models.DecimalField(max_digits=6, decimal_places=1)"
)
else: # Guessed arguments on SQLite, see #5014
assertFieldType(
"decimal_field",
"models.DecimalField(max_digits=10, decimal_places=5) "
"# max_digits and decimal_places have been guessed, "
"as this database handles decimal fields as float",
)
else:
assertFieldType("decimal_field", "models.DecimalField()")
assertFieldType("float_field", "models.FloatField()")
assertFieldType(
@ -299,6 +301,27 @@ class InspectDBTestCase(TestCase):
out.getvalue(),
)
@skipUnlessDBFeature("can_introspect_foreign_keys")
def test_foreign_key_db_on_delete(self):
out = StringIO()
call_command("inspectdb", "inspectdb_dbondeletemodel", stdout=out)
output = out.getvalue()
self.assertIn(
"fk_do_nothing = models.ForeignKey('InspectdbUniquetogether', "
"models.DO_NOTHING)",
output,
)
self.assertIn(
"fk_db_cascade = models.ForeignKey('InspectdbColumntypes', "
"models.DB_CASCADE)",
output,
)
self.assertIn(
"fk_set_null = models.ForeignKey('InspectdbDigitsincolumnname', "
"models.DB_SET_NULL, blank=True, null=True)",
output,
)
def test_digits_column_name_introspection(self):
"""
Introspection of column names consist/start with digits (#16536/#17676)

View file

@ -110,3 +110,18 @@ class DbCommentModel(models.Model):
class Meta:
db_table_comment = "Custom table comment"
required_db_features = {"supports_comments"}
class DbOnDeleteModel(models.Model):
fk_do_nothing = models.ForeignKey(Country, on_delete=models.DO_NOTHING)
fk_db_cascade = models.ForeignKey(City, on_delete=models.DB_CASCADE)
fk_set_null = models.ForeignKey(Reporter, on_delete=models.DB_SET_NULL, null=True)
class DbOnDeleteSetDefaultModel(models.Model):
fk_db_set_default = models.ForeignKey(
Country, on_delete=models.DB_SET_DEFAULT, db_default=models.Value(1)
)
class Meta:
required_db_features = {"supports_on_delete_db_default"}

View file

@ -1,5 +1,5 @@
from django.db import DatabaseError, connection
from django.db.models import Index
from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING, Index
from django.test import TransactionTestCase, skipUnlessDBFeature
from .models import (
@ -10,6 +10,8 @@ from .models import (
Comment,
Country,
DbCommentModel,
DbOnDeleteModel,
DbOnDeleteSetDefaultModel,
District,
Reporter,
UniqueConstraintConditionModel,
@ -219,10 +221,14 @@ class IntrospectionTests(TransactionTestCase):
cursor, Article._meta.db_table
)
# That's {field_name: (field_name_other_table, other_table)}
if connection.vendor == "mysql" and connection.mysql_is_mariadb:
no_db_on_delete = None
else:
no_db_on_delete = DO_NOTHING
# {field_name: (field_name_other_table, other_table, db_on_delete)}
expected_relations = {
"reporter_id": ("id", Reporter._meta.db_table),
"response_to_id": ("id", Article._meta.db_table),
"reporter_id": ("id", Reporter._meta.db_table, no_db_on_delete),
"response_to_id": ("id", Article._meta.db_table, no_db_on_delete),
}
self.assertEqual(relations, expected_relations)
@ -238,6 +244,38 @@ class IntrospectionTests(TransactionTestCase):
editor.add_field(Article, body)
self.assertEqual(relations, expected_relations)
@skipUnlessDBFeature("can_introspect_foreign_keys")
def test_get_relations_db_on_delete(self):
with connection.cursor() as cursor:
relations = connection.introspection.get_relations(
cursor, DbOnDeleteModel._meta.db_table
)
if connection.vendor == "mysql" and connection.mysql_is_mariadb:
no_db_on_delete = None
else:
no_db_on_delete = DO_NOTHING
# {field_name: (field_name_other_table, other_table, db_on_delete)}
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),
"fk_set_null_id": ("id", Reporter._meta.db_table, DB_SET_NULL),
}
self.assertEqual(relations, expected_relations)
@skipUnlessDBFeature("can_introspect_foreign_keys", "supports_on_delete_db_default")
def test_get_relations_db_on_delete_default(self):
with connection.cursor() as cursor:
relations = connection.introspection.get_relations(
cursor, DbOnDeleteSetDefaultModel._meta.db_table
)
# {field_name: (field_name_other_table, other_table, db_on_delete)}
expected_relations = {
"fk_db_set_default_id": ("id", Country._meta.db_table, DB_SET_DEFAULT),
}
self.assertEqual(relations, expected_relations)
def test_get_primary_key_column(self):
with connection.cursor() as cursor:
primary_key_column = connection.introspection.get_primary_key_column(

View file

@ -599,15 +599,16 @@ class DateTimeFieldTests(SimpleTestCase):
@isolate_apps("invalid_models_tests")
class DecimalFieldTests(SimpleTestCase):
def test_required_attributes(self):
class DecimalFieldTests(TestCase):
def test_both_attributes_omitted(self):
class Model(models.Model):
field = models.DecimalField()
field = Model._meta.get_field("field")
self.assertEqual(
field.check(),
[
if connection.features.supports_no_precision_decimalfield:
expected = []
else:
expected = [
Error(
"DecimalFields must define a 'decimal_places' attribute.",
obj=field,
@ -618,6 +619,52 @@ class DecimalFieldTests(SimpleTestCase):
obj=field,
id="fields.E132",
),
]
self.assertEqual(field.check(), expected)
def test_both_attributes_omitted_required_db_features(self):
class Model(models.Model):
field = models.DecimalField()
class Meta:
required_db_features = {"supports_no_precision_decimalfield"}
field = Model._meta.get_field("field")
self.assertEqual(field.check(databases=self.databases), [])
@skipUnlessDBFeature("supports_no_precision_decimalfield")
def test_only_max_digits_defined(self):
class Model(models.Model):
field = models.DecimalField(max_digits=13)
field = Model._meta.get_field("field")
self.assertEqual(
field.check(),
[
Error(
"DecimalFields max_digits and decimal_places must both "
"be defined or both omitted.",
obj=field,
id="fields.E135",
),
],
)
@skipUnlessDBFeature("supports_no_precision_decimalfield")
def test_only_decimal_places_defined(self):
class Model(models.Model):
field = models.DecimalField(decimal_places=5)
field = Model._meta.get_field("field")
self.assertEqual(
field.check(),
[
Error(
"DecimalFields max_digits and decimal_places must both "
"be defined or both omitted.",
obj=field,
id="fields.E135",
),
],
)

View file

@ -113,7 +113,7 @@ class MigrateTests(MigrationTestBase):
out = io.StringIO()
call_command("migrate", skip_checks=False, no_color=True, stdout=out)
self.assertIn("Apply all migrations: migrated_app", out.getvalue())
mocked_check.assert_called_once()
mocked_check.assert_called_once_with(databases=["default"])
def test_migrate_with_custom_system_checks(self):
original_checks = registry.registered_checks.copy()
@ -137,6 +137,25 @@ class MigrateTests(MigrationTestBase):
command = CustomMigrateCommandWithSecurityChecks()
call_command(command, skip_checks=False, stdout=io.StringIO())
@override_settings(
INSTALLED_APPS=[
"django.contrib.auth",
"django.contrib.contenttypes",
"migrations.migrations_test_apps.migrated_app",
]
)
def test_migrate_runs_database_system_checks(self):
original_checks = registry.registered_checks.copy()
self.addCleanup(setattr, registry, "registered_checks", original_checks)
out = io.StringIO()
mock_check = mock.Mock(return_value=[])
register(mock_check, Tags.database)
call_command("migrate", skip_checks=False, no_color=True, stdout=out)
self.assertIn("Apply all migrations: migrated_app", out.getvalue())
mock_check.assert_called_once_with(app_configs=None, databases=["default"])
@override_settings(
INSTALLED_APPS=[
"migrations",

View file

@ -1,7 +1,12 @@
import compileall
import os
import subprocess
import sys
import tempfile
from importlib import import_module
from pathlib import Path
from django.conf import settings
from django.db import connection, connections
from django.db.migrations.exceptions import (
AmbiguityError,
@ -649,6 +654,70 @@ class LoaderTests(TestCase):
test_module.__spec__.origin = module_origin
test_module.__spec__.has_location = module_has_location
def test_loading_order_does_not_create_circular_dependency(self):
"""
Before, for these migrations:
app1
[ ] 0001_squashed_initial <- replaces app1.0001
[ ] 0002_squashed_initial <- replaces app1.0001
depends on app1.0001_squashed_initial & app2.0001_squashed_initial
app2
[ ] 0001_squashed_initial <- replaces app2.0001
When loading app1's migrations, if 0002_squashed_initial was first:
{'0002_squashed_initial', '0001_initial', '0001_squashed_initial'}
Then CircularDependencyError was raised, but it's resolvable as:
{'0001_initial', '0001_squashed_initial', '0002_squashed_initial'}
"""
# Create a test settings file to provide to the subprocess.
MIGRATION_MODULES = {
"app1": "migrations.test_migrations_squashed_replaced_order.app1",
"app2": "migrations.test_migrations_squashed_replaced_order.app2",
}
INSTALLED_APPS = [
"migrations.test_migrations_squashed_replaced_order.app1",
"migrations.test_migrations_squashed_replaced_order.app2",
]
tests_dir = Path(__file__).parent.parent
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", suffix=".py", dir=tests_dir, delete=False
) as test_settings:
for attr, value in settings._wrapped.__dict__.items():
if attr.isupper():
test_settings.write(f"{attr} = {value!r}\n")
# Provide overrides here, instead of via decorators.
test_settings.write(f"DATABASES = {settings.DATABASES}\n")
test_settings.write(f"MIGRATION_MODULES = {MIGRATION_MODULES}\n")
# Isolate away other test apps.
test_settings.write(
"INSTALLED_APPS=[a for a in INSTALLED_APPS if a.startswith('django')]\n"
)
test_settings.write(f"INSTALLED_APPS += {INSTALLED_APPS}\n")
test_settings_name = test_settings.name
self.addCleanup(os.remove, test_settings_name)
test_environ = os.environ.copy()
test_environ["PYTHONPATH"] = str(tests_dir)
# Ensure deterministic failures.
test_environ["PYTHONHASHSEED"] = "1"
args = [
sys.executable,
"-m",
"django",
"showmigrations",
"app1",
"--skip-checks",
"--settings",
Path(test_settings_name).stem,
]
try:
subprocess.run(
args, capture_output=True, env=test_environ, check=True, text=True
)
except subprocess.CalledProcessError as err:
self.fail(err.stderr)
class PycLoaderTests(MigrationTestBase):
def test_valid(self):

View file

@ -0,0 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = []

View file

@ -0,0 +1,13 @@
from django.db import migrations
class Migration(migrations.Migration):
initial = True
replaces = [
("app1", "0001_initial"),
]
dependencies = []
operations = []

View file

@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
initial = True
replaces = [
("app1", "0001_initial"),
]
dependencies = [
("app1", "0001_squashed_initial"),
("app2", "0001_squashed_initial"),
]
operations = []

View file

@ -0,0 +1,11 @@
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = [
("app1", "0001_initial"),
]
operations = []

View file

@ -0,0 +1,13 @@
from django.db import migrations
class Migration(migrations.Migration):
initial = True
replaces = [
("app2", "0001_initial"),
]
dependencies = []
operations = []

View file

@ -1237,6 +1237,12 @@ class WriterTests(SimpleTestCase):
self.assertEqual(result.args, instance.args)
self.assertEqual(result.kwargs, instance.kwargs)
def test_serialize_generic_alias(self):
self.assertSerializedEqual(dict[str, float])
def test_serialize_generic_alias_complex_args(self):
self.assertSerializedEqual(dict[str, models.Manager])
def test_register_serializer(self):
class ComplexSerializer(BaseSerializer):
def serialize(self):

View file

@ -1265,6 +1265,13 @@ class JSONNullTests(TestCase):
def test_repr(self):
self.assertEqual(repr(JSONNull()), "JSONNull()")
def test_deconstruct(self):
jsonnull = JSONNull()
path, args, kwargs = jsonnull.deconstruct()
self.assertEqual(path, "django.db.models.JSONNull")
self.assertEqual(args, ())
self.assertEqual(kwargs, {})
def test_save_load(self):
obj = JSONModel(value=JSONNull())
obj.save()

View file

@ -543,3 +543,22 @@ class ConstraintsModel(models.Model):
violation_error_message="Price must be greater than zero.",
),
]
class AttnameConstraintsModel(models.Model):
left = models.ForeignKey(
"self", related_name="+", null=True, on_delete=models.SET_NULL
)
right = models.ForeignKey(
"self", related_name="+", null=True, on_delete=models.SET_NULL
)
class Meta:
required_db_features = {"supports_table_check_constraints"}
constraints = [
models.CheckConstraint(
name="%(app_label)s_%(class)s_left_not_right",
# right_id here is the ForeignKey's attname, not name.
condition=~models.Q(left=models.F("right_id")),
),
]

View file

@ -30,6 +30,7 @@ from django.utils.version import PY314, PYPY
from .models import (
Article,
ArticleStatus,
AttnameConstraintsModel,
Author,
Author1,
Award,
@ -976,15 +977,15 @@ class TestFieldOverridesByFormMeta(SimpleTestCase):
)
self.assertHTMLEqual(
form["name"].legend_tag(),
'<legend for="id_name">Title:</legend>',
"<legend>Title:</legend>",
)
self.assertHTMLEqual(
form["url"].legend_tag(),
'<legend for="id_url">The URL:</legend>',
"<legend>The URL:</legend>",
)
self.assertHTMLEqual(
form["slug"].legend_tag(),
'<legend for="id_slug">Slug:</legend>',
"<legend>Slug:</legend>",
)
def test_help_text_overrides(self):
@ -3766,3 +3767,17 @@ class ConstraintValidationTests(TestCase):
self.assertEqual(
full_form.errors, {"__all__": ["Price must be greater than zero."]}
)
def test_check_constraint_refs_excluded_field_attname(self):
left = AttnameConstraintsModel.objects.create()
instance = AttnameConstraintsModel.objects.create(left=left)
data = {
"left": str(left.id),
"right": "",
}
AttnameConstraintsModelForm = modelform_factory(
AttnameConstraintsModel, fields="__all__"
)
full_form = AttnameConstraintsModelForm(data, instance=instance)
self.assertFalse(full_form.is_valid())
self.assertEqual(full_form.errors, {"right": ["This field is required."]})

Some files were not shown because too many files have changed in this diff Show more