Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions django/contrib/admin/templatetags/admin_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from django.urls import NoReverseMatch
from django.utils import formats, timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.safestring import SafeString, mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext as _

Expand Down Expand Up @@ -103,11 +103,11 @@ def result_headers(cl):
if field_name == "action_checkbox":
aria_label = _("Select all objects on this page for an action")
yield {
"text": mark_safe(
"text": SafeString(
f'<input type="checkbox" id="action-toggle" '
f'aria-label="{aria_label}">'
),
"class_attrib": mark_safe(' class="action-checkbox-column"'),
"class_attrib": SafeString(' class="action-checkbox-column"'),
"sortable": False,
}
continue
Expand Down Expand Up @@ -256,7 +256,7 @@ def link_in_col(is_first, field_name, cl):
f, (models.DateField, models.TimeField, models.ForeignKey)
):
row_classes.append("nowrap")
row_class = mark_safe(' class="%s"' % " ".join(row_classes))
row_class = SafeString(' class="%s"' % " ".join(row_classes))
# If list_display_links not defined, add the link tag to the first
# field
if link_to_changelist:
Expand Down
4 changes: 2 additions & 2 deletions django/core/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,11 +290,11 @@ def __repr__(self):
def save(self, save_m2m=True, using=None, **kwargs):
# Call save on the Model baseclass directly. This bypasses any
# model-defined save. The save is also forced to be raw.
# raw=True is passed to any pre/post_save signals.
# raw=True is passed to any pre/post_save and m2m_changed signals.
models.Model.save_base(self.object, using=using, raw=True, **kwargs)
if self.m2m_data and save_m2m:
for accessor_name, object_list in self.m2m_data.items():
getattr(self.object, accessor_name).set(object_list)
getattr(self.object, accessor_name).set_base(object_list, raw=True)

# prevent a second (possibly accidental) call to save() from saving
# the m2m data twice.
Expand Down
73 changes: 57 additions & 16 deletions django/db/models/fields/related_descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,15 +1262,16 @@ def count(self):
else:
return super().count()

def add(self, *objs, through_defaults=None):
self._remove_prefetched_objects()
db = router.db_for_write(self.through, instance=self.instance)
def _add_base(self, *objs, through_defaults=None, using=None, raw=False):
db = using or router.db_for_write(self.through, instance=self.instance)
with transaction.atomic(using=db, savepoint=False):
self._add_items(
self.source_field_name,
self.target_field_name,
*objs,
through_defaults=through_defaults,
using=db,
raw=raw,
)
# If this is a symmetrical m2m relation to self, add the mirror
# entry in the m2m table.
Expand All @@ -1280,8 +1281,15 @@ def add(self, *objs, through_defaults=None):
self.source_field_name,
*objs,
through_defaults=through_defaults,
using=db,
raw=raw,
)

def add(self, *objs, through_defaults=None):
self._remove_prefetched_objects()
db = router.db_for_write(self.through, instance=self.instance)
self._add_base(*objs, through_defaults=through_defaults, using=db)

add.alters_data = True

async def aadd(self, *objs, through_defaults=None):
Expand All @@ -1291,9 +1299,16 @@ async def aadd(self, *objs, through_defaults=None):

aadd.alters_data = True

def _remove_base(self, *objs, using=None, raw=False):
db = using or router.db_for_write(self.through, instance=self.instance)
self._remove_items(
self.source_field_name, self.target_field_name, *objs, using=db, raw=raw
)

def remove(self, *objs):
self._remove_prefetched_objects()
self._remove_items(self.source_field_name, self.target_field_name, *objs)
db = router.db_for_write(self.through, instance=self.instance)
self._remove_base(*objs, using=db)

remove.alters_data = True

Expand All @@ -1302,8 +1317,8 @@ async def aremove(self, *objs):

aremove.alters_data = True

def clear(self):
db = router.db_for_write(self.through, instance=self.instance)
def _clear_base(self, using=None, raw=False):
db = using or router.db_for_write(self.through, instance=self.instance)
with transaction.atomic(using=db, savepoint=False):
signals.m2m_changed.send(
sender=self.through,
Expand All @@ -1313,8 +1328,8 @@ def clear(self):
model=self.model,
pk_set=None,
using=db,
raw=raw,
)
self._remove_prefetched_objects()
filters = self._build_remove_filters(super().get_queryset().using(db))
self.through._default_manager.using(db).filter(filters).delete()

Expand All @@ -1326,25 +1341,34 @@ def clear(self):
model=self.model,
pk_set=None,
using=db,
raw=raw,
)

def clear(self):
self._remove_prefetched_objects()
db = router.db_for_write(self.through, instance=self.instance)
self._clear_base(using=db)

clear.alters_data = True

async def aclear(self):
return await sync_to_async(self.clear)()

aclear.alters_data = True

def set(self, objs, *, clear=False, through_defaults=None):
def set_base(self, objs, *, clear=False, through_defaults=None, raw=False):
# Force evaluation of `objs` in case it's a queryset whose value
# could be affected by `manager.clear()`. Refs #19816.
objs = tuple(objs)

db = router.db_for_write(self.through, instance=self.instance)
with transaction.atomic(using=db, savepoint=False):
self._remove_prefetched_objects()
if clear:
self.clear()
self.add(*objs, through_defaults=through_defaults)
self._clear_base(using=db, raw=raw)
self._add_base(
*objs, through_defaults=through_defaults, using=db, raw=raw
)
else:
old_ids = set(
self.using(db).values_list(
Expand All @@ -1364,8 +1388,13 @@ def set(self, objs, *, clear=False, through_defaults=None):
else:
new_objs.append(obj)

self.remove(*old_ids)
self.add(*new_objs, through_defaults=through_defaults)
self._remove_base(*old_ids, using=db, raw=raw)
self._add_base(
*new_objs, through_defaults=through_defaults, using=db, raw=raw
)

def set(self, objs, *, clear=False, through_defaults=None):
self.set_base(objs, clear=clear, through_defaults=through_defaults)

set.alters_data = True

Expand Down Expand Up @@ -1516,7 +1545,13 @@ def _get_add_plan(self, db, source_field_name):
)

def _add_items(
self, source_field_name, target_field_name, *objs, through_defaults=None
self,
source_field_name,
target_field_name,
*objs,
through_defaults=None,
using=None,
raw=False,
):
# source_field_name: the PK fieldname in join table for the source
# object target_field_name: the PK fieldname in join table for the
Expand All @@ -1527,7 +1562,7 @@ def _add_items(

through_defaults = dict(resolve_callables(through_defaults or {}))
target_ids = self._get_target_ids(target_field_name, objs)
db = router.db_for_write(self.through, instance=self.instance)
db = using or router.db_for_write(self.through, instance=self.instance)
can_ignore_conflicts, must_send_signals, can_fast_add = self._get_add_plan(
db, source_field_name
)
Expand Down Expand Up @@ -1559,6 +1594,7 @@ def _add_items(
model=self.model,
pk_set=missing_target_ids,
using=db,
raw=raw,
)
# Add the ones that aren't there already.
self.through._default_manager.using(db).bulk_create(
Expand All @@ -1584,9 +1620,12 @@ def _add_items(
model=self.model,
pk_set=missing_target_ids,
using=db,
raw=raw,
)

def _remove_items(self, source_field_name, target_field_name, *objs):
def _remove_items(
self, source_field_name, target_field_name, *objs, using=None, raw=False
):
# source_field_name: the PK colname in join table for the source
# object target_field_name: the PK colname in join table for the
# target object *objs - objects to remove. Either object instances,
Expand All @@ -1603,7 +1642,7 @@ def _remove_items(self, source_field_name, target_field_name, *objs):
else:
old_ids.add(obj)

db = router.db_for_write(self.through, instance=self.instance)
db = using or router.db_for_write(self.through, instance=self.instance)
with transaction.atomic(using=db, savepoint=False):
# Send a signal to the other end if need be.
signals.m2m_changed.send(
Expand All @@ -1614,6 +1653,7 @@ def _remove_items(self, source_field_name, target_field_name, *objs):
model=self.model,
pk_set=old_ids,
using=db,
raw=raw,
)
target_model_qs = super().get_queryset()
if target_model_qs._has_filters():
Expand All @@ -1633,6 +1673,7 @@ def _remove_items(self, source_field_name, target_field_name, *objs):
model=self.model,
pk_set=old_ids,
using=db,
raw=raw,
)

return ManyRelatedManager
5 changes: 3 additions & 2 deletions django/utils/dateparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
iso8601_duration_re = _lazy_re_compile(
r"^(?P<sign>[-+]?)"
r"P"
r"(?:(?P<weeks>\d+([.,]\d+)?)W)?"
r"(?:(?P<days>\d+([.,]\d+)?)D)?"
r"(?:T"
r"(?:(?P<hours>\d+([.,]\d+)?)H)?"
Expand Down Expand Up @@ -134,8 +135,8 @@ def parse_duration(value):

The preferred format for durations in Django is '%d %H:%M:%S.%f'.

Also supports ISO 8601 representation and PostgreSQL's day-time interval
format.
Also supports ISO 8601 representation (excluding years and months) and
PostgreSQL's day-time interval format.
"""
match = (
standard_duration_re.match(value)
Expand Down
2 changes: 1 addition & 1 deletion django/utils/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ def handle_word(
attrs=nofollow_attr,
url=trimmed,
)
return mark_safe(f"{lead}{middle}{trail}")
return SafeString(f"{lead}{middle}{trail}")
else:
if safe_input:
return mark_safe(word)
Expand Down
13 changes: 13 additions & 0 deletions docs/ref/signals.txt
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,15 @@ Arguments sent with this signal:
``using``
The database alias being used.

``raw``

.. versionadded:: 6.1

A boolean; ``True`` if the model is saved exactly as presented
(i.e. when loading a :ref:`fixture <fixtures-explanation>`). One should not
query/modify other records in the database as the database might not be in
a consistent state yet.

For example, if a ``Pizza`` can have multiple ``Topping`` objects, modeled
like this::

Expand Down Expand Up @@ -357,6 +366,8 @@ Argument Value
``pk_set`` ``{t.id}`` (since only ``Topping t`` was added to the relation)

``using`` ``"default"`` (since the default router sends writes here)

``raw`` ``False`` (since it is not loaded from a fixture)
============== ============================================================

And if we would then do something like this:
Expand Down Expand Up @@ -387,6 +398,8 @@ Argument Value
relation)

``using`` ``"default"`` (since the default router sends writes here)

``raw`` ``False`` (since it is not loaded from a fixture)
============== ============================================================

``class_prepared``
Expand Down
9 changes: 9 additions & 0 deletions docs/ref/utils.txt
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ The functions defined in this module share the following properties:
``P4DT1H15M20S`` which is equivalent to ``4 1:15:20``) or PostgreSQL's
day-time interval format (e.g. ``3 days 04:05:06``).

.. admonition:: Years and months not supported

ISO 8601 years (``Y``) and months (``M``) are unsupported as they
cannot be represented by :class:`~datetime.timedelta`.

.. versionchanged:: 6.1

ISO 8601 duration parsing now supports weeks (``PnW``).

``django.utils.decorators``
===========================

Expand Down
7 changes: 6 additions & 1 deletion docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ Management Commands
``suggest_on_error`` argument to ``True`` by default on Python 3.14, enabling
suggestions for mistyped subcommand names and argument choices.

* The :djadmin:`loaddata` command now calls
:data:`~django.db.models.signals.m2m_changed` signals with ``raw=True`` when
loading fixtures.

Migrations
~~~~~~~~~~

Expand Down Expand Up @@ -321,7 +325,8 @@ URLs
Utilities
~~~~~~~~~

* ...
* :func:`~django.utils.dateparse.parse_duration` now supports ISO 8601
durations expressed in weeks (``PnW``).

Validators
~~~~~~~~~~
Expand Down
9 changes: 7 additions & 2 deletions docs/topics/db/fixtures.txt
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ How fixtures are saved to the database

When fixture files are processed, the data is saved to the database as is.
Model defined :meth:`~django.db.models.Model.save` methods are not called, and
any :data:`~django.db.models.signals.pre_save` or
:data:`~django.db.models.signals.post_save` signals will be called with
any :data:`~django.db.models.signals.pre_save`,
:data:`~django.db.models.signals.post_save`, or
:data:`~django.db.models.signals.m2m_changed` signals will be called with
``raw=True`` since the instance only contains attributes that are local to the
model. You may, for example, want to disable handlers that access
related fields that aren't present during fixture loading and would otherwise
Expand Down Expand Up @@ -163,6 +164,10 @@ You could also write a decorator to encapsulate this logic::
Just be aware that this logic will disable the signals whenever fixtures are
deserialized, not just during :djadmin:`loaddata`.

.. versionchanged:: 6.1

The ``raw`` argument was added to ``m2m_changed`` signals.

Compressed fixtures
===================

Expand Down
Loading