├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── modelcluster ├── __init__.py ├── contrib │ ├── __init__.py │ └── taggit.py ├── datetime_utils.py ├── fields.py ├── forms.py ├── models.py ├── queryset.py ├── tags.py └── utils.py ├── runtests.py ├── setup.cfg ├── setup.py ├── shell.py ├── tests ├── __init__.py ├── fixtures │ └── parentalmanytomany-to-ordered-model.json ├── migrations │ ├── 0001_initial.py │ ├── 0002_add_m2m_models.py │ ├── 0003_gallery_galleryimage.py │ ├── 0004_auto_20170406_1734.py │ ├── 0005_article_fk_to_newspaper.py │ ├── 0006_auto_20171109_0614.py │ ├── 0007_add_bandmember_favourite_restaurant.py │ ├── 0008_prefetch_related_tests.py │ ├── 0009_article_related_articles.py │ ├── 0010_song.py │ ├── 0011_add_room_features.py │ ├── 0012_add_record_label.py │ ├── 0013_add_log_category.py │ └── __init__.py ├── models.py ├── settings.py ├── tests │ ├── __init__.py │ ├── test_cluster.py │ ├── test_cluster_form.py │ ├── test_copy_child_relations.py │ ├── test_copy_cluster.py │ ├── test_fixture_loading.py │ ├── test_formset.py │ ├── test_serialize.py │ └── test_tag.py └── urls.py └── tox.ini /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | name: Python ${{ matrix.python }} - ${{ matrix.django }} - ${{ matrix.database }} 10 | runs-on: ubuntu-latest 11 | continue-on-error: ${{ matrix.experimental }} 12 | strategy: 13 | matrix: 14 | include: 15 | - python: "3.9" 16 | django: "Django>=4.2,<4.3" 17 | taggit: "django-taggit>=2.1.0" 18 | database: "postgresql" 19 | psycopg: "psycopg2>=2.9.2" 20 | experimental: false 21 | - python: "3.12" 22 | django: "Django>=5.0,<5.1" 23 | taggit: "django-taggit>=2.1.0" 24 | database: "sqlite3" 25 | psycopg: "psycopg2>=2.9.2" 26 | experimental: false 27 | - python: "3.13" 28 | django: "Django>=5.1,<5.2" 29 | taggit: "django-taggit>=2.1.0" 30 | database: "sqlite3" 31 | psycopg: "psycopg2>=2.9.2" 32 | experimental: false 33 | - python: "3.10" 34 | django: "git+https://github.com/django/django.git@stable/5.1.x#egg=Django" 35 | taggit: "django-taggit>=2.1.0" 36 | database: "sqlite3" 37 | psycopg: "psycopg2>=2.9.2" 38 | experimental: true 39 | - python: "3.10" 40 | django: "git+https://github.com/django/django.git@main#egg=Django" 41 | taggit: "django-taggit>=2.1.0" 42 | database: "postgresql" 43 | psycopg: "psycopg2>=2.9.2" 44 | experimental: true 45 | 46 | services: 47 | postgres: 48 | image: postgres:latest 49 | env: 50 | POSTGRES_USER: postgres 51 | POSTGRES_PASSWORD: postgres 52 | ports: 53 | - 5432:5432 54 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 55 | 56 | steps: 57 | - uses: actions/checkout@v3 58 | - name: Set up Python ${{ matrix.python }} 59 | uses: actions/setup-python@v4 60 | with: 61 | python-version: ${{ matrix.python }} 62 | - name: Install dependencies 63 | run: | 64 | python -m pip install --upgrade pip 65 | pip install -e . 66 | pip install "${{ matrix.psycopg }}" 67 | pip install "${{ matrix.django }}" 68 | pip install "${{ matrix.taggit }}" 69 | - name: Test 70 | run: ./runtests.py 71 | env: 72 | DATABASE_ENGINE: django.db.backends.${{ matrix.database }} 73 | DATABASE_HOST: localhost 74 | DATABASE_USER: postgres 75 | DATABASE_PASS: postgres 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | dist/ 4 | build/ 5 | .tox/ 6 | MANIFEST 7 | /django_modelcluster.egg-info 8 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 6.5 (xx.xx.xxxx) - IN DEVELOPMENT 5 | ~~~~~~~~~~~~~~~~ 6 | * Handle `get_prefetch_queryset` deprecation in Django 5.0, for preliminary Django 6.0 support (Sage Abdullah) 7 | 8 | 6.4 (18.12.2024) 9 | ~~~~~~~~~~~~~~~~ 10 | * Add `UniqueConstraint` support for uniqueness validation (Sage Abdullah) 11 | * Remove `pytz` dependency (Sage Abdullah) 12 | * Added Django 5.1 and Python 3.13 support 13 | * Removed Django 3.2 and Python 3.8 support 14 | 15 | 6.3 (26.02.2024) 16 | ~~~~~~~~~~~~~~~~ 17 | * Support filtering with Q objects (Shohan Dutta Roy) 18 | * Support random ordering with `.order_by("?")` (Shohan Dutta Roy) 19 | * Support `distinct()` on querysets (Shohan Dutta Roy) 20 | * Support `iso_weekday` and `iso_year` field lookups (Andy Babic) 21 | * Support datetime transform expressions on `values` and `values_list` (Andy Babic) 22 | * Fix: Correctly handle filtering on fields on related models when those fields have names that match a lookup type (Andy Babic) 23 | * Fix: Correctly handle null foreign keys when traversing related fields (Andy Babic) 24 | 25 | 6.2.1 (04.01.2024) 26 | ~~~~~~~~~~~~~~~~~~ 27 | * Fix: Prevent failure on pre-3.1.0 versions of django-taggit without `_remove_prefetched_objects` 28 | 29 | 6.2 (03.01.2024) 30 | ~~~~~~~~~~~~~~~~ 31 | * Added Django 5.0 support 32 | * Removed Django 4.1 support 33 | * Implement prefetching for ClusterTaggableManager (Andy Chosak) 34 | 35 | 6.1 (04.10.2023) 36 | ~~~~~~~~~~~~~~~~ 37 | * Removed Django 2.2, 3.0, 3.1 & 4.0 support 38 | * Added Django 4.2 support (Irtaza Akram) 39 | * Fixed deprecation warning for removal of `django.utils.timezone.utc` (John-Scott Atlakson) 40 | * Fix: Avoid unnecessary call to localtime for timestamps already in UTC (Stefan Hammer) 41 | * Removed Python 3.7 support 42 | * Add Python 3.11 and 3.12 support 43 | 44 | 6.0 (14.03.2022) 45 | ~~~~~~~~~~~~~~~~ 46 | * 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) 47 | * Removed Python 3.5 and 3.6 support 48 | * Removed Django 2.0 and 2.1 support 49 | * Support explicit definitions for nested formsets within ClusterForm, via a `formsets` option on the outer formset's definition (Matt Westcott) 50 | * Add `inherit_kwargs` attribute to ClusterForm child formsets (Matt Westcott) 51 | 52 | 5.3 (10.03.2022) 53 | ~~~~~~~~~~~~~~~~ 54 | * Avoid accessing live queryset on unsaved instances, for preliminary Django 4.1 compatibility (Matt Westcott) 55 | * Support traversing one-to-one and many-to-one relations in `filter` / `order_by` lookups (Andy Babic) 56 | * Implement `values()` method on FakeQuerySet (Andy Babic) 57 | * Allow `values()` and `values_list()` to be chained with other queryset modifiers (Andy Babic) 58 | * Fix: Fix HTML escaping behaviour on `ClusterForm.as_p()` (Matt Westcott) 59 | * Fix: Match standard behaviour queryset of returning foreign keys as IDs in `values_list` (Andy Babic) 60 | 61 | 5.2 (13.10.2021) 62 | ~~~~~~~~~~~~~~~~ 63 | * Implement `copy_cluster` method on ClusterableModel (Karl Hobley) 64 | * Add `formset_name` option on ClusterableModel formsets to allow the formset name to differ from the relation name (Matt Westcott) 65 | * Fix: Fix tests for Django 3.2 (Alex Tomkins, Matt Westcott, María Fernanda Magallanes) 66 | * Fix: Ensure ptr_id fields are correctly populated when deserialising models with multi-level inheritance (Alex Tomkins) 67 | 68 | 5.1 (10.09.2020) 69 | ~~~~~~~~~~~~~~~~ 70 | * Allow child form class to be overridden in the `formsets` Meta property of ClusterForm (Helder Correia) 71 | * Add prefetch_related support to ParentalManyToManyField (Andy Chosak) 72 | * Implement `copy_child_relation` and `copy_all_child_relations` methods on ClusterableModel (Karl Hobley) 73 | * Fix: Fix behavior of ParentalKeys and prefetch_related() supplied with a lookup queryset (Juha Yrjölä) 74 | 75 | 5.0.2 (26.05.2020) 76 | ~~~~~~~~~~~~~~~~~~ 77 | * Fix: Fix compatibility with django-taggit 1.3.0 (Martin Sandström) 78 | 79 | 5.0.1 (06.01.2020) 80 | ~~~~~~~~~~~~~~~~~~ 81 | * Fix: ClusterForm without an explicit `formsets` kwarg now allows formsets to be omitted from form submissions, to fix regression with nested relations 82 | * Fix: ParentalManyToManyField data is now loaded correctly by `manage.py loaddata` (Andy Babic) 83 | 84 | 5.0 (06.08.2019) 85 | ~~~~~~~~~~~~~~~~ 86 | * Removed Python 2 and 3.4 support 87 | * Removed Django 1.10 and 1.11 support 88 | * Added django-taggit 1.x compatibility (Gassan Gousseinov, Matt Westcott) 89 | 90 | 4.4.1 (06.01.2020) 91 | ~~~~~~~~~~~~~~~~~~ 92 | * Fix: ClusterForm without an explicit `formsets` kwarg now allows formsets to be omitted from form submissions, to fix regression with nested relations 93 | 94 | 4.4 (02.04.2019) 95 | ~~~~~~~~~~~~~~~~ 96 | * Django 2.2 compatibility 97 | * Support nested child relationships in ClusterForm (Sam Costigan) 98 | 99 | 4.3 (15.11.2018) 100 | ~~~~~~~~~~~~~~~~ 101 | * Added support for filter lookup expressions such as `__lt` 102 | 103 | 4.2 (08.08.2018) 104 | ~~~~~~~~~~~~~~~~ 105 | * Django 2.1 compatibility 106 | * Python 3.7 compatibility 107 | * Implemented prefetch_related on FakeQuerySet (Haydn Greatnews) 108 | * Fix: Saving a ClusterableModel with a primary key of 0 no longer throws an IntegrityError (A Lee) 109 | * Fix: Serialization now respects `serialize=False` on ParentalManyToManyFields (Tadas Dailyda) 110 | 111 | 4.1 (12.02.2017) 112 | ~~~~~~~~~~~~~~~~ 113 | * `on_delete` on ParentalKey now defaults to CASCADE if not specified 114 | 115 | 4.0 (13.12.2017) 116 | ~~~~~~~~~~~~~~~~ 117 | * Django 2.0 compatibility 118 | * Removed Django 1.8 and 1.9 support 119 | * Child formsets now validate uniqueness constraints 120 | * Fix: Many-to-many relations inside inline formsets are now saved correctly 121 | 122 | 3.1 (07.04.2017) 123 | ~~~~~~~~~~~~~~~~ 124 | * Django 1.11 compatibility 125 | * Python 3.6 compatibility 126 | * Added the ability to install the optional dependency `django-taggit` 127 | using `pip install django-modelcluster[taggit]` 128 | * Fix: ClusterForm.save(commit=True) now correctly writes ParentalManyToManyField relations back to the database rather than requiring a separate model.save() step 129 | * Fix: ClusterForm.is_multipart() now returns True when a child form requires multipart submission 130 | * Fix: ClusterForm.media now includes media defined on child forms 131 | * Fix: ParentalManyToManyField.value_from_object now returns correct result on unsaved objects 132 | 133 | 3.0.1 (02.02.2017) 134 | ~~~~~~~~~~~~~~~~~~ 135 | * Fix: Added _result_cache property on FakeQuerySet (necessary for model forms with ParentalManyToManyFields to work correctly on Django 1.8-1.9) 136 | 137 | 3.0 (02.02.2017) 138 | ~~~~~~~~~~~~~~~~ 139 | * Added support for many-to-many relations (Thejaswi Puthraya, Matt Westcott) 140 | * Added compatibility with django-taggit 0.20 and dropped support for earlier versions 141 | * Deprecated the Model._meta.child_relations property (get_all_child_relations should be used instead) 142 | * Implemented the `set()` method on related managers (introduced in Django 1.9) 143 | 144 | 2.0 (22.04.2016) 145 | ~~~~~~~~~~~~~~~~ 146 | * Removed Django 1.7 and Python 3.2 support 147 | * Added system check to disallow related_name='+' on ParentalKey 148 | * Added support for TAGGIT_CASE_INSENSITIVE on ClusterTaggableManager 149 | * Field values for serialization are now fetched via pre_save (which, in particular, ensures that file fields are committed to storage) 150 | * Fix: System checks now correctly report a model name that cannot be resolved to a model 151 | * Fix: prefetch_related on a ClusterTaggableManager no longer fails (but doesn't prefetch either) 152 | * Fix: Adding invalid types as tags now correctly reports a ValueError 153 | 154 | 1.1 (17.12.2015) 155 | ~~~~~~~~~~~~~~~~ 156 | * Django 1.9 compatibility 157 | * Added exclude() method to FakeQuerySet 158 | * Removed dependency on the 'six' package, in favour of Django's built-in version 159 | 160 | 1.0 (09.10.2015) 161 | ~~~~~~~~~~~~~~~~ 162 | * Removed Django 1.6 and Python 2.6 support 163 | * Added system check to ensure that ParentalKey points to a ClusterableModel 164 | * Added validate_max, min_num and validate_min parameters to childformset_factory 165 | 166 | 0.6.2 (13.04.2015) 167 | ~~~~~~~~~~~~~~~~~~ 168 | * Fix: Updated add_ignored_fields declaration so that South / Django 1.6 correctly ignores modelcluster.contrib.taggit.ClusterTaggableManager again 169 | 170 | 0.6.1 (09.04.2015) 171 | ~~~~~~~~~~~~~~~~~~ 172 | * Django 1.8 compatibility 173 | * 'modelcluster.tags' module has been moved to 'modelcluster.contrib.taggit' 174 | 175 | 0.6 (09.04.2015) 176 | ~~~~~~~~~~~~~~~~ 177 | (withdrawn due to packaging issues) 178 | 179 | 0.5 (03.02.2015) 180 | ~~~~~~~~~~~~~~~~ 181 | * ClusterForm.Meta formsets can now be specified as a dict to allow extra properties to be set on the underlying form. 182 | * Added order_by() method to FakeQuerySet 183 | * Fix: Child object ordering is now applied without needing to save to the database 184 | 185 | 0.4 (04.09.2014) 186 | ~~~~~~~~~~~~~~~~ 187 | * Django 1.7 compatibility 188 | * Fix: Datetimes are converted to UTC on serialisation and to local time on deserialisation, to match Django's behaviour when accessing the database 189 | * Fix: ParentalKey relations to a model's superclass are now picked up correctly by that model 190 | * Fix: Custom Media classes on ClusterForm now behave correctly 191 | 192 | 0.3 (17.06.2014) 193 | ~~~~~~~~~~~~~~~~ 194 | * Added exists(), first() and last() methods on FakeQuerySet 195 | * Fix: Model ordering is applied when adding items to DeferringRelatedManager 196 | 197 | 0.2 (22.05.2014) 198 | ~~~~~~~~~~~~~~~~ 199 | * Python 2.6 compatibility 200 | * Python 3 compatibility 201 | * Django 1.7 beta compatibility 202 | * Added support for prefetch_related on DeferringRelatedManager 203 | 204 | 0.1 (05.02.2014) 205 | ~~~~~~~~~~~~~~~~ 206 | * Initial release. 207 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2018 Torchbox Ltd and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Torchbox nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst CHANGELOG.txt 2 | recursive-include modelcluster *.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-modelcluster 2 | =================== 3 | 4 | If you had a data model like this: 5 | 6 | .. code-block:: python 7 | 8 | class Band(models.Model): 9 | name = models.CharField(max_length=255) 10 | 11 | class BandMember(models.Model): 12 | band = models.ForeignKey('Band', related_name='members', on_delete=models.CASCADE) 13 | name = models.CharField(max_length=255) 14 | 15 | 16 | wouldn't it be nice if you could construct bundles of objects like this, independently of the database: 17 | 18 | .. code-block:: python 19 | 20 | beatles = Band(name='The Beatles') 21 | beatles.members = [ 22 | BandMember(name='John Lennon'), 23 | BandMember(name='Paul McCartney'), 24 | ] 25 | 26 | Unfortunately, you can't. Objects need to exist in the database for foreign key relations to work: 27 | 28 | .. code-block:: python 29 | 30 | IntegrityError: null value in column "band_id" violates not-null constraint 31 | 32 | But what if you could? There are all sorts of scenarios where you might want to work with a 'cluster' of related objects, without necessarily holding them in the database: maybe you want to render a preview of the data the user has just submitted, prior to saving. Maybe you need to construct a tree of things, serialize them and hand them off to some external system. Maybe you have a workflow where your models exist in an incomplete 'draft' state for an extended time, or you need to handle multiple revisions, and you don't want to redesign your database around that requirement. 33 | 34 | **django-modelcluster** extends Django's foreign key relations to make this possible. It introduces a new type of relation, *ParentalKey*, where the related models are stored locally to the 'parent' model until the parent is explicitly saved. Up to that point, the related models can still be accessed through a subset of the QuerySet API: 35 | 36 | .. code-block:: python 37 | 38 | from modelcluster.models import ClusterableModel 39 | from modelcluster.fields import ParentalKey 40 | 41 | 42 | class Band(ClusterableModel): 43 | name = models.CharField(max_length=255) 44 | 45 | class BandMember(models.Model): 46 | band = ParentalKey('Band', related_name='members', on_delete=models.CASCADE) 47 | name = models.CharField(max_length=255) 48 | 49 | 50 | >>> beatles = Band(name='The Beatles') 51 | >>> beatles.members = [ 52 | ... BandMember(name='John Lennon'), 53 | ... BandMember(name='Paul McCartney'), 54 | ... ] 55 | >>> [member.name for member in beatles.members.all()] 56 | ['John Lennon', 'Paul McCartney'] 57 | >>> beatles.members.add(BandMember(name='George Harrison')) 58 | >>> beatles.members.count() 59 | 3 60 | >>> beatles.save() # only now are the records written to the database 61 | 62 | For more examples, see the unit tests. 63 | 64 | 65 | Many-to-many relations 66 | ---------------------- 67 | 68 | For many-to-many relations, a corresponding *ParentalManyToManyField* is available: 69 | 70 | .. code-block:: python 71 | 72 | from modelcluster.models import ClusterableModel 73 | from modelcluster.fields import ParentalManyToManyField 74 | 75 | class Movie(ClusterableModel): 76 | title = models.CharField(max_length=255) 77 | actors = ParentalManyToManyField('Actor', related_name='movies') 78 | 79 | class Actor(models.Model): 80 | name = models.CharField(max_length=255) 81 | 82 | 83 | >>> harrison_ford = Actor.objects.create(name='Harrison Ford') 84 | >>> carrie_fisher = Actor.objects.create(name='Carrie Fisher') 85 | >>> star_wars = Movie(title='Star Wars') 86 | >>> star_wars.actors = [harrison_ford, carrie_fisher] 87 | >>> blade_runner = Movie(title='Blade Runner') 88 | >>> blade_runner.actors.add(harrison_ford) 89 | >>> star_wars.actors.count() 90 | 2 91 | >>> [movie.title for movie in harrison_ford.movies.all()] # the Movie records are not in the database yet 92 | [] 93 | >>> star_wars.save() # Star Wars now exists in the database (along with the 'actor' relations) 94 | >>> [movie.title for movie in harrison_ford.movies.all()] 95 | ['Star Wars'] 96 | 97 | Note that ``ParentalManyToManyField`` is defined on the parent model rather than the related model, just as a standard ``ManyToManyField`` would be. Also note that the related objects - the ``Actor`` instances in the above example - must exist in the database before being associated with the parent record. (The ``ParentalManyToManyField`` allows the relations between Movies and Actors to be stored in memory without writing to the database, but not the ``Actor`` records themselves.) 98 | 99 | 100 | Introspection 101 | ------------- 102 | If you need to find out which child relations exist on a parent model - to create a deep copy of the model and all its children, say - use the ``modelcluster.models.get_all_child_relations`` function: 103 | 104 | .. code-block:: python 105 | 106 | >>> from modelcluster.models import get_all_child_relations 107 | >>> get_all_child_relations(Band) 108 | [, ] 109 | 110 | This includes relations that are defined on any superclasses of the parent model. 111 | 112 | To retrieve a list of all ParentalManyToManyFields defined on a parent model, use ``modelcluster.models.get_all_child_m2m_relations``: 113 | 114 | .. code-block:: python 115 | 116 | >>> from modelcluster.models import get_all_child_m2m_relations 117 | >>> get_all_child_m2m_relations(Movie) 118 | [] 119 | -------------------------------------------------------------------------------- /modelcluster/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail/django-modelcluster/4957ba243192d9c09f63b4e559b9e638b4fc4d73/modelcluster/__init__.py -------------------------------------------------------------------------------- /modelcluster/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail/django-modelcluster/4957ba243192d9c09f63b4e559b9e638b4fc4d73/modelcluster/contrib/__init__.py -------------------------------------------------------------------------------- /modelcluster/contrib/taggit.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import absolute_import 3 | 4 | from taggit import VERSION as TAGGIT_VERSION 5 | from taggit.managers import TaggableManager, _TaggableManager 6 | from taggit.utils import require_instance_manager 7 | 8 | from modelcluster.queryset import FakeQuerySet 9 | 10 | 11 | if TAGGIT_VERSION < (0, 20, 0): 12 | raise Exception("modelcluster.contrib.taggit requires django-taggit version 0.20 or above") 13 | 14 | 15 | class _ClusterTaggableManager(_TaggableManager): 16 | @require_instance_manager 17 | def get_tagged_item_manager(self): 18 | """Return the manager that handles the relation from this instance to the tagged_item class. 19 | If content_object on the tagged_item class is defined as a ParentalKey, this will be a 20 | DeferringRelatedManager which allows writing related objects without committing them 21 | to the database. 22 | """ 23 | rel_name = self.through._meta.get_field('content_object').remote_field.get_accessor_name() 24 | return getattr(self.instance, rel_name) 25 | 26 | def get_queryset(self, extra_filters=None): 27 | if self.instance is not None: 28 | tagged_item_manager = self.get_tagged_item_manager() 29 | 30 | # If we're already managing tags in memory for this instance, 31 | # we want to return those uncommitted changes. This shouldn't 32 | # require a request to the database. 33 | if tagged_item_manager.is_deferring: 34 | return FakeQuerySet( 35 | self.through.tag_model(), 36 | [tagged_item.tag for tagged_item in tagged_item_manager.all()], 37 | ) 38 | 39 | # If we don't have any uncommitted changes for this instance, 40 | # we'd ideally like to use the default taggit logic. There's one 41 | # case that we need to handle specially, which is the ability to 42 | # query tags on an unsaved model instance, for example: 43 | # 44 | # class TaggedPlace(TaggedItemBase): 45 | # content_object = ParentalKey( 46 | # "Place", 47 | # related_name="tagged_items", 48 | # on_delete=models.CASCADE, 49 | # ) 50 | # 51 | # class Place(ClusterableModel): 52 | # tags = ClusterTaggableManager( 53 | # through=TaggedPlace, 54 | # blank=True, 55 | # ) 56 | # 57 | # instance = Place() 58 | # instance.tags.count() 59 | # 60 | # Under the hood this call invokes this get_queryset method with an 61 | # unsaved self.instance, which would trigger this query using the 62 | # default taggit logic: 63 | # 64 | # TaggedPlace.objects.filter(content_object=Place()) 65 | # 66 | # This works on Django < 5.0, returning an empty list as expected. 67 | # But as of Django 5.0, passing unsaved model instances to related 68 | # filters is no longer allowed, see 69 | # https://code.djangoproject.com/ticket/31486. 70 | # 71 | # To handle this case we return an empty tag list since there won't 72 | # be any existing tags in the database for an unsaved instance. 73 | elif self.instance.pk is None: 74 | return FakeQuerySet(self.through.tag_model(), []) 75 | 76 | # If we've reached this point then either this manager isn't associated 77 | # with a specific model, which probably means it's being invoked within 78 | # a prefetch_related operation: 79 | # 80 | # Place.objects.prefetch_related("tags") 81 | # 82 | # or we're fetching tags for a model instance that doesn't have any 83 | # uncommitted tag changes in memory: 84 | # 85 | # place = Place.objects.first() 86 | # place.tags.all() 87 | # 88 | # In these cases we can fallback to the default taggit manager behavior 89 | # which will fetch the tags from the database. 90 | return super().get_queryset(extra_filters) 91 | 92 | @require_instance_manager 93 | def add(self, *tags): 94 | if TAGGIT_VERSION >= (3, 1, 0): 95 | self._remove_prefetched_objects() 96 | 97 | if TAGGIT_VERSION >= (1, 3, 0): 98 | tag_objs = self._to_tag_model_instances(tags, {}) 99 | else: 100 | tag_objs = self._to_tag_model_instances(tags) 101 | 102 | # Now write these to the relation 103 | tagged_item_manager = self.get_tagged_item_manager() 104 | for tag in tag_objs: 105 | if not tagged_item_manager.filter(tag=tag): 106 | # make an instance of the self.through model and add it to the relation 107 | tagged_item = self.through(tag=tag) 108 | tagged_item_manager.add(tagged_item) 109 | 110 | @require_instance_manager 111 | def remove(self, *tags): 112 | if TAGGIT_VERSION >= (3, 1, 0): 113 | self._remove_prefetched_objects() 114 | 115 | tagged_item_manager = self.get_tagged_item_manager() 116 | tagged_items = [ 117 | tagged_item for tagged_item in tagged_item_manager.all() 118 | if tagged_item.tag.name in tags 119 | ] 120 | tagged_item_manager.remove(*tagged_items) 121 | 122 | @require_instance_manager 123 | def set(self, *args, **kwargs): 124 | # Ignore the 'clear' kwarg (which defaults to False) and override it to be always true; 125 | # this means that set is implemented as a clear then an add, which was the standard behaviour 126 | # prior to django-taggit 0.19 (https://github.com/alex/django-taggit/commit/6542a702b590a5cfb91ea0de218b7f71ffd07c33). 127 | # 128 | # In this way, we avoid a live database lookup that occurs in the clear=False branch. 129 | # 130 | # The clear=True behaviour is fine for our purposes; the distinction only exists in django-taggit 131 | # to ensure that the correct set of m2m_changed signals is fired, and our reimplementation here 132 | # doesn't fire them at all (which makes logical sense, because the whole point of this module is 133 | # that the add/remove/set/clear operations don't write to the database). 134 | # 135 | # super().set() already calls self._remove_prefetched_objects() so we don't need to do so here. 136 | return super().set(*args, clear=True) 137 | 138 | @require_instance_manager 139 | def clear(self): 140 | if TAGGIT_VERSION >= (3, 1, 0): 141 | self._remove_prefetched_objects() 142 | self.get_tagged_item_manager().clear() 143 | 144 | 145 | class ClusterTaggableManager(TaggableManager): 146 | _need_commit_after_assignment = True 147 | 148 | def __get__(self, instance, model): 149 | # override TaggableManager's requirement for instance to have a primary key 150 | # before we can access its tags 151 | manager = _ClusterTaggableManager( 152 | through=self.through, model=model, instance=instance, prefetch_cache_name=self.name 153 | ) 154 | 155 | return manager 156 | 157 | def value_from_object(self, instance): 158 | # retrieve the queryset via the related manager on the content object, 159 | # to accommodate the possibility of this having uncommitted changes relative to 160 | # the live database 161 | rel_name = self.through._meta.get_field('content_object').remote_field.get_accessor_name() 162 | ret = getattr(instance, rel_name).all() 163 | if TAGGIT_VERSION >= (1, ): # expects a Tag list instead of TaggedItem List 164 | ret = [tagged_item.tag for tagged_item in ret] 165 | return ret 166 | -------------------------------------------------------------------------------- /modelcluster/datetime_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django import forms 4 | 5 | 6 | TIMEFIELD_TRANSFORM_EXPRESSIONS = {"hour", "minute", "second"} 7 | DATEFIELD_TRANSFORM_EXPRESSIONS = { 8 | "year", 9 | "iso_year", 10 | "month", 11 | "day", 12 | "week", 13 | "week_day", 14 | "iso_week_day", 15 | "quarter", 16 | } 17 | DATETIMEFIELD_TRANSFORM_EXPRESSIONS = ( 18 | {"date", "time"} 19 | | TIMEFIELD_TRANSFORM_EXPRESSIONS 20 | | DATEFIELD_TRANSFORM_EXPRESSIONS 21 | ) 22 | TRANSFORM_FIELD_TYPES = { 23 | "year": forms.IntegerField, 24 | "iso_year": forms.IntegerField, 25 | "month": forms.IntegerField, 26 | "hour": forms.IntegerField, 27 | "minute": forms.IntegerField, 28 | "second": forms.IntegerField, 29 | "day": forms.IntegerField, 30 | "week": forms.IntegerField, 31 | "week_day": forms.IntegerField, 32 | "iso_week_day": forms.IntegerField, 33 | "quarter": forms.IntegerField, 34 | "date": forms.DateField, 35 | "time": forms.TimeField, 36 | } 37 | 38 | 39 | def derive_from_value(value, expr): 40 | if isinstance(value, datetime.datetime): 41 | return derive_from_datetime(value, expr) 42 | if isinstance(value, datetime.date): 43 | return derive_from_date(value, expr) 44 | if isinstance(value, datetime.time): 45 | return derive_from_time(value, expr) 46 | return None 47 | 48 | 49 | def derive_from_time(value, expr): 50 | """ 51 | Mimics the behaviour of the ``hour``, ``minute`` and ``second`` lookup 52 | expressions that Django querysets support for ``TimeField`` and 53 | ``DateTimeField``, by extracting the relevant value from an in-memory 54 | ``time`` or ``datetime`` value. 55 | """ 56 | if expr == "hour": 57 | return value.hour 58 | if expr == "minute": 59 | return value.minute 60 | if expr == "second": 61 | return value.second 62 | raise ValueError( 63 | "Expression '{expression}' is not supported for {value}".format( 64 | expression=expr, value=repr(value) 65 | ) 66 | ) 67 | 68 | 69 | def derive_from_date(value, expr): 70 | """ 71 | Mimics the behaviour of the ``year``, ``iso_year`` ``month``, ``day``, 72 | ``week``, ``week_day``, ``iso_week_day`` and ``quarter`` lookup 73 | expressions that Django querysets support for ``DateField`` and 74 | ``DateTimeField`` columns, by extracting the relevant value from an 75 | in-memory ``date`` or ``datetime`` value. 76 | """ 77 | if expr == "year": 78 | return value.year 79 | if expr == "iso_year": 80 | return value.isocalendar()[0] 81 | if expr == "month": 82 | return value.month 83 | if expr == "day": 84 | return value.day 85 | if expr == "week": 86 | return value.isocalendar()[1] 87 | if expr == "week_day": 88 | v = value.isoweekday() 89 | return 1 if v == 7 else v + 1 90 | if expr == "iso_week_day": 91 | return value.isoweekday() 92 | if expr == "quarter": 93 | return (value.month - 1) // 3 + 1 94 | raise ValueError( 95 | "Expression '{expression}' is not supported for {value}".format( 96 | expression=expr, value=repr(value) 97 | ) 98 | ) 99 | 100 | 101 | def derive_from_datetime(value, expr): 102 | """ 103 | Mimics the behaviour of the ``date``, ``time`` and other lookup 104 | expressions that Django querysets support for ``DateTimeField`` columns, 105 | by extracting the relevant value from an in-memory ``datetime`` value. 106 | """ 107 | if expr == "date": 108 | return value.date() 109 | if expr == "time": 110 | return value.time() 111 | if expr in TIMEFIELD_TRANSFORM_EXPRESSIONS: 112 | return derive_from_time(value, expr) 113 | if expr in DATEFIELD_TRANSFORM_EXPRESSIONS: 114 | return derive_from_date(value, expr) 115 | raise ValueError( 116 | "Expression '{expression}' is not supported for {value}".format( 117 | expression=expr, value=repr(value) 118 | ) 119 | ) 120 | -------------------------------------------------------------------------------- /modelcluster/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.forms import ValidationError 4 | from django.core.exceptions import NON_FIELD_ERRORS 5 | from django.forms.formsets import TOTAL_FORM_COUNT 6 | from django.forms.models import ( 7 | BaseModelFormSet, modelformset_factory, 8 | ModelForm, _get_foreign_key, ModelFormMetaclass, ModelFormOptions 9 | ) 10 | from django.db.models.fields.related import ForeignObjectRel 11 | from django.utils.html import format_html_join 12 | 13 | 14 | from modelcluster.models import get_all_child_relations 15 | 16 | 17 | class BaseTransientModelFormSet(BaseModelFormSet): 18 | """ A ModelFormSet that doesn't assume that all its initial data instances exist in the db """ 19 | def _construct_form(self, i, **kwargs): 20 | # Need to override _construct_form to avoid calling to_python on an empty string PK value 21 | 22 | if self.is_bound and i < self.initial_form_count(): 23 | pk_key = "%s-%s" % (self.add_prefix(i), self.model._meta.pk.name) 24 | pk = self.data[pk_key] 25 | if pk == '': 26 | kwargs['instance'] = self.model() 27 | else: 28 | pk_field = self.model._meta.pk 29 | to_python = self._get_to_python(pk_field) 30 | pk = to_python(pk) 31 | kwargs['instance'] = self._existing_object(pk) 32 | if i < self.initial_form_count() and 'instance' not in kwargs: 33 | kwargs['instance'] = self.get_queryset()[i] 34 | if i >= self.initial_form_count() and self.initial_extra: 35 | # Set initial values for extra forms 36 | try: 37 | kwargs['initial'] = self.initial_extra[i - self.initial_form_count()] 38 | except IndexError: 39 | pass 40 | 41 | # bypass BaseModelFormSet's own _construct_form 42 | return super(BaseModelFormSet, self)._construct_form(i, **kwargs) 43 | 44 | def save_existing_objects(self, commit=True): 45 | # Need to override _construct_form so that it doesn't skip over initial forms whose instance 46 | # has a blank PK (which is taken as an indication that the form was constructed with an 47 | # instance not present in our queryset) 48 | 49 | self.changed_objects = [] 50 | self.deleted_objects = [] 51 | if not self.initial_forms: 52 | return [] 53 | 54 | saved_instances = [] 55 | forms_to_delete = self.deleted_forms 56 | for form in self.initial_forms: 57 | obj = form.instance 58 | if form in forms_to_delete: 59 | if obj.pk is None: 60 | # no action to be taken to delete an object which isn't in the database 61 | continue 62 | self.deleted_objects.append(obj) 63 | self.delete_existing(obj, commit=commit) 64 | elif form.has_changed(): 65 | self.changed_objects.append((obj, form.changed_data)) 66 | saved_instances.append(self.save_existing(form, obj, commit=commit)) 67 | if not commit: 68 | self.saved_forms.append(form) 69 | return saved_instances 70 | 71 | 72 | def transientmodelformset_factory(model, formset=BaseTransientModelFormSet, **kwargs): 73 | return modelformset_factory(model, formset=formset, **kwargs) 74 | 75 | 76 | class BaseChildFormSet(BaseTransientModelFormSet): 77 | inherit_kwargs = None 78 | 79 | def __init__(self, data=None, files=None, instance=None, queryset=None, **kwargs): 80 | if instance is None: 81 | self.instance = self.fk.remote_field.model() 82 | else: 83 | self.instance = instance 84 | 85 | self.rel_name = ForeignObjectRel(self.fk, self.fk.remote_field.model, related_name=self.fk.remote_field.related_name).get_accessor_name() 86 | 87 | if queryset is None: 88 | queryset = getattr(self.instance, self.rel_name).all() 89 | 90 | super().__init__(data, files, queryset=queryset, **kwargs) 91 | 92 | def save(self, commit=True): 93 | # The base ModelFormSet's save(commit=False) will populate the lists 94 | # self.changed_objects, self.deleted_objects and self.new_objects; 95 | # use these to perform the appropriate updates on the relation's manager. 96 | saved_instances = super().save(commit=False) 97 | 98 | manager = getattr(self.instance, self.rel_name) 99 | 100 | # if model has a sort_order_field defined, assign order indexes to the attribute 101 | # named in it 102 | if self.can_order and hasattr(self.model, 'sort_order_field'): 103 | sort_order_field = getattr(self.model, 'sort_order_field') 104 | for i, form in enumerate(self.ordered_forms): 105 | setattr(form.instance, sort_order_field, i) 106 | 107 | # If the manager has existing instances with a blank ID, we have no way of knowing 108 | # whether these correspond to items in the submitted data. We'll assume that they do, 109 | # as that's the most common case (i.e. the formset contains the full set of child objects, 110 | # not just a selection of additions / updates) and so we delete all ID-less objects here 111 | # on the basis that they will be re-added by the formset saving mechanism. 112 | no_id_instances = [obj for obj in manager.all() if obj.pk is None] 113 | if no_id_instances: 114 | manager.remove(*no_id_instances) 115 | 116 | manager.add(*saved_instances) 117 | manager.remove(*self.deleted_objects) 118 | 119 | self.save_m2m() # ensures any parental-m2m fields are saved. 120 | if commit: 121 | manager.commit() 122 | 123 | return saved_instances 124 | 125 | def clean(self, *args, **kwargs): 126 | self.validate_unique() 127 | return super().clean(*args, **kwargs) 128 | 129 | def validate_unique(self): 130 | '''This clean method will check for unique_together condition''' 131 | # Collect unique_checks and to run from all the forms. 132 | all_unique_checks = set() 133 | all_date_checks = set() 134 | forms_to_delete = self.deleted_forms 135 | valid_forms = [form for form in self.forms if form.is_valid() and form not in forms_to_delete] 136 | for form in valid_forms: 137 | unique_checks, date_checks = form.instance._get_unique_checks( 138 | include_meta_constraints=True 139 | ) 140 | all_unique_checks.update(unique_checks) 141 | all_date_checks.update(date_checks) 142 | 143 | errors = [] 144 | # Do each of the unique checks (unique and unique_together) 145 | for uclass, unique_check in all_unique_checks: 146 | seen_data = set() 147 | for form in valid_forms: 148 | # Get the data for the set of fields that must be unique among the forms. 149 | row_data = ( 150 | field if field in self.unique_fields else form.cleaned_data[field] 151 | for field in unique_check if field in form.cleaned_data 152 | ) 153 | # Reduce Model instances to their primary key values 154 | row_data = tuple(d._get_pk_val() if hasattr(d, '_get_pk_val') else d 155 | for d in row_data) 156 | if row_data and None not in row_data: 157 | # if we've already seen it then we have a uniqueness failure 158 | if row_data in seen_data: 159 | # poke error messages into the right places and mark 160 | # the form as invalid 161 | errors.append(self.get_unique_error_message(unique_check)) 162 | form._errors[NON_FIELD_ERRORS] = self.error_class([self.get_form_error()]) 163 | # remove the data from the cleaned_data dict since it was invalid 164 | for field in unique_check: 165 | if field in form.cleaned_data: 166 | del form.cleaned_data[field] 167 | # mark the data as seen 168 | seen_data.add(row_data) 169 | 170 | if errors: 171 | raise ValidationError(errors) 172 | 173 | 174 | def childformset_factory( 175 | parent_model, model, form=ModelForm, 176 | formset=BaseChildFormSet, fk_name=None, fields=None, exclude=None, 177 | extra=3, can_order=False, can_delete=True, max_num=None, validate_max=False, 178 | formfield_callback=None, widgets=None, min_num=None, validate_min=False, 179 | inherit_kwargs=None, formsets=None, exclude_formsets=None 180 | ): 181 | 182 | fk = _get_foreign_key(parent_model, model, fk_name=fk_name) 183 | # enforce a max_num=1 when the foreign key to the parent model is unique. 184 | if fk.unique: 185 | max_num = 1 186 | validate_max = True 187 | 188 | if exclude is None: 189 | exclude = [] 190 | exclude += [fk.name] 191 | 192 | if issubclass(form, ClusterForm) and (formsets is not None or exclude_formsets is not None): 193 | # the modelformset_factory helper that we ultimately hand off to doesn't recognise 194 | # formsets / exclude_formsets, so we need to prepare a specific subclass of our `form` 195 | # class, with these pre-embedded in Meta, to use as the base form 196 | 197 | # If parent form class already has an inner Meta, the Meta we're 198 | # creating needs to inherit from the parent's inner meta. 199 | bases = (form.Meta,) if hasattr(form, "Meta") else () 200 | Meta = type("Meta", bases, { 201 | 'formsets': formsets, 202 | 'exclude_formsets': exclude_formsets, 203 | }) 204 | 205 | # Instantiate type(form) in order to use the same metaclass as form. 206 | form = type(form)("_ClusterForm", (form,), {"Meta": Meta}) 207 | 208 | kwargs = { 209 | 'form': form, 210 | 'formfield_callback': formfield_callback, 211 | 'formset': formset, 212 | 'extra': extra, 213 | 'can_delete': can_delete, 214 | # if the model supplies a sort_order_field, enable ordering regardless of 215 | # the current setting of can_order 216 | 'can_order': (can_order or hasattr(model, 'sort_order_field')), 217 | 'fields': fields, 218 | 'exclude': exclude, 219 | 'max_num': max_num, 220 | 'validate_max': validate_max, 221 | 'widgets': widgets, 222 | 'min_num': min_num, 223 | 'validate_min': validate_min, 224 | } 225 | FormSet = transientmodelformset_factory(model, **kwargs) 226 | FormSet.fk = fk 227 | 228 | # A list of keyword argument names that should be passed on from ClusterForm's constructor 229 | # to child forms in this formset 230 | FormSet.inherit_kwargs = inherit_kwargs 231 | 232 | return FormSet 233 | 234 | 235 | class ClusterFormOptions(ModelFormOptions): 236 | def __init__(self, options=None): 237 | super().__init__(options=options) 238 | self.formsets = getattr(options, 'formsets', None) 239 | self.exclude_formsets = getattr(options, 'exclude_formsets', None) 240 | 241 | 242 | class ClusterFormMetaclass(ModelFormMetaclass): 243 | extra_form_count = 3 244 | 245 | @classmethod 246 | def child_form(cls): 247 | return ClusterForm 248 | 249 | def __new__(cls, name, bases, attrs): 250 | try: 251 | parents = [b for b in bases if issubclass(b, ClusterForm)] 252 | except NameError: 253 | # We are defining ClusterForm itself. 254 | parents = None 255 | 256 | # grab any formfield_callback that happens to be defined in attrs - 257 | # so that we can pass it on to child formsets - before ModelFormMetaclass deletes it. 258 | # BAD METACLASS NO BISCUIT. 259 | formfield_callback = attrs.get('formfield_callback') 260 | 261 | new_class = super().__new__(cls, name, bases, attrs) 262 | if not parents: 263 | return new_class 264 | 265 | # ModelFormMetaclass will have set up new_class._meta as a ModelFormOptions instance; 266 | # replace that with ClusterFormOptions so that we can access _meta.formsets 267 | opts = new_class._meta = ClusterFormOptions(getattr(new_class, 'Meta', None)) 268 | if opts.model: 269 | formsets = {} 270 | 271 | for rel in get_all_child_relations(opts.model): 272 | # to build a childformset class from this relation, we need to specify: 273 | # - the base model (opts.model) 274 | # - the child model (rel.field.model) 275 | # - the fk_name from the child model to the base (rel.field.name) 276 | 277 | rel_name = rel.get_accessor_name() 278 | 279 | # apply 'formsets' and 'exclude_formsets' rules from meta 280 | if opts.exclude_formsets is not None and rel_name in opts.exclude_formsets: 281 | # formset is explicitly excluded 282 | continue 283 | elif opts.formsets is not None and rel_name not in opts.formsets: 284 | # a formset list has been specified and this isn't on it 285 | continue 286 | elif opts.formsets is None and opts.exclude_formsets is None: 287 | # neither formsets nor exclude_formsets has been specified - no formsets at all 288 | continue 289 | 290 | try: 291 | widgets = opts.widgets.get(rel_name) 292 | except AttributeError: # thrown if opts.widgets is None 293 | widgets = None 294 | 295 | kwargs = { 296 | 'extra': cls.extra_form_count, 297 | 'form': cls.child_form(), 298 | 'formfield_callback': formfield_callback, 299 | 'fk_name': rel.field.name, 300 | 'widgets': widgets, 301 | 'formset_name': rel_name 302 | } 303 | 304 | # see if opts.formsets looks like a dict; if so, allow the value 305 | # to override kwargs 306 | try: 307 | kwargs.update(opts.formsets.get(rel_name)) 308 | except AttributeError: 309 | pass 310 | 311 | formset_name = kwargs.pop('formset_name') 312 | formset = childformset_factory(opts.model, rel.field.model, **kwargs) 313 | formsets[formset_name] = formset 314 | 315 | new_class.formsets = formsets 316 | 317 | return new_class 318 | 319 | 320 | class ClusterForm(ModelForm, metaclass=ClusterFormMetaclass): 321 | def __init__(self, data=None, files=None, instance=None, prefix=None, **kwargs): 322 | super().__init__(data, files, instance=instance, prefix=prefix, **kwargs) 323 | 324 | self.formsets = {} 325 | for rel_name, formset_class in self.__class__.formsets.items(): 326 | if prefix: 327 | formset_prefix = "%s-%s" % (prefix, rel_name) 328 | else: 329 | formset_prefix = rel_name 330 | 331 | child_form_kwargs = {} 332 | if formset_class.inherit_kwargs: 333 | for kwarg_name in formset_class.inherit_kwargs: 334 | child_form_kwargs[kwarg_name] = getattr(self, kwarg_name, None) 335 | 336 | self.formsets[rel_name] = formset_class( 337 | data, files, instance=instance, prefix=formset_prefix, form_kwargs=child_form_kwargs 338 | ) 339 | 340 | def as_p(self): 341 | form_as_p = super().as_p() 342 | return form_as_p + format_html_join('', '{}', [(formset.as_p(),) for formset in self.formsets.values()]) 343 | 344 | def is_valid(self): 345 | form_is_valid = super().is_valid() 346 | formsets_are_valid = all(formset.is_valid() for formset in self.formsets.values()) 347 | return form_is_valid and formsets_are_valid 348 | 349 | def is_multipart(self): 350 | return ( 351 | super().is_multipart() 352 | or any(formset.is_multipart() for formset in self.formsets.values()) 353 | ) 354 | 355 | @property 356 | def media(self): 357 | media = super().media 358 | for formset in self.formsets.values(): 359 | media = media + formset.media 360 | return media 361 | 362 | def save(self, commit=True): 363 | # do we have any fields that expect us to call save_m2m immediately? 364 | save_m2m_now = False 365 | exclude = self._meta.exclude 366 | fields = self._meta.fields 367 | 368 | for f in self.instance._meta.get_fields(): 369 | if fields and f.name not in fields: 370 | continue 371 | if exclude and f.name in exclude: 372 | continue 373 | if getattr(f, '_need_commit_after_assignment', False): 374 | save_m2m_now = True 375 | break 376 | 377 | instance = super().save(commit=(commit and not save_m2m_now)) 378 | 379 | # The M2M-like fields designed for use with ClusterForm (currently 380 | # ParentalManyToManyField and ClusterTaggableManager) will manage their own in-memory 381 | # relations, and not immediately write to the database when we assign to them. 382 | # For these fields (identified by the _need_commit_after_assignment 383 | # flag), save_m2m() is a safe operation that does not affect the database and is thus 384 | # valid for commit=False. In the commit=True case, committing to the database happens 385 | # in the subsequent instance.save (so this needs to happen after save_m2m to ensure 386 | # we have the updated relation data in place). 387 | 388 | # For annoying legacy reasons we sometimes need to accommodate 'classic' M2M fields 389 | # (particularly taggit.TaggableManager) within ClusterForm. These fields 390 | # generally do require our instance to exist in the database at the point we call 391 | # save_m2m() - for this reason, we only proceed with the customisation described above 392 | # (i.e. postpone the instance.save() operation until after save_m2m) if there's a 393 | # _need_commit_after_assignment field on the form that demands it. 394 | 395 | if save_m2m_now: 396 | self.save_m2m() 397 | 398 | if commit: 399 | instance.save() 400 | 401 | for formset in self.formsets.values(): 402 | formset.instance = instance 403 | formset.save(commit=commit) 404 | return instance 405 | 406 | def has_changed(self): 407 | """Return True if data differs from initial.""" 408 | 409 | # Need to recurse over nested formsets so that the form is saved if there are changes 410 | # to child forms but not the parent 411 | if self.formsets: 412 | for formset in self.formsets.values(): 413 | for form in formset.forms: 414 | if form.has_changed(): 415 | return True 416 | return bool(self.changed_data) 417 | 418 | 419 | def clusterform_factory(model, form=ClusterForm, **kwargs): 420 | # Same as Django's modelform_factory, but arbitrary kwargs are accepted and passed on to the 421 | # Meta class. 422 | 423 | # Build up a list of attributes that the Meta object will have. 424 | meta_class_attrs = kwargs 425 | meta_class_attrs["model"] = model 426 | 427 | # If parent form class already has an inner Meta, the Meta we're 428 | # creating needs to inherit from the parent's inner meta. 429 | bases = (form.Meta,) if hasattr(form, "Meta") else () 430 | Meta = type("Meta", bases, meta_class_attrs) 431 | formfield_callback = meta_class_attrs.get('formfield_callback') 432 | if formfield_callback: 433 | Meta.formfield_callback = staticmethod(formfield_callback) 434 | # Give this new form class a reasonable name. 435 | class_name = model.__name__ + "Form" 436 | 437 | # Class attributes for the new form class. 438 | form_class_attrs = {"Meta": Meta, "formfield_callback": formfield_callback} 439 | 440 | # Instantiate type(form) in order to use the same metaclass as form. 441 | return type(form)(class_name, (form,), form_class_attrs) 442 | -------------------------------------------------------------------------------- /modelcluster/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | import datetime 5 | 6 | from django.core.exceptions import FieldDoesNotExist 7 | from django.db import models, transaction 8 | from django.db.models.fields.related import ForeignObjectRel 9 | from django.utils.encoding import is_protected_type 10 | from django.core.serializers.json import DjangoJSONEncoder 11 | from django.conf import settings 12 | from django.utils import timezone 13 | 14 | from modelcluster.fields import ParentalKey, ParentalManyToManyField 15 | 16 | 17 | def get_field_value(field, model): 18 | if field.remote_field is None: 19 | value = field.pre_save(model, add=model.pk is None) 20 | 21 | # Make datetimes timezone aware 22 | # https://github.com/django/django/blob/master/django/db/models/fields/__init__.py#L1394-L1403 23 | if isinstance(value, datetime.datetime) and settings.USE_TZ: 24 | if timezone.is_naive(value): 25 | default_timezone = timezone.get_default_timezone() 26 | value = timezone.make_aware(value, default_timezone).astimezone(datetime.timezone.utc) 27 | else: 28 | # convert to UTC 29 | value = timezone.localtime(value, datetime.timezone.utc) 30 | 31 | if is_protected_type(value): 32 | return value 33 | else: 34 | return field.value_to_string(model) 35 | else: 36 | return getattr(model, field.get_attname()) 37 | 38 | 39 | def get_serializable_data_for_fields(model): 40 | """ 41 | Return a serialised version of the model's fields which exist as local database 42 | columns (i.e. excluding m2m and incoming foreign key relations) 43 | """ 44 | pk_field = model._meta.pk 45 | # If model is a child via multitable inheritance, use parent's pk 46 | while pk_field.remote_field and pk_field.remote_field.parent_link: 47 | pk_field = pk_field.remote_field.model._meta.pk 48 | 49 | obj = {'pk': get_field_value(pk_field, model)} 50 | 51 | for field in model._meta.fields: 52 | if field.serialize: 53 | obj[field.name] = get_field_value(field, model) 54 | 55 | return obj 56 | 57 | 58 | def model_from_serializable_data(model, data, check_fks=True, strict_fks=False): 59 | pk_field = model._meta.pk 60 | kwargs = {} 61 | 62 | # If model is a child via multitable inheritance, we need to set ptr_id fields all the way up 63 | # to the main PK field, as Django won't populate these for us automatically. 64 | while pk_field.remote_field and pk_field.remote_field.parent_link: 65 | kwargs[pk_field.attname] = data['pk'] 66 | pk_field = pk_field.remote_field.model._meta.pk 67 | 68 | kwargs[pk_field.attname] = data['pk'] 69 | 70 | for field_name, field_value in data.items(): 71 | try: 72 | field = model._meta.get_field(field_name) 73 | except FieldDoesNotExist: 74 | continue 75 | 76 | # Filter out reverse relations 77 | if isinstance(field, ForeignObjectRel): 78 | continue 79 | 80 | if field.remote_field and isinstance(field.remote_field, models.ManyToManyRel): 81 | related_objects = field.remote_field.model._default_manager.filter(pk__in=field_value) 82 | kwargs[field.attname] = list(related_objects) 83 | 84 | elif field.remote_field and isinstance(field.remote_field, models.ManyToOneRel): 85 | if field_value is None: 86 | kwargs[field.attname] = None 87 | else: 88 | clean_value = field.remote_field.model._meta.get_field(field.remote_field.field_name).to_python(field_value) 89 | kwargs[field.attname] = clean_value 90 | if check_fks: 91 | try: 92 | field.remote_field.model._default_manager.get(**{field.remote_field.field_name: clean_value}) 93 | except field.remote_field.model.DoesNotExist: 94 | if field.remote_field.on_delete == models.DO_NOTHING: 95 | pass 96 | elif field.remote_field.on_delete == models.CASCADE: 97 | if strict_fks: 98 | return None 99 | else: 100 | kwargs[field.attname] = None 101 | 102 | elif field.remote_field.on_delete == models.SET_NULL: 103 | kwargs[field.attname] = None 104 | 105 | else: 106 | raise Exception("can't currently handle on_delete types other than CASCADE, SET_NULL and DO_NOTHING") 107 | else: 108 | value = field.to_python(field_value) 109 | 110 | # Make sure datetimes are converted to localtime 111 | if isinstance(field, models.DateTimeField) and settings.USE_TZ and value is not None: 112 | default_timezone = timezone.get_default_timezone() 113 | if timezone.is_aware(value): 114 | value = timezone.localtime(value, default_timezone) 115 | else: 116 | value = timezone.make_aware(value, default_timezone) 117 | 118 | kwargs[field.name] = value 119 | 120 | obj = model(**kwargs) 121 | 122 | if data['pk'] is not None: 123 | # Set state to indicate that this object has come from the database, so that 124 | # ModelForm validation doesn't try to enforce a uniqueness check on the primary key 125 | obj._state.adding = False 126 | 127 | return obj 128 | 129 | 130 | def get_all_child_relations(model): 131 | """ 132 | Return a list of RelatedObject records for child relations of the given model, 133 | including ones attached to ancestors of the model 134 | """ 135 | return [ 136 | field for field in model._meta.get_fields() 137 | if isinstance(field.remote_field, ParentalKey) 138 | ] 139 | 140 | 141 | def get_all_child_m2m_relations(model): 142 | """ 143 | Return a list of ParentalManyToManyFields on the given model, 144 | including ones attached to ancestors of the model 145 | """ 146 | return [ 147 | field for field in model._meta.get_fields() 148 | if isinstance(field, ParentalManyToManyField) 149 | ] 150 | 151 | 152 | class ClusterableModel(models.Model): 153 | def __init__(self, *args, **kwargs): 154 | """ 155 | Extend the standard model constructor to allow child object lists to be passed in 156 | via kwargs 157 | """ 158 | child_relation_names = ( 159 | [rel.get_accessor_name() for rel in get_all_child_relations(self)] + 160 | [field.name for field in get_all_child_m2m_relations(self)] 161 | ) 162 | 163 | if any(name in kwargs for name in child_relation_names): 164 | # One or more child relation values is being passed in the constructor; need to 165 | # separate these from the standard field kwargs to be passed to 'super' 166 | kwargs_for_super = kwargs.copy() 167 | relation_assignments = {} 168 | for rel_name in child_relation_names: 169 | if rel_name in kwargs: 170 | relation_assignments[rel_name] = kwargs_for_super.pop(rel_name) 171 | 172 | super().__init__(*args, **kwargs_for_super) 173 | for (field_name, related_instances) in relation_assignments.items(): 174 | setattr(self, field_name, related_instances) 175 | else: 176 | super().__init__(*args, **kwargs) 177 | 178 | def save(self, **kwargs): 179 | """ 180 | Save the model and commit all child relations. 181 | """ 182 | child_relation_names = [rel.get_accessor_name() for rel in get_all_child_relations(self)] 183 | child_m2m_field_names = [field.name for field in get_all_child_m2m_relations(self)] 184 | 185 | update_fields = kwargs.pop('update_fields', None) 186 | if update_fields is None: 187 | real_update_fields = None 188 | relations_to_commit = child_relation_names 189 | m2m_fields_to_commit = child_m2m_field_names 190 | else: 191 | real_update_fields = [] 192 | relations_to_commit = [] 193 | m2m_fields_to_commit = [] 194 | for field in update_fields: 195 | if field in child_relation_names: 196 | relations_to_commit.append(field) 197 | elif field in child_m2m_field_names: 198 | m2m_fields_to_commit.append(field) 199 | else: 200 | real_update_fields.append(field) 201 | 202 | super().save(update_fields=real_update_fields, **kwargs) 203 | 204 | for relation in relations_to_commit: 205 | getattr(self, relation).commit() 206 | 207 | for field in m2m_fields_to_commit: 208 | getattr(self, field).commit() 209 | 210 | def serializable_data(self): 211 | obj = get_serializable_data_for_fields(self) 212 | 213 | for rel in get_all_child_relations(self): 214 | rel_name = rel.get_accessor_name() 215 | children = getattr(self, rel_name).all() 216 | 217 | if hasattr(rel.related_model, 'serializable_data'): 218 | obj[rel_name] = [child.serializable_data() for child in children] 219 | else: 220 | obj[rel_name] = [get_serializable_data_for_fields(child) for child in children] 221 | 222 | for field in get_all_child_m2m_relations(self): 223 | if field.serialize: 224 | children = getattr(self, field.name).all() 225 | obj[field.name] = [child.pk for child in children] 226 | 227 | return obj 228 | 229 | def to_json(self): 230 | return json.dumps(self.serializable_data(), cls=DjangoJSONEncoder) 231 | 232 | @classmethod 233 | def from_serializable_data(cls, data, check_fks=True, strict_fks=False): 234 | """ 235 | Build an instance of this model from the JSON-like structure passed in, 236 | recursing into related objects as required. 237 | If check_fks is true, it will check whether referenced foreign keys still 238 | exist in the database. 239 | - dangling foreign keys on related objects are dealt with by either nullifying the key or 240 | dropping the related object, according to the 'on_delete' setting. 241 | - dangling foreign keys on the base object will be nullified, unless strict_fks is true, 242 | in which case any dangling foreign keys with on_delete=CASCADE will cause None to be 243 | returned for the entire object. 244 | """ 245 | obj = model_from_serializable_data(cls, data, check_fks=check_fks, strict_fks=strict_fks) 246 | if obj is None: 247 | return None 248 | 249 | child_relations = get_all_child_relations(cls) 250 | 251 | for rel in child_relations: 252 | rel_name = rel.get_accessor_name() 253 | try: 254 | child_data_list = data[rel_name] 255 | except KeyError: 256 | continue 257 | 258 | related_model = rel.related_model 259 | if hasattr(related_model, 'from_serializable_data'): 260 | children = [ 261 | related_model.from_serializable_data(child_data, check_fks=check_fks, strict_fks=True) 262 | for child_data in child_data_list 263 | ] 264 | else: 265 | children = [ 266 | model_from_serializable_data(related_model, child_data, check_fks=check_fks, strict_fks=True) 267 | for child_data in child_data_list 268 | ] 269 | 270 | children = filter(lambda child: child is not None, children) 271 | 272 | setattr(obj, rel_name, children) 273 | 274 | return obj 275 | 276 | @classmethod 277 | def from_json(cls, json_data, check_fks=True, strict_fks=False): 278 | return cls.from_serializable_data(json.loads(json_data), check_fks=check_fks, strict_fks=strict_fks) 279 | 280 | @transaction.atomic 281 | def copy_child_relation(self, child_relation, target, commit=False, append=False): 282 | """ 283 | Copies all of the objects in the accessor_name to the target object. 284 | 285 | For example, say we have an event with speakers (my_event) and we need to copy these to another event (my_other_event): 286 | 287 | my_event.copy_child_relation('speakers', my_other_event) 288 | 289 | By default, this copies the child objects without saving them. Set the commit paremter to True to save the objects 290 | but note that this would cause an exception if the target object is not saved. 291 | 292 | This will overwrite the child relation on the target object. This is to avoid any issues with unique keys 293 | and/or sort_order. If you want it to append. set the `append` parameter to True. 294 | 295 | This method returns a dictionary mapping the child relation/primary key on the source object to the new object created for the 296 | target object. 297 | """ 298 | # A dict that maps child objects from their old IDs to their new objects 299 | child_object_map = {} 300 | 301 | if isinstance(child_relation, str): 302 | child_relation = self._meta.get_field(child_relation) 303 | 304 | if not isinstance(child_relation.remote_field, ParentalKey): 305 | raise LookupError("copy_child_relation can only be used for relationships defined with a ParentalKey") 306 | 307 | # The name of the ParentalKey field on the child model 308 | parental_key_name = child_relation.field.attname 309 | 310 | # Get managers for both the source and target objects 311 | source_manager = getattr(self, child_relation.get_accessor_name()) 312 | target_manager = getattr(target, child_relation.get_accessor_name()) 313 | 314 | if not append: 315 | target_manager.clear() 316 | 317 | for child_object in source_manager.all().order_by('pk'): 318 | old_pk = child_object.pk 319 | is_saved = old_pk is not None 320 | child_object.pk = None 321 | setattr(child_object, parental_key_name, target.id) 322 | target_manager.add(child_object) 323 | 324 | # Add mapping to object 325 | # If the PK is none, add them into a list since there may be multiple of these 326 | if old_pk is not None: 327 | child_object_map[(child_relation, old_pk)] = child_object 328 | else: 329 | if (child_relation, None) not in child_object_map: 330 | child_object_map[(child_relation, None)] = [] 331 | 332 | child_object_map[(child_relation, None)].append(child_object) 333 | 334 | if commit: 335 | target_manager.commit() 336 | 337 | return child_object_map 338 | 339 | def copy_all_child_relations(self, target, exclude=None, commit=False, append=False): 340 | """ 341 | Copies all of the objects in all child relations to the target object. 342 | 343 | This will overwrite all of the child relations on the target object. 344 | 345 | Set exclude to a list of child relation accessor names that shouldn't be copied. 346 | 347 | This method returns a dictionary mapping the child_relation/primary key on the source object to the new object created for the 348 | target object. 349 | """ 350 | exclude = exclude or [] 351 | child_object_map = {} 352 | 353 | for child_relation in get_all_child_relations(self): 354 | if child_relation.get_accessor_name() in exclude: 355 | continue 356 | 357 | child_object_map.update(self.copy_child_relation(child_relation, target, commit=commit, append=append)) 358 | 359 | return child_object_map 360 | 361 | def copy_cluster(self, exclude_fields=None): 362 | """ 363 | Makes a copy of this object and all child relations. 364 | 365 | Includes all field data including child relations and parental many to many fields. 366 | 367 | Doesn't include non-parental many to many. 368 | 369 | The result of this method is unsaved. 370 | """ 371 | exclude_fields = exclude_fields or [] 372 | 373 | # Extract field data from self into a dictionary 374 | data_dict = {} 375 | for field in self._meta.get_fields(): 376 | # Ignore explicitly excluded fields 377 | if field.name in exclude_fields: 378 | continue 379 | 380 | # Ignore reverse relations 381 | if field.auto_created: 382 | continue 383 | 384 | # Copy parental m2m relations 385 | # Otherwise add them to the m2m dict to be set after saving 386 | if field.many_to_many: 387 | if isinstance(field, ParentalManyToManyField): 388 | parental_field = getattr(self, field.name) 389 | if hasattr(parental_field, 'all'): 390 | values = parental_field.all() 391 | if values: 392 | data_dict[field.name] = values 393 | continue 394 | 395 | # Ignore parent links (page_ptr) 396 | if isinstance(field, models.OneToOneField) and field.remote_field.parent_link: 397 | continue 398 | 399 | if isinstance(field, models.ForeignKey): 400 | # Use attname to copy the ID instead of retrieving the instance 401 | 402 | # Note: We first need to set the field to None to unset any object 403 | # that's there already just setting _id on its own won't change the 404 | # field until its saved. 405 | 406 | data_dict[field.name] = None 407 | data_dict[field.attname] = getattr(self, field.attname) 408 | 409 | else: 410 | data_dict[field.name] = getattr(self, field.name) 411 | 412 | # Create copy 413 | copy = self.__class__(**data_dict) 414 | 415 | # Copy child relations 416 | child_object_map = self.copy_all_child_relations(copy, exclude=exclude_fields) 417 | 418 | return copy, child_object_map 419 | 420 | class Meta: 421 | abstract = True 422 | -------------------------------------------------------------------------------- /modelcluster/queryset.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import re 4 | 5 | from django.core.exceptions import FieldDoesNotExist 6 | from django.db.models import Model, Q, prefetch_related_objects 7 | 8 | from modelcluster.utils import NullRelationshipValueEncountered, extract_field_value, get_model_field, sort_by_fields 9 | 10 | 11 | # Constructor for test functions that determine whether an object passes some boolean condition 12 | def test_exact(model, attribute_name, value): 13 | if isinstance(value, Model): 14 | if value.pk is None: 15 | # comparing against an unsaved model, so objects need to match by reference 16 | def _test(obj): 17 | try: 18 | other_value = extract_field_value(obj, attribute_name) 19 | except NullRelationshipValueEncountered: 20 | return False 21 | return other_value is value 22 | 23 | return _test 24 | 25 | else: 26 | # comparing against a saved model; objects need to match by type and ID. 27 | # Additionally, where model inheritance is involved, we need to treat it as a 28 | # positive match if one is a subclass of the other 29 | def _test(obj): 30 | try: 31 | other_value = extract_field_value(obj, attribute_name) 32 | except NullRelationshipValueEncountered: 33 | return False 34 | return value.pk == other_value.pk and ( 35 | isinstance(value, other_value.__class__) 36 | or isinstance(other_value, value.__class__) 37 | ) 38 | 39 | return _test 40 | else: 41 | field = get_model_field(model, attribute_name) 42 | # convert value to the correct python type for this field 43 | typed_value = field.to_python(value) 44 | 45 | # just a plain Python value = do a normal equality check 46 | def _test(obj): 47 | try: 48 | other_value = extract_field_value(obj, attribute_name) 49 | except NullRelationshipValueEncountered: 50 | return False 51 | return other_value == typed_value 52 | 53 | return _test 54 | 55 | 56 | def test_iexact(model, attribute_name, match_value): 57 | field = get_model_field(model, attribute_name) 58 | match_value = field.to_python(match_value) 59 | 60 | if match_value is None: 61 | 62 | def _test(obj): 63 | try: 64 | val = extract_field_value(obj, attribute_name) 65 | except NullRelationshipValueEncountered: 66 | return False 67 | return val is None 68 | else: 69 | match_value = match_value.upper() 70 | 71 | def _test(obj): 72 | try: 73 | val = extract_field_value(obj, attribute_name) 74 | except NullRelationshipValueEncountered: 75 | return False 76 | return val is not None and val.upper() == match_value 77 | 78 | return _test 79 | 80 | 81 | def test_contains(model, attribute_name, value): 82 | field = get_model_field(model, attribute_name) 83 | match_value = field.to_python(value) 84 | 85 | def _test(obj): 86 | try: 87 | val = extract_field_value(obj, attribute_name) 88 | except NullRelationshipValueEncountered: 89 | return False 90 | return val is not None and match_value in val 91 | 92 | return _test 93 | 94 | 95 | def test_icontains(model, attribute_name, value): 96 | field = get_model_field(model, attribute_name) 97 | match_value = field.to_python(value).upper() 98 | 99 | def _test(obj): 100 | try: 101 | val = extract_field_value(obj, attribute_name) 102 | except NullRelationshipValueEncountered: 103 | return False 104 | return val is not None and match_value in val.upper() 105 | 106 | return _test 107 | 108 | 109 | def test_lt(model, attribute_name, value): 110 | field = get_model_field(model, attribute_name) 111 | match_value = field.to_python(value) 112 | 113 | def _test(obj): 114 | try: 115 | val = extract_field_value(obj, attribute_name) 116 | except NullRelationshipValueEncountered: 117 | return False 118 | return val is not None and val < match_value 119 | 120 | return _test 121 | 122 | 123 | def test_lte(model, attribute_name, value): 124 | field = get_model_field(model, attribute_name) 125 | match_value = field.to_python(value) 126 | 127 | def _test(obj): 128 | try: 129 | val = extract_field_value(obj, attribute_name) 130 | except NullRelationshipValueEncountered: 131 | return False 132 | return val is not None and val <= match_value 133 | 134 | return _test 135 | 136 | 137 | def test_gt(model, attribute_name, value): 138 | field = get_model_field(model, attribute_name) 139 | match_value = field.to_python(value) 140 | 141 | def _test(obj): 142 | try: 143 | val = extract_field_value(obj, attribute_name) 144 | except NullRelationshipValueEncountered: 145 | return False 146 | return val is not None and val > match_value 147 | 148 | return _test 149 | 150 | 151 | def test_gte(model, attribute_name, value): 152 | field = get_model_field(model, attribute_name) 153 | match_value = field.to_python(value) 154 | 155 | def _test(obj): 156 | try: 157 | val = extract_field_value(obj, attribute_name) 158 | except NullRelationshipValueEncountered: 159 | return False 160 | return val is not None and val >= match_value 161 | 162 | return _test 163 | 164 | 165 | def test_in(model, attribute_name, value_list): 166 | field = get_model_field(model, attribute_name) 167 | match_values = set(field.to_python(val) for val in value_list) 168 | 169 | def _test(obj): 170 | try: 171 | val = extract_field_value(obj, attribute_name) 172 | except NullRelationshipValueEncountered: 173 | return False 174 | return val in match_values 175 | 176 | return _test 177 | 178 | 179 | def test_startswith(model, attribute_name, value): 180 | field = get_model_field(model, attribute_name) 181 | match_value = field.to_python(value) 182 | 183 | def _test(obj): 184 | try: 185 | val = extract_field_value(obj, attribute_name) 186 | except NullRelationshipValueEncountered: 187 | return False 188 | return val is not None and val.startswith(match_value) 189 | 190 | return _test 191 | 192 | 193 | def test_istartswith(model, attribute_name, value): 194 | field = get_model_field(model, attribute_name) 195 | match_value = field.to_python(value).upper() 196 | 197 | def _test(obj): 198 | try: 199 | val = extract_field_value(obj, attribute_name) 200 | except NullRelationshipValueEncountered: 201 | return False 202 | return val is not None and val.upper().startswith(match_value) 203 | 204 | return _test 205 | 206 | 207 | def test_endswith(model, attribute_name, value): 208 | field = get_model_field(model, attribute_name) 209 | match_value = field.to_python(value) 210 | 211 | def _test(obj): 212 | try: 213 | val = extract_field_value(obj, attribute_name) 214 | except NullRelationshipValueEncountered: 215 | return False 216 | return val is not None and val.endswith(match_value) 217 | 218 | return _test 219 | 220 | 221 | def test_iendswith(model, attribute_name, value): 222 | field = get_model_field(model, attribute_name) 223 | match_value = field.to_python(value).upper() 224 | 225 | def _test(obj): 226 | try: 227 | val = extract_field_value(obj, attribute_name) 228 | except NullRelationshipValueEncountered: 229 | return False 230 | return val is not None and val.upper().endswith(match_value) 231 | 232 | return _test 233 | 234 | 235 | def test_range(model, attribute_name, range_val): 236 | field = get_model_field(model, attribute_name) 237 | start_val = field.to_python(range_val[0]) 238 | end_val = field.to_python(range_val[1]) 239 | 240 | def _test(obj): 241 | try: 242 | val = extract_field_value(obj, attribute_name) 243 | except NullRelationshipValueEncountered: 244 | return False 245 | return (val is not None and val >= start_val and val <= end_val) 246 | 247 | return _test 248 | 249 | 250 | def test_isnull(model, attribute_name, sense): 251 | def _test(obj): 252 | try: 253 | val = extract_field_value(obj, attribute_name) 254 | except NullRelationshipValueEncountered: 255 | return False 256 | if sense: 257 | return val is None 258 | else: 259 | return val is not None 260 | 261 | return _test 262 | 263 | 264 | def test_regex(model, attribute_name, regex_string): 265 | regex = re.compile(regex_string) 266 | 267 | def _test(obj): 268 | try: 269 | val = extract_field_value(obj, attribute_name) 270 | except NullRelationshipValueEncountered: 271 | return False 272 | return val is not None and regex.search(val) 273 | 274 | return _test 275 | 276 | 277 | def test_iregex(model, attribute_name, regex_string): 278 | regex = re.compile(regex_string, re.I) 279 | 280 | def _test(obj): 281 | try: 282 | val = extract_field_value(obj, attribute_name) 283 | except NullRelationshipValueEncountered: 284 | return False 285 | return val is not None and regex.search(val) 286 | 287 | return _test 288 | 289 | 290 | FILTER_EXPRESSION_TOKENS = { 291 | 'exact': test_exact, 292 | 'iexact': test_iexact, 293 | 'contains': test_contains, 294 | 'icontains': test_icontains, 295 | 'lt': test_lt, 296 | 'lte': test_lte, 297 | 'gt': test_gt, 298 | 'gte': test_gte, 299 | 'in': test_in, 300 | 'startswith': test_startswith, 301 | 'istartswith': test_istartswith, 302 | 'endswith': test_endswith, 303 | 'iendswith': test_iendswith, 304 | 'range': test_range, 305 | 'isnull': test_isnull, 306 | 'regex': test_regex, 307 | 'iregex': test_iregex, 308 | } 309 | 310 | 311 | def _build_test_function_from_filter(model, key_clauses, val): 312 | # Translate a filter kwarg rule (e.g. foo__bar__exact=123) into a function which can 313 | # take a model instance and return a boolean indicating whether it passes the rule 314 | try: 315 | get_model_field(model, "__".join(key_clauses)) 316 | except FieldDoesNotExist: 317 | # it is safe to assume the last clause indicates the type of test 318 | field_match_found = False 319 | else: 320 | field_match_found = True 321 | 322 | if not field_match_found and key_clauses[-1] in FILTER_EXPRESSION_TOKENS: 323 | constructor = FILTER_EXPRESSION_TOKENS[key_clauses.pop()] 324 | else: 325 | constructor = test_exact 326 | # recombine the remaining items to be interpretted 327 | # by get_model_field() and extract_field_value() 328 | attribute_name = "__".join(key_clauses) 329 | return constructor(model, attribute_name, val) 330 | 331 | 332 | class FakeQuerySetIterable: 333 | def __init__(self, queryset): 334 | self.queryset = queryset 335 | 336 | 337 | class ModelIterable(FakeQuerySetIterable): 338 | def __iter__(self): 339 | yield from self.queryset.results 340 | 341 | 342 | class DictIterable(FakeQuerySetIterable): 343 | def __iter__(self): 344 | field_names = self.queryset.dict_fields or [field.name for field in self.queryset.model._meta.fields] 345 | for obj in self.queryset.results: 346 | yield { 347 | field_name: extract_field_value(obj, field_name, pk_only=True, suppress_fielddoesnotexist=True, suppress_nullrelationshipvalueencountered=True) 348 | for field_name in field_names 349 | } 350 | 351 | 352 | class ValuesListIterable(FakeQuerySetIterable): 353 | def __iter__(self): 354 | field_names = self.queryset.tuple_fields or [field.name for field in self.queryset.model._meta.fields] 355 | for obj in self.queryset.results: 356 | yield tuple([extract_field_value(obj, field_name, pk_only=True, suppress_fielddoesnotexist=True, suppress_nullrelationshipvalueencountered=True) for field_name in field_names]) 357 | 358 | 359 | class FlatValuesListIterable(FakeQuerySetIterable): 360 | def __iter__(self): 361 | field_name = self.queryset.tuple_fields[0] 362 | for obj in self.queryset.results: 363 | yield extract_field_value(obj, field_name, pk_only=True, suppress_fielddoesnotexist=True, suppress_nullrelationshipvalueencountered=True) 364 | 365 | 366 | class FakeQuerySet(object): 367 | def __init__(self, model, results): 368 | self.model = model 369 | self.results = results 370 | self.dict_fields = [] 371 | self.tuple_fields = [] 372 | self.iterable_class = ModelIterable 373 | 374 | def all(self): 375 | return self 376 | 377 | def get_clone(self, results = None): 378 | new = FakeQuerySet(self.model, results if results is not None else self.results) 379 | new.dict_fields = self.dict_fields 380 | new.tuple_fields = self.tuple_fields 381 | new.iterable_class = self.iterable_class 382 | return new 383 | 384 | def resolve_q_object(self, q_object): 385 | connector = q_object.connector 386 | filters = [] 387 | 388 | def test(filters): 389 | def test_inner(obj): 390 | result = False 391 | if connector == Q.AND: 392 | result = all([test(obj) for test in filters]) 393 | elif connector == Q.OR: 394 | result = any([test(obj) for test in filters]) 395 | else: 396 | result = sum([test(obj) for test in filters]) == 1 397 | if q_object.negated: 398 | return not result 399 | return result 400 | return test_inner 401 | 402 | for child in q_object.children: 403 | if isinstance(child, Q): 404 | filters.append(self.resolve_q_object(child)) 405 | else: 406 | key_clauses, val = child 407 | filters.append(_build_test_function_from_filter(self.model, key_clauses.split('__'), val)) 408 | 409 | return test(filters) 410 | 411 | def _get_filters(self, *args, **kwargs): 412 | # a list of test functions; objects must pass all tests to be included 413 | # in the filtered list 414 | filters = [] 415 | 416 | for q_object in args: 417 | filters.append(self.resolve_q_object(q_object)) 418 | 419 | for key, val in kwargs.items(): 420 | filters.append( 421 | _build_test_function_from_filter(self.model, key.split('__'), val) 422 | ) 423 | 424 | return filters 425 | 426 | def filter(self, *args, **kwargs): 427 | filters = self._get_filters(*args, **kwargs) 428 | 429 | clone = self.get_clone(results=[ 430 | obj for obj in self.results 431 | if all([test(obj) for test in filters]) 432 | ]) 433 | return clone 434 | 435 | def exclude(self, *args, **kwargs): 436 | filters = self._get_filters(*args, **kwargs) 437 | 438 | clone = self.get_clone(results=[ 439 | obj for obj in self.results 440 | if not all([test(obj) for test in filters]) 441 | ]) 442 | return clone 443 | 444 | def get(self, *args, **kwargs): 445 | clone = self.filter(*args, **kwargs) 446 | result_count = clone.count() 447 | 448 | if result_count == 0: 449 | raise self.model.DoesNotExist("%s matching query does not exist." % self.model._meta.object_name) 450 | elif result_count == 1: 451 | for result in clone: 452 | return result 453 | else: 454 | raise self.model.MultipleObjectsReturned( 455 | "get() returned more than one %s -- it returned %s!" % (self.model._meta.object_name, result_count) 456 | ) 457 | 458 | def count(self): 459 | return len(self.results) 460 | 461 | def exists(self): 462 | return bool(self.results) 463 | 464 | def first(self): 465 | for result in self: 466 | return result 467 | 468 | def last(self): 469 | if self.results: 470 | clone = self.get_clone(results=reversed(self.results)) 471 | for result in clone: 472 | return result 473 | 474 | def select_related(self, *args): 475 | # has no meaningful effect on non-db querysets 476 | return self 477 | 478 | def prefetch_related(self, *args): 479 | prefetch_related_objects(self.results, *args) 480 | return self 481 | 482 | def only(self, *args): 483 | # has no meaningful effect on non-db querysets 484 | return self 485 | 486 | def defer(self, *args): 487 | # has no meaningful effect on non-db querysets 488 | return self 489 | 490 | def values(self, *fields): 491 | clone = self.get_clone() 492 | clone.dict_fields = fields 493 | # Ensure all 'fields' are available model fields 494 | for f in fields: 495 | get_model_field(self.model, f) 496 | clone.iterable_class = DictIterable 497 | return clone 498 | 499 | def values_list(self, *fields, flat=None): 500 | clone = self.get_clone() 501 | clone.tuple_fields = fields 502 | # Ensure all 'fields' are available model fields 503 | for f in fields: 504 | get_model_field(self.model, f) 505 | if flat: 506 | if len(fields) > 1: 507 | raise TypeError("'flat' is not valid when values_list is called with more than one field.") 508 | clone.iterable_class = FlatValuesListIterable 509 | else: 510 | clone.iterable_class = ValuesListIterable 511 | return clone 512 | 513 | def order_by(self, *fields): 514 | clone = self.get_clone(results=self.results[:]) 515 | sort_by_fields(clone.results, fields) 516 | return clone 517 | 518 | def distinct(self, *fields): 519 | unique_results = [] 520 | if not fields: 521 | fields = [field.name for field in self.model._meta.fields if not field.primary_key] 522 | seen_keys = set() 523 | for result in self.results: 524 | key = tuple(str(extract_field_value(result, field)) for field in fields) 525 | if key not in seen_keys: 526 | seen_keys.add(key) 527 | unique_results.append(result) 528 | return self.get_clone(results=unique_results) 529 | 530 | # a standard QuerySet will store the results in _result_cache on running the query; 531 | # this is effectively the same as self.results on a FakeQuerySet, and so we'll make 532 | # _result_cache an alias of self.results for the benefit of Django internals that 533 | # exploit it 534 | def _get_result_cache(self): 535 | return self.results 536 | 537 | def _set_result_cache(self, val): 538 | self.results = list(val) 539 | 540 | _result_cache = property(_get_result_cache, _set_result_cache) 541 | 542 | def __getitem__(self, k): 543 | return self.results[k] 544 | 545 | def __iter__(self): 546 | iterator = self.iterable_class(self) 547 | yield from iterator 548 | 549 | def __nonzero__(self): 550 | return bool(self.results) 551 | 552 | def __repr__(self): 553 | return repr(list(self)) 554 | 555 | def __len__(self): 556 | return len(self.results) 557 | 558 | ordered = True # results are returned in a consistent order 559 | -------------------------------------------------------------------------------- /modelcluster/tags.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from modelcluster.contrib.taggit import * # NOQA 4 | 5 | 6 | warnings.warn( 7 | "The modelcluster.tags module has been moved to " 8 | "modelcluster.contrib.taggit", DeprecationWarning) 9 | -------------------------------------------------------------------------------- /modelcluster/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from functools import lru_cache 3 | import random 4 | from django.core.exceptions import FieldDoesNotExist 5 | from django.db.models import ( 6 | DateField, 7 | DateTimeField, 8 | ManyToManyField, 9 | ManyToManyRel, 10 | Model, 11 | TimeField, 12 | ) 13 | 14 | from modelcluster import datetime_utils 15 | 16 | 17 | REL_DELIMETER = "__" 18 | 19 | 20 | class ManyToManyTraversalError(ValueError): 21 | pass 22 | 23 | 24 | class NullRelationshipValueEncountered(Exception): 25 | pass 26 | 27 | 28 | class TraversedRelationship: 29 | __slots__ = ['from_model', 'field'] 30 | 31 | def __init__(self, from_model, field): 32 | self.from_model = from_model 33 | self.field = field 34 | 35 | @property 36 | def field_name(self) -> str: 37 | return self.field.name 38 | 39 | @property 40 | def to_model(self): 41 | return self.field.target_model 42 | 43 | 44 | @lru_cache(maxsize=None) 45 | def get_model_field(model, name): 46 | """ 47 | Returns a model field matching the supplied ``name``, which can include 48 | double-underscores (`'__'`) to indicate relationship traversal - in which 49 | case, the model field will be lookuped up from the related model. 50 | 51 | Multiple traversals for the same field are supported, but at this 52 | moment in time, only traversal of many-to-one and one-to-one relationships 53 | is supported. 54 | 55 | Details of any relationships traversed in order to reach the returned 56 | field are made available as `field.traversals`. The value is a tuple of 57 | ``TraversedRelationship`` instances. 58 | 59 | Raises ``FieldDoesNotExist`` if the name cannot be mapped to a model field. 60 | """ 61 | subject_model = model 62 | traversals = [] 63 | field = None 64 | for field_name in name.split(REL_DELIMETER): 65 | 66 | if field is not None: 67 | if isinstance(field, (ManyToManyField, ManyToManyRel)): 68 | raise ManyToManyTraversalError( 69 | "The lookup '{name}' from {model} cannot be replicated " 70 | "by modelcluster, because the '{field_name}' " 71 | "relationship from {subject_model} is a many-to-many, " 72 | "and traversal is only supported for one-to-one or " 73 | "many-to-one relationships." 74 | .format( 75 | name=name, 76 | model=model, 77 | field_name=field_name, 78 | subject_model=subject_model, 79 | ) 80 | ) 81 | elif getattr(field, "related_model", None): 82 | traversals.append(TraversedRelationship(subject_model, field)) 83 | subject_model = field.related_model 84 | elif ( 85 | ( 86 | isinstance(field, DateTimeField) 87 | and field_name in datetime_utils.DATETIMEFIELD_TRANSFORM_EXPRESSIONS 88 | ) or ( 89 | isinstance(field, DateField) 90 | and field_name in datetime_utils.DATEFIELD_TRANSFORM_EXPRESSIONS 91 | ) or ( 92 | isinstance(field, TimeField) 93 | and field_name in datetime_utils.TIMEFIELD_TRANSFORM_EXPRESSIONS 94 | ) 95 | ): 96 | transform_field_type = datetime_utils.TRANSFORM_FIELD_TYPES[field_name] 97 | field = transform_field_type() 98 | break 99 | else: 100 | raise FieldDoesNotExist( 101 | "Failed attempting to traverse from {from_field} (a {from_field_type}) to '{to_field}'." 102 | .format( 103 | from_field=subject_model._meta.label + '.' + field.name, 104 | from_field_type=type(field), 105 | to_field=field_name, 106 | ) 107 | ) 108 | try: 109 | field = subject_model._meta.get_field(field_name) 110 | except FieldDoesNotExist: 111 | if field_name.endswith("_id"): 112 | field = subject_model._meta.get_field(field_name[:-3]).target_field 113 | raise 114 | 115 | field.traversals = tuple(traversals) 116 | return field 117 | 118 | 119 | def extract_field_value(obj, key, pk_only=False, suppress_fielddoesnotexist=False, suppress_nullrelationshipvalueencountered=False): 120 | """ 121 | Attempts to extract a field value from ``obj`` matching the ``key`` - which, 122 | can contain double-underscores (`'__'`) to indicate traversal of relationships 123 | to related objects. 124 | 125 | For keys that specify ``ForeignKey`` or ``OneToOneField`` field values, full 126 | related objects are returned by default. If only the primary key values are 127 | required ((.g. when ordering, or using ``values()`` or ``values_list()``)), 128 | call the function with ``pk_only=True``. 129 | 130 | By default, ``FieldDoesNotExist`` is raised if the key cannot be mapped to 131 | a model field. Call the function with ``suppress_fielddoesnotexist=True`` 132 | to instead receive a ``None`` value when this occurs. 133 | 134 | By default, ``NullRelationshipValueEncountered`` is raised if a ``None`` 135 | value is encountered while attempting to traverse relationships in order to 136 | access further fields. Call the function with 137 | ``suppress_nullrelationshipvalueencountered`` to instead receive a ``None`` 138 | value when this occurs. 139 | """ 140 | source = obj 141 | latest_obj = obj 142 | segments = key.split(REL_DELIMETER) 143 | for i, segment in enumerate(segments, start=1): 144 | if ( 145 | ( 146 | isinstance(source, datetime.datetime) 147 | and segment in datetime_utils.DATETIMEFIELD_TRANSFORM_EXPRESSIONS 148 | ) 149 | or ( 150 | isinstance(source, datetime.date) 151 | and segment in datetime_utils.DATEFIELD_TRANSFORM_EXPRESSIONS 152 | ) 153 | or ( 154 | isinstance(source, datetime.time) 155 | and segment in datetime_utils.TIMEFIELD_TRANSFORM_EXPRESSIONS 156 | ) 157 | ): 158 | source = datetime_utils.derive_from_value(source, segment) 159 | value = source 160 | elif hasattr(source, segment): 161 | value = getattr(source, segment) 162 | if isinstance(value, Model): 163 | latest_obj = value 164 | if value is None and i < len(segments): 165 | if suppress_nullrelationshipvalueencountered: 166 | return None 167 | raise NullRelationshipValueEncountered( 168 | "'{key}' cannot be reached for {obj} because {model_class}.{field_name} " 169 | "is null.".format( 170 | key=key, 171 | obj=repr(obj), 172 | model_class=latest_obj._meta.label, 173 | field_name=segment, 174 | ) 175 | ) 176 | source = value 177 | elif suppress_fielddoesnotexist: 178 | return None 179 | else: 180 | raise FieldDoesNotExist( 181 | "'{name}' is not a valid field name for {model}".format( 182 | name=segment, model=type(source) 183 | ) 184 | ) 185 | if pk_only and hasattr(value, 'pk'): 186 | return value.pk 187 | return value 188 | 189 | 190 | def sort_by_fields(items, fields): 191 | """ 192 | Sort a list of objects on the given fields. The field list works analogously to 193 | queryset.order_by(*fields): each field is either a property of the object, 194 | or is prefixed by '-' (e.g. '-name') to indicate reverse ordering. 195 | """ 196 | # To get the desired behaviour, we need to order by keys in reverse order 197 | # See: https://docs.python.org/2/howto/sorting.html#sort-stability-and-complex-sorts 198 | for key in reversed(fields): 199 | if key == '?': 200 | random.shuffle(items) 201 | continue 202 | 203 | # Check if this key has been reversed 204 | reverse = False 205 | if key[0] == '-': 206 | reverse = True 207 | key = key[1:] 208 | 209 | def get_sort_value(item): 210 | # Use a tuple of (v is not None, v) as the key, to ensure that None sorts before other values, 211 | # as comparing directly with None breaks on python3 212 | value = extract_field_value(item, key, pk_only=True, suppress_fielddoesnotexist=True, suppress_nullrelationshipvalueencountered=True) 213 | return (value is not None, value) 214 | 215 | # Sort items 216 | items.sort(key=get_sort_value, reverse=reverse) 217 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import shutil 4 | import sys 5 | 6 | from django.core.management import execute_from_command_line 7 | from django.conf import settings 8 | 9 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 10 | 11 | 12 | def runtests(): 13 | argv = sys.argv[:1] + ['test'] + sys.argv[1:] 14 | try: 15 | execute_from_command_line(argv) 16 | finally: 17 | shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) 18 | 19 | 20 | if __name__ == '__main__': 21 | runtests() 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | try: 4 | from setuptools import setup, find_packages 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | setup( 9 | name='django-modelcluster', 10 | version='6.4', 11 | description="Django extension to allow working with 'clusters' of models as a single unit, independently of the database", 12 | author='Matthew Westcott', 13 | author_email='matthew.westcott@torchbox.com', 14 | url='https://github.com/wagtail/django-modelcluster', 15 | packages=find_packages(exclude=('tests*',)), 16 | license='BSD', 17 | long_description=open('README.rst').read(), 18 | python_requires=">=3.9", 19 | install_requires=[ 20 | "django>=4.2", 21 | ], 22 | extras_require={ 23 | 'taggit': ['django-taggit>=3.1'], 24 | }, 25 | classifiers=[ 26 | 'Development Status :: 5 - Production/Stable', 27 | 'Environment :: Web Environment', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: BSD License', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Programming Language :: Python :: 3.10', 35 | 'Programming Language :: Python :: 3.11', 36 | 'Programming Language :: Python :: 3.12', 37 | 'Programming Language :: Python :: 3.13', 38 | 'Programming Language :: Python :: 3 :: Only', 39 | 'Framework :: Django', 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from django.core.management import execute_from_command_line 6 | 7 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 8 | 9 | 10 | def runshell(): 11 | argv = sys.argv[:1] + ['shell'] + sys.argv[1:] 12 | execute_from_command_line(argv) 13 | 14 | 15 | if __name__ == '__main__': 16 | runshell() 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail/django-modelcluster/4957ba243192d9c09f63b4e559b9e638b4fc4d73/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/parentalmanytomany-to-ordered-model.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "tests.house", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Weekend home", 7 | "address": "1 Homely Drive, Hometown", 8 | "owner": null, 9 | "main_room": null 10 | } 11 | }, 12 | { 13 | "model": "tests.house", 14 | "pk": 2, 15 | "fields": { 16 | "name": "Midweek home", 17 | "address": "1 Business Park, Worktown", 18 | "owner": null, 19 | "main_room": null 20 | } 21 | }, 22 | { 23 | "model": "tests.person", 24 | "pk": 1, 25 | "fields": { 26 | "name": "Mr Two Houses", 27 | "houses": [1, 2] 28 | } 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import modelcluster.fields 6 | import django.db.models.deletion 7 | import modelcluster.contrib.taggit 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('taggit', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Album', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 21 | ('name', models.CharField(max_length=255)), 22 | ('release_date', models.DateField(null=True, blank=True)), 23 | ('sort_order', models.IntegerField(null=True, editable=False, blank=True)), 24 | ], 25 | options={ 26 | 'ordering': ['sort_order'], 27 | }, 28 | ), 29 | migrations.CreateModel( 30 | name='Band', 31 | fields=[ 32 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 33 | ('name', models.CharField(max_length=255)), 34 | ], 35 | options={ 36 | 'abstract': False, 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name='BandMember', 41 | fields=[ 42 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 43 | ('name', models.CharField(max_length=255)), 44 | ('band', modelcluster.fields.ParentalKey(related_name='members', to='tests.Band', on_delete=django.db.models.deletion.CASCADE)), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name='Chef', 49 | fields=[ 50 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 51 | ('name', models.CharField(max_length=255)), 52 | ], 53 | ), 54 | migrations.CreateModel( 55 | name='Dish', 56 | fields=[ 57 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 58 | ('name', models.CharField(max_length=255)), 59 | ], 60 | ), 61 | migrations.CreateModel( 62 | name='Log', 63 | fields=[ 64 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 65 | ('time', models.DateTimeField(null=True, blank=True)), 66 | ('data', models.CharField(max_length=255)), 67 | ], 68 | options={ 69 | 'abstract': False, 70 | }, 71 | ), 72 | migrations.CreateModel( 73 | name='MenuItem', 74 | fields=[ 75 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 76 | ('price', models.DecimalField(max_digits=6, decimal_places=2)), 77 | ('dish', models.ForeignKey(related_name='+', to='tests.Dish', on_delete=django.db.models.deletion.CASCADE)), 78 | ], 79 | ), 80 | migrations.CreateModel( 81 | name='Place', 82 | fields=[ 83 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 84 | ('name', models.CharField(max_length=255)), 85 | ], 86 | options={ 87 | 'abstract': False, 88 | }, 89 | ), 90 | migrations.CreateModel( 91 | name='Review', 92 | fields=[ 93 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 94 | ('author', models.CharField(max_length=255)), 95 | ('body', models.TextField()), 96 | ], 97 | ), 98 | migrations.CreateModel( 99 | name='TaggedPlace', 100 | fields=[ 101 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 102 | ], 103 | options={ 104 | 'abstract': False, 105 | }, 106 | ), 107 | migrations.CreateModel( 108 | name='Wine', 109 | fields=[ 110 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 111 | ('name', models.CharField(max_length=255)), 112 | ], 113 | ), 114 | migrations.CreateModel( 115 | name='Restaurant', 116 | fields=[ 117 | ('place_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='tests.Place', on_delete=django.db.models.deletion.CASCADE)), 118 | ('serves_hot_dogs', models.BooleanField(default=False)), 119 | ('proprietor', models.ForeignKey(related_name='restaurants', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='tests.Chef', null=True)), 120 | ], 121 | options={ 122 | 'abstract': False, 123 | }, 124 | bases=('tests.place',), 125 | ), 126 | migrations.CreateModel( 127 | name='Document', 128 | fields=[ 129 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 130 | ('title', models.CharField(max_length=255)), 131 | ('file', models.FileField(upload_to='documents')), 132 | ], 133 | options={ 134 | 'abstract': False, 135 | }, 136 | ), 137 | migrations.AddField( 138 | model_name='taggedplace', 139 | name='content_object', 140 | field=modelcluster.fields.ParentalKey(related_name='tagged_items', to='tests.Place', on_delete=django.db.models.deletion.CASCADE), 141 | ), 142 | migrations.AddField( 143 | model_name='taggedplace', 144 | name='tag', 145 | field=models.ForeignKey(related_name='tests_taggedplace_items', to='taggit.Tag', on_delete=django.db.models.deletion.CASCADE), 146 | ), 147 | migrations.AddField( 148 | model_name='review', 149 | name='place', 150 | field=modelcluster.fields.ParentalKey(related_name='reviews', to='tests.Place', on_delete=django.db.models.deletion.CASCADE), 151 | ), 152 | migrations.AddField( 153 | model_name='place', 154 | name='tags', 155 | field=modelcluster.contrib.taggit.ClusterTaggableManager(to='taggit.Tag', through='tests.TaggedPlace', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags'), 156 | ), 157 | migrations.AddField( 158 | model_name='menuitem', 159 | name='recommended_wine', 160 | field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='tests.Wine', null=True), 161 | ), 162 | migrations.AddField( 163 | model_name='album', 164 | name='band', 165 | field=modelcluster.fields.ParentalKey(related_name='albums', to='tests.Band', on_delete=django.db.models.deletion.CASCADE), 166 | ), 167 | migrations.AddField( 168 | model_name='menuitem', 169 | name='restaurant', 170 | field=modelcluster.fields.ParentalKey(related_name='menu_items', to='tests.Restaurant', on_delete=django.db.models.deletion.CASCADE), 171 | ), 172 | ] 173 | -------------------------------------------------------------------------------- /tests/migrations/0002_add_m2m_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.4 on 2017-01-09 23:32 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import modelcluster.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('tests', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Article', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('title', models.CharField(max_length=255)), 21 | ], 22 | options={ 23 | 'abstract': False, 24 | }, 25 | ), 26 | migrations.CreateModel( 27 | name='Author', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('name', models.CharField(max_length=255)), 31 | ], 32 | options={ 33 | 'ordering': ['name'] 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name='Category', 38 | fields=[ 39 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 40 | ('name', models.CharField(max_length=255)), 41 | ], 42 | ), 43 | migrations.AddField( 44 | model_name='article', 45 | name='authors', 46 | field=modelcluster.fields.ParentalManyToManyField(related_name='articles_by_author', to='tests.Author'), 47 | ), 48 | migrations.AddField( 49 | model_name='article', 50 | name='categories', 51 | field=modelcluster.fields.ParentalManyToManyField(related_name='articles_by_category', to='tests.Category'), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /tests/migrations/0003_gallery_galleryimage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-03-09 16:13 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import modelcluster.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('tests', '0002_add_m2m_models'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Gallery', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('title', models.CharField(max_length=255)), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | migrations.CreateModel( 28 | name='GalleryImage', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('image', models.FileField(upload_to='')), 32 | ('gallery', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='tests.Gallery')), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /tests/migrations/0004_auto_20170406_1734.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-04-06 22:34 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import taggit.managers 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('taggit', '0002_auto_20150616_2121'), 14 | ('tests', '0003_gallery_galleryimage'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='NonClusterPlace', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=255)), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='TaggedNonClusterPlace', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='tests.NonClusterPlace')), 30 | ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_taggednonclusterplace_items', to='taggit.Tag')), 31 | ], 32 | options={ 33 | 'abstract': False, 34 | }, 35 | ), 36 | migrations.AddField( 37 | model_name='nonclusterplace', 38 | name='tags', 39 | field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='tests.TaggedNonClusterPlace', to='taggit.Tag', verbose_name='Tags'), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /tests/migrations/0005_article_fk_to_newspaper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.4 on 2017-08-05 12:48 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import modelcluster.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('tests', '0004_auto_20170406_1734'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='NewsPaper', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('title', models.CharField(max_length=255)), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | migrations.AddField( 28 | model_name='article', 29 | name='paper', 30 | field=modelcluster.fields.ParentalKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tests.NewsPaper'), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /tests/migrations/0006_auto_20171109_0614.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.4 on 2017-11-09 12:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import modelcluster.contrib.taggit 8 | import modelcluster.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('taggit', '0002_auto_20150616_2121'), 15 | ('tests', '0005_article_fk_to_newspaper'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='TaggedArticle', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='tests.Article')), 24 | ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_taggedarticle_items', to='taggit.Tag')), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | }, 29 | ), 30 | migrations.AddField( 31 | model_name='article', 32 | name='tags', 33 | field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='tests.TaggedArticle', to='taggit.Tag', verbose_name='Tags'), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /tests/migrations/0007_add_bandmember_favourite_restaurant.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-08-07 15:51 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('tests', '0006_auto_20171109_0614'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='SeafoodRestaurant', 16 | fields=[ 17 | ('restaurant_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.Restaurant')), 18 | ], 19 | options={ 20 | 'abstract': False, 21 | }, 22 | bases=('tests.restaurant',), 23 | ), 24 | migrations.AddField( 25 | model_name='bandmember', 26 | name='favourite_restaurant', 27 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tests.Restaurant'), 28 | ), 29 | migrations.AlterUniqueTogether( 30 | name='bandmember', 31 | unique_together={('band', 'name')}, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /tests/migrations/0008_prefetch_related_tests.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-05-19 11:02 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import modelcluster.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('tests', '0007_add_bandmember_favourite_restaurant'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='House', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=50)), 20 | ('address', models.CharField(max_length=255)), 21 | ], 22 | options={ 23 | 'ordering': ['id'], 24 | }, 25 | ), 26 | migrations.CreateModel( 27 | name='Person', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('name', models.CharField(max_length=50)), 31 | ('houses', modelcluster.fields.ParentalManyToManyField(related_name='occupants', to='tests.House')), 32 | ], 33 | options={ 34 | 'ordering': ['id'], 35 | }, 36 | ), 37 | migrations.CreateModel( 38 | name='Room', 39 | fields=[ 40 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 41 | ('name', models.CharField(max_length=50)), 42 | ], 43 | options={ 44 | 'ordering': ['id'], 45 | }, 46 | ), 47 | migrations.AddField( 48 | model_name='house', 49 | name='main_room', 50 | field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='main_room_of', to='tests.Room'), 51 | ), 52 | migrations.AddField( 53 | model_name='house', 54 | name='owner', 55 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='tests.Person'), 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /tests/migrations/0009_article_related_articles.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2018-04-20 10:39 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import modelcluster.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('tests', '0008_prefetch_related_tests'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='article', 19 | name='related_articles', 20 | field=modelcluster.fields.ParentalManyToManyField(blank=True, related_name='_article_related_articles_+', serialize=False, to='tests.Article'), 21 | ), 22 | migrations.AddField( 23 | model_name='article', 24 | name='view_count', 25 | field=models.IntegerField(blank=True, null=True, serialize=False), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tests/migrations/0010_song.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-01-17 21:49 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import modelcluster.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('tests', '0009_article_related_articles'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Song', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=255)), 20 | ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), 21 | ('album', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='songs', to='tests.Album')), 22 | ], 23 | options={ 24 | 'ordering': ['sort_order'], 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tests/migrations/0011_add_room_features.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2021-12-15 12:00 2 | 3 | from django.db import migrations, models 4 | import modelcluster.fields 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tests', '0010_song'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Feature', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('name', models.CharField(max_length=255)), 18 | ('desirability', models.PositiveIntegerField()), 19 | ], 20 | options={ 21 | 'ordering': ['-desirability'], 22 | }, 23 | ), 24 | migrations.AddField( 25 | model_name='Room', 26 | name='features', 27 | field=modelcluster.fields.ParentalManyToManyField(blank=True, related_name='rooms', serialize=False, to='tests.Feature'), 28 | ) 29 | ] 30 | -------------------------------------------------------------------------------- /tests/migrations/0012_add_record_label.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-02-04 06:59 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import modelcluster.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("taggit", "0005_auto_20220424_2025"), 12 | ("tests", "0011_add_room_features"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="RecordLabel", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("name", models.CharField(max_length=200)), 29 | ("range", models.SmallIntegerField(blank=True, default=5)), 30 | ], 31 | ), 32 | migrations.AddField( 33 | model_name="album", 34 | name="label", 35 | field=models.ForeignKey( 36 | blank=True, 37 | null=True, 38 | on_delete=django.db.models.deletion.SET_NULL, 39 | to="tests.recordlabel", 40 | ), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /tests/migrations/0013_add_log_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-11-25 12:55 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import modelcluster.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('tests', '0012_add_record_label'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='LogCategory', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=32)), 20 | ('log', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='tests.log')), 21 | ], 22 | ), 23 | migrations.AddConstraint( 24 | model_name='logcategory', 25 | constraint=models.UniqueConstraint(fields=('log', 'name'), name='unique_log_category'), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail/django-modelcluster/4957ba243192d9c09f63b4e559b9e638b4fc4d73/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | 5 | from modelcluster.contrib.taggit import ClusterTaggableManager 6 | from taggit.managers import TaggableManager 7 | from taggit.models import TaggedItemBase 8 | 9 | from modelcluster.fields import ParentalKey, ParentalManyToManyField 10 | from modelcluster.models import ClusterableModel 11 | 12 | 13 | class Band(ClusterableModel): 14 | name = models.CharField(max_length=255) 15 | 16 | def __str__(self): 17 | return self.name 18 | 19 | 20 | class BandMember(models.Model): 21 | band = ParentalKey('Band', related_name='members', on_delete=models.CASCADE) 22 | name = models.CharField(max_length=255) 23 | favourite_restaurant = models.ForeignKey('Restaurant', null=True, blank=True, on_delete=models.SET_NULL) 24 | 25 | def __str__(self): 26 | return self.name 27 | 28 | class Meta: 29 | unique_together = [ 30 | ['band', 'name'] 31 | ] 32 | 33 | 34 | class Album(ClusterableModel): 35 | band = ParentalKey('Band', related_name='albums') 36 | name = models.CharField(max_length=255) 37 | release_date = models.DateField(null=True, blank=True) 38 | sort_order = models.IntegerField(null=True, blank=True, editable=False) 39 | label = models.ForeignKey("RecordLabel", blank=True, null=True, on_delete=models.SET_NULL) 40 | 41 | sort_order_field = 'sort_order' 42 | 43 | def __str__(self): 44 | return self.name 45 | 46 | class Meta: 47 | ordering = ['sort_order'] 48 | 49 | 50 | class Song(models.Model): 51 | album = ParentalKey('Album', related_name='songs') 52 | name = models.CharField(max_length=255) 53 | sort_order = models.IntegerField(null=True, blank=True, editable=False) 54 | 55 | sort_order_field = 'sort_order' 56 | 57 | def __str__(self): 58 | return self.name 59 | 60 | class Meta: 61 | ordering = ['sort_order'] 62 | 63 | 64 | class RecordLabel(models.Model): 65 | name = models.CharField(max_length=200) 66 | range = models.SmallIntegerField(default=5, blank=True) 67 | 68 | def __str__(self): 69 | return self.name 70 | 71 | 72 | class TaggedPlace(TaggedItemBase): 73 | content_object = ParentalKey('Place', related_name='tagged_items', on_delete=models.CASCADE) 74 | 75 | 76 | class Place(ClusterableModel): 77 | name = models.CharField(max_length=255) 78 | tags = ClusterTaggableManager(through=TaggedPlace, blank=True) 79 | 80 | def __str__(self): 81 | return self.name 82 | 83 | 84 | class Restaurant(Place): 85 | serves_hot_dogs = models.BooleanField(default=False) 86 | proprietor = models.ForeignKey('Chef', null=True, blank=True, on_delete=models.SET_NULL, related_name='restaurants') 87 | 88 | 89 | class SeafoodRestaurant(Restaurant): 90 | pass 91 | 92 | 93 | class TaggedNonClusterPlace(TaggedItemBase): 94 | content_object = models.ForeignKey('NonClusterPlace', related_name='tagged_items', on_delete=models.CASCADE) 95 | 96 | 97 | class NonClusterPlace(models.Model): 98 | """ 99 | For backwards compatibility we need ClusterModel to work with 100 | plain TaggableManagers (as opposed to ClusterTaggableManager), albeit 101 | without the in-memory relation behaviour 102 | """ 103 | name = models.CharField(max_length=255) 104 | tags = TaggableManager(through=TaggedNonClusterPlace, blank=True) 105 | 106 | def __str__(self): 107 | return self.name 108 | 109 | 110 | class Dish(models.Model): 111 | name = models.CharField(max_length=255) 112 | 113 | def __str__(self): 114 | return self.name 115 | 116 | 117 | class Wine(models.Model): 118 | name = models.CharField(max_length=255) 119 | 120 | def __str__(self): 121 | return self.name 122 | 123 | 124 | class Chef(models.Model): 125 | name = models.CharField(max_length=255) 126 | 127 | def __str__(self): 128 | return self.name 129 | 130 | 131 | class MenuItem(models.Model): 132 | restaurant = ParentalKey('Restaurant', related_name='menu_items', on_delete=models.CASCADE) 133 | dish = models.ForeignKey('Dish', related_name='+', on_delete=models.CASCADE) 134 | price = models.DecimalField(max_digits=6, decimal_places=2) 135 | recommended_wine = models.ForeignKey('Wine', null=True, blank=True, on_delete=models.SET_NULL, related_name='+') 136 | 137 | def __str__(self): 138 | return "%s - %f" % (self.dish, self.price) 139 | 140 | 141 | class Review(models.Model): 142 | place = ParentalKey('Place', related_name='reviews', on_delete=models.CASCADE) 143 | author = models.CharField(max_length=255) 144 | body = models.TextField() 145 | 146 | def __str__(self): 147 | return "%s on %s" % (self.author, self.place.name) 148 | 149 | 150 | class Log(ClusterableModel): 151 | time = models.DateTimeField(blank=True, null=True) 152 | data = models.CharField(max_length=255) 153 | 154 | def __str__(self): 155 | if self.time is None: 156 | return "[None] %s" % self.data 157 | return "[%s] %s" % (self.time.isoformat(), self.data) 158 | 159 | 160 | class LogCategory(models.Model): 161 | log = ParentalKey(Log, related_name="categories", on_delete=models.CASCADE) 162 | name = models.CharField(max_length=32) 163 | 164 | def __str__(self): 165 | return self.name 166 | 167 | class Meta: 168 | constraints = [ 169 | models.UniqueConstraint( 170 | fields=["log", "name"], 171 | name="unique_log_category", 172 | ) 173 | ] 174 | 175 | 176 | class Document(ClusterableModel): 177 | title = models.CharField(max_length=255) 178 | file = models.FileField(upload_to='documents') 179 | 180 | def __str__(self): 181 | return self.title 182 | 183 | 184 | class NewsPaper(ClusterableModel): 185 | title = models.CharField(max_length=255) 186 | 187 | def __str__(self): 188 | return self.title 189 | 190 | 191 | class TaggedArticle(TaggedItemBase): 192 | content_object = ParentalKey('Article', related_name='tagged_items', on_delete=models.CASCADE) 193 | 194 | 195 | class Article(ClusterableModel): 196 | paper = ParentalKey(NewsPaper, blank=True, null=True, on_delete=models.CASCADE) 197 | title = models.CharField(max_length=255) 198 | authors = ParentalManyToManyField('Author', related_name='articles_by_author') 199 | categories = ParentalManyToManyField('Category', related_name='articles_by_category') 200 | tags = ClusterTaggableManager(through=TaggedArticle, blank=True) 201 | related_articles = ParentalManyToManyField('self', serialize=False, blank=True) 202 | view_count = models.IntegerField(null=True, blank=True, serialize=False) 203 | 204 | def __str__(self): 205 | return self.title 206 | 207 | 208 | class Author(models.Model): 209 | name = models.CharField(max_length=255) 210 | 211 | def __str__(self): 212 | return self.name 213 | 214 | class Meta: 215 | ordering = ['name'] 216 | 217 | 218 | class Category(models.Model): 219 | name = models.CharField(max_length=255) 220 | 221 | def __str__(self): 222 | return self.name 223 | 224 | 225 | class Gallery(ClusterableModel): 226 | title = models.CharField(max_length=255) 227 | 228 | def __str__(self): 229 | return self.title 230 | 231 | 232 | class GalleryImage(models.Model): 233 | gallery = ParentalKey(Gallery, related_name='images', on_delete=models.CASCADE) 234 | image = models.FileField() 235 | 236 | # Models for fakequeryset prefetch_related test 237 | 238 | class House(models.Model): 239 | name = models.CharField(max_length=50) 240 | address = models.CharField(max_length=255) 241 | owner = models.ForeignKey('Person', models.SET_NULL, null=True) 242 | main_room = models.OneToOneField('Room', models.SET_NULL, related_name='main_room_of', null=True) 243 | 244 | class Meta: 245 | ordering = ['id'] 246 | 247 | 248 | class Feature(models.Model): 249 | name = models.CharField(max_length=255) 250 | desirability = models.PositiveIntegerField() 251 | 252 | class Meta: 253 | ordering = ["-desirability"] 254 | 255 | 256 | class Room(ClusterableModel): 257 | name = models.CharField(max_length=50) 258 | features = ParentalManyToManyField(Feature, blank=True, related_name='rooms') 259 | 260 | class Meta: 261 | ordering = ['id'] 262 | 263 | 264 | class Person(ClusterableModel): 265 | name = models.CharField(max_length=50) 266 | houses = ParentalManyToManyField(House, related_name='occupants') 267 | 268 | @property 269 | def primary_house(self): 270 | # Assume business logic forces every person to have at least one house. 271 | return sorted(self.houses.all(), key=lambda house: -house.rooms.count())[0] 272 | 273 | @property 274 | def all_houses(self): 275 | return list(self.houses.all()) 276 | 277 | class Meta: 278 | ordering = ['id'] 279 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | MODELCLUSTER_ROOT = os.path.dirname(os.path.dirname(__file__)) 4 | MEDIA_ROOT = os.path.join(MODELCLUSTER_ROOT, 'test-media') 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': os.environ.get('DATABASE_ENGINE', 'django.db.backends.sqlite3'), 9 | 'NAME': os.environ.get('DATABASE_NAME', 'modelcluster'), 10 | 'USER': os.environ.get('DATABASE_USER', None), 11 | 'PASSWORD': os.environ.get('DATABASE_PASS', None), 12 | 'HOST': os.environ.get('DATABASE_HOST', None), 13 | } 14 | } 15 | 16 | SECRET_KEY = 'not needed' 17 | 18 | INSTALLED_APPS = [ 19 | 'modelcluster', 20 | 21 | 'django.contrib.contenttypes', 22 | 'taggit', 23 | 24 | 'tests', 25 | ] 26 | 27 | MIDDLEWARE_CLASSES = ( 28 | 'django.contrib.sessions.middleware.SessionMiddleware', 29 | 'django.middleware.common.CommonMiddleware', 30 | 'django.middleware.csrf.CsrfViewMiddleware', 31 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 32 | 'django.contrib.messages.middleware.MessageMiddleware', 33 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 34 | ) 35 | 36 | USE_TZ = True 37 | TIME_ZONE = 'America/Chicago' 38 | ROOT_URLCONF = 'tests.urls' 39 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 40 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wagtail/django-modelcluster/4957ba243192d9c09f63b4e559b9e638b4fc4d73/tests/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests/test_copy_child_relations.py: -------------------------------------------------------------------------------- 1 | from django.db.utils import IntegrityError 2 | from django.test import TestCase 3 | 4 | from modelcluster.models import get_all_child_relations 5 | 6 | from tests.models import Album, Band, BandMember 7 | 8 | # Get child relations 9 | band_child_rels_by_model = { 10 | rel.related_model: rel 11 | for rel in get_all_child_relations(Band) 12 | } 13 | band_members_rel = band_child_rels_by_model[BandMember] 14 | band_albums_rel = band_child_rels_by_model[Album] 15 | 16 | 17 | class TestCopyChildRelations(TestCase): 18 | def setUp(self): 19 | self.beatles = Band(name='The Beatles', members=[ 20 | BandMember(name='John Lennon'), 21 | BandMember(name='Paul McCartney'), 22 | ]) 23 | 24 | def test_copy_child_relations_between_unsaved_objects(self): 25 | # This test clones the Beatles into a new band. We haven't saved them in either the old record 26 | # or the new one. 27 | 28 | # Clone the beatle 29 | beatles_clone = Band(name='The Beatles 2020 comeback') 30 | 31 | child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) 32 | 33 | new_john = beatles_clone.members.get(name='John Lennon') 34 | new_paul = beatles_clone.members.get(name='Paul McCartney') 35 | 36 | self.assertIsNone(new_john.pk) 37 | self.assertIsNone(new_paul.pk) 38 | self.assertEqual(new_john.band, beatles_clone) 39 | self.assertEqual(new_paul.band, beatles_clone) 40 | 41 | # As the source is unsaved, both band members are added into a list in the key with PK None 42 | self.assertEqual(child_object_mapping, { 43 | (band_members_rel, None): [new_john, new_paul] 44 | }) 45 | 46 | def test_copy_child_relations_from_saved_to_unsaved_object(self): 47 | # This test clones the beatles from a previously saved band/child objects. 48 | # The only difference here is we can return the old IDs in the mapping. 49 | 50 | self.beatles.save() 51 | john = self.beatles.members.get(name='John Lennon') 52 | paul = self.beatles.members.get(name='Paul McCartney') 53 | 54 | beatles_clone = Band(name='The Beatles 2020 comeback') 55 | 56 | child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) 57 | 58 | new_john = beatles_clone.members.get(name='John Lennon') 59 | new_paul = beatles_clone.members.get(name='Paul McCartney') 60 | 61 | self.assertIsNone(new_john.pk) 62 | self.assertIsNone(new_paul.pk) 63 | self.assertEqual(new_john.band, beatles_clone) 64 | self.assertEqual(new_paul.band, beatles_clone) 65 | 66 | # The objects are saved in the source, so we can give each item it's own entry in the mapping 67 | self.assertEqual(child_object_mapping, { 68 | (band_members_rel, john.pk): new_john, 69 | (band_members_rel, paul.pk): new_paul, 70 | }) 71 | 72 | def test_copy_child_relations_from_saved_and_unsaved_to_unsaved_object(self): 73 | # This test combines the two above tests. We save the beatles band to the database with John and Paul. 74 | # But we then add George and Ringo in memory. When we clone them, we have IDs for John and Paul but 75 | # the others are treated like the unsaved John and Paul from earlier. 76 | 77 | self.beatles.save() 78 | john = self.beatles.members.get(name='John Lennon') 79 | paul = self.beatles.members.get(name='Paul McCartney') 80 | george = self.beatles.members.add(BandMember(name='George Harrison')) 81 | ringo = self.beatles.members.add(BandMember(name='Ringo Starr')) 82 | 83 | beatles_clone = Band(name='The Beatles 2020 comeback') 84 | 85 | child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) 86 | 87 | new_john = beatles_clone.members.get(name='John Lennon') 88 | new_paul = beatles_clone.members.get(name='Paul McCartney') 89 | new_george = beatles_clone.members.get(name='George Harrison') 90 | new_ringo = beatles_clone.members.get(name='Ringo Starr') 91 | 92 | self.assertIsNone(new_john.pk) 93 | self.assertIsNone(new_paul.pk) 94 | self.assertIsNone(new_george.pk) 95 | self.assertIsNone(new_ringo.pk) 96 | self.assertEqual(new_john.band, beatles_clone) 97 | self.assertEqual(new_paul.band, beatles_clone) 98 | self.assertEqual(new_george.band, beatles_clone) 99 | self.assertEqual(new_ringo.band, beatles_clone) 100 | 101 | # The objects are saved in the source, so we can give each item it's own entry in the mapping 102 | self.assertEqual(child_object_mapping, { 103 | (band_members_rel, john.pk): new_john, 104 | (band_members_rel, paul.pk): new_paul, 105 | (band_members_rel, None): [new_george, new_ringo], 106 | }) 107 | 108 | def test_copy_child_relations_from_unsaved_to_saved_object(self): 109 | # This test copies unsaved child relations into a saved object. 110 | # This shouldn't commit the new child objects to the database 111 | 112 | john = self.beatles.members.get(name='John Lennon') 113 | paul = self.beatles.members.get(name='Paul McCartney') 114 | 115 | beatles_clone = Band(name='The Beatles 2020 comeback') 116 | beatles_clone.save() 117 | 118 | child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) 119 | 120 | new_john = beatles_clone.members.get(name='John Lennon') 121 | new_paul = beatles_clone.members.get(name='Paul McCartney') 122 | 123 | self.assertIsNone(new_john.pk) 124 | self.assertIsNone(new_paul.pk) 125 | self.assertEqual(new_john.band, beatles_clone) 126 | self.assertEqual(new_paul.band, beatles_clone) 127 | 128 | self.assertEqual(child_object_mapping, { 129 | (band_members_rel, None): [new_john, new_paul], 130 | }) 131 | 132 | # Bonus test! Let's save the clone again, and see if we can access the new PKs from child_object_mapping 133 | # (Django should mutate the objects we already have when we save them) 134 | beatles_clone.save() 135 | self.assertTrue(child_object_mapping[(band_members_rel, None)][0].pk) 136 | self.assertTrue(child_object_mapping[(band_members_rel, None)][1].pk) 137 | 138 | def test_copy_child_relations_between_saved_objects(self): 139 | # This test copies child relations between two saved objects 140 | # This also shouldn't commit the new child objects to the database 141 | 142 | self.beatles.save() 143 | john = self.beatles.members.get(name='John Lennon') 144 | paul = self.beatles.members.get(name='Paul McCartney') 145 | 146 | beatles_clone = Band(name='The Beatles 2020 comeback') 147 | beatles_clone.save() 148 | 149 | child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) 150 | 151 | new_john = beatles_clone.members.get(name='John Lennon') 152 | new_paul = beatles_clone.members.get(name='Paul McCartney') 153 | 154 | self.assertIsNone(new_john.pk) 155 | self.assertIsNone(new_paul.pk) 156 | self.assertEqual(new_john.band, beatles_clone) 157 | self.assertEqual(new_paul.band, beatles_clone) 158 | 159 | self.assertEqual(child_object_mapping, { 160 | (band_members_rel, john.pk): new_john, 161 | (band_members_rel, paul.pk): new_paul, 162 | }) 163 | 164 | def test_overwrites_existing_child_relations(self): 165 | # By default, the copy_child_relations should overwrite existing items 166 | # This is the safest option as there could be unique keys or sort_order 167 | # fields that might not like being duplicated in this way. 168 | 169 | self.beatles.save() 170 | john = self.beatles.members.get(name='John Lennon') 171 | paul = self.beatles.members.get(name='Paul McCartney') 172 | 173 | beatles_clone = Band(name='The Beatles 2020 comeback') 174 | beatles_clone.members.add(BandMember(name='Julian Lennon')) 175 | beatles_clone.save() 176 | 177 | self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) 178 | 179 | self.beatles.copy_child_relation('members', beatles_clone) 180 | 181 | self.assertFalse(beatles_clone.members.filter(name='Julian Lennon').exists()) 182 | 183 | def test_commit(self): 184 | # The commit parameter will instruct the method to save the child objects straight away 185 | 186 | self.beatles.save() 187 | john = self.beatles.members.get(name='John Lennon') 188 | paul = self.beatles.members.get(name='Paul McCartney') 189 | 190 | beatles_clone = Band(name='The Beatles 2020 comeback') 191 | beatles_clone.save() 192 | 193 | child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone, commit=True) 194 | 195 | new_john = beatles_clone.members.get(name='John Lennon') 196 | new_paul = beatles_clone.members.get(name='Paul McCartney') 197 | 198 | self.assertIsNotNone(new_john.pk) 199 | self.assertIsNotNone(new_paul.pk) 200 | self.assertEqual(new_john.band, beatles_clone) 201 | self.assertEqual(new_paul.band, beatles_clone) 202 | 203 | self.assertEqual(child_object_mapping, { 204 | (band_members_rel, john.pk): new_john, 205 | (band_members_rel, paul.pk): new_paul, 206 | }) 207 | 208 | def test_commit_to_unsaved(self): 209 | # You can't use commit if the target isn't saved 210 | self.beatles.save() 211 | john = self.beatles.members.get(name='John Lennon') 212 | paul = self.beatles.members.get(name='Paul McCartney') 213 | 214 | beatles_clone = Band(name='The Beatles 2020 comeback') 215 | 216 | with self.assertRaises(IntegrityError): 217 | self.beatles.copy_child_relation('members', beatles_clone, commit=True) 218 | 219 | def test_append(self): 220 | # But you can specify append=True, which appends them to the existing list 221 | 222 | self.beatles.save() 223 | john = self.beatles.members.get(name='John Lennon') 224 | paul = self.beatles.members.get(name='Paul McCartney') 225 | 226 | beatles_clone = Band(name='The Beatles 2020 comeback') 227 | beatles_clone.members.add(BandMember(name='Julian Lennon')) 228 | beatles_clone.save() 229 | 230 | self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) 231 | 232 | child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone, append=True) 233 | 234 | self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) 235 | 236 | new_john = beatles_clone.members.get(name='John Lennon') 237 | new_paul = beatles_clone.members.get(name='Paul McCartney') 238 | 239 | self.assertIsNone(new_john.pk) 240 | self.assertIsNone(new_paul.pk) 241 | self.assertEqual(new_john.band, beatles_clone) 242 | self.assertEqual(new_paul.band, beatles_clone) 243 | 244 | self.assertEqual(child_object_mapping, { 245 | (band_members_rel, john.pk): new_john, 246 | (band_members_rel, paul.pk): new_paul, 247 | }) 248 | 249 | 250 | class TestCopyAllChildRelations(TestCase): 251 | def setUp(self): 252 | self.beatles = Band(name='The Beatles', members=[ 253 | BandMember(name='John Lennon'), 254 | BandMember(name='Paul McCartney'), 255 | ], albums=[ 256 | Album(name='Please Please Me', sort_order=1), 257 | Album(name='With The Beatles', sort_order=2), 258 | Album(name='Abbey Road', sort_order=3), 259 | ]) 260 | 261 | def test_copy_all_child_relations_unsaved(self): 262 | # Let's imagine that cloned bands own the albums of their source 263 | # (I'm not creative enough to come up with new album names to keep this analogy going...) 264 | 265 | beatles_clone = Band(name='The Beatles 2020 comeback') 266 | child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone) 267 | 268 | new_john = beatles_clone.members.get(name='John Lennon') 269 | new_paul = beatles_clone.members.get(name='Paul McCartney') 270 | 271 | new_album_1 = beatles_clone.albums.get(sort_order=1) 272 | new_album_2 = beatles_clone.albums.get(sort_order=2) 273 | new_album_3 = beatles_clone.albums.get(sort_order=3) 274 | 275 | self.assertEqual(child_object_mapping, { 276 | (band_members_rel, None): [new_john, new_paul], 277 | (band_albums_rel, None): [new_album_1, new_album_2, new_album_3], 278 | }) 279 | 280 | def test_copy_all_child_relations_saved(self): 281 | self.beatles.save() 282 | 283 | john = self.beatles.members.get(name='John Lennon') 284 | paul = self.beatles.members.get(name='Paul McCartney') 285 | album_1 = self.beatles.albums.get(sort_order=1) 286 | album_2 = self.beatles.albums.get(sort_order=2) 287 | album_3 = self.beatles.albums.get(sort_order=3) 288 | 289 | beatles_clone = Band(name='The Beatles 2020 comeback') 290 | child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone) 291 | 292 | new_john = beatles_clone.members.get(name='John Lennon') 293 | new_paul = beatles_clone.members.get(name='Paul McCartney') 294 | 295 | new_album_1 = beatles_clone.albums.get(sort_order=1) 296 | new_album_2 = beatles_clone.albums.get(sort_order=2) 297 | new_album_3 = beatles_clone.albums.get(sort_order=3) 298 | 299 | self.assertEqual(child_object_mapping, { 300 | (band_members_rel, john.pk): new_john, 301 | (band_members_rel, paul.pk): new_paul, 302 | (band_albums_rel, album_1.pk): new_album_1, 303 | (band_albums_rel, album_2.pk): new_album_2, 304 | (band_albums_rel, album_3.pk): new_album_3, 305 | }) 306 | 307 | def test_exclude(self): 308 | beatles_clone = Band(name='The Beatles 2020 comeback') 309 | child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone, exclude=['albums']) 310 | 311 | new_john = beatles_clone.members.get(name='John Lennon') 312 | new_paul = beatles_clone.members.get(name='Paul McCartney') 313 | 314 | self.assertFalse(beatles_clone.albums.exists()) 315 | 316 | self.assertEqual(child_object_mapping, { 317 | (band_members_rel, None): [new_john, new_paul], 318 | }) 319 | 320 | def test_overwrites_existing_child_relations(self): 321 | john = self.beatles.members.get(name='John Lennon') 322 | paul = self.beatles.members.get(name='Paul McCartney') 323 | 324 | beatles_clone = Band(name='The Beatles 2020 comeback') 325 | beatles_clone.members.add(BandMember(name='Julian Lennon')) 326 | 327 | self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) 328 | 329 | child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone) 330 | 331 | self.assertFalse(beatles_clone.members.filter(name='Julian Lennon').exists()) 332 | 333 | new_john = beatles_clone.members.get(name='John Lennon') 334 | new_paul = beatles_clone.members.get(name='Paul McCartney') 335 | 336 | new_album_1 = beatles_clone.albums.get(sort_order=1) 337 | new_album_2 = beatles_clone.albums.get(sort_order=2) 338 | new_album_3 = beatles_clone.albums.get(sort_order=3) 339 | 340 | self.assertIsNone(new_john.pk) 341 | self.assertIsNone(new_paul.pk) 342 | self.assertEqual(new_john.band, beatles_clone) 343 | self.assertEqual(new_paul.band, beatles_clone) 344 | 345 | self.assertEqual(child_object_mapping, { 346 | (band_members_rel, None): [new_john, new_paul], 347 | (band_albums_rel, None): [new_album_1, new_album_2, new_album_3], 348 | }) 349 | 350 | def test_commit(self): 351 | # The commit parameter will instruct the method to save the child objects straight away 352 | 353 | self.beatles.save() 354 | john = self.beatles.members.get(name='John Lennon') 355 | paul = self.beatles.members.get(name='Paul McCartney') 356 | album_1 = self.beatles.albums.get(sort_order=1) 357 | album_2 = self.beatles.albums.get(sort_order=2) 358 | album_3 = self.beatles.albums.get(sort_order=3) 359 | 360 | beatles_clone = Band(name='The Beatles 2020 comeback') 361 | beatles_clone.save() 362 | 363 | child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone, commit=True) 364 | 365 | new_john = beatles_clone.members.get(name='John Lennon') 366 | new_paul = beatles_clone.members.get(name='Paul McCartney') 367 | 368 | new_album_1 = beatles_clone.albums.get(sort_order=1) 369 | new_album_2 = beatles_clone.albums.get(sort_order=2) 370 | new_album_3 = beatles_clone.albums.get(sort_order=3) 371 | 372 | self.assertIsNotNone(new_john.pk) 373 | self.assertIsNotNone(new_paul.pk) 374 | self.assertIsNotNone(new_album_1.pk) 375 | self.assertIsNotNone(new_album_2.pk) 376 | self.assertIsNotNone(new_album_3.pk) 377 | 378 | self.assertEqual(new_john.band, beatles_clone) 379 | self.assertEqual(new_paul.band, beatles_clone) 380 | self.assertEqual(new_album_1.band, beatles_clone) 381 | self.assertEqual(new_album_2.band, beatles_clone) 382 | self.assertEqual(new_album_3.band, beatles_clone) 383 | 384 | self.assertEqual(child_object_mapping, { 385 | (band_members_rel, john.pk): new_john, 386 | (band_members_rel, paul.pk): new_paul, 387 | (band_albums_rel, album_1.pk): new_album_1, 388 | (band_albums_rel, album_2.pk): new_album_2, 389 | (band_albums_rel, album_3.pk): new_album_3, 390 | }) 391 | 392 | def test_commit_to_unsaved(self): 393 | # You can't use commit if the target isn't saved 394 | self.beatles.save() 395 | john = self.beatles.members.get(name='John Lennon') 396 | paul = self.beatles.members.get(name='Paul McCartney') 397 | 398 | beatles_clone = Band(name='The Beatles 2020 comeback') 399 | 400 | with self.assertRaises(IntegrityError): 401 | self.beatles.copy_all_child_relations(beatles_clone, commit=True) 402 | 403 | def test_append(self): 404 | beatles_clone = Band(name='The Beatles 2020 comeback') 405 | beatles_clone.members.add(BandMember(name='Julian Lennon')) 406 | 407 | self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) 408 | 409 | child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone, append=True) 410 | 411 | self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) 412 | 413 | new_john = beatles_clone.members.get(name='John Lennon') 414 | new_paul = beatles_clone.members.get(name='Paul McCartney') 415 | new_album_1 = beatles_clone.albums.get(sort_order=1) 416 | new_album_2 = beatles_clone.albums.get(sort_order=2) 417 | new_album_3 = beatles_clone.albums.get(sort_order=3) 418 | 419 | self.assertIsNone(new_john.pk) 420 | self.assertIsNone(new_paul.pk) 421 | self.assertEqual(new_john.band, beatles_clone) 422 | self.assertEqual(new_paul.band, beatles_clone) 423 | 424 | self.assertEqual(child_object_mapping, { 425 | (band_members_rel, None): [new_john, new_paul], 426 | (band_albums_rel, None): [new_album_1, new_album_2, new_album_3], 427 | }) 428 | -------------------------------------------------------------------------------- /tests/tests/test_copy_cluster.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from modelcluster.models import get_all_child_relations 4 | 5 | from tests.models import Band, BandMember, Article, Author, Category 6 | 7 | 8 | # Get child relations 9 | band_child_rels_by_model = { 10 | rel.related_model: rel 11 | for rel in get_all_child_relations(Band) 12 | } 13 | band_members_rel = band_child_rels_by_model[BandMember] 14 | 15 | 16 | class TestCopyCluster(TestCase): 17 | def test_can_create_cluster(self): 18 | beatles = Band(name='The Beatles') 19 | 20 | self.assertEqual(0, beatles.members.count()) 21 | 22 | beatles.members = [ 23 | BandMember(name='John Lennon'), 24 | BandMember(name='Paul McCartney'), 25 | ] 26 | beatles.save() 27 | 28 | beatles_copy, child_object_map = beatles.copy_cluster() 29 | 30 | # The copy should be unsaved 31 | self.assertIsNone(beatles_copy.pk) 32 | beatles_copy.save() 33 | 34 | # Check that both versions have the same content 35 | self.assertEqual(beatles.name, beatles_copy.name) 36 | self.assertEqual([member.name for member in beatles.members.all()], [member.name for member in beatles_copy.members.all()]) 37 | 38 | # Check that the content has been copied 39 | self.assertNotEqual(beatles.pk, beatles_copy.pk) 40 | self.assertNotEqual([member.pk for member in beatles.members.all()], [member.pk for member in beatles_copy.members.all()]) 41 | 42 | # Check child_object_map 43 | old_john = beatles.members.get(name='John Lennon') 44 | old_paul = beatles.members.get(name='Paul McCartney') 45 | new_john = beatles_copy.members.get(name='John Lennon') 46 | new_paul = beatles_copy.members.get(name='Paul McCartney') 47 | self.assertEqual(child_object_map, { 48 | (band_members_rel, old_john.pk): new_john, 49 | (band_members_rel, old_paul.pk): new_paul, 50 | }) 51 | 52 | def test_copies_parental_many_to_many_fields(self): 53 | article = Article(title="Test Title") 54 | author_1 = Author.objects.create(name="Author 1") 55 | author_2 = Author.objects.create(name="Author 2") 56 | article.authors = [author_1, author_2] 57 | category_1 = Category.objects.create(name="Category 1") 58 | category_2 = Category.objects.create(name="Category 2") 59 | article.categories = [category_1, category_2] 60 | article.save() 61 | 62 | article_copy, child_object_map = article.copy_cluster() 63 | 64 | # The copy should be unsaved 65 | self.assertIsNone(article_copy.pk) 66 | article_copy.save() 67 | 68 | # Check that both versions have the same content 69 | self.assertEqual(article.title, article_copy.title) 70 | self.assertEqual([author.name for author in article.authors.all()], [author.name for author in article_copy.authors.all()]) 71 | self.assertEqual([category.name for category in article.categories.all()], [category.name for category in article_copy.categories.all()]) 72 | 73 | # Check that the content has been copied 74 | self.assertNotEqual(article.pk, article_copy.pk) 75 | 76 | # Check child_object_map 77 | # ParentalManyToManyField creates an invisible through table to the referenced objects 78 | # We don't need to care about this though since this table is usually plain 79 | self.assertEqual(child_object_map, {}) 80 | -------------------------------------------------------------------------------- /tests/tests/test_fixture_loading.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.test import TestCase 4 | 5 | from tests.models import Person 6 | 7 | 8 | class TestLoadsParentalManyToManyToOrderedModel(TestCase): 9 | 10 | fixtures = ["parentalmanytomany-to-ordered-model.json"] 11 | 12 | def test_data_loads_from_fixture(self): 13 | """ 14 | The main test here is that the fixture loads without errors. The code 15 | below code then confirms that the relationship was set correctly. 16 | """ 17 | person = Person.objects.get(id=1) 18 | self.assertEqual(list(person.houses.values_list("id", flat=True)), [1, 2]) 19 | -------------------------------------------------------------------------------- /tests/tests/test_formset.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.test import TestCase 4 | from modelcluster.forms import ClusterForm, transientmodelformset_factory, childformset_factory 5 | from tests.models import NewsPaper, Article, Author, Band, BandMember, Album, Song 6 | 7 | 8 | class TransientFormsetTest(TestCase): 9 | BandMembersFormset = transientmodelformset_factory(BandMember, exclude=['band'], extra=3, can_delete=True) 10 | 11 | def test_can_create_formset(self): 12 | beatles = Band(name='The Beatles', members=[ 13 | BandMember(name='John Lennon'), 14 | BandMember(name='Paul McCartney'), 15 | ]) 16 | band_members_formset = self.BandMembersFormset(queryset=beatles.members.all()) 17 | 18 | self.assertEqual(5, len(band_members_formset.forms)) 19 | self.assertEqual('John Lennon', band_members_formset.forms[0].instance.name) 20 | 21 | def test_incoming_formset_data(self): 22 | beatles = Band(name='The Beatles', members=[ 23 | BandMember(name='George Harrison'), 24 | ]) 25 | 26 | band_members_formset = self.BandMembersFormset({ 27 | 'form-TOTAL_FORMS': 3, 28 | 'form-INITIAL_FORMS': 1, 29 | 'form-MAX_NUM_FORMS': 1000, 30 | 31 | 'form-0-name': 'John Lennon', 32 | 'form-0-id': '', 33 | 34 | 'form-1-name': 'Paul McCartney', 35 | 'form-1-id': '', 36 | 37 | 'form-2-name': '', 38 | 'form-2-id': '', 39 | }, queryset=beatles.members.all()) 40 | 41 | self.assertTrue(band_members_formset.is_valid()) 42 | members = band_members_formset.save(commit=False) 43 | self.assertEqual(2, len(members)) 44 | self.assertEqual('John Lennon', members[0].name) 45 | # should not exist in the database yet 46 | self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) 47 | 48 | def test_save_commit_false(self): 49 | john = BandMember(name='John Lennon') 50 | paul = BandMember(name='Paul McCartney') 51 | ringo = BandMember(name='Richard Starkey') 52 | beatles = Band(name='The Beatles', members=[ 53 | john, paul, ringo 54 | ]) 55 | beatles.save() 56 | 57 | john_id, paul_id, ringo_id = john.id, paul.id, ringo.id 58 | 59 | self.assertTrue(john_id) 60 | self.assertTrue(paul_id) 61 | 62 | band_members_formset = self.BandMembersFormset({ 63 | 'form-TOTAL_FORMS': 5, 64 | 'form-INITIAL_FORMS': 3, 65 | 'form-MAX_NUM_FORMS': 1000, 66 | 67 | 'form-0-name': 'John Lennon', 68 | 'form-0-DELETE': 'form-0-DELETE', 69 | 'form-0-id': john_id, 70 | 71 | 'form-1-name': 'Paul McCartney', 72 | 'form-1-id': paul_id, 73 | 74 | 'form-2-name': 'Ringo Starr', # changing data of an existing record 75 | 'form-2-id': ringo_id, 76 | 77 | 'form-3-name': '', 78 | 'form-3-id': '', 79 | 80 | 'form-4-name': 'George Harrison', # Adding a record 81 | 'form-4-id': '', 82 | }, queryset=beatles.members.all()) 83 | self.assertTrue(band_members_formset.is_valid()) 84 | 85 | updated_members = band_members_formset.save(commit=False) 86 | self.assertEqual(2, len(updated_members)) 87 | self.assertEqual('Ringo Starr', updated_members[0].name) 88 | self.assertEqual(ringo_id, updated_members[0].id) 89 | 90 | # should not be updated in the db yet 91 | self.assertEqual('Richard Starkey', BandMember.objects.get(id=ringo_id).name) 92 | 93 | self.assertEqual('George Harrison', updated_members[1].name) 94 | self.assertFalse(updated_members[1].id) # no ID yet 95 | 96 | def test_save_commit_true(self): 97 | john = BandMember(name='John Lennon') 98 | paul = BandMember(name='Paul McCartney') 99 | ringo = BandMember(name='Richard Starkey') 100 | beatles = Band(name='The Beatles', members=[ 101 | john, paul, ringo 102 | ]) 103 | beatles.save() 104 | 105 | john_id, paul_id, ringo_id = john.id, paul.id, ringo.id 106 | 107 | self.assertTrue(john_id) 108 | self.assertTrue(paul_id) 109 | 110 | band_members_formset = self.BandMembersFormset({ 111 | 'form-TOTAL_FORMS': 4, 112 | 'form-INITIAL_FORMS': 3, 113 | 'form-MAX_NUM_FORMS': 1000, 114 | 115 | 'form-0-name': 'John Lennon', 116 | 'form-0-DELETE': 'form-0-DELETE', 117 | 'form-0-id': john_id, 118 | 119 | 'form-1-name': 'Paul McCartney', 120 | 'form-1-id': paul_id, 121 | 122 | 'form-2-name': 'Ringo Starr', # changing data of an existing record 123 | 'form-2-id': ringo_id, 124 | 125 | 'form-3-name': '', 126 | 'form-3-id': '', 127 | }, queryset=beatles.members.all()) 128 | self.assertTrue(band_members_formset.is_valid()) 129 | 130 | updated_members = band_members_formset.save() 131 | self.assertEqual(1, len(updated_members)) 132 | self.assertEqual('Ringo Starr', updated_members[0].name) 133 | self.assertEqual(ringo_id, updated_members[0].id) 134 | 135 | self.assertFalse(BandMember.objects.filter(id=john_id).exists()) 136 | self.assertEqual('Paul McCartney', BandMember.objects.get(id=paul_id).name) 137 | self.assertEqual(beatles.id, BandMember.objects.get(id=paul_id).band_id) 138 | self.assertEqual('Ringo Starr', BandMember.objects.get(id=ringo_id).name) 139 | self.assertEqual(beatles.id, BandMember.objects.get(id=ringo_id).band_id) 140 | 141 | 142 | class ChildFormsetTest(TestCase): 143 | def test_can_create_formset(self): 144 | beatles = Band(name='The Beatles', members=[ 145 | BandMember(name='John Lennon'), 146 | BandMember(name='Paul McCartney'), 147 | ]) 148 | BandMembersFormset = childformset_factory(Band, BandMember, extra=3) 149 | band_members_formset = BandMembersFormset(instance=beatles) 150 | 151 | self.assertEqual(5, len(band_members_formset.forms)) 152 | self.assertEqual('John Lennon', band_members_formset.forms[0].instance.name) 153 | 154 | def test_empty_formset(self): 155 | BandMembersFormset = childformset_factory(Band, BandMember, extra=3) 156 | band_members_formset = BandMembersFormset() 157 | self.assertEqual(3, len(band_members_formset.forms)) 158 | 159 | def test_save_commit_false(self): 160 | john = BandMember(name='John Lennon') 161 | paul = BandMember(name='Paul McCartney') 162 | ringo = BandMember(name='Richard Starkey') 163 | beatles = Band(name='The Beatles', members=[ 164 | john, paul, ringo 165 | ]) 166 | beatles.save() 167 | john_id, paul_id, ringo_id = john.id, paul.id, ringo.id 168 | 169 | BandMembersFormset = childformset_factory(Band, BandMember, extra=3) 170 | 171 | band_members_formset = BandMembersFormset({ 172 | 'form-TOTAL_FORMS': 5, 173 | 'form-INITIAL_FORMS': 3, 174 | 'form-MAX_NUM_FORMS': 1000, 175 | 176 | 'form-0-name': 'John Lennon', 177 | 'form-0-DELETE': 'form-0-DELETE', 178 | 'form-0-id': john_id, 179 | 180 | 'form-1-name': 'Paul McCartney', 181 | 'form-1-id': paul_id, 182 | 183 | 'form-2-name': 'Ringo Starr', # changing data of an existing record 184 | 'form-2-id': ringo_id, 185 | 186 | 'form-3-name': '', 187 | 'form-3-id': '', 188 | 189 | 'form-4-name': 'George Harrison', # adding a record 190 | 'form-4-id': '', 191 | }, instance=beatles) 192 | self.assertTrue(band_members_formset.is_valid()) 193 | updated_members = band_members_formset.save(commit=False) 194 | 195 | # updated_members should only include the items that have been changed and not deleted 196 | self.assertEqual(2, len(updated_members)) 197 | self.assertEqual('Ringo Starr', updated_members[0].name) 198 | self.assertEqual(ringo_id, updated_members[0].id) 199 | 200 | self.assertEqual('George Harrison', updated_members[1].name) 201 | self.assertEqual(None, updated_members[1].id) 202 | 203 | # Changes should not be committed to the db yet 204 | self.assertTrue(BandMember.objects.filter(name='John Lennon', id=john_id).exists()) 205 | self.assertEqual('Richard Starkey', BandMember.objects.get(id=ringo_id).name) 206 | self.assertFalse(BandMember.objects.filter(name='George Harrison').exists()) 207 | 208 | beatles.members.commit() 209 | # this should create/update/delete database entries 210 | self.assertEqual('Ringo Starr', BandMember.objects.get(id=ringo_id).name) 211 | self.assertTrue(BandMember.objects.filter(name='George Harrison').exists()) 212 | self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) 213 | 214 | def test_child_updates_without_ids(self): 215 | john = BandMember(name='John Lennon') 216 | beatles = Band(name='The Beatles', members=[ 217 | john 218 | ]) 219 | beatles.save() 220 | john_id = john.id 221 | 222 | paul = BandMember(name='Paul McCartney') 223 | beatles.members.add(paul) 224 | 225 | BandMembersFormset = childformset_factory(Band, BandMember, extra=3) 226 | band_members_formset = BandMembersFormset({ 227 | 'form-TOTAL_FORMS': 2, 228 | 'form-INITIAL_FORMS': 2, 229 | 'form-MAX_NUM_FORMS': 1000, 230 | 231 | 'form-0-name': 'John Lennon', 232 | 'form-0-id': john_id, 233 | 234 | 'form-1-name': 'Paul McCartney', # NB no way to know programmatically that this form corresponds to the 'paul' object 235 | 'form-1-id': '', 236 | }, instance=beatles) 237 | 238 | self.assertTrue(band_members_formset.is_valid()) 239 | band_members_formset.save(commit=False) 240 | self.assertEqual(2, beatles.members.count()) 241 | 242 | def test_max_num_ignored_in_validation_when_validate_max_false(self): 243 | BandMembersFormset = childformset_factory(Band, BandMember, max_num=2) 244 | 245 | band_members_formset = BandMembersFormset({ 246 | 'form-TOTAL_FORMS': 3, 247 | 'form-INITIAL_FORMS': 1, 248 | 'form-MAX_NUM_FORMS': 1000, 249 | 250 | 'form-0-name': 'John Lennon', 251 | 'form-0-id': '', 252 | 253 | 'form-1-name': 'Paul McCartney', 254 | 'form-1-id': '', 255 | 256 | 'form-2-name': 'Ringo Starr', 257 | 'form-2-id': '', 258 | }) 259 | self.assertTrue(band_members_formset.is_valid()) 260 | 261 | def test_max_num_fail_validation(self): 262 | BandMembersFormset = childformset_factory(Band, BandMember, max_num=2, validate_max=True) 263 | 264 | band_members_formset = BandMembersFormset({ 265 | 'form-TOTAL_FORMS': 3, 266 | 'form-INITIAL_FORMS': 1, 267 | 'form-MAX_NUM_FORMS': 1000, 268 | 269 | 'form-0-name': 'John Lennon', 270 | 'form-0-id': '', 271 | 272 | 'form-1-name': 'Paul McCartney', 273 | 'form-1-id': '', 274 | 275 | 'form-2-name': 'Ringo Starr', 276 | 'form-2-id': '', 277 | }) 278 | self.assertFalse(band_members_formset.is_valid()) 279 | self.assertEqual(band_members_formset.non_form_errors().as_data()[0].code, "too_many_forms") 280 | 281 | def test_max_num_pass_validation(self): 282 | BandMembersFormset = childformset_factory(Band, BandMember, max_num=2, validate_max=True) 283 | 284 | band_members_formset = BandMembersFormset({ 285 | 'form-TOTAL_FORMS': 2, 286 | 'form-INITIAL_FORMS': 1, 287 | 'form-MAX_NUM_FORMS': 1000, 288 | 289 | 'form-0-name': 'John Lennon', 290 | 'form-0-id': '', 291 | 292 | 'form-1-name': 'Paul McCartney', 293 | 'form-1-id': '', 294 | }) 295 | self.assertTrue(band_members_formset.is_valid()) 296 | 297 | def test_min_num_ignored_in_validation_when_validate_max_false(self): 298 | BandMembersFormset = childformset_factory(Band, BandMember, min_num=2) 299 | 300 | band_members_formset = BandMembersFormset({ 301 | 'form-TOTAL_FORMS': 1, 302 | 'form-INITIAL_FORMS': 1, 303 | 'form-MAX_NUM_FORMS': 1000, 304 | 305 | 'form-0-name': 'John Lennon', 306 | 'form-0-id': '', 307 | }) 308 | self.assertTrue(band_members_formset.is_valid()) 309 | 310 | def test_min_num_fail_validation(self): 311 | BandMembersFormset = childformset_factory(Band, BandMember, min_num=2, validate_min=True) 312 | 313 | band_members_formset = BandMembersFormset({ 314 | 'form-TOTAL_FORMS': 1, 315 | 'form-INITIAL_FORMS': 1, 316 | 'form-MAX_NUM_FORMS': 1000, 317 | 318 | 'form-0-name': 'John Lennon', 319 | 'form-0-id': '', 320 | }) 321 | self.assertFalse(band_members_formset.is_valid()) 322 | self.assertEqual(band_members_formset.non_form_errors().as_data()[0].code, "too_few_forms") 323 | 324 | def test_min_num_pass_validation(self): 325 | BandMembersFormset = childformset_factory(Band, BandMember, min_num=2, validate_min=True) 326 | 327 | band_members_formset = BandMembersFormset({ 328 | 'form-TOTAL_FORMS': 2, 329 | 'form-INITIAL_FORMS': 1, 330 | 'form-MAX_NUM_FORMS': 1000, 331 | 332 | 'form-0-name': 'John Lennon', 333 | 'form-0-id': '', 334 | 335 | 'form-1-name': 'Paul McCartney', 336 | 'form-1-id': '', 337 | }) 338 | self.assertTrue(band_members_formset.is_valid()) 339 | 340 | 341 | class ChildFormsetWithM2MTest(TestCase): 342 | 343 | def setUp(self): 344 | self.james_joyce = Author.objects.create(name='James Joyce') 345 | self.charles_dickens = Author.objects.create(name='Charles Dickens') 346 | 347 | self.paper = NewsPaper.objects.create(title='the daily record') 348 | self.article = Article.objects.create( 349 | paper=self.paper, 350 | title='Test article', 351 | authors=[self.james_joyce], 352 | ) 353 | ArticleFormset = childformset_factory(NewsPaper, Article, exclude=['categories', 'tags'], extra=3) 354 | self.formset = ArticleFormset({ 355 | 'form-TOTAL_FORMS': 1, 356 | 'form-INITIAL_FORMS': 1, 357 | 'form-MAX_NUM_FORMS': 10, 358 | 359 | 'form-0-id': self.article.id, 360 | 'form-0-title': self.article.title, 361 | 'form-0-authors': [self.james_joyce.id, self.charles_dickens.id], 362 | }, instance=self.paper) 363 | 364 | ArticleTagsFormset = childformset_factory(NewsPaper, Article, exclude=['categories', 'authors'], extra=3) 365 | self.tags_formset = ArticleTagsFormset({ 366 | 'form-TOTAL_FORMS': 1, 367 | 'form-INITIAL_FORMS': 1, 368 | 'form-MAX_NUM_FORMS': 10, 369 | 370 | 'form-0-id': self.article.id, 371 | 'form-0-title': self.article.title, 372 | 'form-0-tags': 'tag1, tagtwo', 373 | }, instance=self.paper) 374 | 375 | 376 | def test_save_with_commit_false(self): 377 | self.assertTrue(self.formset.is_valid()) 378 | saved_articles = self.formset.save(commit=False) 379 | updated_article = saved_articles[0] 380 | 381 | # in memory 382 | self.assertIn(self.james_joyce, updated_article.authors.all()) 383 | self.assertIn(self.charles_dickens, updated_article.authors.all()) 384 | 385 | # in db 386 | db_article = Article.objects.get(id=self.article.id) 387 | self.assertIn(self.james_joyce, db_article.authors.all()) 388 | self.assertNotIn(self.charles_dickens, db_article.authors.all()) 389 | 390 | 391 | def test_save_with_commit_true(self): 392 | self.assertTrue(self.formset.is_valid()) 393 | saved_articles = self.formset.save(commit=True) 394 | updated_article = saved_articles[0] 395 | 396 | # in db 397 | db_article = Article.objects.get(id=self.article.id) 398 | self.assertIn(self.james_joyce, db_article.authors.all()) 399 | self.assertIn(self.charles_dickens, db_article.authors.all()) 400 | 401 | # in memory 402 | self.assertIn(self.james_joyce, updated_article.authors.all()) 403 | self.assertIn(self.charles_dickens, updated_article.authors.all()) 404 | 405 | 406 | def test_tags_save_with_commit_false(self): 407 | self.assertTrue(self.tags_formset.is_valid()) 408 | saved_articles = self.tags_formset.save(commit=False) 409 | updated_article = saved_articles[0] 410 | 411 | # in memory 412 | self.assertIn('tag1', [t.slug for t in updated_article.tags.all()]) 413 | self.assertIn('tagtwo', [t.slug for t in updated_article.tags.all()]) 414 | 415 | # in db 416 | db_article = Article.objects.get(id=self.article.id) 417 | self.assertNotIn('tag1', [t.slug for t in db_article.tags.all()]) 418 | self.assertNotIn('tagtwo', [t.slug for t in db_article.tags.all()]) 419 | 420 | 421 | def test_tags_save_with_commit_true(self): 422 | self.assertTrue(self.tags_formset.is_valid()) 423 | saved_articles = self.tags_formset.save(commit=True) 424 | updated_article = saved_articles[0] 425 | 426 | # in db 427 | db_article = Article.objects.get(id=self.article.id) 428 | self.assertIn('tag1', [t.slug for t in db_article.tags.all()]) 429 | self.assertIn('tagtwo', [t.slug for t in db_article.tags.all()]) 430 | 431 | # in memory 432 | self.assertIn('tag1', [t.slug for t in updated_article.tags.all()]) 433 | self.assertIn('tagtwo', [t.slug for t in updated_article.tags.all()]) 434 | 435 | 436 | class OrderedFormsetTest(TestCase): 437 | def test_saving_formset_preserves_order(self): 438 | AlbumsFormset = childformset_factory(Band, Album, extra=3, can_order=True) 439 | beatles = Band(name='The Beatles') 440 | albums_formset = AlbumsFormset({ 441 | 'form-TOTAL_FORMS': 2, 442 | 'form-INITIAL_FORMS': 0, 443 | 'form-MAX_NUM_FORMS': 1000, 444 | 445 | 'form-0-name': 'With The Beatles', 446 | 'form-0-id': '', 447 | 'form-0-ORDER': '2', 448 | 449 | 'form-1-name': 'Please Please Me', 450 | 'form-1-id': '', 451 | 'form-1-ORDER': '1', 452 | }, instance=beatles) 453 | self.assertTrue(albums_formset.is_valid()) 454 | 455 | albums_formset.save(commit=False) 456 | 457 | album_names = [album.name for album in beatles.albums.all()] 458 | self.assertEqual(['Please Please Me', 'With The Beatles'], album_names) 459 | 460 | 461 | class NestedChildFormsetTest(TestCase): 462 | 463 | def test_can_create_formset(self): 464 | beatles = Band(name='The Beatles', albums=[ 465 | Album(name='Please Please Me', songs=[ 466 | Song(name='I Saw Her Standing There'), 467 | Song(name='Misery') 468 | ]) 469 | ]) 470 | AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) 471 | albums_formset = AlbumsFormset(instance=beatles) 472 | 473 | self.assertEqual(4, len(albums_formset.forms)) 474 | self.assertEqual('Please Please Me', albums_formset.forms[0].instance.name) 475 | 476 | self.assertEqual(5, len(albums_formset.forms[0].formsets['songs'].forms)) 477 | self.assertEqual( 478 | 'I Saw Her Standing There', 479 | albums_formset.forms[0].formsets['songs'].forms[0].instance.name 480 | ) 481 | 482 | def test_empty_formset(self): 483 | AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) 484 | albums_formset = AlbumsFormset() 485 | self.assertEqual(3, len(albums_formset.forms)) 486 | self.assertEqual(3, len(albums_formset.forms[0].formsets['songs'].forms)) 487 | 488 | def test_save_commit_false(self): 489 | first_song = Song(name='I Saw Her Standing There') 490 | second_song = Song(name='Mystery') 491 | album = Album(name='Please Please Me', songs=[first_song, second_song]) 492 | beatles = Band(name='The Beatles', albums=[album]) 493 | beatles.save() 494 | first_song_id, second_song_id = first_song.id, second_song.id 495 | 496 | AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) 497 | 498 | albums_formset = AlbumsFormset({ 499 | 'form-TOTAL_FORMS': 1, 500 | 'form-INITIAL_FORMS': 1, 501 | 'form-MAX_NUM_FORMS': 1000, 502 | 503 | 'form-0-name': 'Please Please Me', 504 | 'form-0-id': album.id, 505 | 506 | 'form-0-songs-TOTAL_FORMS': 4, 507 | 'form-0-songs-INITIAL_FORMS': 2, 508 | 'form-0-songs-MAX_NUM_FORMS': 1000, 509 | 510 | 'form-0-songs-0-name': 'I Saw Her Standing There', 511 | 'form-0-songs-0-DELETE': 'form-0-songs-0-DELETE', 512 | 'form-0-songs-0-id': first_song_id, 513 | 514 | 'form-0-songs-1-name': 'Misery', # changing data of an existing record 515 | 'form-0-songs-1-id': second_song_id, 516 | 517 | 'form-0-songs-2-name': '', 518 | 'form-0-songs-2-id': '', 519 | 520 | 'form-0-songs-3-name': 'Chains', # adding a record 521 | 'form-0-songs-3-id': '', 522 | }, instance=beatles) 523 | self.assertTrue(albums_formset.is_valid()) 524 | updated_albums = albums_formset.save(commit=False) 525 | 526 | # updated_members should only include the items that have been changed and not deleted 527 | self.assertEqual(1, len(updated_albums)) 528 | self.assertEqual('Please Please Me', updated_albums[0].name) 529 | self.assertEqual(2, updated_albums[0].songs.count()) 530 | self.assertEqual('Misery', updated_albums[0].songs.first().name) 531 | self.assertEqual(second_song_id, updated_albums[0].songs.first().id) 532 | 533 | self.assertEqual('Chains', updated_albums[0].songs.all()[1].name) 534 | self.assertEqual(None, updated_albums[0].songs.all()[1].id) 535 | 536 | # Changes should not be committed to the db yet 537 | self.assertTrue(Song.objects.filter(name='I Saw Her Standing There', id=first_song_id).exists()) 538 | self.assertEqual('Mystery', Song.objects.get(id=second_song_id).name) 539 | self.assertFalse(Song.objects.filter(name='Chains').exists()) 540 | 541 | beatles.albums.first().songs.commit() 542 | # this should create/update/delete database entries 543 | self.assertEqual('Misery', Song.objects.get(id=second_song_id).name) 544 | self.assertTrue(Song.objects.filter(name='Chains').exists()) 545 | self.assertFalse(Song.objects.filter(name='I Saw Her Standing There').exists()) 546 | 547 | def test_child_updates_without_ids(self): 548 | first_song = Song(name='I Saw Her Standing There') 549 | album = Album(name='Please Please Me', songs=[first_song]) 550 | beatles = Band(name='The Beatles', albums=[album]) 551 | beatles.save() 552 | 553 | first_song_id = first_song.id 554 | 555 | second_song = Song(name='Misery') 556 | album.songs.add(second_song) 557 | 558 | AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, formsets=['songs'], extra=3) 559 | 560 | albums_formset = AlbumsFormset({ 561 | 'form-TOTAL_FORMS': 1, 562 | 'form-INITIAL_FORMS': 1, 563 | 'form-MAX_NUM_FORMS': 1000, 564 | 565 | 'form-0-name': 'Please Please Me', 566 | 'form-0-id': album.id, 567 | 568 | 'form-0-songs-TOTAL_FORMS': 2, 569 | 'form-0-songs-INITIAL_FORMS': 2, 570 | 'form-0-songs-MAX_NUM_FORMS': 1000, 571 | 572 | 'form-0-songs-0-name': 'I Saw Her Standing There', 573 | 'form-0-songs-0-id': first_song_id, 574 | 575 | 'form-0-songs-1-name': 'Misery', 576 | 'form-0-songs-1-id': '', 577 | }, instance=beatles) 578 | 579 | self.assertTrue(albums_formset.is_valid()) 580 | albums_formset.save(commit=False) 581 | self.assertEqual(2, beatles.albums.first().songs.count()) 582 | -------------------------------------------------------------------------------- /tests/tests/test_serialize.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | import datetime 5 | 6 | from django.core.files.uploadedfile import SimpleUploadedFile 7 | from django.test import TestCase 8 | from django.utils import timezone 9 | 10 | from tests.models import Band, BandMember, Album, Place, Restaurant, SeafoodRestaurant, Dish, \ 11 | MenuItem, Chef, Wine, Review, Log, Document, Article, Author, Category 12 | 13 | 14 | class SerializeTest(TestCase): 15 | def test_serialize(self): 16 | beatles = Band(name='The Beatles', members=[ 17 | BandMember(name='John Lennon'), 18 | BandMember(name='Paul McCartney'), 19 | ]) 20 | 21 | expected = {'pk': None, 'albums': [], 'name': 'The Beatles', 'members': [ 22 | {'pk': None, 'name': 'John Lennon', 'band': None, 'favourite_restaurant': None}, 23 | {'pk': None, 'name': 'Paul McCartney', 'band': None, 'favourite_restaurant': None} 24 | ]} 25 | self.assertEqual(expected, beatles.serializable_data()) 26 | 27 | def test_serialize_m2m(self): 28 | george_orwell = Author.objects.create(name='George Orwell') 29 | charles_dickens = Author.objects.create(name='Charles Dickens') 30 | 31 | article = Article( 32 | title='Down and Out in Paris and London', 33 | authors=[george_orwell, charles_dickens], 34 | ) 35 | 36 | article_serialised = article.serializable_data() 37 | self.assertEqual(article_serialised['title'], 'Down and Out in Paris and London') 38 | self.assertIn(george_orwell.pk, article_serialised['authors']) 39 | self.assertEqual(article_serialised['categories'], []) 40 | 41 | def test_serialize_json_with_dates(self): 42 | beatles = Band(name='The Beatles', members=[ 43 | BandMember(name='John Lennon'), 44 | BandMember(name='Paul McCartney'), 45 | ], albums=[ 46 | Album(name='Rubber Soul', release_date=datetime.date(1965, 12, 3)) 47 | ]) 48 | 49 | beatles_json = beatles.to_json() 50 | self.assertTrue("John Lennon" in beatles_json) 51 | self.assertTrue("1965-12-03" in beatles_json) 52 | unpacked_beatles = Band.from_json(beatles_json) 53 | self.assertEqual(datetime.date(1965, 12, 3), unpacked_beatles.albums.all()[0].release_date) 54 | 55 | def test_deserialize(self): 56 | beatles = Band.from_serializable_data({ 57 | 'pk': 9, 58 | 'albums': [], 59 | 'name': 'The Beatles', 60 | 'members': [ 61 | {'pk': None, 'name': 'John Lennon', 'band': None}, 62 | {'pk': None, 'name': 'Paul McCartney', 'band': None}, 63 | ] 64 | }) 65 | self.assertEqual(9, beatles.id) 66 | self.assertEqual('The Beatles', beatles.name) 67 | self.assertEqual(2, beatles.members.count()) 68 | self.assertEqual(BandMember, beatles.members.all()[0].__class__) 69 | 70 | def test_deserialize_m2m(self): 71 | authors = {} 72 | categories = {} 73 | for i in range(1, 6): 74 | authors[i] = Author.objects.create(name="Author %d" % i) 75 | categories[i] = Category.objects.create(name="Category %d" % i) 76 | 77 | article = Article.from_serializable_data({ 78 | 'pk': 1, 79 | 'title': 'Article Title 1', 80 | 'authors': [authors[1].pk, authors[2].pk], 81 | 'categories': [categories[2].pk, categories[3].pk, categories[4].pk] 82 | }) 83 | self.assertEqual(article.id, 1) 84 | self.assertEqual(article.title, 'Article Title 1') 85 | self.assertEqual(article.authors.count(), 2) 86 | self.assertEqual( 87 | [author.name for author in article.authors.order_by('name')], 88 | ['Author 1', 'Author 2'] 89 | ) 90 | self.assertEqual(article.categories.count(), 3) 91 | 92 | def test_deserialize_json(self): 93 | beatles = Band.from_json('{"pk": 9, "albums": [], "name": "The Beatles", "members": [{"pk": null, "name": "John Lennon", "band": null}, {"pk": null, "name": "Paul McCartney", "band": null}]}') 94 | self.assertEqual(9, beatles.id) 95 | self.assertEqual('The Beatles', beatles.name) 96 | self.assertEqual(2, beatles.members.count()) 97 | self.assertEqual(BandMember, beatles.members.all()[0].__class__) 98 | 99 | def test_serialize_with_multi_table_inheritance(self): 100 | fat_duck = Restaurant(name='The Fat Duck', serves_hot_dogs=False, reviews=[ 101 | Review(author='Michael Winner', body='Rubbish.') 102 | ]) 103 | data = json.loads(fat_duck.to_json()) 104 | self.assertEqual(data['name'], 'The Fat Duck') 105 | self.assertEqual(data['serves_hot_dogs'], False) 106 | self.assertEqual(data['reviews'][0]['author'], 'Michael Winner') 107 | 108 | def test_deserialize_with_multi_table_inheritance(self): 109 | fat_duck = Restaurant.from_json('{"pk": 42, "name": "The Fat Duck", "serves_hot_dogs": false, "reviews": [{"pk": null, "author": "Michael Winner", "body": "Rubbish."}]}') 110 | self.assertEqual(fat_duck.id, 42) 111 | self.assertEqual(fat_duck.name, "The Fat Duck") 112 | self.assertEqual(fat_duck.serves_hot_dogs, False) 113 | self.assertEqual(fat_duck.reviews.all()[0].author, "Michael Winner") 114 | 115 | def test_deserialize_with_second_level_multi_table_inheritance(self): 116 | oyster_club = SeafoodRestaurant.from_json('{"pk": 43, "name": "The Oyster Club"}') 117 | self.assertEqual(oyster_club.id, 43) 118 | self.assertEqual(oyster_club.restaurant_ptr_id, 43) 119 | self.assertEqual(oyster_club.place_ptr_id, 43) 120 | self.assertEqual(oyster_club.restaurant_ptr.__class__, Restaurant) 121 | self.assertEqual(oyster_club.place_ptr.__class__, Place) 122 | self.assertEqual(oyster_club.name, "The Oyster Club") 123 | 124 | def test_dangling_foreign_keys(self): 125 | heston_blumenthal = Chef.objects.create(name="Heston Blumenthal") 126 | snail_ice_cream = Dish.objects.create(name="Snail ice cream") 127 | chateauneuf = Wine.objects.create(name="Chateauneuf-du-Pape 1979") 128 | fat_duck = Restaurant(name="The Fat Duck", proprietor=heston_blumenthal, serves_hot_dogs=False, menu_items=[ 129 | MenuItem(dish=snail_ice_cream, price='20.00', recommended_wine=chateauneuf) 130 | ]) 131 | fat_duck_json = fat_duck.to_json() 132 | 133 | fat_duck = Restaurant.from_json(fat_duck_json) 134 | self.assertEqual("Heston Blumenthal", fat_duck.proprietor.name) 135 | self.assertEqual("Chateauneuf-du-Pape 1979", fat_duck.menu_items.all()[0].recommended_wine.name) 136 | 137 | heston_blumenthal.delete() 138 | fat_duck = Restaurant.from_json(fat_duck_json) 139 | # the deserialised record should recognise that the heston_blumenthal record is now missing 140 | self.assertEqual(None, fat_duck.proprietor) 141 | self.assertEqual("Chateauneuf-du-Pape 1979", fat_duck.menu_items.all()[0].recommended_wine.name) 142 | 143 | chateauneuf.delete() # oh dear, looks like we just drank the last bottle 144 | fat_duck = Restaurant.from_json(fat_duck_json) 145 | # the deserialised record should now have a null recommended_wine field 146 | self.assertEqual(None, fat_duck.menu_items.all()[0].recommended_wine) 147 | 148 | snail_ice_cream.delete() # NOM NOM NOM 149 | fat_duck = Restaurant.from_json(fat_duck_json) 150 | # the menu item should now be dropped entirely (because the foreign key to Dish has on_delete=CASCADE) 151 | self.assertEqual(0, fat_duck.menu_items.count()) 152 | 153 | def test_deserialize_with_sort_order(self): 154 | beatles = Band.from_json('{"pk": null, "albums": [{"pk": null, "name": "With The Beatles", "sort_order": 2}, {"pk": null, "name": "Please Please Me", "sort_order": 1}], "name": "The Beatles", "members": []}') 155 | self.assertEqual(2, beatles.albums.count()) 156 | 157 | # Make sure the albums were ordered correctly 158 | self.assertEqual("Please Please Me", beatles.albums.all()[0].name) 159 | self.assertEqual("With The Beatles", beatles.albums.all()[1].name) 160 | 161 | def test_deserialize_with_reversed_sort_order(self): 162 | Album._meta.ordering = ['-sort_order'] 163 | beatles = Band.from_json('{"pk": null, "albums": [{"pk": null, "name": "Please Please Me", "sort_order": 1}, {"pk": null, "name": "With The Beatles", "sort_order": 2}], "name": "The Beatles", "members": []}') 164 | Album._meta.ordering = ['sort_order'] 165 | self.assertEqual(2, beatles.albums.count()) 166 | 167 | # Make sure the albums were ordered correctly 168 | self.assertEqual("With The Beatles", beatles.albums.all()[0].name) 169 | self.assertEqual("Please Please Me", beatles.albums.all()[1].name) 170 | 171 | def test_deserialize_with_multiple_sort_order(self): 172 | Album._meta.ordering = ['sort_order', 'name'] 173 | beatles = Band.from_json('{"pk": null, "albums": [{"pk": 1, "name": "With The Beatles", "sort_order": 1}, {"pk": 2, "name": "Please Please Me", "sort_order": 1}, {"pk": 3, "name": "Please Please Me", "sort_order": 2}], "name": "The Beatles", "members": []}') 174 | Album._meta.ordering = ['sort_order'] 175 | self.assertEqual(3, beatles.albums.count()) 176 | 177 | # Make sure the albums were ordered correctly 178 | self.assertEqual(2, beatles.albums.all()[0].pk) 179 | self.assertEqual(1, beatles.albums.all()[1].pk) 180 | self.assertEqual(3, beatles.albums.all()[2].pk) 181 | 182 | WAGTAIL_05_RELEASE_DATETIME = datetime.datetime(2014, 8, 1, 11, 1, 42) 183 | 184 | def test_serialise_with_naive_datetime(self): 185 | """ 186 | This tests that naive datetimes are saved as UTC 187 | """ 188 | # Time is in America/Chicago time 189 | log = Log(time=self.WAGTAIL_05_RELEASE_DATETIME, data="Wagtail 0.5 released") 190 | log_json = json.loads(log.to_json()) 191 | 192 | # Now check that the time is stored correctly with the timezone information at the end 193 | self.assertEqual(log_json['time'], '2014-08-01T16:01:42Z') 194 | 195 | def test_serialise_with_aware_datetime(self): 196 | """ 197 | This tests that aware datetimes are converted to as UTC 198 | """ 199 | # make an aware datetime, consisting of WAGTAIL_05_RELEASE_DATETIME 200 | # in a timezone 1hr west of UTC 201 | one_hour_west = timezone.get_fixed_timezone(-60) 202 | 203 | local_time = timezone.make_aware(self.WAGTAIL_05_RELEASE_DATETIME, one_hour_west) 204 | log = Log(time=local_time, data="Wagtail 0.5 released") 205 | log_json = json.loads(log.to_json()) 206 | 207 | # Now check that the time is stored correctly with the timezone information at the end 208 | self.assertEqual(log_json['time'], '2014-08-01T12:01:42Z') 209 | 210 | def test_deserialise_with_utc_datetime(self): 211 | """ 212 | This tests that a datetimes saved as UTC are converted back correctly 213 | """ 214 | # Time is in UTC 215 | log = Log.from_json('{"data": "Wagtail 0.5 released", "time": "2014-08-01T16:01:42Z", "pk": null}') 216 | 217 | # Naive and aware timezones cannot be compared so make the release date timezone-aware before comparison 218 | expected_time = timezone.make_aware(self.WAGTAIL_05_RELEASE_DATETIME, timezone.get_default_timezone()) 219 | 220 | # Check that the datetime is correct and was converted back into the correct timezone 221 | self.assertEqual(log.time, expected_time) 222 | self.assertEqual(log.time.tzinfo, expected_time.tzinfo) 223 | 224 | def test_deserialise_with_local_datetime(self): 225 | """ 226 | This tests that a datetime without timezone information is interpreted as a local time 227 | """ 228 | log = Log.from_json('{"data": "Wagtail 0.5 released", "time": "2014-08-01T11:01:42", "pk": null}') 229 | 230 | expected_time = timezone.make_aware(self.WAGTAIL_05_RELEASE_DATETIME, timezone.get_default_timezone()) 231 | self.assertEqual(log.time, expected_time) 232 | self.assertEqual(log.time.tzinfo, expected_time.tzinfo) 233 | 234 | def test_serialise_with_null_datetime(self): 235 | log = Log(time=None, data="Someone scanned a QR code") 236 | log_json = json.loads(log.to_json()) 237 | self.assertEqual(log_json['time'], None) 238 | 239 | def test_deserialise_with_null_datetime(self): 240 | log = Log.from_json('{"data": "Someone scanned a QR code", "time": null, "pk": null}') 241 | self.assertEqual(log.time, None) 242 | 243 | def test_serialise_saves_file_fields(self): 244 | doc = Document(title='Hello') 245 | doc.file = SimpleUploadedFile('hello.txt', b'Hello world') 246 | 247 | doc_json = doc.to_json() 248 | new_doc = Document.from_json(doc_json) 249 | 250 | self.assertEqual(new_doc.file.read(), b'Hello world') 251 | 252 | def test_ignored_relations(self): 253 | george_orwell = Author.objects.create(name='George Orwell') 254 | charles_dickens = Author.objects.create(name='Charles Dickens') 255 | 256 | rel_article = Article( 257 | title='Round and round wherever', 258 | authors=[george_orwell], 259 | ) 260 | article = Article( 261 | title='Down and Out in Paris and London', 262 | authors=[george_orwell, charles_dickens], 263 | related_articles=[rel_article], 264 | view_count=123 265 | ) 266 | 267 | article_serialised = article.serializable_data() 268 | # check that related_articles and view_count are not serialized (marked with serialize=False) 269 | self.assertNotIn('related_articles', article_serialised) 270 | self.assertNotIn('view_count', article_serialised) 271 | 272 | rel_article.save() 273 | article.save() 274 | 275 | article_json = article.to_json() 276 | restored_article = Article.from_json(article_json) 277 | restored_article.save() 278 | restored_article = Article.objects.get(pk=restored_article.pk) 279 | # check that related_articles and view_count hasn't been touched 280 | self.assertIn(rel_article, restored_article.related_articles.all()) 281 | -------------------------------------------------------------------------------- /tests/tests/test_tag.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | from django import VERSION as DJANGO_VERSION 6 | from django.test import TestCase, override_settings 7 | from taggit import VERSION as TAGGIT_VERSION 8 | from taggit.models import Tag 9 | 10 | from modelcluster.forms import ClusterForm 11 | from tests.models import NonClusterPlace, Place, TaggedPlace 12 | 13 | 14 | class TagTest(TestCase): 15 | def test_can_access_tags_on_unsaved_instance(self): 16 | mission_burrito = Place(name='Mission Burrito') 17 | self.assertEqual(0, mission_burrito.tags.count()) 18 | 19 | mission_burrito.tags.add('mexican', 'burrito') 20 | self.assertEqual(2, mission_burrito.tags.count()) 21 | self.assertEqual(Tag, mission_burrito.tags.all()[0].__class__) 22 | self.assertTrue([tag for tag in mission_burrito.tags.all() if tag.name == 'mexican']) 23 | 24 | mission_burrito.save() 25 | self.assertEqual(2, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) 26 | 27 | mission_burrito.tags.remove('burrito') 28 | self.assertEqual(1, mission_burrito.tags.count()) 29 | # should not affect database until we save 30 | self.assertEqual(2, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) 31 | mission_burrito.save() 32 | self.assertEqual(1, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) 33 | 34 | mission_burrito.tags.clear() 35 | self.assertEqual(0, mission_burrito.tags.count()) 36 | # should not affect database until we save 37 | self.assertEqual(1, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) 38 | mission_burrito.save() 39 | self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) 40 | 41 | if TAGGIT_VERSION >= (2, 0): 42 | mission_burrito.tags.set(['mexican', 'burrito']) 43 | else: 44 | mission_burrito.tags.set('mexican', 'burrito') 45 | self.assertEqual(2, mission_burrito.tags.count()) 46 | self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) 47 | mission_burrito.save() 48 | self.assertEqual(2, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) 49 | 50 | def test_prefetch_tags_actually_prefetches(self): 51 | mission_burrito = Place(name="Mission Burrito") 52 | mission_burrito.tags.add("mexican", "burrito") 53 | mission_burrito.save() 54 | 55 | atomic_burger = Place(name="Atomic Burger") 56 | atomic_burger.tags.add("burger") 57 | atomic_burger.save() 58 | 59 | with self.assertNumQueries(2): 60 | places = list(Place.objects.order_by("name").prefetch_related("tags")) 61 | self.assertEqual(places[0].name, "Atomic Burger") 62 | self.assertCountEqual( 63 | [tag.name for tag in places[0].tags.all()], ["burger"] 64 | ) 65 | 66 | self.assertEqual(places[1].name, "Mission Burrito") 67 | self.assertCountEqual( 68 | [tag.name for tag in places[1].tags.all()], ["mexican", "burrito"] 69 | ) 70 | 71 | def test_prefetching_and_then_adding_works_as_expected(self): 72 | mission_burrito = Place(name="Mission Burrito") 73 | mission_burrito.tags.add("mexican", "burrito") 74 | mission_burrito.save() 75 | 76 | with self.assertNumQueries(2): 77 | places = list(Place.objects.order_by("name").prefetch_related("tags")) 78 | self.assertEqual(places[0].name, "Mission Burrito") 79 | self.assertCountEqual( 80 | [tag.name for tag in places[0].tags.all()], ["mexican", "burrito"] 81 | ) 82 | 83 | places[0].tags.add("pizza") 84 | places[0].save() 85 | 86 | self.assertCountEqual( 87 | [tag.name for tag in places[0].tags.all()], 88 | ["mexican", "burrito", "pizza"], 89 | ) 90 | 91 | def test_tag_form_field(self): 92 | class PlaceForm(ClusterForm): 93 | class Meta: 94 | model = Place 95 | exclude_formsets = ['tagged_items', 'reviews'] 96 | fields = ['name', 'tags'] 97 | 98 | mission_burrito = Place(name='Mission Burrito') 99 | mission_burrito.tags.add('mexican', 'burrito') 100 | 101 | form = PlaceForm(instance=mission_burrito) 102 | self.assertEqual(2, len(form['tags'].value())) 103 | expected_instance = TaggedPlace if TAGGIT_VERSION < (1,) else Tag 104 | self.assertEqual(expected_instance, form['tags'].value()[0].__class__) 105 | 106 | form = PlaceForm({ 107 | 'name': "Mission Burrito", 108 | 'tags': "burrito, fajita" 109 | }, instance=mission_burrito) 110 | self.assertTrue(form.is_valid()) 111 | mission_burrito = form.save(commit=False) 112 | self.assertTrue(Tag.objects.get(name='burrito') in mission_burrito.tags.all()) 113 | self.assertTrue(Tag.objects.get(name='fajita') in mission_burrito.tags.all()) 114 | self.assertFalse(Tag.objects.get(name='mexican') in mission_burrito.tags.all()) 115 | 116 | def test_create_with_tags(self): 117 | class PlaceForm(ClusterForm): 118 | class Meta: 119 | model = Place 120 | exclude_formsets = ['tagged_items', 'reviews'] 121 | fields = ['name', 'tags'] 122 | 123 | form = PlaceForm({ 124 | 'name': "Mission Burrito", 125 | 'tags': "burrito, fajita" 126 | }, instance=Place()) 127 | self.assertTrue(form.is_valid()) 128 | mission_burrito = form.save() 129 | reloaded_mission_burrito = Place.objects.get(pk=mission_burrito.pk) 130 | self.assertEqual( 131 | set(reloaded_mission_burrito.tags.all()), 132 | set([Tag.objects.get(name='burrito'), Tag.objects.get(name='fajita')]) 133 | ) 134 | 135 | def test_create_with_tags_with_plain_taggable_manager(self): 136 | class PlaceForm(ClusterForm): 137 | class Meta: 138 | model = NonClusterPlace 139 | exclude_formsets = ['tagged_items', 'reviews'] 140 | fields = ['name', 'tags'] 141 | 142 | form = PlaceForm({ 143 | 'name': "Mission Burrito", 144 | 'tags': "burrito, fajita" 145 | }, instance=NonClusterPlace()) 146 | self.assertTrue(form.is_valid()) 147 | mission_burrito = form.save() 148 | reloaded_mission_burrito = NonClusterPlace.objects.get(pk=mission_burrito.pk) 149 | self.assertEqual( 150 | set(reloaded_mission_burrito.tags.all()), 151 | set([Tag.objects.get(name='burrito'), Tag.objects.get(name='fajita')]) 152 | ) 153 | 154 | def test_render_tag_form(self): 155 | class PlaceForm(ClusterForm): 156 | class Meta: 157 | model = Place 158 | exclude_formsets = ['tagged_items', 'reviews'] 159 | fields = ['name', 'tags'] 160 | 161 | mission_burrito = Place(name="Mission Burrito") 162 | mission_burrito.tags.add('burrito', 'mexican') 163 | form = PlaceForm(instance=mission_burrito) 164 | form_html = form.as_p() 165 | html = '' 166 | 167 | if DJANGO_VERSION >= (5, 0): 168 | # https://docs.djangoproject.com/en/dev/releases/5.0/#forms 169 | html = '' 170 | 171 | self.assertInHTML(html, form_html) 172 | 173 | @override_settings(TAGGIT_CASE_INSENSITIVE=True) 174 | def test_case_insensitive_tags(self): 175 | mission_burrito = Place(name='Mission Burrito') 176 | mission_burrito.tags.add('burrito') 177 | mission_burrito.tags.add('Burrito') 178 | 179 | self.assertEqual(1, mission_burrito.tags.count()) 180 | 181 | def test_integers(self): 182 | """Adding an integer as a tag should raise a ValueError""" 183 | mission_burrito = Place(name='Mission Burrito') 184 | with self.assertRaisesRegex(ValueError, ( 185 | r"Cannot add 1 \(<(type|class) 'int'>\). " 186 | r"Expected or str.")): 187 | mission_burrito.tags.add(1) 188 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39}-dj{42}-{sqlite,postgres}-taggit2 4 | py{310,311,312,313}-dj{42,50,51,51stable,master}-{sqlite,postgres}-taggit2 5 | 6 | [testenv] 7 | allowlist_externals = ./runtests.py 8 | commands = ./runtests.py --noinput {posargs} 9 | 10 | basepython = 11 | py39: python3.9 12 | py310: python3.10 13 | py311: python3.11 14 | py312: python3.12 15 | py313: python3.12 16 | 17 | deps = 18 | taggit2: django-taggit>=2.0 19 | dj42: Django>=4.2,<4.3 20 | dj50: Django>=5.0,<5.1 21 | dj51: Django>=5.1,<5.2 22 | dj51stable: git+https://github.com/django/django.git@stable/5.1.x#egg=Django 23 | djmaster: git+https://github.com/django/django.git@main#egg=Django 24 | postgres: psycopg2>=2.9 25 | 26 | setenv = 27 | postgres: DATABASE_ENGINE=django.db.backends.postgresql_psycopg2 28 | --------------------------------------------------------------------------------