From 0d8548e5831bc610102d5e4b8a2366f26818a28a Mon Sep 17 00:00:00 2001 From: varunkasyap Date: Sat, 22 Nov 2025 17:54:05 +0530 Subject: [PATCH 1/6] Fixed #36747 -- Parsed weeks from ISO 8601 format in parse_duration(). --- django/utils/dateparse.py | 5 +++-- docs/ref/utils.txt | 9 +++++++++ docs/releases/6.1.txt | 3 ++- tests/utils_tests/test_dateparse.py | 6 +++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/django/utils/dateparse.py b/django/utils/dateparse.py index 42c2684d7cb1..b95fb2110214 100644 --- a/django/utils/dateparse.py +++ b/django/utils/dateparse.py @@ -40,6 +40,7 @@ iso8601_duration_re = _lazy_re_compile( r"^(?P[-+]?)" r"P" + r"(?:(?P\d+([.,]\d+)?)W)?" r"(?:(?P\d+([.,]\d+)?)D)?" r"(?:T" r"(?:(?P\d+([.,]\d+)?)H)?" @@ -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) diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 69af23ad8345..1c42784d1394 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -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`` =========================== diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index a392b3d75560..4036514984b6 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -321,7 +321,8 @@ URLs Utilities ~~~~~~~~~ -* ... +* :func:`~django.utils.dateparse.parse_duration` now supports ISO 8601 + durations expressed in weeks (``PnW``). Validators ~~~~~~~~~~ diff --git a/tests/utils_tests/test_dateparse.py b/tests/utils_tests/test_dateparse.py index a01942bfd03c..b37cc9544a43 100644 --- a/tests/utils_tests/test_dateparse.py +++ b/tests/utils_tests/test_dateparse.py @@ -194,7 +194,11 @@ def test_iso_8601(self): test_values = ( ("P4Y", None), ("P4M", None), - ("P4W", None), + ("P4W", timedelta(weeks=4)), + ("P0.5W", timedelta(weeks=0.5)), + ("P0,5W", timedelta(weeks=0.5)), + ("-P0.5W", timedelta(weeks=-0.5)), + ("P1W1D", timedelta(weeks=1, days=1)), ("P4D", timedelta(days=4)), ("-P1D", timedelta(days=-1)), ("P0.5D", timedelta(hours=12)), From 4774adfaa18391854bb5449b4bcb0e9ab5e66b73 Mon Sep 17 00:00:00 2001 From: Pravin Kamble Date: Wed, 17 Dec 2025 21:44:48 +0530 Subject: [PATCH 2/6] Fixed #32568 -- Replaced mark_safe() with SafeString for literal values. Replaced instances of mark_safe('some string literal') with SafeString to avoid the overhead of managing lazy objects. Thanks Tim McCurrach for the idea and David Smith and Jacob Walls for reviews. --- django/contrib/admin/templatetags/admin_list.py | 8 ++++---- django/utils/html.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index 52aae9e58933..3aa937c787a6 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -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 _ @@ -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'' ), - "class_attrib": mark_safe(' class="action-checkbox-column"'), + "class_attrib": SafeString(' class="action-checkbox-column"'), "sortable": False, } continue @@ -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: diff --git a/django/utils/html.py b/django/utils/html.py index 68260af3372e..71122ac79e8c 100644 --- a/django/utils/html.py +++ b/django/utils/html.py @@ -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) From c3b6c2969171d8867308406bb03d60f86a422904 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 7 Nov 2025 09:39:42 +0100 Subject: [PATCH 3/6] Refs #27380 -- Made ManyRelatedManager.add()/clear()/remove() implementation more consistent. This also adds _add_base(), _clear_base(), and _remove_base() internal hooks. --- .../db/models/fields/related_descriptors.py | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 4728233a6a15..6056af366095 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -1262,15 +1262,15 @@ 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): + 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, ) # If this is a symmetrical m2m relation to self, add the mirror # entry in the m2m table. @@ -1280,8 +1280,14 @@ def add(self, *objs, through_defaults=None): self.source_field_name, *objs, through_defaults=through_defaults, + using=db, ) + 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): @@ -1291,9 +1297,16 @@ async def aadd(self, *objs, through_defaults=None): aadd.alters_data = True + def _remove_base(self, *objs, using=None): + 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 + ) + 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 @@ -1302,8 +1315,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): + 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, @@ -1314,7 +1327,6 @@ def clear(self): pk_set=None, using=db, ) - self._remove_prefetched_objects() filters = self._build_remove_filters(super().get_queryset().using(db)) self.through._default_manager.using(db).filter(filters).delete() @@ -1328,6 +1340,11 @@ def clear(self): using=db, ) + 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): @@ -1516,7 +1533,12 @@ 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, ): # source_field_name: the PK fieldname in join table for the source # object target_field_name: the PK fieldname in join table for the @@ -1527,7 +1549,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 ) @@ -1586,7 +1608,9 @@ def _add_items( using=db, ) - def _remove_items(self, source_field_name, target_field_name, *objs): + def _remove_items( + self, source_field_name, target_field_name, *objs, using=None + ): # 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, @@ -1603,7 +1627,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( From 6fc2406fc534f97f245b8267703ab598a020fee5 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 7 Nov 2025 09:57:31 +0100 Subject: [PATCH 4/6] Refs #27380 -- Made ManyRelatedManager.set() use *_base() hooks. --- django/db/models/fields/related_descriptors.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 6056af366095..866fc58fc294 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -1359,9 +1359,10 @@ def set(self, objs, *, clear=False, through_defaults=None): 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) + self._add_base(*objs, through_defaults=through_defaults, using=db) else: old_ids = set( self.using(db).values_list( @@ -1381,8 +1382,10 @@ 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) + self._add_base( + *new_objs, through_defaults=through_defaults, using=db + ) set.alters_data = True From 12d574407c466c7929e455cfcfef4c0ff564e465 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 7 Nov 2025 10:07:21 +0100 Subject: [PATCH 5/6] Refs #27380 -- Added ManyRelatedManager.set_base() hook. --- django/db/models/fields/related_descriptors.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 866fc58fc294..622c49837fd3 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -1352,7 +1352,7 @@ async def aclear(self): aclear.alters_data = True - def set(self, objs, *, clear=False, through_defaults=None): + def set_base(self, objs, *, clear=False, through_defaults=None): # Force evaluation of `objs` in case it's a queryset whose value # could be affected by `manager.clear()`. Refs #19816. objs = tuple(objs) @@ -1387,6 +1387,9 @@ def set(self, objs, *, clear=False, through_defaults=None): *new_objs, through_defaults=through_defaults, using=db ) + def set(self, objs, *, clear=False, through_defaults=None): + self.set_base(objs, clear=clear, through_defaults=through_defaults) + set.alters_data = True async def aset(self, objs, *, clear=False, through_defaults=None): From 4702b36120ea4c736d3f6b5595496f96e0021e46 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 7 Nov 2025 10:17:22 +0100 Subject: [PATCH 6/6] Fixed #27380 -- Added "raw" argument to m2m_changed signals. --- django/core/serializers/base.py | 4 +- .../db/models/fields/related_descriptors.py | 31 +++-- docs/ref/signals.txt | 13 ++ docs/releases/6.1.txt | 4 + docs/topics/db/fixtures.txt | 9 +- tests/m2m_signals/tests.py | 123 ++++++++++++++++++ tests/serializers/test_data.py | 2 +- 7 files changed, 171 insertions(+), 15 deletions(-) diff --git a/django/core/serializers/base.py b/django/core/serializers/base.py index cc9caf9a82b9..12bd497ac663 100644 --- a/django/core/serializers/base.py +++ b/django/core/serializers/base.py @@ -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. diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 622c49837fd3..40ad8b260fe6 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -1262,7 +1262,7 @@ def count(self): else: return super().count() - def _add_base(self, *objs, through_defaults=None, using=None): + 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( @@ -1271,6 +1271,7 @@ def _add_base(self, *objs, through_defaults=None, using=None): *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. @@ -1281,6 +1282,7 @@ def _add_base(self, *objs, through_defaults=None, using=None): *objs, through_defaults=through_defaults, using=db, + raw=raw, ) def add(self, *objs, through_defaults=None): @@ -1297,10 +1299,10 @@ async def aadd(self, *objs, through_defaults=None): aadd.alters_data = True - def _remove_base(self, *objs, using=None): + 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 + self.source_field_name, self.target_field_name, *objs, using=db, raw=raw ) def remove(self, *objs): @@ -1315,7 +1317,7 @@ async def aremove(self, *objs): aremove.alters_data = True - def _clear_base(self, using=None): + 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( @@ -1326,6 +1328,7 @@ def _clear_base(self, using=None): model=self.model, pk_set=None, using=db, + raw=raw, ) filters = self._build_remove_filters(super().get_queryset().using(db)) self.through._default_manager.using(db).filter(filters).delete() @@ -1338,6 +1341,7 @@ def _clear_base(self, using=None): model=self.model, pk_set=None, using=db, + raw=raw, ) def clear(self): @@ -1352,7 +1356,7 @@ async def aclear(self): aclear.alters_data = True - def set_base(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) @@ -1361,8 +1365,10 @@ def set_base(self, objs, *, clear=False, through_defaults=None): with transaction.atomic(using=db, savepoint=False): self._remove_prefetched_objects() if clear: - self._clear_base(using=db) - self._add_base(*objs, through_defaults=through_defaults, using=db) + 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( @@ -1382,9 +1388,9 @@ def set_base(self, objs, *, clear=False, through_defaults=None): else: new_objs.append(obj) - self._remove_base(*old_ids, using=db) + self._remove_base(*old_ids, using=db, raw=raw) self._add_base( - *new_objs, through_defaults=through_defaults, using=db + *new_objs, through_defaults=through_defaults, using=db, raw=raw ) def set(self, objs, *, clear=False, through_defaults=None): @@ -1545,6 +1551,7 @@ def _add_items( *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 @@ -1587,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( @@ -1612,10 +1620,11 @@ 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, using=None + 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 @@ -1644,6 +1653,7 @@ def _remove_items( model=self.model, pk_set=old_ids, using=db, + raw=raw, ) target_model_qs = super().get_queryset() if target_model_qs._has_filters(): @@ -1663,6 +1673,7 @@ def _remove_items( model=self.model, pk_set=old_ids, using=db, + raw=raw, ) return ManyRelatedManager diff --git a/docs/ref/signals.txt b/docs/ref/signals.txt index 44958dcef3e8..aecd7f9a4419 100644 --- a/docs/ref/signals.txt +++ b/docs/ref/signals.txt @@ -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 `). 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:: @@ -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: @@ -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`` diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 4036514984b6..b72d6f3f0a2b 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -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 ~~~~~~~~~~ diff --git a/docs/topics/db/fixtures.txt b/docs/topics/db/fixtures.txt index 6066d34f8e66..9e91e0d8450a 100644 --- a/docs/topics/db/fixtures.txt +++ b/docs/topics/db/fixtures.txt @@ -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 @@ -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 =================== diff --git a/tests/m2m_signals/tests.py b/tests/m2m_signals/tests.py index 488c0a6fbe77..980771d54bf1 100644 --- a/tests/m2m_signals/tests.py +++ b/tests/m2m_signals/tests.py @@ -35,6 +35,7 @@ def m2m_changed_signal_receiver(self, signal, sender, **kwargs): "action": kwargs["action"], "reverse": kwargs["reverse"], "model": kwargs["model"], + "raw": kwargs["raw"], } if kwargs["pk_set"]: message["objects"] = list( @@ -114,6 +115,7 @@ def test_m2m_relations_add_remove_clear(self): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -123,6 +125,7 @@ def test_m2m_relations_add_remove_clear(self): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -136,6 +139,7 @@ def test_m2m_relations_add_remove_clear(self): "action": "pre_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -145,6 +149,7 @@ def test_m2m_relations_add_remove_clear(self): "action": "post_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -163,6 +168,7 @@ def test_m2m_relations_signals_remove_relation(self): "action": "pre_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.engine], }, { @@ -170,6 +176,7 @@ def test_m2m_relations_signals_remove_relation(self): "action": "post_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.engine], }, ], @@ -188,6 +195,7 @@ def test_m2m_relations_signals_give_the_self_vw_some_optional_parts(self): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.sunroof], } ) @@ -197,6 +205,7 @@ def test_m2m_relations_signals_give_the_self_vw_some_optional_parts(self): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.sunroof], } ) @@ -210,6 +219,7 @@ def test_m2m_relations_signals_give_the_self_vw_some_optional_parts(self): "action": "pre_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -219,6 +229,7 @@ def test_m2m_relations_signals_give_the_self_vw_some_optional_parts(self): "action": "post_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -237,6 +248,7 @@ def test_m2m_relations_signals_reverse_relation_with_custom_related_name(self): "action": "pre_remove", "reverse": True, "model": Car, + "raw": False, "objects": [self.vw], }, { @@ -244,6 +256,7 @@ def test_m2m_relations_signals_reverse_relation_with_custom_related_name(self): "action": "post_remove", "reverse": True, "model": Car, + "raw": False, "objects": [self.vw], }, ], @@ -261,12 +274,14 @@ def test_m2m_relations_signals_clear_all_parts_of_the_self_vw(self): "action": "pre_clear", "reverse": False, "model": Part, + "raw": False, }, { "instance": self.vw, "action": "post_clear", "reverse": False, "model": Part, + "raw": False, }, ], ) @@ -283,12 +298,14 @@ def test_m2m_relations_signals_all_the_doors_off_of_cars(self): "action": "pre_clear", "reverse": True, "model": Car, + "raw": False, }, { "instance": self.doors, "action": "post_clear", "reverse": True, "model": Car, + "raw": False, }, ], ) @@ -306,12 +323,14 @@ def test_m2m_relations_signals_reverse_relation(self): "action": "pre_clear", "reverse": True, "model": Car, + "raw": False, }, { "instance": self.airbag, "action": "post_clear", "reverse": True, "model": Car, + "raw": False, }, ], ) @@ -330,6 +349,7 @@ def test_m2m_relations_signals_alternative_ways(self): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -339,6 +359,7 @@ def test_m2m_relations_signals_alternative_ways(self): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -352,6 +373,7 @@ def test_m2m_relations_signals_alternative_ways(self): "action": "pre_remove", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -361,6 +383,7 @@ def test_m2m_relations_signals_alternative_ways(self): "action": "post_remove", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -370,6 +393,7 @@ def test_m2m_relations_signals_alternative_ways(self): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -379,6 +403,7 @@ def test_m2m_relations_signals_alternative_ways(self): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -397,6 +422,7 @@ def test_m2m_relations_signals_clearing_removing(self): "action": "pre_clear", "reverse": False, "model": Part, + "raw": False, } ) expected_messages.append( @@ -405,6 +431,7 @@ def test_m2m_relations_signals_clearing_removing(self): "action": "post_clear", "reverse": False, "model": Part, + "raw": False, } ) expected_messages.append( @@ -413,6 +440,7 @@ def test_m2m_relations_signals_clearing_removing(self): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -422,6 +450,7 @@ def test_m2m_relations_signals_clearing_removing(self): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -435,6 +464,7 @@ def test_m2m_relations_signals_clearing_removing(self): "action": "pre_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.engine], } ) @@ -444,6 +474,7 @@ def test_m2m_relations_signals_clearing_removing(self): "action": "post_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.engine], } ) @@ -464,6 +495,7 @@ def test_m2m_relations_signals_when_inheritance(self): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors], } ) @@ -473,6 +505,7 @@ def test_m2m_relations_signals_when_inheritance(self): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors], } ) @@ -485,6 +518,7 @@ def test_m2m_relations_signals_when_inheritance(self): "action": "pre_add", "reverse": True, "model": Car, + "raw": False, "objects": [c4b], } ) @@ -494,6 +528,7 @@ def test_m2m_relations_signals_when_inheritance(self): "action": "post_add", "reverse": True, "model": Car, + "raw": False, "objects": [c4b], } ) @@ -519,6 +554,7 @@ def test_m2m_relations_with_self_add_friends(self): "action": "pre_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.bob, self.chuck], }, { @@ -526,6 +562,7 @@ def test_m2m_relations_with_self_add_friends(self): "action": "post_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.bob, self.chuck], }, ], @@ -542,6 +579,7 @@ def test_m2m_relations_with_self_add_fan(self): "action": "pre_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.daisy], }, { @@ -549,6 +587,7 @@ def test_m2m_relations_with_self_add_fan(self): "action": "post_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.daisy], }, ], @@ -565,6 +604,7 @@ def test_m2m_relations_with_self_add_idols(self): "action": "pre_add", "reverse": True, "model": Person, + "raw": False, "objects": [self.alice, self.bob], }, { @@ -572,6 +612,89 @@ def test_m2m_relations_with_self_add_idols(self): "action": "post_add", "reverse": True, "model": Person, + "raw": False, + "objects": [self.alice, self.bob], + }, + ], + ) + + def test_m2m_relations_set_base_raw(self): + self.chuck.idols.add(self.daisy) + self._initialize_signal_person() + self.chuck.idols.set_base([self.alice, self.bob], raw=True) + self.assertEqual( + self.m2m_changed_messages, + [ + { + "instance": self.chuck, + "action": "pre_remove", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.daisy], + }, + { + "instance": self.chuck, + "action": "post_remove", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.daisy], + }, + { + "instance": self.chuck, + "action": "pre_add", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.alice, self.bob], + }, + { + "instance": self.chuck, + "action": "post_add", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.alice, self.bob], + }, + ], + ) + + def test_m2m_relations_set_base_raw_clear(self): + self.chuck.idols.set([self.daisy, self.bob]) + self._initialize_signal_person() + self.chuck.idols.set_base([self.alice, self.bob], clear=True, raw=True) + self.assertEqual( + self.m2m_changed_messages, + [ + { + "instance": self.chuck, + "action": "pre_clear", + "reverse": True, + "model": Person, + "raw": True, + }, + { + "instance": self.chuck, + "action": "post_clear", + "reverse": True, + "model": Person, + "raw": True, + }, + { + "instance": self.chuck, + "action": "pre_add", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.alice, self.bob], + }, + { + "instance": self.chuck, + "action": "post_add", + "reverse": True, + "model": Person, + "raw": True, "objects": [self.alice, self.bob], }, ], diff --git a/tests/serializers/test_data.py b/tests/serializers/test_data.py index c626f2550ae8..121404a0ef46 100644 --- a/tests/serializers/test_data.py +++ b/tests/serializers/test_data.py @@ -112,7 +112,7 @@ def fk_create(pk, klass, data): def m2m_create(pk, klass, data): instance = klass(id=pk) models.Model.save_base(instance, raw=True) - instance.data.set(data) + instance.data.set_base(data, raw=True) return [instance]