diff --git a/AUTHORS b/AUTHORS index 9ff64f9717..91d01520b1 100644 --- a/AUTHORS +++ b/AUTHORS @@ -416,6 +416,7 @@ answer newbie questions, and generally made Django that much better: Himanshu Chauhan hipertracker@gmail.com Hiroki Kiyohara + Hisham Mahmood Honza Král Horst Gutmann Hugo Osvaldo Barrera diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index e8760c2931..2257b3072e 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1026,7 +1026,9 @@ class ModelAdmin(BaseModelAdmin): """ attrs = { "class": "action-select", - "aria-label": format_html(_("Select this object for an action - {}"), obj), + "aria-label": format_html( + _("Select this object for an action - {}"), str(obj) + ), } checkbox = forms.CheckboxInput(attrs, lambda value: False) return checkbox.render(helpers.ACTION_CHECKBOX_NAME, str(obj.pk)) diff --git a/django/core/files/temp.py b/django/core/files/temp.py index 5bd31dd5f2..b719d94a84 100644 --- a/django/core/files/temp.py +++ b/django/core/files/temp.py @@ -12,7 +12,7 @@ processes in a manner that works across platforms. The custom version of NamedTemporaryFile doesn't support the same keyword arguments available in tempfile.NamedTemporaryFile. -1: https://mail.python.org/pipermail/python-list/2005-December/336957.html +1: https://mail.python.org/pipermail/python-list/2005-December/336955.html 2: https://bugs.python.org/issue14243 """ diff --git a/django/db/models/constraints.py b/django/db/models/constraints.py index 3e6c5205c6..0a63e38d83 100644 --- a/django/db/models/constraints.py +++ b/django/db/models/constraints.py @@ -8,7 +8,7 @@ from django.db import connections from django.db.models.constants import LOOKUP_SEP from django.db.models.expressions import Exists, ExpressionList, F, RawSQL from django.db.models.indexes import IndexExpression -from django.db.models.lookups import Exact +from django.db.models.lookups import Exact, IsNull from django.db.models.query_utils import Q from django.db.models.sql.query import Query from django.db.utils import DEFAULT_DB_ALIAS @@ -642,30 +642,31 @@ class UniqueConstraint(BaseConstraint): meta=model._meta, exclude=exclude ).items() } - expressions = [] + filters = [] for expr in self.expressions: if hasattr(expr, "get_expression_for_validation"): expr = expr.get_expression_for_validation() - expressions.append(Exact(expr, expr.replace_expressions(replacements))) - queryset = queryset.filter(*expressions) + rhs = expr.replace_expressions(replacements) + condition = Exact(expr, rhs) + if self.nulls_distinct is False: + condition = Q(condition) | Q(IsNull(expr, True), IsNull(rhs, True)) + filters.append(condition) + queryset = queryset.filter(*filters) model_class_pk = instance._get_pk_val(model._meta) if not instance._state.adding and model_class_pk is not None: queryset = queryset.exclude(pk=model_class_pk) if not self.condition: if queryset.exists(): - if self.expressions: + if self.fields: + # When fields are defined, use the unique_error_message() for + # backward compatibility. raise ValidationError( - self.get_violation_error_message(), - code=self.violation_error_code, + instance.unique_error_message(model, self.fields), ) - # When fields are defined, use the unique_error_message() for - # backward compatibility. - for model, constraints in instance.get_constraints(): - for constraint in constraints: - if constraint is self: - raise ValidationError( - instance.unique_error_message(model, self.fields), - ) + raise ValidationError( + self.get_violation_error_message(), + code=self.violation_error_code, + ) else: against = instance._get_field_value_map(meta=model._meta, exclude=exclude) try: diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py index dcba973ff4..ffb9f3c816 100644 --- a/django/db/models/expressions.py +++ b/django/db/models/expressions.py @@ -884,6 +884,11 @@ class F(Combinable): def __getitem__(self, subscript): return Sliced(self, subscript) + def __contains__(self, other): + # Disable old-style iteration protocol inherited from implementing + # __getitem__() to prevent this method from hanging. + raise TypeError(f"argument of type '{self.__class__.__name__}' is not iterable") + def resolve_expression( self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False ): diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py index f3aed06d81..1d426f49b6 100644 --- a/django/db/models/sql/compiler.py +++ b/django/db/models/sql/compiler.py @@ -583,50 +583,28 @@ class SQLCompiler: raise DatabaseError( "ORDER BY not allowed in subqueries of compound statements." ) - elif self.query.is_sliced and combinator == "union": - for compiler in compilers: - # A sliced union cannot have its parts elided as some of them - # might be sliced as well and in the event where only a single - # part produces a non-empty resultset it might be impossible to - # generate valid SQL. - compiler.elide_empty = False - parts = () - selected = self.query.selected + parts = [] + empty_compiler = None for compiler in compilers: try: - # If the columns list is limited, then all combined queries - # must have the same columns list. Set the selects defined on - # the query on all combined queries, if not already set. - if selected is not None and compiler.query.selected is None: - compiler.query = compiler.query.clone() - compiler.query.set_values(selected) - part_sql, part_args = compiler.as_sql(with_col_aliases=True) - if compiler.query.combinator: - # Wrap in a subquery if wrapping in parentheses isn't - # supported. - if not features.supports_parentheses_in_compound: - part_sql = "SELECT * FROM ({})".format(part_sql) - # Add parentheses when combining with compound query if not - # already added for all compound queries. - elif ( - self.query.subquery - or not features.supports_slicing_ordering_in_compound - ): - part_sql = "({})".format(part_sql) - elif ( - self.query.subquery - and features.supports_slicing_ordering_in_compound - ): - part_sql = "({})".format(part_sql) - parts += ((part_sql, part_args),) + parts.append(self._get_combinator_part_sql(compiler)) except EmptyResultSet: # Omit the empty queryset with UNION and with DIFFERENCE if the # first queryset is nonempty. if combinator == "union" or (combinator == "difference" and parts): + empty_compiler = compiler continue raise if not parts: raise EmptyResultSet + elif len(parts) == 1 and combinator == "union" and self.query.is_sliced: + # A sliced union cannot be composed of a single component because + # in the event the later is also sliced it might result in invalid + # SQL due to the usage of multiple LIMIT clauses. Prevent that from + # happening by always including an empty resultset query to force + # the creation of an union. + empty_compiler.elide_empty = False + parts.append(self._get_combinator_part_sql(empty_compiler)) combinator_sql = self.connection.ops.set_operators[combinator] if all and combinator == "union": combinator_sql += " ALL" @@ -642,6 +620,32 @@ class SQLCompiler: params.extend(part) return result, params + def _get_combinator_part_sql(self, compiler): + features = self.connection.features + # If the columns list is limited, then all combined queries + # must have the same columns list. Set the selects defined on + # the query on all combined queries, if not already set. + selected = self.query.selected + if selected is not None and compiler.query.selected is None: + compiler.query = compiler.query.clone() + compiler.query.set_values(selected) + part_sql, part_args = compiler.as_sql(with_col_aliases=True) + if compiler.query.combinator: + # Wrap in a subquery if wrapping in parentheses isn't + # supported. + if not features.supports_parentheses_in_compound: + part_sql = "SELECT * FROM ({})".format(part_sql) + # Add parentheses when combining with compound query if not + # already added for all compound queries. + elif ( + self.query.subquery + or not features.supports_slicing_ordering_in_compound + ): + part_sql = "({})".format(part_sql) + elif self.query.subquery and features.supports_slicing_ordering_in_compound: + part_sql = "({})".format(part_sql) + return part_sql, part_args + def get_qualify_sql(self): where_parts = [] if self.where: @@ -1134,15 +1138,12 @@ class SQLCompiler: """ result = [] params = [] - for alias in tuple(self.query.alias_map): + # Copy alias_map to a tuple in case Join.as_sql() subclasses (objects + # in alias_map) alter compiler.query.alias_map. That would otherwise + # raise "RuntimeError: dictionary changed size during iteration". + for alias, from_clause in tuple(self.query.alias_map.items()): if not self.query.alias_refcount[alias]: continue - try: - from_clause = self.query.alias_map[alias] - except KeyError: - # Extra tables can end up in self.tables, but not in the - # alias_map if they aren't in a join. That's OK. We skip them. - continue clause_sql, clause_params = self.compile(from_clause) result.append(clause_sql) params.extend(clause_params) @@ -1615,14 +1616,15 @@ class SQLCompiler: def as_subquery_condition(self, alias, columns, compiler): qn = compiler.quote_name_unless_alias qn2 = self.connection.ops.quote_name + query = self.query.clone() - for index, select_col in enumerate(self.query.select): + for index, select_col in enumerate(query.select): lhs_sql, lhs_params = self.compile(select_col) rhs = "%s.%s" % (qn(alias), qn2(columns[index])) - self.query.where.add(RawSQL("%s = %s" % (lhs_sql, rhs), lhs_params), AND) + query.where.add(RawSQL("%s = %s" % (lhs_sql, rhs), lhs_params), AND) - sql, params = self.as_sql() - return "EXISTS (%s)" % sql, params + sql, params = query.as_sql(compiler, self.connection) + return "EXISTS %s" % sql, params def explain_query(self): result = list(self.execute_sql()) diff --git a/django/db/transaction.py b/django/db/transaction.py index 4150cbcbbe..0c2eee8e73 100644 --- a/django/db/transaction.py +++ b/django/db/transaction.py @@ -156,7 +156,7 @@ class Atomic(ContextDecorator): It's possible to disable the creation of savepoints if the goal is to ensure that some code runs within a transaction without creating overhead. - A stack of savepoints identifiers is maintained as an attribute of the + A stack of savepoint identifiers is maintained as an attribute of the connection. None denotes the absence of a savepoint. This allows reentrancy even if the same AtomicWrapper is reused. For @@ -165,10 +165,10 @@ class Atomic(ContextDecorator): Since database connections are thread-local, this is thread-safe. - An atomic block can be tagged as durable. In this case, raise a - RuntimeError if it's nested within another atomic block. This guarantees + An atomic block can be tagged as durable. In this case, a RuntimeError is + raised if it's nested within another atomic block. This guarantees that database changes in a durable block are committed to the database when - the block exists without error. + the block exits without error. This is a private API. """ diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py index dd0a6b3579..ae74679ec6 100644 --- a/django/template/defaulttags.py +++ b/django/template/defaulttags.py @@ -1169,8 +1169,8 @@ def now(parser, token): return NowNode(format_string, asvar) -@register.simple_tag(takes_context=True) -def query_string(context, query_dict=None, **kwargs): +@register.simple_tag(name="querystring", takes_context=True) +def querystring(context, query_dict=None, **kwargs): """ Add, remove, and change parameters of a ``QueryDict`` and return the result as a query string. If the ``query_dict`` argument is not provided, default @@ -1178,19 +1178,19 @@ def query_string(context, query_dict=None, **kwargs): For example:: - {% query_string foo=3 %} + {% querystring foo=3 %} To remove a key:: - {% query_string foo=None %} + {% querystring foo=None %} To use with pagination:: - {% query_string page=page_obj.next_page_number %} + {% querystring page=page_obj.next_page_number %} A custom ``QueryDict`` can also be used:: - {% query_string my_query_dict foo=3 %} + {% querystring my_query_dict foo=3 %} """ if query_dict is None: query_dict = context.request.GET diff --git a/django/template/utils.py b/django/template/utils.py index 2b118f900e..05e9d46cad 100644 --- a/django/template/utils.py +++ b/django/template/utils.py @@ -102,10 +102,9 @@ def get_app_template_dirs(dirname): dirname is the name of the subdirectory containing templates inside installed applications. """ - template_dirs = [ - Path(app_config.path) / dirname - for app_config in apps.get_app_configs() - if app_config.path and (Path(app_config.path) / dirname).is_dir() - ] # Immutable return value because it will be cached and shared by callers. - return tuple(template_dirs) + return tuple( + path + for app_config in apps.get_app_configs() + if app_config.path and (path := Path(app_config.path) / dirname).is_dir() + ) diff --git a/django/test/testcases.py b/django/test/testcases.py index f1c6b5ae9c..6027332cd5 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -405,8 +405,8 @@ class SimpleTestCase(unittest.TestCase): def modify_settings(self, **kwargs): """ - A context manager that temporarily applies changes a list setting and - reverts back to the original value when exiting the context. + A context manager that temporarily applies changes to a list setting + and reverts back to the original value when exiting the context. """ return modify_settings(**kwargs) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 504ab62368..6af2140717 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -437,9 +437,7 @@ subclass:: * ``description`` A string of optional extra text to be displayed at the top of each - fieldset, under the heading of the fieldset. This string is not - rendered for :class:`~django.contrib.admin.TabularInline` due to its - layout. + fieldset, under the heading of the fieldset. Note that this value is *not* HTML-escaped when it's displayed in the admin interface. This lets you include HTML if you so desire. @@ -447,6 +445,17 @@ subclass:: :func:`django.utils.html.escape` to escape any HTML special characters. + .. admonition:: :class:`~django.contrib.admin.TabularInline` has limited + support for ``fieldsets`` + + Using ``fieldsets`` with :class:`~django.contrib.admin.TabularInline` + has limited functionality. You can specify which fields will be + displayed and their order within the ``TabularInline`` layout by + defining ``fields`` in the ``field_options`` dictionary. + + All other features are not supported. This includes the use of ``name`` + to define a title for a group of fields. + .. attribute:: ModelAdmin.filter_horizontal By default, a :class:`~django.db.models.ManyToManyField` is displayed in diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index 4cfd1d8f71..2413cb1d82 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -952,77 +952,110 @@ output (as a string) inside a variable. This is useful if you want to use {% now "Y" as current_year %} {% blocktranslate %}Copyright {{ current_year }}{% endblocktranslate %} -.. templatetag:: query_string +.. templatetag:: querystring -``query_string`` +``querystring`` ---------------- .. versionadded:: 5.1 -Outputs the query string from a given :class:`~django.http.QueryDict` instance, -if provided, or ``request.GET`` if not and the -``django.template.context_processors.request`` context processor is enabled. -If the ``QueryDict`` is empty, then the output will be an empty string. -Otherwise, the query string will be returned with a leading ``"?"``. +Outputs a URL-encoded formatted query string based on the provided parameters. -If not using the ``django.template.context_processors.request`` context -processor, you must pass either the ``request`` into the template context or a -``QueryDict`` instance into this tag. +This tag requires a :class:`~django.http.QueryDict` instance, which defaults to +:attr:`request.GET ` if none is provided. -The following example outputs the current query string verbatim. So if the -query string is ``?color=green&size=M``, the output would be -``?color=green&size=M``: +If the :class:`~django.http.QueryDict` is empty and no additional parameters +are provided, an empty string is returned. A non-empty result includes a +leading ``"?"``. + +.. admonition:: Using ``request.GET`` as default + + To use ``request.GET`` as the default ``QueryDict`` instance, the + ``django.template.context_processors.request`` context processor should be + enabled. If it's not enabled, you must either explicitly pass the + ``request`` object into the template context, or provide a ``QueryDict`` + instance to this tag. + +Basic usage +~~~~~~~~~~~ .. code-block:: html+django - {% query_string %} + {% querystring %} -You can also pass in a custom ``QueryDict`` that will be used instead of -``request.GET``: +Outputs the current query string verbatim. So if the query string is +``?color=green``, the output would be ``?color=green``. .. code-block:: html+django - {% query_string my_query_dict %} + {% querystring size="M" %} -Each keyword argument will be added to the query string, replacing any existing -value for that key. With the query string ``?color=blue``, the following would -result in ``?color=red&size=S``: +Outputs the current query string with the addition of the ``size`` parameter. +Following the previous example, the output would be ``?color=green&size=M``. + +Custom QueryDict +~~~~~~~~~~~~~~~~ .. code-block:: html+django - {% query_string color="red" size="S" %} + {% querystring my_query_dict %} -It is possible to remove parameters by passing ``None`` as a value. With the -query string ``?color=blue&size=M``, the following would result in ``?size=M``: +You can provide a custom ``QueryDict`` to be used instead of ``request.GET``. +So if ``my_query_dict`` is ````, this outputs +``?color=blue``. + +Setting items +~~~~~~~~~~~~~ .. code-block:: html+django - {% query_string color=None %} + {% querystring color="red" size="S" %} -If the given parameter is a list, the value will remain as a list. For example, -if ``my_list`` is set to ``["red", "blue"]``, the following would result in -``?color=red&color=blue``: +Adds or modifies parameters in the query string. Each keyword argument will be +added to the query string, replacing any existing value for that key. For +instance, if the current query string is ``?color=green``, the output will be +``?color=red&size=S``. + +Removing items +~~~~~~~~~~~~~~ .. code-block:: html+django - {% query_string color=my_list %} + {% querystring color=None %} + +Passing ``None`` as the value removes the parameter from the query string. For +example, if the current query string is ``?color=green&size=M``, the output +will be ``?size=M``. + +Handling lists +~~~~~~~~~~~~~~ + +.. code-block:: html+django + + {% querystring color=my_list %} + +If ``my_list`` is ``["red", "blue"]``, the output will be +``?color=red&color=blue``, preserving the list structure in the query string. + +Dynamic usage +~~~~~~~~~~~~~ A common example of using this tag is to preserve the current query string when displaying a page of results, while adding a link to the next and previous -pages of results. For example, if the paginator is currently on page 3, and -the current query string is ``?color=blue&size=M&page=3``, the following code -would output ``?color=blue&size=M&page=4``: +pages of results. For example, if the paginator is currently on page 3, and the +current query string is ``?color=blue&size=M&page=3``, the following code would +output ``?color=blue&size=M&page=4``: .. code-block:: html+django - {% query_string page=page.next_page_number %} + {% querystring page=page.next_page_number %} -You can also store the value in a variable, for example, if you need multiple -links to the same page with syntax such as: +You can also store the value in a variable. For example, if you need multiple +links to the same page, define it as: .. code-block:: html+django - {% query_string page=page.next_page_number as next_page %} + {% querystring page=page.next_page_number as next_page %} .. templatetag:: regroup diff --git a/docs/releases/5.0.8.txt b/docs/releases/5.0.8.txt index 1c30ed4766..1037b78f75 100644 --- a/docs/releases/5.0.8.txt +++ b/docs/releases/5.0.8.txt @@ -9,4 +9,9 @@ Django 5.0.8 fixes several bugs in 5.0.7. Bugfixes ======== -* ... +* Added missing validation for ``UniqueConstraint(nulls_distinct=False)`` when + using ``*expressions`` (:ticket:`35594`). + +* Fixed a regression in Django 5.0 where ``ModelAdmin.action_checkbox`` could + break the admin changelist HTML page when rendering a model instance with a + ``__html__`` method (:ticket:`35606`). diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt index a50c48f5cd..431e13a173 100644 --- a/docs/releases/5.1.txt +++ b/docs/releases/5.1.txt @@ -37,10 +37,10 @@ database-level actions: * ``DB_SET_NULL`` - set the referring foreign key to SQL ``NULL``. * ``DB_SET_DEFAULT`` - set the referring column to its ``db_default`` value. -``{% query_string %}`` template tag +``{% querystring %}`` template tag ----------------------------------- -Django 5.1 introduces the :ttag:`{% query_string %} ` template +Django 5.1 introduces the :ttag:`{% querystring %} ` template tag, simplifying the modification of query parameters in URLs, making it easier to generate links that maintain existing query parameters while adding or changing specific ones. @@ -64,7 +64,7 @@ When switching to using this new template tag, the above magically becomes: .. code-block:: html+django - Next page + Next page PostgreSQL Connection Pools --------------------------- diff --git a/tests/admin_changelist/models.py b/tests/admin_changelist/models.py index 290a3ea4ec..78e65ab878 100644 --- a/tests/admin_changelist/models.py +++ b/tests/admin_changelist/models.py @@ -23,6 +23,12 @@ class GrandChild(models.Model): parent = models.ForeignKey(Child, models.SET_NULL, editable=False, null=True) name = models.CharField(max_length=30, blank=True) + def __str__(self): + return self.name + + def __html__(self): + return f'

{self.name}

' + class Genre(models.Model): name = models.CharField(max_length=20) diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index bf85cf038f..4d8845e11e 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -364,6 +364,33 @@ class ChangeListTests(TestCase): table_output, ) + def test_action_checkbox_for_model_with_dunder_html(self): + grandchild = GrandChild.objects.create(name="name") + request = self._mocked_authenticated_request("/grandchild/", self.superuser) + m = GrandChildAdmin(GrandChild, custom_site) + cl = m.get_changelist_instance(request) + cl.formset = None + template = Template( + "{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}" + ) + context = Context({"cl": cl, "opts": GrandChild._meta}) + table_output = template.render(context) + link = reverse( + "admin:admin_changelist_grandchild_change", args=(grandchild.id,) + ) + row_html = build_tbody_html( + grandchild, + link, + "name", + '-' + '-', + ) + self.assertNotEqual( + table_output.find(row_html), + -1, + "Failed to find expected row element: %s" % table_output, + ) + def test_result_list_editable_html(self): """ Regression tests for #11791: Inclusion tag result_list generates a diff --git a/tests/admin_inlines/admin.py b/tests/admin_inlines/admin.py index 578142d192..c3983985c3 100644 --- a/tests/admin_inlines/admin.py +++ b/tests/admin_inlines/admin.py @@ -106,14 +106,18 @@ class PhotoInlineMixin: model = Photo extra = 2 fieldsets = [ - (None, {"fields": ["image", "title"]}), + (None, {"fields": ["image", "title"], "description": "First group"}), ( "Details", - {"fields": ["description", "creation_date"], "classes": ["collapse"]}, + { + "fields": ["description", "creation_date"], + "classes": ["collapse"], + "description": "Second group", + }, ), ( "Details", # Fieldset name intentionally duplicated - {"fields": ["update_date", "updated_by"]}, + {"fields": ["update_date", "updated_by"], "description": "Third group"}, ), ] @@ -145,7 +149,6 @@ class PhotographerAdmin(admin.ModelAdmin): ), ] inlines = [ - PhotoTabularInline, PhotoTabularInline, PhotoStackedExtra2Inline, PhotoStackedExtra3Inline, diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py index 04f0a37e02..620aac10a8 100644 --- a/tests/admin_inlines/tests.py +++ b/tests/admin_inlines/tests.py @@ -2422,3 +2422,39 @@ class SeleniumTests(AdminSeleniumTestCase): ) self.assertEqual(available.text, "AVAILABLE ATTENDANT") self.assertEqual(chosen.text, "CHOSEN ATTENDANT") + + def test_tabular_inline_layout(self): + from selenium.webdriver.common.by import By + + self.admin_login(username="super", password="secret") + self.selenium.get( + self.live_server_url + reverse("admin:admin_inlines_photographer_add") + ) + tabular_inline = self.selenium.find_element( + By.CSS_SELECTOR, "[data-inline-type='tabular']" + ) + headers = tabular_inline.find_elements(By.TAG_NAME, "th") + self.assertEqual( + [h.get_attribute("innerText") for h in headers], + [ + "", + "IMAGE", + "TITLE", + "DESCRIPTION", + "CREATION DATE", + "UPDATE DATE", + "UPDATED BY", + "DELETE?", + ], + ) + # There are no fieldset section names rendered. + self.assertNotIn("Details", tabular_inline.text) + # There are no fieldset section descriptions rendered. + self.assertNotIn("First group", tabular_inline.text) + self.assertNotIn("Second group", tabular_inline.text) + self.assertNotIn("Third group", tabular_inline.text) + # There are no fieldset classes applied. + self.assertEqual( + tabular_inline.find_elements(By.CSS_SELECTOR, ".collapse"), + [], + ) diff --git a/tests/constraints/tests.py b/tests/constraints/tests.py index 86efaa79e7..31c5d64652 100644 --- a/tests/constraints/tests.py +++ b/tests/constraints/tests.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError, connection, models from django.db.models import F from django.db.models.constraints import BaseConstraint, UniqueConstraint -from django.db.models.functions import Lower +from django.db.models.functions import Abs, Lower from django.db.transaction import atomic from django.test import SimpleTestCase, TestCase, skipIfDBFeature, skipUnlessDBFeature from django.test.utils import ignore_warnings @@ -896,6 +896,13 @@ class UniqueConstraintTests(TestCase): ChildUniqueConstraintProduct(name=self.p1.name, color=self.p1.color), ) + def test_validate_fields_unattached(self): + Product.objects.create(price=42) + constraint = models.UniqueConstraint(fields=["price"], name="uniq_prices") + msg = "Product with this Price already exists." + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate(Product, Product(price=42)) + @skipUnlessDBFeature("supports_partial_indexes") def test_validate_condition(self): p1 = UniqueConstraintConditionProduct.objects.create(name="p1") @@ -921,7 +928,7 @@ class UniqueConstraintTests(TestCase): ) @skipUnlessDBFeature("supports_partial_indexes") - def test_validate_conditon_custom_error(self): + def test_validate_condition_custom_error(self): p1 = UniqueConstraintConditionProduct.objects.create(name="p1") constraint = models.UniqueConstraint( fields=["name"], @@ -1063,6 +1070,32 @@ class UniqueConstraintTests(TestCase): is_not_null_constraint.validate(Product, Product(price=4, discounted_price=3)) is_not_null_constraint.validate(Product, Product(price=2, discounted_price=1)) + def test_validate_nulls_distinct_fields(self): + Product.objects.create(price=42) + constraint = models.UniqueConstraint( + fields=["price"], + nulls_distinct=False, + name="uniq_prices_nulls_distinct", + ) + constraint.validate(Product, Product(price=None)) + Product.objects.create(price=None) + msg = "Product with this Price already exists." + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate(Product, Product(price=None)) + + def test_validate_nulls_distinct_expressions(self): + Product.objects.create(price=42) + constraint = models.UniqueConstraint( + Abs("price"), + nulls_distinct=False, + name="uniq_prices_nulls_distinct", + ) + constraint.validate(Product, Product(price=None)) + Product.objects.create(price=None) + msg = f"Constraint “{constraint.name}” is violated." + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate(Product, Product(price=None)) + def test_name(self): constraints = get_constraints(UniqueConstraintProduct._meta.db_table) expected_name = "name_color_uniq" diff --git a/tests/expressions/tests.py b/tests/expressions/tests.py index 3538900092..64103f14db 100644 --- a/tests/expressions/tests.py +++ b/tests/expressions/tests.py @@ -1302,6 +1302,11 @@ class FTests(SimpleTestCase): self.assertNotEqual(f, value) self.assertNotEqual(value, f) + def test_contains(self): + msg = "argument of type 'F' is not iterable" + with self.assertRaisesMessage(TypeError, msg): + "" in F("name") + class ExpressionsTests(TestCase): def test_F_reuse(self): diff --git a/tests/foreign_object/tests.py b/tests/foreign_object/tests.py index c9e8da5792..2d3aa800f7 100644 --- a/tests/foreign_object/tests.py +++ b/tests/foreign_object/tests.py @@ -223,6 +223,13 @@ class MultiColumnFKTests(TestCase): [m2], ) + def test_query_does_not_mutate(self): + """ + Recompiling the same subquery doesn't mutate it. + """ + queryset = Friendship.objects.filter(to_friend__in=Person.objects.all()) + self.assertEqual(str(queryset.query), str(queryset.query)) + def test_select_related_foreignkey_forward_works(self): Membership.objects.create( membership_country=self.usa, person=self.bob, group=self.cia diff --git a/tests/forms_tests/tests/tests.py b/tests/forms_tests/tests/tests.py index 196085ceb2..38735bfb78 100644 --- a/tests/forms_tests/tests/tests.py +++ b/tests/forms_tests/tests/tests.py @@ -259,6 +259,7 @@ class FormsModelTestCase(TestCase): m.file.name, "tests/\u6211\u96bb\u6c23\u588a\u8239\u88dd\u6eff\u6652\u9c54.txt", ) + m.file.delete() m.delete() def test_boundary_conditions(self): diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index d9811ba164..5dfed00329 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -667,7 +667,7 @@ class GeneratedModelUniqueConstraint(GeneratedModelBase): class Meta: required_db_features = { "supports_stored_generated_columns", - "supports_table_check_constraints", + "supports_expression_indexes", } constraints = [ models.UniqueConstraint(F("a"), name="Generated model unique constraint a"), diff --git a/tests/model_forms/models.py b/tests/model_forms/models.py index c28461d862..f9441a4c77 100644 --- a/tests/model_forms/models.py +++ b/tests/model_forms/models.py @@ -135,7 +135,7 @@ class WriterProfile(models.Model): class Document(models.Model): - myfile = models.FileField(upload_to="unused", blank=True) + myfile = models.FileField(storage=temp_storage, upload_to="unused", blank=True) class TextFile(models.Model): diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index c6e12e1aab..3c4c510440 100644 --- a/tests/model_forms/tests.py +++ b/tests/model_forms/tests.py @@ -1,5 +1,6 @@ import datetime import os +import shutil from decimal import Decimal from unittest import mock, skipUnless @@ -72,6 +73,7 @@ from .models import ( Triple, Writer, WriterProfile, + temp_storage_dir, test_images, ) @@ -2482,6 +2484,12 @@ class ModelOneToOneFieldTests(TestCase): class FileAndImageFieldTests(TestCase): + def setUp(self): + if os.path.exists(temp_storage_dir): + shutil.rmtree(temp_storage_dir) + os.mkdir(temp_storage_dir) + self.addCleanup(shutil.rmtree, temp_storage_dir) + def test_clean_false(self): """ If the ``clean`` method on a non-required FileField receives False as diff --git a/tests/queries/test_qs_combinators.py b/tests/queries/test_qs_combinators.py index eac1533803..ad1017c8af 100644 --- a/tests/queries/test_qs_combinators.py +++ b/tests/queries/test_qs_combinators.py @@ -76,6 +76,12 @@ class QuerySetSetOperationTests(TestCase): qs3 = qs1.union(qs2) self.assertNumbersEqual(qs3[:1], [0]) + def test_union_all_none_slice(self): + qs = Number.objects.filter(id__in=[]) + with self.assertNumQueries(0): + self.assertSequenceEqual(qs.union(qs), []) + self.assertSequenceEqual(qs.union(qs)[0:0], []) + def test_union_empty_filter_slice(self): qs1 = Number.objects.filter(num__lte=0) qs2 = Number.objects.filter(pk__in=[]) diff --git a/tests/template_tests/syntax_tests/test_query_string.py b/tests/template_tests/syntax_tests/test_querystring.py similarity index 52% rename from tests/template_tests/syntax_tests/test_query_string.py rename to tests/template_tests/syntax_tests/test_querystring.py index 13c0dc1d08..3f1cf3d281 100644 --- a/tests/template_tests/syntax_tests/test_query_string.py +++ b/tests/template_tests/syntax_tests/test_querystring.py @@ -9,92 +9,88 @@ class QueryStringTagTests(SimpleTestCase): def setUp(self): self.request_factory = RequestFactory() - @setup({"query_string_empty": "{% query_string %}"}) - def test_query_string_empty(self): + @setup({"querystring_empty": "{% querystring %}"}) + def test_querystring_empty(self): request = self.request_factory.get("/") - template = self.engine.get_template("query_string_empty") + template = self.engine.get_template("querystring_empty") context = RequestContext(request) output = template.render(context) self.assertEqual(output, "") - @setup({"query_string_non_empty": "{% query_string %}"}) - def test_query_string_non_empty(self): + @setup({"querystring_non_empty": "{% querystring %}"}) + def test_querystring_non_empty(self): request = self.request_factory.get("/", {"a": "b"}) - template = self.engine.get_template("query_string_non_empty") + template = self.engine.get_template("querystring_non_empty") context = RequestContext(request) output = template.render(context) self.assertEqual(output, "?a=b") - @setup({"query_string_multiple": "{% query_string %}"}) - def test_query_string_multiple(self): + @setup({"querystring_multiple": "{% querystring %}"}) + def test_querystring_multiple(self): request = self.request_factory.get("/", {"x": "y", "a": "b"}) - template = self.engine.get_template("query_string_multiple") + template = self.engine.get_template("querystring_multiple") context = RequestContext(request) output = template.render(context) self.assertEqual(output, "?x=y&a=b") - @setup({"query_string_replace": "{% query_string a=1 %}"}) - def test_query_string_replace(self): + @setup({"querystring_replace": "{% querystring a=1 %}"}) + def test_querystring_replace(self): request = self.request_factory.get("/", {"x": "y", "a": "b"}) - template = self.engine.get_template("query_string_replace") + template = self.engine.get_template("querystring_replace") context = RequestContext(request) output = template.render(context) self.assertEqual(output, "?x=y&a=1") - @setup({"query_string_add": "{% query_string test_new='something' %}"}) - def test_query_string_add(self): + @setup({"querystring_add": "{% querystring test_new='something' %}"}) + def test_querystring_add(self): request = self.request_factory.get("/", {"a": "b"}) - template = self.engine.get_template("query_string_add") + template = self.engine.get_template("querystring_add") context = RequestContext(request) output = template.render(context) self.assertEqual(output, "?a=b&test_new=something") - @setup({"query_string_remove": "{% query_string test=None a=1 %}"}) - def test_query_string_remove(self): + @setup({"querystring_remove": "{% querystring test=None a=1 %}"}) + def test_querystring_remove(self): request = self.request_factory.get("/", {"test": "value", "a": "1"}) - template = self.engine.get_template("query_string_remove") + template = self.engine.get_template("querystring_remove") context = RequestContext(request) output = template.render(context) self.assertEqual(output, "?a=1") - @setup( - {"query_string_remove_nonexistent": "{% query_string nonexistent=None a=1 %}"} - ) - def test_query_string_remove_nonexistent(self): + @setup({"querystring_remove_nonexistent": "{% querystring nonexistent=None a=1 %}"}) + def test_querystring_remove_nonexistent(self): request = self.request_factory.get("/", {"x": "y", "a": "1"}) - template = self.engine.get_template("query_string_remove_nonexistent") + template = self.engine.get_template("querystring_remove_nonexistent") context = RequestContext(request) output = template.render(context) self.assertEqual(output, "?x=y&a=1") - @setup({"query_string_list": "{% query_string a=my_list %}"}) - def test_query_string_add_list(self): + @setup({"querystring_list": "{% querystring a=my_list %}"}) + def test_querystring_add_list(self): request = self.request_factory.get("/") - template = self.engine.get_template("query_string_list") + template = self.engine.get_template("querystring_list") context = RequestContext(request, {"my_list": [2, 3]}) output = template.render(context) self.assertEqual(output, "?a=2&a=3") - @setup({"query_string_query_dict": "{% query_string request.GET a=2 %}"}) - def test_query_string_with_explicit_query_dict(self): + @setup({"querystring_query_dict": "{% querystring request.GET a=2 %}"}) + def test_querystring_with_explicit_query_dict(self): request = self.request_factory.get("/", {"a": 1}) output = self.engine.render_to_string( - "query_string_query_dict", {"request": request} + "querystring_query_dict", {"request": request} ) self.assertEqual(output, "?a=2") - @setup( - {"query_string_query_dict_no_request": "{% query_string my_query_dict a=2 %}"} - ) - def test_query_string_with_explicit_query_dict_and_no_request(self): + @setup({"querystring_query_dict_no_request": "{% querystring my_query_dict a=2 %}"}) + def test_querystring_with_explicit_query_dict_and_no_request(self): context = {"my_query_dict": QueryDict("a=1&b=2")} output = self.engine.render_to_string( - "query_string_query_dict_no_request", context + "querystring_query_dict_no_request", context ) self.assertEqual(output, "?a=2&b=2") - @setup({"query_string_no_request_no_query_dict": "{% query_string %}"}) - def test_query_string_without_request_or_explicit_query_dict(self): + @setup({"querystring_no_request_no_query_dict": "{% querystring %}"}) + def test_querystring_without_request_or_explicit_query_dict(self): msg = "'Context' object has no attribute 'request'" with self.assertRaisesMessage(AttributeError, msg): - self.engine.render_to_string("query_string_no_request_no_query_dict") + self.engine.render_to_string("querystring_no_request_no_query_dict") diff --git a/tests/validation/models.py b/tests/validation/models.py index f6b1e0cd62..653be4a239 100644 --- a/tests/validation/models.py +++ b/tests/validation/models.py @@ -217,17 +217,3 @@ class UniqueConstraintConditionProduct(models.Model): condition=models.Q(color__isnull=True), ), ] - - -class UniqueConstraintNullsDistinctProduct(models.Model): - name = models.CharField(max_length=255, blank=True, null=True) - - class Meta: - required_db_features = {"supports_nulls_distinct_unique_constraints"} - constraints = [ - models.UniqueConstraint( - fields=["name"], - name="name_nulls_not_distinct_uniq", - nulls_distinct=False, - ), - ] diff --git a/tests/validation/test_constraints.py b/tests/validation/test_constraints.py index eea2d0c533..0b1ee6518e 100644 --- a/tests/validation/test_constraints.py +++ b/tests/validation/test_constraints.py @@ -6,7 +6,6 @@ from .models import ( ChildUniqueConstraintProduct, Product, UniqueConstraintConditionProduct, - UniqueConstraintNullsDistinctProduct, UniqueConstraintProduct, ) @@ -94,25 +93,3 @@ class PerformConstraintChecksTest(TestCase): UniqueConstraintConditionProduct.objects.create(name="product") product = UniqueConstraintConditionProduct(name="product") product.full_clean(validate_constraints=False) - - @skipUnlessDBFeature("supports_nulls_distinct_unique_constraints") - def test_full_clean_with_nulls_distinct_unique_constraints(self): - UniqueConstraintNullsDistinctProduct.objects.create(name=None) - product = UniqueConstraintNullsDistinctProduct(name=None) - with self.assertRaises(ValidationError) as cm: - product.full_clean() - self.assertEqual( - cm.exception.message_dict, - { - "name": [ - "Unique constraint nulls distinct product with this Name " - "already exists." - ] - }, - ) - - @skipUnlessDBFeature("supports_nulls_distinct_unique_constraints") - def test_full_clean_with_nulls_distinct_unique_constraints_disabled(self): - UniqueConstraintNullsDistinctProduct.objects.create(name=None) - product = UniqueConstraintNullsDistinctProduct(name=None) - product.full_clean(validate_constraints=False)