diff -Nru python-django-modelcluster-5.2/CHANGELOG.txt python-django-modelcluster-6.0/CHANGELOG.txt --- python-django-modelcluster-5.2/CHANGELOG.txt 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/CHANGELOG.txt 2022-03-14 21:08:23.000000000 +0000 @@ -1,6 +1,23 @@ Changelog ========= +6.0 (14.03.2022) +~~~~~~~~~~~~~~~~ +* BREAKING: ClusterForm now builds no child formsets when neither `formsets` nor `exclude_formsets` is specified in the Meta class, rather than building a formset for every child relation (Matt Westcott) +* Removed Python 3.5 and 3.6 support +* Removed Django 2.0 and 2.1 support +* Support explicit definitions for nested formsets within ClusterForm, via a `formsets` option on the outer formset's definition (Matt Westcott) +* Add `inherit_kwargs` attribute to ClusterForm child formsets (Matt Westcott) + +5.3 (10.03.2022) +~~~~~~~~~~~~~~~~ +* Avoid accessing live queryset on unsaved instances, for preliminary Django 4.1 compatibility (Matt Westcott) +* Support traversing one-to-one and many-to-one relations in `filter` / `order_by` lookups (Andy Babic) +* Implement `values()` method on FakeQuerySet (Andy Babic) +* Allow `values()` and `values_list()` to be chained with other queryset modifiers (Andy Babic) +* Fix: Fix HTML escaping behaviour on `ClusterForm.as_p()` (Matt Westcott) +* Fix: Match standard behaviour queryset of returning foreign keys as IDs in `values_list` (Andy Babic) + 5.2 (13.10.2021) ~~~~~~~~~~~~~~~~ * Implement `copy_cluster` method on ClusterableModel (Karl Hobley) diff -Nru python-django-modelcluster-5.2/debian/changelog python-django-modelcluster-6.0/debian/changelog --- python-django-modelcluster-5.2/debian/changelog 2022-01-06 15:54:34.000000000 +0000 +++ python-django-modelcluster-6.0/debian/changelog 2022-03-18 10:14:08.000000000 +0000 @@ -1,3 +1,18 @@ +python-django-modelcluster (6.0-1) unstable; urgency=medium + + * Team upload + * New upstream version 6.0 + + -- Carsten Schoenert Fri, 18 Mar 2022 11:14:08 +0100 + +python-django-modelcluster (5.3-1) unstable; urgency=medium + + * New upstream version 5.3 + * Dropping patch queue + The previously added patches are now included upstream. + + -- Carsten Schoenert Sun, 13 Mar 2022 20:01:26 +0100 + python-django-modelcluster (5.2-2) unstable; urgency=medium * Team upload diff -Nru python-django-modelcluster-5.2/debian/patches/0001-Fix-test-to-use-the-appropriate-variant-of-TaggableM.patch python-django-modelcluster-6.0/debian/patches/0001-Fix-test-to-use-the-appropriate-variant-of-TaggableM.patch --- python-django-modelcluster-5.2/debian/patches/0001-Fix-test-to-use-the-appropriate-variant-of-TaggableM.patch 2022-01-06 15:52:48.000000000 +0000 +++ python-django-modelcluster-6.0/debian/patches/0001-Fix-test-to-use-the-appropriate-variant-of-TaggableM.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,51 +0,0 @@ -From: Matt Westcott -Date: Thu, 6 Jan 2022 12:38:18 +0000 -Subject: Fix test to use the appropriate variant of TaggableManager.set for - the taggit version - -Cherry-picked from upstream. - -Forwarded: https://github.com/wagtail/django-modelcluster/commit/4034e69b0a0799d0eae1d4726f9c1432107f6e10 ---- - modelcluster/contrib/taggit.py | 4 ++-- - tests/tests/test_tag.py | 5 ++++- - 2 files changed, 6 insertions(+), 3 deletions(-) - -diff --git a/modelcluster/contrib/taggit.py b/modelcluster/contrib/taggit.py -index 1b0b58a..cd55b5b 100644 ---- a/modelcluster/contrib/taggit.py -+++ b/modelcluster/contrib/taggit.py -@@ -64,7 +64,7 @@ class _ClusterTaggableManager(_TaggableManager): - tagged_item_manager.remove(*tagged_items) - - @require_instance_manager -- def set(self, *tags, **kwargs): -+ def set(self, *args, **kwargs): - # Ignore the 'clear' kwarg (which defaults to False) and override it to be always true; - # this means that set is implemented as a clear then an add, which was the standard behaviour - # prior to django-taggit 0.19 (https://github.com/alex/django-taggit/commit/6542a702b590a5cfb91ea0de218b7f71ffd07c33). -@@ -75,7 +75,7 @@ class _ClusterTaggableManager(_TaggableManager): - # to ensure that the correct set of m2m_changed signals is fired, and our reimplementation here - # doesn't fire them at all (which makes logical sense, because the whole point of this module is - # that the add/remove/set/clear operations don't write to the database). -- return super(_ClusterTaggableManager, self).set(*tags, clear=True) -+ return super(_ClusterTaggableManager, self).set(*args, clear=True) - - @require_instance_manager - def clear(self): -diff --git a/tests/tests/test_tag.py b/tests/tests/test_tag.py -index a88551f..0fce45f 100644 ---- a/tests/tests/test_tag.py -+++ b/tests/tests/test_tag.py -@@ -37,7 +37,10 @@ class TagTest(TestCase): - mission_burrito.save() - self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) - -- mission_burrito.tags.set('mexican', 'burrito') -+ if TAGGIT_VERSION >= (2, 0): -+ mission_burrito.tags.set(['mexican', 'burrito']) -+ else: -+ mission_burrito.tags.set('mexican', 'burrito') - self.assertEqual(2, mission_burrito.tags.count()) - self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) - mission_burrito.save() diff -Nru python-django-modelcluster-5.2/debian/patches/0002-Update-CI-to-test-against-taggit-1.3-2.0-and-2.0-sep.patch python-django-modelcluster-6.0/debian/patches/0002-Update-CI-to-test-against-taggit-1.3-2.0-and-2.0-sep.patch --- python-django-modelcluster-5.2/debian/patches/0002-Update-CI-to-test-against-taggit-1.3-2.0-and-2.0-sep.patch 2022-01-06 15:54:34.000000000 +0000 +++ python-django-modelcluster-6.0/debian/patches/0002-Update-CI-to-test-against-taggit-1.3-2.0-and-2.0-sep.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,91 +0,0 @@ -From: Matt Westcott -Date: Thu, 6 Jan 2022 12:45:03 +0000 -Subject: Update CI to test against taggit 1.3-<2.0 and >=2.0 separately - -Cherry-picked from upstream. - -Forwarded: https://github.com/wagtail/django-modelcluster/commit/63341b82a38f72d90a21c5aa5ce419c15654105c ---- - .github/workflows/test.yml | 12 ++++++------ - tox.ini | 8 ++++++-- - 2 files changed, 12 insertions(+), 8 deletions(-) - -diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml -index 81285d5..201db48 100644 ---- a/.github/workflows/test.yml -+++ b/.github/workflows/test.yml -@@ -32,31 +32,31 @@ jobs: - experimental: false - - python: "3.8" - django: "Django>=3.0,<3.1" -- taggit: "django-taggit>=1.3" -+ taggit: "django-taggit>=1.3,<2.0" - database: "postgresql" - psycopg: "psycopg2>=2.6,<2.9" - experimental: false - - python: "3.9" - django: "Django>=3.1,<3.2" -- taggit: "django-taggit>=1.3" -+ taggit: "django-taggit>=1.3,<2.0" - database: "sqlite3" - psycopg: "psycopg2>=2.6,<2.9" - experimental: false - - python: "3.10" - django: "Django>=3.2,<3.3" -- taggit: "django-taggit>=1.3" -+ taggit: "django-taggit>=2.0" - database: "postgresql" - psycopg: "psycopg2>=2.6" - experimental: false - - python: "3.10" -- django: "git+https://github.com/django/django.git@stable/3.2.x#egg=Django" -- taggit: "django-taggit>=1.3" -+ django: "git+https://github.com/django/django.git@stable/4.0.x#egg=Django" -+ taggit: "django-taggit>=2.0" - database: "sqlite3" - psycopg: "psycopg2>=2.6" - experimental: true - - python: "3.10" - django: "git+https://github.com/django/django.git@main#egg=Django" -- taggit: "django-taggit>=1.3" -+ taggit: "django-taggit>=2.0" - database: "postgresql" - psycopg: "psycopg2>=2.6" - experimental: true -diff --git a/tox.ini b/tox.ini -index 97d42cd..6f236fc 100644 ---- a/tox.ini -+++ b/tox.ini -@@ -3,6 +3,7 @@ envlist = - py{35,36,37}-dj{20,21}-{sqlite,postgres}-taggit{0,1} - py{35,36,37}-dj22-{sqlite,postgres}-taggit{0,1,13} - py{36,37,38}-dj{30,31,32}-{sqlite,postgres}-taggit13 -+ py{37,38,39,310}-dj32-{sqlite,postgres}-taggit2 - - [testenv] - commands=./runtests.py --noinput {posargs} -@@ -12,11 +13,14 @@ basepython = - py36: python3.6 - py37: python3.7 - py38: python3.8 -+ py39: python3.8 -+ py310: python3.10 - - deps = - taggit0: django-taggit>=0.24,<1 - taggit1: django-taggit>=1,<1.3 -- taggit13: django-taggit>=1.3 -+ taggit13: django-taggit>=1.3,<2.0 -+ taggit2: django-taggit>=2 - pytz>=2014.7 - dj20: Django>=2.0,<2.1 - dj21: Django>=2.1,<2.2 -@@ -24,7 +28,7 @@ deps = - dj30: Django>=3.0,<3.1 - dj31: Django>=3.1,<3.2 - dj32: Django>=3.2,<3.3 -- dj32stable: git+https://github.com/django/django.git@stable/3.2.x#egg=Django -+ dj40stable: git+https://github.com/django/django.git@stable/4.0.x#egg=Django - djmaster: git+https://github.com/django/django.git@master#egg=Django - postgres: psycopg2>=2.6 - diff -Nru python-django-modelcluster-5.2/debian/patches/series python-django-modelcluster-6.0/debian/patches/series --- python-django-modelcluster-5.2/debian/patches/series 2022-01-06 15:52:48.000000000 +0000 +++ python-django-modelcluster-6.0/debian/patches/series 1970-01-01 00:00:00.000000000 +0000 @@ -1,2 +0,0 @@ -0001-Fix-test-to-use-the-appropriate-variant-of-TaggableM.patch -0002-Update-CI-to-test-against-taggit-1.3-2.0-and-2.0-sep.patch diff -Nru python-django-modelcluster-5.2/.github/workflows/test.yml python-django-modelcluster-6.0/.github/workflows/test.yml --- python-django-modelcluster-5.2/.github/workflows/test.yml 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/.github/workflows/test.yml 2022-03-14 21:08:23.000000000 +0000 @@ -2,7 +2,6 @@ on: push: - branches: [ main ] pull_request: jobs: @@ -12,18 +11,6 @@ strategy: matrix: include: - - python: "3.5" - django: "Django>=2.0,<2.1" - taggit: "django-taggit>=0.24,<1" - database: "sqlite3" - psycopg: "psycopg2>=2.6,<2.9" - experimental: false - - python: "3.6" - django: "Django>=2.1,<2.2" - taggit: "django-taggit>=0.24,<1" - database: "postgresql" - psycopg: "psycopg2>=2.6,<2.9" - experimental: false - python: "3.7" django: "Django>=2.2,<2.3" taggit: "django-taggit>=1,<1.3" @@ -32,31 +19,37 @@ experimental: false - python: "3.8" django: "Django>=3.0,<3.1" - taggit: "django-taggit>=1.3" + taggit: "django-taggit>=1.3,<2.0" database: "postgresql" psycopg: "psycopg2>=2.6,<2.9" experimental: false - python: "3.9" django: "Django>=3.1,<3.2" - taggit: "django-taggit>=1.3" + taggit: "django-taggit>=1.3,<2.0" database: "sqlite3" psycopg: "psycopg2>=2.6,<2.9" experimental: false - python: "3.10" django: "Django>=3.2,<3.3" - taggit: "django-taggit>=1.3" + taggit: "django-taggit>=2.0" database: "postgresql" psycopg: "psycopg2>=2.6" experimental: false - python: "3.10" - django: "git+https://github.com/django/django.git@stable/3.2.x#egg=Django" - taggit: "django-taggit>=1.3" + django: "Django>=4.0,<4.1" + taggit: "django-taggit>=2.0" + database: "postgresql" + psycopg: "psycopg2>=2.6" + experimental: false + - python: "3.10" + django: "git+https://github.com/django/django.git@stable/4.0.x#egg=Django" + taggit: "django-taggit>=2.0" database: "sqlite3" psycopg: "psycopg2>=2.6" experimental: true - python: "3.10" django: "git+https://github.com/django/django.git@main#egg=Django" - taggit: "django-taggit>=1.3" + taggit: "django-taggit>=2.0" database: "postgresql" psycopg: "psycopg2>=2.6" experimental: true @@ -76,10 +69,10 @@ - name: Install dependencies run: | python -m pip install --upgrade pip + pip install -e . pip install "${{ matrix.psycopg }}" pip install "${{ matrix.django }}" pip install "${{ matrix.taggit }}" - pip install -e . - name: Test run: ./runtests.py env: diff -Nru python-django-modelcluster-5.2/modelcluster/contrib/taggit.py python-django-modelcluster-6.0/modelcluster/contrib/taggit.py --- python-django-modelcluster-5.2/modelcluster/contrib/taggit.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/modelcluster/contrib/taggit.py 2022-03-14 21:08:23.000000000 +0000 @@ -29,7 +29,7 @@ # (which probably means it's being invoked within a prefetch_related operation); # this means that we don't have to deal with uncommitted models/tags, and can just # use the standard taggit handler - return super(_ClusterTaggableManager, self).get_queryset(extra_filters) + return super().get_queryset(extra_filters) else: # FIXME: we ought to have some way of querying the tagged item manager about whether # it has uncommitted changes, and return a real queryset (using the original taggit logic) @@ -64,7 +64,7 @@ tagged_item_manager.remove(*tagged_items) @require_instance_manager - def set(self, *tags, **kwargs): + def set(self, *args, **kwargs): # Ignore the 'clear' kwarg (which defaults to False) and override it to be always true; # this means that set is implemented as a clear then an add, which was the standard behaviour # prior to django-taggit 0.19 (https://github.com/alex/django-taggit/commit/6542a702b590a5cfb91ea0de218b7f71ffd07c33). @@ -75,7 +75,7 @@ # to ensure that the correct set of m2m_changed signals is fired, and our reimplementation here # doesn't fire them at all (which makes logical sense, because the whole point of this module is # that the add/remove/set/clear operations don't write to the database). - return super(_ClusterTaggableManager, self).set(*tags, clear=True) + return super().set(*args, clear=True) @require_instance_manager def clear(self): diff -Nru python-django-modelcluster-5.2/modelcluster/fields.py python-django-modelcluster-6.0/modelcluster/fields.py --- python-django-modelcluster-5.2/modelcluster/fields.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/modelcluster/fields.py 2022-03-14 21:08:23.000000000 +0000 @@ -29,7 +29,7 @@ class DeferringRelatedManager(superclass): def __init__(self, instance): - super(DeferringRelatedManager, self).__init__() + super().__init__() self.model = rel_model self.instance = instance @@ -62,7 +62,11 @@ try: results = self.instance._cluster_related_objects[relation_name] except (AttributeError, KeyError): - return self.get_live_queryset() + if self.instance.pk is None: + # use an empty fake queryset if the instance is unsaved + results = [] + else: + return self.get_live_queryset() return FakeQuerySet(related.related_model, results) @@ -77,7 +81,7 @@ def get_prefetch_queryset(self, instances, queryset=None): if queryset is None: db = self._db or router.db_for_read(self.model, instance=instances[0]) - queryset = super(DeferringRelatedManager, self).get_queryset().using(db) + queryset = super().get_queryset().using(db) rel_obj_attr = rel_field.get_local_related_value instance_attr = rel_field.get_foreign_related_value @@ -105,7 +109,10 @@ try: object_list = cluster_related_objects[relation_name] except KeyError: - object_list = list(self.get_live_queryset()) + if self.instance.pk is None: + object_list = [] + else: + object_list = list(self.get_live_queryset()) cluster_related_objects[relation_name] = object_list return object_list @@ -236,12 +243,12 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('on_delete', CASCADE) - super(ParentalKey, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def check(self, **kwargs): from modelcluster.models import ClusterableModel - errors = super(ParentalKey, self).check(**kwargs) + errors = super().check(**kwargs) # Check that the destination model is a subclass of ClusterableModel. # If self.rel.to is a string at this point, it means that Django has been unable @@ -284,7 +291,7 @@ class DeferringManyRelatedManager(superclass): def __init__(self, instance=None): - super(DeferringManyRelatedManager, self).__init__() + super().__init__() self.model = rel_model self.through = rel_through self.instance = instance @@ -506,7 +513,7 @@ # https://github.com/django/django/blob/6157cd6da1b27716e8f3d1ed692a6e33d970ae46/django/db/models/fields/related.py#L1538 # So, we'll let the original contribute_to_class do its thing, and then overwrite # the accessor... - super(ParentalManyToManyField, self).contribute_to_class(cls, name, **kwargs) + super().contribute_to_class(cls, name, **kwargs) setattr(cls, self.name, self.related_accessor_class(self.remote_field)) def value_from_object(self, obj): diff -Nru python-django-modelcluster-5.2/modelcluster/forms.py python-django-modelcluster-6.0/modelcluster/forms.py --- python-django-modelcluster-5.2/modelcluster/forms.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/modelcluster/forms.py 2022-03-14 21:08:23.000000000 +0000 @@ -8,6 +8,7 @@ ModelForm, _get_foreign_key, ModelFormMetaclass, ModelFormOptions ) from django.db.models.fields.related import ForeignObjectRel +from django.utils.html import format_html_join from modelcluster.models import get_all_child_relations @@ -73,6 +74,8 @@ class BaseChildFormSet(BaseTransientModelFormSet): + inherit_kwargs = None + def __init__(self, data=None, files=None, instance=None, queryset=None, **kwargs): if instance is None: self.instance = self.fk.remote_field.model() @@ -84,13 +87,13 @@ if queryset is None: queryset = getattr(self.instance, self.rel_name).all() - super(BaseChildFormSet, self).__init__(data, files, queryset=queryset, **kwargs) + super().__init__(data, files, queryset=queryset, **kwargs) def save(self, commit=True): # The base ModelFormSet's save(commit=False) will populate the lists # self.changed_objects, self.deleted_objects and self.new_objects; # use these to perform the appropriate updates on the relation's manager. - saved_instances = super(BaseChildFormSet, self).save(commit=False) + saved_instances = super().save(commit=False) manager = getattr(self.instance, self.rel_name) @@ -121,7 +124,7 @@ def clean(self, *args, **kwargs): self.validate_unique() - return super(BaseChildFormSet, self).clean(*args, **kwargs) + return super().clean(*args, **kwargs) def validate_unique(self): '''This clean method will check for unique_together condition''' @@ -170,7 +173,8 @@ parent_model, model, form=ModelForm, formset=BaseChildFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, validate_max=False, - formfield_callback=None, widgets=None, min_num=None, validate_min=False + formfield_callback=None, widgets=None, min_num=None, validate_min=False, + inherit_kwargs=None, formsets=None, exclude_formsets=None ): fk = _get_foreign_key(parent_model, model, fk_name=fk_name) @@ -183,6 +187,22 @@ exclude = [] exclude += [fk.name] + if issubclass(form, ClusterForm) and (formsets is not None or exclude_formsets is not None): + # the modelformset_factory helper that we ultimately hand off to doesn't recognise + # formsets / exclude_formsets, so we need to prepare a specific subclass of our `form` + # class, with these pre-embedded in Meta, to use as the base form + + # If parent form class already has an inner Meta, the Meta we're + # creating needs to inherit from the parent's inner meta. + bases = (form.Meta,) if hasattr(form, "Meta") else () + Meta = type("Meta", bases, { + 'formsets': formsets, + 'exclude_formsets': exclude_formsets, + }) + + # Instantiate type(form) in order to use the same metaclass as form. + form = type(form)("_ClusterForm", (form,), {"Meta": Meta}) + kwargs = { 'form': form, 'formfield_callback': formfield_callback, @@ -202,12 +222,17 @@ } FormSet = transientmodelformset_factory(model, **kwargs) FormSet.fk = fk + + # A list of keyword argument names that should be passed on from ClusterForm's constructor + # to child forms in this formset + FormSet.inherit_kwargs = inherit_kwargs + return FormSet class ClusterFormOptions(ModelFormOptions): def __init__(self, options=None): - super(ClusterFormOptions, self).__init__(options=options) + super().__init__(options=options) self.formsets = getattr(options, 'formsets', None) self.exclude_formsets = getattr(options, 'exclude_formsets', None) @@ -231,7 +256,7 @@ # BAD METACLASS NO BISCUIT. formfield_callback = attrs.get('formfield_callback') - new_class = super(ClusterFormMetaclass, cls).__new__(cls, name, bases, attrs) + new_class = super().__new__(cls, name, bases, attrs) if not parents: return new_class @@ -240,6 +265,7 @@ opts = new_class._meta = ClusterFormOptions(getattr(new_class, 'Meta', None)) if opts.model: formsets = {} + for rel in get_all_child_relations(opts.model): # to build a childformset class from this relation, we need to specify: # - the base model (opts.model) @@ -249,9 +275,14 @@ rel_name = rel.get_accessor_name() # apply 'formsets' and 'exclude_formsets' rules from meta - if opts.formsets is not None and rel_name not in opts.formsets: + if opts.exclude_formsets is not None and rel_name in opts.exclude_formsets: + # formset is explicitly excluded + continue + elif opts.formsets is not None and rel_name not in opts.formsets: + # a formset list has been specified and this isn't on it continue - if opts.exclude_formsets and rel_name in opts.exclude_formsets: + elif opts.formsets is None and opts.exclude_formsets is None: + # neither formsets nor exclude_formsets has been specified - no formsets at all continue try: @@ -280,14 +311,13 @@ formsets[formset_name] = formset new_class.formsets = formsets - new_class._has_explicit_formsets = (opts.formsets is not None or opts.exclude_formsets is not None) return new_class class ClusterForm(ModelForm, metaclass=ClusterFormMetaclass): def __init__(self, data=None, files=None, instance=None, prefix=None, **kwargs): - super(ClusterForm, self).__init__(data, files, instance=instance, prefix=prefix, **kwargs) + super().__init__(data, files, instance=instance, prefix=prefix, **kwargs) self.formsets = {} for rel_name, formset_class in self.__class__.formsets.items(): @@ -295,39 +325,34 @@ formset_prefix = "%s-%s" % (prefix, rel_name) else: formset_prefix = rel_name - self.formsets[rel_name] = formset_class(data, files, instance=instance, prefix=formset_prefix) - if self.is_bound and not self._has_explicit_formsets: - # check which formsets have actually been provided as part of the form submission - - # if no `formsets` or `exclude_formsets` was specified, we allow them to be omitted - # (https://github.com/wagtail/wagtail/issues/5414#issuecomment-567468127). - self._posted_formsets = [ - formset - for formset in self.formsets.values() - if '%s-%s' % (formset.prefix, TOTAL_FORM_COUNT) in self.data - ] - else: - # expect all defined formsets to be part of the post - self._posted_formsets = self.formsets.values() + child_form_kwargs = {} + if formset_class.inherit_kwargs: + for kwarg_name in formset_class.inherit_kwargs: + child_form_kwargs[kwarg_name] = getattr(self, kwarg_name, None) + + self.formsets[rel_name] = formset_class( + data, files, instance=instance, prefix=formset_prefix, form_kwargs=child_form_kwargs + ) def as_p(self): - form_as_p = super(ClusterForm, self).as_p() - return form_as_p + ''.join([formset.as_p() for formset in self.formsets.values()]) + form_as_p = super().as_p() + return form_as_p + format_html_join('', '{}', [(formset.as_p(),) for formset in self.formsets.values()]) def is_valid(self): - form_is_valid = super(ClusterForm, self).is_valid() - formsets_are_valid = all(formset.is_valid() for formset in self._posted_formsets) + form_is_valid = super().is_valid() + formsets_are_valid = all(formset.is_valid() for formset in self.formsets.values()) return form_is_valid and formsets_are_valid def is_multipart(self): return ( - super(ClusterForm, self).is_multipart() + super().is_multipart() or any(formset.is_multipart() for formset in self.formsets.values()) ) @property def media(self): - media = super(ClusterForm, self).media + media = super().media for formset in self.formsets.values(): media = media + formset.media return media @@ -347,7 +372,7 @@ save_m2m_now = True break - instance = super(ClusterForm, self).save(commit=(commit and not save_m2m_now)) + instance = super().save(commit=(commit and not save_m2m_now)) # The M2M-like fields designed for use with ClusterForm (currently # ParentalManyToManyField and ClusterTaggableManager) will manage their own in-memory @@ -371,7 +396,7 @@ if commit: instance.save() - for formset in self._posted_formsets: + for formset in self.formsets.values(): formset.instance = instance formset.save(commit=commit) return instance @@ -382,8 +407,33 @@ # Need to recurse over nested formsets so that the form is saved if there are changes # to child forms but not the parent if self.formsets: - for formset in self._posted_formsets: + for formset in self.formsets.values(): for form in formset.forms: if form.has_changed(): return True return bool(self.changed_data) + + +def clusterform_factory(model, form=ClusterForm, **kwargs): + # Same as Django's modelform_factory, but arbitrary kwargs are accepted and passed on to the + # Meta class. + + # Build up a list of attributes that the Meta object will have. + meta_class_attrs = kwargs + meta_class_attrs["model"] = model + + # If parent form class already has an inner Meta, the Meta we're + # creating needs to inherit from the parent's inner meta. + bases = (form.Meta,) if hasattr(form, "Meta") else () + Meta = type("Meta", bases, meta_class_attrs) + formfield_callback = meta_class_attrs.get('formfield_callback') + if formfield_callback: + Meta.formfield_callback = staticmethod(formfield_callback) + # Give this new form class a reasonable name. + class_name = model.__name__ + "Form" + + # Class attributes for the new form class. + form_class_attrs = {"Meta": Meta, "formfield_callback": formfield_callback} + + # Instantiate type(form) in order to use the same metaclass as form. + return type(form)(class_name, (form,), form_class_attrs) diff -Nru python-django-modelcluster-5.2/modelcluster/models.py python-django-modelcluster-6.0/modelcluster/models.py --- python-django-modelcluster-5.2/modelcluster/models.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/modelcluster/models.py 2022-03-14 21:08:23.000000000 +0000 @@ -168,11 +168,11 @@ if rel_name in kwargs: relation_assignments[rel_name] = kwargs_for_super.pop(rel_name) - super(ClusterableModel, self).__init__(*args, **kwargs_for_super) + super().__init__(*args, **kwargs_for_super) for (field_name, related_instances) in relation_assignments.items(): setattr(self, field_name, related_instances) else: - super(ClusterableModel, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def save(self, **kwargs): """ @@ -198,7 +198,7 @@ else: real_update_fields.append(field) - super(ClusterableModel, self).save(update_fields=real_update_fields, **kwargs) + super().save(update_fields=real_update_fields, **kwargs) for relation in relations_to_commit: getattr(self, relation).commit() diff -Nru python-django-modelcluster-5.2/modelcluster/queryset.py python-django-modelcluster-6.0/modelcluster/queryset.py --- python-django-modelcluster-5.2/modelcluster/queryset.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/modelcluster/queryset.py 2022-03-14 21:08:23.000000000 +0000 @@ -5,7 +5,7 @@ from django.db.models import Model, prefetch_related_objects -from modelcluster.utils import sort_by_fields +from modelcluster.utils import extract_field_value, get_model_field, sort_by_fields # Constructor for test functions that determine whether an object passes some boolean condition @@ -13,27 +13,27 @@ if isinstance(value, Model): if value.pk is None: # comparing against an unsaved model, so objects need to match by reference - return lambda obj: getattr(obj, attribute_name) is value + return lambda obj: extract_field_value(obj, attribute_name) is value else: # comparing against a saved model; objects need to match by type and ID. # Additionally, where model inheritance is involved, we need to treat it as a # positive match if one is a subclass of the other def _test(obj): - other_value = getattr(obj, attribute_name) + other_value = extract_field_value(obj, attribute_name) if not (isinstance(value, other_value.__class__) or isinstance(other_value, value.__class__)): return False return value.pk == other_value.pk return _test else: - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) # convert value to the correct python type for this field typed_value = field.to_python(value) # just a plain Python value = do a normal equality check - return lambda obj: getattr(obj, attribute_name) == typed_value + return lambda obj: extract_field_value(obj, attribute_name) == typed_value def test_iexact(model, attribute_name, match_value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(match_value) if match_value is None: @@ -42,135 +42,135 @@ match_value = match_value.upper() def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.upper() == match_value return _test def test_contains(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and match_value in val return _test def test_icontains(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value).upper() def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and match_value in val.upper() return _test def test_lt(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val < match_value return _test def test_lte(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val <= match_value return _test def test_gt(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val > match_value return _test def test_gte(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val >= match_value return _test def test_in(model, attribute_name, value_list): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_values = set(field.to_python(val) for val in value_list) - return lambda obj: getattr(obj, attribute_name) in match_values + return lambda obj: extract_field_value(obj, attribute_name) in match_values def test_startswith(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.startswith(match_value) return _test def test_istartswith(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value).upper() def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.upper().startswith(match_value) return _test def test_endswith(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.endswith(match_value) return _test def test_iendswith(model, attribute_name, value): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) match_value = field.to_python(value).upper() def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.upper().endswith(match_value) return _test def test_range(model, attribute_name, range_val): - field = model._meta.get_field(attribute_name) + field = get_model_field(model, attribute_name) start_val = field.to_python(range_val[0]) end_val = field.to_python(range_val[1]) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return (val is not None and val >= start_val and val <= end_val) return _test @@ -178,7 +178,7 @@ def test_date(model, attribute_name, match_value): def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) if isinstance(val, datetime.datetime): return val.date() == match_value else: @@ -191,7 +191,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.year == match_value return _test @@ -201,7 +201,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.month == match_value return _test @@ -211,7 +211,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.day == match_value return _test @@ -221,7 +221,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.isocalendar()[1] == match_value return _test @@ -231,7 +231,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.isoweekday() % 7 + 1 == match_value return _test @@ -241,7 +241,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and int((val.month - 1) / 3) + 1 == match_value return _test @@ -249,7 +249,7 @@ def test_time(model, attribute_name, match_value): def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) if isinstance(val, datetime.datetime): return val.time() == match_value else: @@ -262,7 +262,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.hour == match_value return _test @@ -272,7 +272,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.minute == match_value return _test @@ -282,7 +282,7 @@ match_value = int(match_value) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and val.second == match_value return _test @@ -290,16 +290,16 @@ def test_isnull(model, attribute_name, sense): if sense: - return lambda obj: getattr(obj, attribute_name) is None + return lambda obj: extract_field_value(obj, attribute_name) is None else: - return lambda obj: getattr(obj, attribute_name) is not None + return lambda obj: extract_field_value(obj, attribute_name) is not None def test_regex(model, attribute_name, regex_string): regex = re.compile(regex_string) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and regex.search(val) return _test @@ -309,7 +309,7 @@ regex = re.compile(regex_string, re.I) def _test(obj): - val = getattr(obj, attribute_name) + val = extract_field_value(obj, attribute_name) return val is not None and regex.search(val) return _test @@ -350,34 +350,77 @@ def _build_test_function_from_filter(model, key_clauses, val): # Translate a filter kwarg rule (e.g. foo__bar__exact=123) into a function which can # take a model instance and return a boolean indicating whether it passes the rule - if len(key_clauses) == 1: - # key is a single clause; treat as an exact match test - return test_exact(model, key_clauses[0], val) - elif len(key_clauses) == 2 and key_clauses[1] in FILTER_EXPRESSION_TOKENS: - # second clause indicates the type of test - constructor = FILTER_EXPRESSION_TOKENS[key_clauses[1]] - return constructor(model, key_clauses[0], val) + if key_clauses[-1] in FILTER_EXPRESSION_TOKENS: + # the last clause indicates the type of test + constructor = FILTER_EXPRESSION_TOKENS[key_clauses.pop()] else: - raise NotImplementedError("Filter expression not supported: %s" % '__'.join(key_clauses)) + constructor = test_exact + # recombine the remaining items to be interpretted + # by get_model_field() and extract_field_value() + attribute_name = "__".join(key_clauses) + return constructor(model, attribute_name, val) + + +class FakeQuerySetIterable: + def __init__(self, queryset): + self.queryset = queryset + + +class ModelIterable(FakeQuerySetIterable): + def __iter__(self): + yield from self.queryset.results + + +class DictIterable(FakeQuerySetIterable): + def __iter__(self): + field_names = self.queryset.dict_fields or [field.name for field in self.queryset.model._meta.fields] + for obj in self.queryset.results: + yield { + field_name: extract_field_value(obj, field_name, pk_only=True, suppress_fielddoesnotexist=True) + for field_name in field_names + } + + +class ValuesListIterable(FakeQuerySetIterable): + def __iter__(self): + field_names = self.queryset.tuple_fields or [field.name for field in self.queryset.model._meta.fields] + for obj in self.queryset.results: + yield tuple([extract_field_value(obj, field_name, pk_only=True, suppress_fielddoesnotexist=True) for field_name in field_names]) + + +class FlatValuesListIterable(FakeQuerySetIterable): + def __iter__(self): + field_name = self.queryset.tuple_fields[0] + for obj in self.queryset.results: + yield extract_field_value(obj, field_name, pk_only=True, suppress_fielddoesnotexist=True) class FakeQuerySet(object): def __init__(self, model, results): self.model = model self.results = results + self.dict_fields = [] + self.tuple_fields = [] + self.iterable_class = ModelIterable def all(self): return self + def get_clone(self, results = None): + new = FakeQuerySet(self.model, results if results is not None else self.results) + new.dict_fields = self.dict_fields + new.tuple_fields = self.tuple_fields + new.iterable_class = self.iterable_class + return new + def _get_filters(self, **kwargs): # a list of test functions; objects must pass all tests to be included # in the filtered list filters = [] for key, val in kwargs.items(): - key_clauses = key.split('__') filters.append( - _build_test_function_from_filter(self.model, key_clauses, val) + _build_test_function_from_filter(self.model, key.split('__'), val) ) return filters @@ -385,31 +428,30 @@ def filter(self, **kwargs): filters = self._get_filters(**kwargs) - filtered_results = [ + clone = self.get_clone(results=[ obj for obj in self.results if all([test(obj) for test in filters]) - ] - - return FakeQuerySet(self.model, filtered_results) + ]) + return clone def exclude(self, **kwargs): filters = self._get_filters(**kwargs) - filtered_results = [ + clone = self.get_clone(results=[ obj for obj in self.results if not all([test(obj) for test in filters]) - ] - - return FakeQuerySet(self.model, filtered_results) + ]) + return clone def get(self, **kwargs): - results = self.filter(**kwargs) - result_count = results.count() + clone = self.filter(**kwargs) + result_count = clone.count() if result_count == 0: raise self.model.DoesNotExist("%s matching query does not exist." % self.model._meta.object_name) elif result_count == 1: - return results[0] + for result in clone: + return result else: raise self.model.MultipleObjectsReturned( "get() returned more than one %s -- it returned %s!" % (self.model._meta.object_name, result_count) @@ -422,12 +464,14 @@ return bool(self.results) def first(self): - if self.results: - return self.results[0] + for result in self: + return result def last(self): if self.results: - return self.results[-1] + clone = self.get_clone(results=reversed(self.results)) + for result in clone: + return result def select_related(self, *args): # has no meaningful effect on non-db querysets @@ -437,35 +481,41 @@ prefetch_related_objects(self.results, *args) return self - def values_list(self, *fields, **kwargs): - # FIXME: values_list should return an object that behaves like both a queryset and a list, - # so that we can do things like Foo.objects.values_list('id').order_by('id') - - flat = kwargs.get('flat') # TODO: throw TypeError if other kwargs are present - - if not fields: - # return a tuple of all fields - field_names = [field.name for field in self.model._meta.fields] - return [ - tuple([getattr(obj, field_name) for field_name in field_names]) - for obj in self.results - ] + def only(self, *args): + # has no meaningful effect on non-db querysets + return self + + def defer(self, *args): + # has no meaningful effect on non-db querysets + return self + def values(self, *fields): + clone = self.get_clone() + clone.dict_fields = fields + # Ensure all 'fields' are available model fields + for f in fields: + get_model_field(self.model, f) + clone.iterable_class = DictIterable + return clone + + def values_list(self, *fields, flat=None): + clone = self.get_clone() + clone.tuple_fields = fields + # Ensure all 'fields' are available model fields + for f in fields: + get_model_field(self.model, f) if flat: if len(fields) > 1: raise TypeError("'flat' is not valid when values_list is called with more than one field.") - field_name = fields[0] - return [getattr(obj, field_name) for obj in self.results] + clone.iterable_class = FlatValuesListIterable else: - return [ - tuple([getattr(obj, field_name) for field_name in fields]) - for obj in self.results - ] + clone.iterable_class = ValuesListIterable + return clone def order_by(self, *fields): - results = self.results[:] # make a copy of results - sort_by_fields(results, fields) - return FakeQuerySet(self.model, results) + clone = self.get_clone(results=self.results[:]) + sort_by_fields(clone.results, fields) + return clone # a standard QuerySet will store the results in _result_cache on running the query; # this is effectively the same as self.results on a FakeQuerySet, and so we'll make @@ -483,7 +533,8 @@ return self.results[k] def __iter__(self): - return self.results.__iter__() + iterator = self.iterable_class(self) + yield from iterator def __nonzero__(self): return bool(self.results) diff -Nru python-django-modelcluster-5.2/modelcluster/utils.py python-django-modelcluster-6.0/modelcluster/utils.py --- python-django-modelcluster-5.2/modelcluster/utils.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/modelcluster/utils.py 2022-03-14 21:08:23.000000000 +0000 @@ -1,3 +1,115 @@ +from functools import lru_cache +from django.core.exceptions import FieldDoesNotExist +from django.db.models import ManyToManyField, ManyToManyRel + +REL_DELIMETER = "__" + + +class ManyToManyTraversalError(ValueError): + pass + + +class TraversedRelationship: + __slots__ = ['from_model', 'field'] + + def __init__(self, from_model, field): + self.from_model = from_model + self.field = field + + @property + def field_name(self) -> str: + return self.field.name + + @property + def to_model(self): + return self.field.target_model + + +@lru_cache(maxsize=None) +def get_model_field(model, name): + """ + Returns a model field matching the supplied ``name``, which can include + double-underscores (`'__'`) to indicate relationship traversal - in which + case, the model field will be lookuped up from the related model. + + Multiple traversals for the same field are supported, but at this + moment in time, only traversal of many-to-one and one-to-one relationships + is supported. + + Details of any relationships traversed in order to reach the returned + field are made available as `field.traversals`. The value is a tuple of + ``TraversedRelationship`` instances. + + Raises ``FieldDoesNotExist`` if the name cannot be mapped to a model field. + """ + subject_model = model + traversals = [] + field = None + for field_name in name.split(REL_DELIMETER): + + if field is not None: + if isinstance(field, (ManyToManyField, ManyToManyRel)): + raise ManyToManyTraversalError( + "The lookup '{name}' from {model} cannot be replicated " + "by modelcluster, because the '{field_name}' " + "relationship from {subject_model} is a many-to-many, " + "and traversal is only supported for one-to-one or " + "many-to-one relationships." + .format( + name=name, + model=model, + field_name=field_name, + subject_model=subject_model, + ) + ) + if hasattr(field, "related_model"): + traversals.append(TraversedRelationship(subject_model, field)) + subject_model = field.related_model + try: + field = subject_model._meta.get_field(field_name) + except FieldDoesNotExist: + if field_name.endswith("_id"): + field = subject_model._meta.get_field(field_name[:-3]).target_field + raise + + field.traversals = tuple(traversals) + return field + + +def extract_field_value(obj, key, pk_only=False, suppress_fielddoesnotexist=False): + """ + Attempts to extract a field value from ``obj`` matching the ``key`` - which, + can contain double-underscores (`'__'`) to indicate traversal of relationships + to related objects. + + For keys that specify ``ForeignKey`` or ``OneToOneField`` field values, full + related objects are returned by default. If only the primary key values are + required ((.g. when ordering, or using ``values()`` or ``values_list()``)), + call the function with ``pk_only=True``. + + By default, ``FieldDoesNotExist`` is raised if the key cannot be mapped to + a model field. Call the function with ``suppress_fielddoesnotexist=True`` + to get ``None`` values instead. + """ + source = obj + for attr in key.split(REL_DELIMETER): + if hasattr(source, attr): + value = getattr(source, attr) + source = value + continue + elif suppress_fielddoesnotexist: + return None + else: + raise FieldDoesNotExist( + "'{name}' is not a valid field name for {model}".format( + name=attr, model=type(source) + ) + ) + if pk_only and hasattr(value, 'pk'): + return value.pk + return value + + def sort_by_fields(items, fields): """ Sort a list of objects on the given fields. The field list works analogously to @@ -13,7 +125,11 @@ reverse = True key = key[1:] - # Sort - # Use a tuple of (v is not None, v) as the key, to ensure that None sorts before other values, - # as comparing directly with None breaks on python3 - items.sort(key=lambda x: (getattr(x, key) is not None, getattr(x, key)), reverse=reverse) + def get_sort_value(item): + # Use a tuple of (v is not None, v) as the key, to ensure that None sorts before other values, + # as comparing directly with None breaks on python3 + value = extract_field_value(item, key, pk_only=True, suppress_fielddoesnotexist=True) + return (value is not None, value) + + # Sort items + items.sort(key=get_sort_value, reverse=reverse) diff -Nru python-django-modelcluster-5.2/setup.py python-django-modelcluster-6.0/setup.py --- python-django-modelcluster-5.2/setup.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/setup.py 2022-03-14 21:08:23.000000000 +0000 @@ -7,7 +7,7 @@ setup( name='django-modelcluster', - version='5.2', + version='6.0', description="Django extension to allow working with 'clusters' of models as a single unit, independently of the database", author='Matthew Westcott', author_email='matthew.westcott@torchbox.com', @@ -15,9 +15,10 @@ packages=find_packages(exclude=('tests*',)), license='BSD', long_description=open('README.rst').read(), - python_requires=">=3.5", + python_requires=">=3.7", install_requires=[ "pytz>=2015.2", + "django>=2.2", ], extras_require={ 'taggit': ['django-taggit>=0.20'], @@ -30,8 +31,6 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', diff -Nru python-django-modelcluster-5.2/tests/migrations/0011_add_room_features.py python-django-modelcluster-6.0/tests/migrations/0011_add_room_features.py --- python-django-modelcluster-5.2/tests/migrations/0011_add_room_features.py 1970-01-01 00:00:00.000000000 +0000 +++ python-django-modelcluster-6.0/tests/migrations/0011_add_room_features.py 2022-03-14 21:08:23.000000000 +0000 @@ -0,0 +1,29 @@ +# Generated by Django 2.1 on 2021-12-15 12:00 + +from django.db import migrations, models +import modelcluster.fields + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0010_song'), + ] + + operations = [ + migrations.CreateModel( + name='Feature', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('desirability', models.PositiveIntegerField()), + ], + options={ + 'ordering': ['-desirability'], + }, + ), + migrations.AddField( + model_name='Room', + name='features', + field=modelcluster.fields.ParentalManyToManyField(blank=True, related_name='rooms', serialize=False, to='tests.Feature'), + ) + ] diff -Nru python-django-modelcluster-5.2/tests/models.py python-django-modelcluster-6.0/tests/models.py --- python-django-modelcluster-5.2/tests/models.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/tests/models.py 2022-03-14 21:08:23.000000000 +0000 @@ -218,8 +218,17 @@ ordering = ['id'] -class Room(models.Model): +class Feature(models.Model): + name = models.CharField(max_length=255) + desirability = models.PositiveIntegerField() + + class Meta: + ordering = ["-desirability"] + + +class Room(ClusterableModel): name = models.CharField(max_length=50) + features = ParentalManyToManyField(Feature, blank=True, related_name='rooms') class Meta: ordering = ['id'] diff -Nru python-django-modelcluster-5.2/tests/settings.py python-django-modelcluster-6.0/tests/settings.py --- python-django-modelcluster-5.2/tests/settings.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/tests/settings.py 2022-03-14 21:08:23.000000000 +0000 @@ -36,3 +36,4 @@ USE_TZ = True TIME_ZONE = 'America/Chicago' ROOT_URLCONF = 'tests.urls' +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff -Nru python-django-modelcluster-5.2/tests/tests/test_cluster_form.py python-django-modelcluster-6.0/tests/tests/test_cluster_form.py --- python-django-modelcluster-5.2/tests/tests/test_cluster_form.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/tests/tests/test_cluster_form.py 2022-03-14 21:08:23.000000000 +0000 @@ -9,16 +9,33 @@ from modelcluster.forms import ClusterForm from django.forms import Textarea, CharField from django.forms.widgets import TextInput, FileInput +from django.utils.safestring import SafeString import datetime class ClusterFormTest(TestCase): + def test_cluster_form_with_no_formsets(self): + class BandForm(ClusterForm): + class Meta: + model = Band + fields = ['name'] + + self.assertFalse(BandForm.formsets) + + beatles = Band(name='The Beatles') + form = BandForm(instance=beatles) + form_html = form.as_p() + self.assertIsInstance(form_html, SafeString) + self.assertInHTML('', form_html) + self.assertInHTML('', form_html, count=0) + def test_cluster_form(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] self.assertTrue(BandForm.formsets) @@ -30,13 +47,17 @@ form = BandForm(instance=beatles) self.assertEqual(5, len(form.formsets['members'].forms)) - self.assertTrue('albums' in form.as_p()) + form_html = form.as_p() + self.assertIsInstance(form_html, SafeString) + self.assertInHTML('', form_html) + self.assertInHTML('', form_html) def test_empty_cluster_form(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] form = BandForm() @@ -47,6 +68,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] beatles = Band(name='The Beatles', members=[ BandMember(name='George Harrison'), @@ -130,6 +152,7 @@ } } fields = ['name'] + formsets = ['members', 'albums'] form = BandForm() self.assertEqual(Textarea, type(form['name'].field.widget)) @@ -155,6 +178,46 @@ self.assertNotIn('release_date', form.formsets['albums'].forms[0].fields) self.assertEqual(Textarea, type(form.formsets['albums'].forms[0]['name'].field.widget)) + def test_without_kwarg_inheritance(self): + # by default, kwargs passed to the ClusterForm do not propagate to child forms + class BandForm(ClusterForm): + class Meta: + model = Band + formsets = { + 'members': {'fields': ['name']} + } + fields = ['name'] + + form = BandForm(label_suffix="!!!:") + form_html = form.as_p() + # band name field should have label_suffix applied + self.assertInHTML('', form_html) + # but this should not propagate to member form fields + self.assertInHTML('', form_html, count=0) + + def test_with_kwarg_inheritance(self): + # inherit_kwargs should allow kwargs passed to the ClusterForm to propagate to child forms + class BandForm(ClusterForm): + class Meta: + model = Band + formsets = { + 'members': {'fields': ['name'], 'inherit_kwargs': ['label_suffix']} + } + fields = ['name'] + + form = BandForm(label_suffix="!!!:") + form_html = form.as_p() + # band name field should have label_suffix applied + self.assertInHTML('', form_html) + # and this should propagate to member form fields too + self.assertInHTML('', form_html) + + # the form should still work without a label_suffix kwarg + form = BandForm() + form_html = form.as_p() + self.assertInHTML('', form_html) + self.assertInHTML('', form_html) + def test_custom_formset_form(self): class AlbumForm(ClusterForm): pass @@ -207,6 +270,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] form = BandFormWithFFC() self.assertEqual(Textarea, type(form['name'].field.widget)) @@ -217,6 +281,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') @@ -257,48 +322,6 @@ self.assertTrue(BandMember.objects.filter(name='George Harrison').exists()) self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) - def test_can_omit_formset_from_submission(self): - """ - If no explicit `formsets` parameter has been given, any formsets missing from the - submission should be skipped over. - https://github.com/wagtail/wagtail/issues/5414#issuecomment-567468127 - """ - class BandForm(ClusterForm): - class Meta: - model = Band - fields = ['name'] - - john = BandMember(name='John Lennon') - paul = BandMember(name='Paul McCartney') - abbey_road = Album(name='Abbey Road') - beatles = Band(name='The Beatles', members=[john, paul], albums=[abbey_road]) - beatles.save() - - form = BandForm({ - 'name': "The Beatles", - - 'members-TOTAL_FORMS': 3, - 'members-INITIAL_FORMS': 2, - 'members-MAX_NUM_FORMS': 1000, - - 'members-0-name': john.name, - 'members-0-DELETE': 'members-0-DELETE', - 'members-0-id': john.id, - - 'members-1-name': paul.name, - 'members-1-id': paul.id, - - 'members-2-name': 'George Harrison', - 'members-2-id': '', - }, instance=beatles) - self.assertTrue(form.is_valid()) - form.save() - - beatles = Band.objects.get(id=beatles.id) - self.assertEqual(1, beatles.albums.count()) - self.assertTrue(BandMember.objects.filter(name='George Harrison').exists()) - self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) - def test_cannot_omit_explicit_formset_from_submission(self): """ If an explicit `formsets` parameter has been given, formsets missing from a form submission @@ -346,6 +369,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') @@ -392,6 +416,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] form = BandForm({ 'name': "The Beatles", @@ -432,6 +457,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] form = BandForm() form_html = form.as_p() @@ -443,6 +469,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] form = BandForm({ 'name': "The Beatles", @@ -482,6 +509,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] please_please_me = Album(name='Please Please Me', release_date=datetime.date(1963, 3, 22)) beatles = Band(name='The Beatles', albums=[please_please_me]) @@ -539,7 +567,7 @@ beatles.save() self.assertEqual(0, Band.objects.get(id=beatles.id).albums.count()) - def test_cluster_form_without_formsets(self): + def test_cluster_form_with_empty_formsets_list(self): class BandForm(ClusterForm): class Meta: model = Band @@ -566,6 +594,7 @@ class Meta: model = Restaurant fields = ['name', 'tags', 'serves_hot_dogs', 'proprietor'] + formsets = ['menu_items', 'reviews', 'tagged_items'] self.assertIn('reviews', RestaurantForm.formsets) @@ -635,6 +664,7 @@ class Meta: model = Restaurant fields = ['name', 'tags', 'serves_hot_dogs', 'proprietor'] + formsets = ['menu_items', 'reviews', 'tagged_items'] widgets = { 'name': WidgetWithMedia } @@ -717,6 +747,7 @@ class Meta: model = Band fields = ['name'] + formsets = ['members', 'albums'] form = BandForm({ 'name': "The Beatles", @@ -818,11 +849,37 @@ class NestedClusterFormTest(TestCase): + def test_no_nested_formsets_without_explicit_formset_definition(self): + class BandForm(ClusterForm): + class Meta: + model = Band + fields = ['name'] + formsets=['members', 'albums'] + + self.assertTrue(BandForm.formsets) + + beatles = Band(name='The Beatles', albums=[ + Album(name='Please Please Me', songs=[ + Song(name='I Saw Her Standing There'), + Song(name='Misery') + ]), + ]) + + form = BandForm(instance=beatles) + + self.assertEqual(4, len(form.formsets['albums'].forms)) + self.assertNotIn('songs', form.formsets['albums'].forms[0].formsets) + self.assertNotIn('songs', form.as_p()) + def test_nested_formsets(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } self.assertTrue(BandForm.formsets) @@ -844,6 +901,10 @@ class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } form = BandForm() self.assertEqual(3, len(form.formsets['albums'].forms)) @@ -854,6 +915,10 @@ class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } beatles = Band(name='The Beatles', albums=[ Album(name='Please Please Me', songs=[ @@ -908,7 +973,6 @@ self.assertTrue(Song.objects.filter(name='Misery').exists()) self.assertFalse(Song.objects.filter(name='I Saw Her Standing There').exists()) - @unittest.skip('Explicit nested formsets not yet enabled') def test_explicit_nested_formset_list(self): class BandForm(ClusterForm): class Meta: @@ -925,7 +989,6 @@ self.assertTrue('albums' in form.as_p()) self.assertTrue('songs' in form.as_p()) - @unittest.skip('Excluded nested formsets not yet enabled') def test_excluded_nested_formset_list(self): class BandForm(ClusterForm): class Meta: @@ -946,6 +1009,10 @@ class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } first_song = Song(name='I Saw Her Standing There') second_song = Song(name='Misery') @@ -1001,6 +1068,10 @@ class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } first_song = Song(name='I Saw Her Standing There') second_song = Song(name='Misery') @@ -1054,6 +1125,10 @@ class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } form = BandForm({ 'name': "The Beatles", @@ -1103,6 +1178,10 @@ class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } form = BandForm() form_html = form.as_p() @@ -1114,6 +1193,10 @@ class Meta: model = Band fields = ['name'] + formsets={ + 'members': [], + 'albums': {'formsets': ['songs']} + } form = BandForm({ 'name': "The Beatles", diff -Nru python-django-modelcluster-5.2/tests/tests/test_cluster.py python-django-modelcluster-6.0/tests/tests/test_cluster.py --- python-django-modelcluster-5.2/tests/tests/test_cluster.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/tests/tests/test_cluster.py 2022-03-14 21:08:23.000000000 +0000 @@ -9,12 +9,23 @@ from modelcluster.models import get_all_child_relations from modelcluster.queryset import FakeQuerySet +from modelcluster.utils import ManyToManyTraversalError -from tests.models import Band, BandMember, Place, Restaurant, SeafoodRestaurant, Review, Album, \ - Article, Author, Category, Person, Room, House, Log, Dish, MenuItem, Wine +from tests.models import Band, BandMember, Chef, Feature, Place, Restaurant, SeafoodRestaurant, \ + Review, Album, Article, Author, Category, Person, Room, House, Log, Dish, MenuItem, Wine class ClusterTest(TestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.gordon_ramsay = Chef.objects.create(name="Gordon Ramsay") + cls.strawberry_fields = Restaurant.objects.create(name="Strawberry Fields", proprietor=cls.gordon_ramsay) + + cls.marco_pierre_white = Chef.objects.create(name="Marco Pierre White") + cls.the_yellow_submarine = Restaurant.objects.create(name="The Yellow Submarine", proprietor=cls.marco_pierre_white) + def test_can_create_cluster(self): beatles = Band(name='The Beatles') @@ -96,11 +107,6 @@ self.assertRaises(BandMember.DoesNotExist, lambda: beatles.members.get(name='Reginald Dwight')) self.assertRaises(BandMember.MultipleObjectsReturned, lambda: beatles.members.get()) - self.assertEqual([('Paul McCartney',)], beatles.members.filter(name='Paul McCartney').values_list('name')) - self.assertEqual(['Paul McCartney'], beatles.members.filter(name='Paul McCartney').values_list('name', flat=True)) - # quick-and-dirty check that we can invoke values_list with empty args list - beatles.members.filter(name='Paul McCartney').values_list() - self.assertTrue(beatles.members.filter(name='Paul McCartney').exists()) self.assertFalse(beatles.members.filter(name='Reginald Dwight').exists()) @@ -136,6 +142,129 @@ # queries on beatles.members should now revert to SQL self.assertTrue(beatles.members.extra(where=["tests_bandmember.name='John Lennon'"]).exists()) + def test_values_list(self): + beatles = Band( + name="The Beatles", + members=[ + BandMember(name="John Lennon", favourite_restaurant=self.strawberry_fields), + BandMember(name="Paul McCartney", favourite_restaurant=self.the_yellow_submarine), + BandMember(name="George Harrison"), + BandMember(name="Ringo Starr"), + ], + ) + + # Not specifying 'fields' should return a tuple of all field values + self.assertEqual( + [ + # ID, band_id, name, favourite_restaurant_id + (None, None, 'Paul McCartney', self.the_yellow_submarine.id) + ], + list(beatles.members.filter(name='Paul McCartney').values_list()) + ) + + NAME_ONLY_TUPLE = ('Paul McCartney',) + + # Specifying 'fields' should return a tuple of just those field values + self.assertEqual([NAME_ONLY_TUPLE], list(beatles.members.filter(name='Paul McCartney').values_list('name'))) + + # 'fields' can span relationships using '__' + members = beatles.members.all().values_list('name', 'favourite_restaurant__proprietor__name') + self.assertEqual( + list(members), + [ + ("John Lennon", "Gordon Ramsay"), + ("Paul McCartney", "Marco Pierre White"), + ("George Harrison", None), + ("Ringo Starr", None), + ] + ) + + # Ordering on the related fields will work too, and items with `None`` values will appear first + self.assertEqual( + list(members.order_by('favourite_restaurant__proprietor__name')), + [ + ("George Harrison", None), + ("Ringo Starr", None), + ("John Lennon", "Gordon Ramsay"), + ("Paul McCartney", "Marco Pierre White"), + + ] + ) + + # get() should return a tuple if used after values_list() + self.assertEqual(NAME_ONLY_TUPLE, beatles.members.filter(name='Paul McCartney').values_list('name').get()) + + # first() should return a tuple if used after values_list() + self.assertEqual(NAME_ONLY_TUPLE, beatles.members.filter(name='Paul McCartney').values_list('name').first()) + + # last() should return a tuple if used after values_list() + self.assertEqual(NAME_ONLY_TUPLE, beatles.members.filter(name='Paul McCartney').values_list('name').last()) + + # And the 'flat' argument should work as it does in Django + self.assertEqual(['Paul McCartney'], list(beatles.members.filter(name='Paul McCartney').values_list('name', flat=True))) + + # Filtering or ordering after using values_list() should not raise an error + beatles.members.values_list("name").filter(name__contains="n").order_by("name") + + def test_values(self): + beatles = Band( + name="The Beatles", + members=[ + BandMember(name="John Lennon", favourite_restaurant=self.strawberry_fields), + BandMember(name="Paul McCartney", favourite_restaurant=self.the_yellow_submarine), + BandMember(name="George Harrison"), + BandMember(name="Ringo Starr"), + ], + ) + + # Not specifying 'fields' should return dictionaries with all field values + self.assertEqual( + [ + {"id": None, "band": None, "name": "Paul McCartney", "favourite_restaurant": self.the_yellow_submarine.id} + ], + list(beatles.members.filter(name='Paul McCartney').values()) + ) + + NAME_ONLY_DICT = {"name": "Paul McCartney"} + + # Specifying 'fields' should return a dictionary of just those field values + self.assertEqual([NAME_ONLY_DICT], list(beatles.members.filter(name='Paul McCartney').values('name'))) + + # 'fields' can span relationships using '__' + members = beatles.members.all().values('name', 'favourite_restaurant__proprietor__name') + self.assertEqual( + list(members), + [ + {"name": "John Lennon", "favourite_restaurant__proprietor__name": "Gordon Ramsay"}, + {"name": "Paul McCartney", "favourite_restaurant__proprietor__name": "Marco Pierre White"}, + {"name": "George Harrison", "favourite_restaurant__proprietor__name": None}, + {"name": "Ringo Starr", "favourite_restaurant__proprietor__name": None}, + ] + ) + + # Ordering on the related fields will work too, and items with `None`` values will appear first + self.assertEqual( + list(members.order_by('favourite_restaurant__proprietor__name')), + [ + {"name": "George Harrison", "favourite_restaurant__proprietor__name": None}, + {"name": "Ringo Starr", "favourite_restaurant__proprietor__name": None}, + {"name": "John Lennon", "favourite_restaurant__proprietor__name": "Gordon Ramsay"}, + {"name": "Paul McCartney", "favourite_restaurant__proprietor__name": "Marco Pierre White"}, + ] + ) + + # get() should return a dict if used after values() + self.assertEqual(NAME_ONLY_DICT, beatles.members.filter(name='Paul McCartney').values('name').get()) + + # first() should return a dict if used after values_list() + self.assertEqual(NAME_ONLY_DICT, beatles.members.filter(name='Paul McCartney').values('name').first()) + + # last() should return a dict if used after values_list() + self.assertEqual(NAME_ONLY_DICT, beatles.members.filter(name='Paul McCartney').values('name').last()) + + # Filtering or ordering after using values() should not raise an error + beatles.members.values("name").filter(name__contains="n").order_by("name") + def test_related_manager_assignment_ops(self): beatles = Band(name='The Beatles') john = BandMember(name='John Lennon') @@ -265,10 +394,7 @@ self.assertEqual(3, beatles.members.filter(band=also_beatles).count()) def test_queryset_filtering_on_models_with_inheritance(self): - strawberry_fields = Restaurant.objects.create(name='Strawberry Fields') - the_yellow_submarine = SeafoodRestaurant.objects.create(name='The Yellow Submarine') - - john = BandMember(name='John Lennon', favourite_restaurant=strawberry_fields) + john = BandMember(name='John Lennon', favourite_restaurant=self.strawberry_fields) ringo = BandMember(name='Ringo Starr', favourite_restaurant=Restaurant.objects.get(name='The Yellow Submarine')) beatles = Band(name='The Beatles', members=[john, ringo]) @@ -281,7 +407,7 @@ # queried instance is more specific self.assertEqual( - list(beatles.members.filter(favourite_restaurant=the_yellow_submarine)), + list(beatles.members.filter(favourite_restaurant=self.the_yellow_submarine)), [ringo] ) @@ -478,6 +604,147 @@ "one person died" ) + def test_queryset_filtering_accross_foreignkeys(self): + band = Band( + name="The Beatles", + members=[ + BandMember(name="John Lennon", favourite_restaurant=self.strawberry_fields), + BandMember(name="Ringo Starr", favourite_restaurant=self.the_yellow_submarine) + ], + ) + + # Filter over a single relationship + # --------------------------------------- + # Using the default/exact lookup type + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__name="Strawberry Fields")), + (band.members.get(name="John Lennon"),) + ) + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__name="The Yellow Submarine")), + (band.members.get(name="Ringo Starr"),) + ) + # Using an alternative lookup type + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__name__icontains="straw")), + (band.members.get(name="John Lennon"),) + ) + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__name__icontains="yello")), + (band.members.get(name="Ringo Starr"),) + ) + + # Filtering over 2 relationships + # --------------------------------------- + # Using a default/exact field lookup + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__proprietor__name="Gordon Ramsay")), + (band.members.get(name="John Lennon"),) + ) + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__proprietor__name="Marco Pierre White")), + (band.members.get(name="Ringo Starr"),) + ) + # Using an alternative lookup type + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__proprietor__name__iexact="gORDON rAMSAY")), + (band.members.get(name="John Lennon"),) + ) + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__proprietor__name__iexact="mARCO pIERRE wHITE")), + (band.members.get(name="Ringo Starr"),) + ) + # Using an exact proprietor comparisson + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__proprietor=self.gordon_ramsay)), + (band.members.get(name="John Lennon"),) + ) + self.assertEqual( + tuple(band.members.filter(favourite_restaurant__proprietor=self.marco_pierre_white)), + (band.members.get(name="Ringo Starr"),) + ) + + def test_filtering_via_reverse_foreignkey(self): + band = Band( + name="The Beatles", + members=[ + BandMember(name="John Lennon"), + BandMember(name="Ringo Starr"), + ], + ) + self.assertEqual( + tuple(band.members.filter(band__name="The Beatles")), + tuple(band.members.all()) + ) + self.assertEqual( + tuple(band.members.filter(band__name="The Monkeys")), + () + ) + + def test_ordering_accross_foreignkeys(self): + band = Band( + name="The Beatles", + members=[ + BandMember(name="John Lennon", favourite_restaurant=self.strawberry_fields), + BandMember(name="Ringo Starr", favourite_restaurant=self.the_yellow_submarine), + ], + ) + + # Ordering accross a single relationship + # --------------------------------------- + self.assertEqual( + tuple(band.members.order_by("favourite_restaurant__name")), + ( + band.members.get(name="John Lennon"), + band.members.get(name="Ringo Starr"), + ) + ) + # How about ordering in reverse? + self.assertEqual( + tuple(band.members.order_by("-favourite_restaurant__name")), + ( + band.members.get(name="Ringo Starr"), + band.members.get(name="John Lennon"), + ) + ) + + # Ordering accross 2 relationships + # -------------------------------- + self.assertEqual( + tuple(band.members.order_by("favourite_restaurant__proprietor__name")), + ( + band.members.get(name="John Lennon"), + band.members.get(name="Ringo Starr"), + ) + ) + # How about ordering in reverse? + self.assertEqual( + tuple(band.members.order_by("-favourite_restaurant__proprietor__name")), + ( + band.members.get(name="Ringo Starr"), + band.members.get(name="John Lennon"), + ) + ) + + def test_filtering_via_manytomany_raises_exception(self): + bay_window = Feature.objects.create(name="Bay window", desirability=6) + underfloor_heating = Feature.objects.create(name="Underfloor heading", desirability=10) + open_fire = Feature.objects.create(name="Open fire", desirability=3) + log_burner = Feature.objects.create(name="Log burner", desirability=10) + + modern_living_room = Room.objects.create(name="Modern living room", features=[bay_window, underfloor_heating, log_burner]) + classic_living_room = Room.objects.create(name="Classic living room", features=[bay_window, open_fire]) + + modern_house = House.objects.create(name="Modern house", address="1 Yellow Brick Road", main_room=modern_living_room) + classic_house = House.objects.create(name="Classic house", address="3 Yellow Brick Road", main_room=classic_living_room) + + tenant = Person( + name="Alex", houses=[modern_house, classic_house] + ) + + with self.assertRaises(ManyToManyTraversalError): + tenant.houses.filter(main_room__features__name="Bay window") + def test_prefetch_related(self): Band.objects.create(name='The Beatles', members=[ BandMember(id=1, name='John Lennon'), diff -Nru python-django-modelcluster-5.2/tests/tests/test_formset.py python-django-modelcluster-6.0/tests/tests/test_formset.py --- python-django-modelcluster-5.2/tests/tests/test_formset.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/tests/tests/test_formset.py 2022-03-14 21:08:23.000000000 +0000 @@ -467,7 +467,7 @@ Song(name='Misery') ]) ]) - AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) + AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) albums_formset = AlbumsFormset(instance=beatles) self.assertEqual(4, len(albums_formset.forms)) @@ -480,7 +480,7 @@ ) def test_empty_formset(self): - AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) + AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) albums_formset = AlbumsFormset() self.assertEqual(3, len(albums_formset.forms)) self.assertEqual(3, len(albums_formset.forms[0].formsets['songs'].forms)) @@ -493,7 +493,7 @@ beatles.save() first_song_id, second_song_id = first_song.id, second_song.id - AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) + AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) albums_formset = AlbumsFormset({ 'form-TOTAL_FORMS': 1, @@ -555,7 +555,7 @@ second_song = Song(name='Misery') album.songs.add(second_song) - AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) + AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) albums_formset = AlbumsFormset({ 'form-TOTAL_FORMS': 1, diff -Nru python-django-modelcluster-5.2/tests/tests/test_tag.py python-django-modelcluster-6.0/tests/tests/test_tag.py --- python-django-modelcluster-5.2/tests/tests/test_tag.py 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/tests/tests/test_tag.py 2022-03-14 21:08:23.000000000 +0000 @@ -37,7 +37,10 @@ mission_burrito.save() self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) - mission_burrito.tags.set('mexican', 'burrito') + if TAGGIT_VERSION >= (2, 0): + mission_burrito.tags.set(['mexican', 'burrito']) + else: + mission_burrito.tags.set('mexican', 'burrito') self.assertEqual(2, mission_burrito.tags.count()) self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) mission_burrito.save() @@ -158,7 +161,7 @@ def test_integers(self): """Adding an integer as a tag should raise a ValueError""" mission_burrito = Place(name='Mission Burrito') - with self.assertRaisesRegexp(ValueError, ( + with self.assertRaisesRegex(ValueError, ( r"Cannot add 1 \(<(type|class) 'int'>\). " r"Expected or str.")): mission_burrito.tags.add(1) diff -Nru python-django-modelcluster-5.2/tox.ini python-django-modelcluster-6.0/tox.ini --- python-django-modelcluster-5.2/tox.ini 2021-10-13 12:51:21.000000000 +0000 +++ python-django-modelcluster-6.0/tox.ini 2022-03-14 21:08:23.000000000 +0000 @@ -3,6 +3,7 @@ py{35,36,37}-dj{20,21}-{sqlite,postgres}-taggit{0,1} py{35,36,37}-dj22-{sqlite,postgres}-taggit{0,1,13} py{36,37,38}-dj{30,31,32}-{sqlite,postgres}-taggit13 + py{37,38,39,310}-dj{32,40}-{sqlite,postgres}-taggit2 [testenv] commands=./runtests.py --noinput {posargs} @@ -12,11 +13,14 @@ py36: python3.6 py37: python3.7 py38: python3.8 + py39: python3.8 + py310: python3.10 deps = taggit0: django-taggit>=0.24,<1 taggit1: django-taggit>=1,<1.3 - taggit13: django-taggit>=1.3 + taggit13: django-taggit>=1.3,<2.0 + taggit2: django-taggit>=2 pytz>=2014.7 dj20: Django>=2.0,<2.1 dj21: Django>=2.1,<2.2 @@ -24,7 +28,8 @@ dj30: Django>=3.0,<3.1 dj31: Django>=3.1,<3.2 dj32: Django>=3.2,<3.3 - dj32stable: git+https://github.com/django/django.git@stable/3.2.x#egg=Django + dj40: Django>=4.0,<4.1 + dj40stable: git+https://github.com/django/django.git@stable/4.0.x#egg=Django djmaster: git+https://github.com/django/django.git@master#egg=Django postgres: psycopg2>=2.6