pax_global_header00006660000000000000000000000064136046776670014540gustar00rootroot0000000000000052 comment=8299b6ac41ac4e01453b28440164b12e393bc37e django-modelcluster-5.0.1/000077500000000000000000000000001360467766700155055ustar00rootroot00000000000000django-modelcluster-5.0.1/.gitignore000066400000000000000000000000771360467766700175010ustar00rootroot00000000000000*.pyc *.swp dist/ .tox/ MANIFEST /django_modelcluster.egg-info django-modelcluster-5.0.1/.travis.yml000066400000000000000000000022401360467766700176140ustar00rootroot00000000000000language: python dist: xenial addons: postgresql: "9.6" matrix: include: - env: TOXENV=py35-dj20-sqlite-taggit0 python: 3.5 - env: TOXENV=py36-dj20-sqlite-taggit1 python: 3.6 - env: TOXENV=py36-dj21-sqlite-taggit0 python: 3.6 - env: TOXENV=py35-dj22-sqlite-taggit0 python: 3.5 - env: TOXENV=py36-dj22-sqlite-taggit1 python: 3.6 - env: TOXENV=py37-dj22-sqlite-taggit0 python: 3.7 - env: TOXENV=py35-dj20-postgres-taggit0 python: 3.5 - env: TOXENV=py36-dj20-postgres-taggit0 python: 3.6 - env: TOXENV=py37-dj21-postgres-taggit1 python: 3.7 - env: TOXENV=py35-dj22-postgres-taggit1 python: 3.5 - env: TOXENV=py36-dj22-postgres-taggit1 python: 3.6 - env: TOXENV=py37-dj22-postgres-taggit1 python: 3.7 - env: TOXENV=py37-dj22stable-postgres-taggit1 python: 3.7 - env: TOXENV=py37-djmaster-postgres-taggit1 python: 3.7 allow_failures: # allow failures against Django git master and 2.2.x stable - env: TOXENV=py37-dj22stable-postgres-taggit1 - env: TOXENV=py37-djmaster-postgres-taggit1 install: - pip install tox - createdb modelcluster script: tox django-modelcluster-5.0.1/CHANGELOG.txt000066400000000000000000000122671360467766700175450ustar00rootroot00000000000000Changelog ========= 5.0.1 (06.01.2020) ~~~~~~~~~~~~~~~~~~ * Fix: ClusterForm without an explicit `formsets` kwarg now allows formsets to be omitted from form submissions, to fix regression with nested relations * Fix: ParentalManyToManyField data is now loaded correctly by `manage.py loaddata` (Andy Babic) 5.0 (06.08.2019) ~~~~~~~~~~~~~~~~ * Removed Python 2 and 3.4 support * Removed Django 1.10 and 1.11 support * Added django-taggit 1.x compatibility (Gassan Gousseinov, Matt Westcott) 4.4.1 (06.01.2020) ~~~~~~~~~~~~~~~~~~ * Fix: ClusterForm without an explicit `formsets` kwarg now allows formsets to be omitted from form submissions, to fix regression with nested relations 4.4 (02.04.2019) ~~~~~~~~~~~~~~~~ * Django 2.2 compatibility * Support nested child relationships in ClusterForm (Sam Costigan) 4.3 (15.11.2018) ~~~~~~~~~~~~~~~~ * Added support for filter lookup expressions such as `__lt` 4.2 (08.08.2018) ~~~~~~~~~~~~~~~~ * Django 2.1 compatibility * Python 3.7 compatibility * Implemented prefetch_related on FakeQuerySet (Haydn Greatnews) * Fix: Saving a ClusterableModel with a primary key of 0 no longer throws an IntegrityError (A Lee) * Fix: Serialization now respects `serialize=False` on ParentalManyToManyFields (Tadas Dailyda) 4.1 (12.02.2017) ~~~~~~~~~~~~~~~~ * `on_delete` on ParentalKey now defaults to CASCADE if not specified 4.0 (13.12.2017) ~~~~~~~~~~~~~~~~ * Django 2.0 compatibility * Removed Django 1.8 and 1.9 support * Child formsets now validate uniqueness constraints * Fix: Many-to-many relations inside inline formsets are now saved correctly 3.1 (07.04.2017) ~~~~~~~~~~~~~~~~ * Django 1.11 compatibility * Python 3.6 compatibility * Added the ability to install the optional dependency `django-taggit` using `pip install django-modelcluster[taggit]` * Fix: ClusterForm.save(commit=True) now correctly writes ParentalManyToManyField relations back to the database rather than requiring a separate model.save() step * Fix: ClusterForm.is_multipart() now returns True when a child form requires multipart submission * Fix: ClusterForm.media now includes media defined on child forms * Fix: ParentalManyToManyField.value_from_object now returns correct result on unsaved objects 3.0.1 (02.02.2017) ~~~~~~~~~~~~~~~~~~ * Fix: Added _result_cache property on FakeQuerySet (necessary for model forms with ParentalManyToManyFields to work correctly on Django 1.8-1.9) 3.0 (02.02.2017) ~~~~~~~~~~~~~~~~ * Added support for many-to-many relations (Thejaswi Puthraya, Matt Westcott) * Added compatibility with django-taggit 0.20 and dropped support for earlier versions * Deprecated the Model._meta.child_relations property (get_all_child_relations should be used instead) * Implemented the `set()` method on related managers (introduced in Django 1.9) 2.0 (22.04.2016) ~~~~~~~~~~~~~~~~ * Removed Django 1.7 and Python 3.2 support * Added system check to disallow related_name='+' on ParentalKey * Added support for TAGGIT_CASE_INSENSITIVE on ClusterTaggableManager * Field values for serialization are now fetched via pre_save (which, in particular, ensures that file fields are committed to storage) * Fix: System checks now correctly report a model name that cannot be resolved to a model * Fix: prefetch_related on a ClusterTaggableManager no longer fails (but doesn't prefetch either) * Fix: Adding invalid types as tags now correctly reports a ValueError 1.1 (17.12.2015) ~~~~~~~~~~~~~~~~ * Django 1.9 compatibility * Added exclude() method to FakeQuerySet * Removed dependency on the 'six' package, in favour of Django's built-in version 1.0 (09.10.2015) ~~~~~~~~~~~~~~~~ * Removed Django 1.6 and Python 2.6 support * Added system check to ensure that ParentalKey points to a ClusterableModel * Added validate_max, min_num and validate_min parameters to childformset_factory 0.6.2 (13.04.2015) ~~~~~~~~~~~~~~~~~~ * Fix: Updated add_ignored_fields declaration so that South / Django 1.6 correctly ignores modelcluster.contrib.taggit.ClusterTaggableManager again 0.6.1 (09.04.2015) ~~~~~~~~~~~~~~~~~~ * Django 1.8 compatibility * 'modelcluster.tags' module has been moved to 'modelcluster.contrib.taggit' 0.6 (09.04.2015) ~~~~~~~~~~~~~~~~ (withdrawn due to packaging issues) 0.5 (03.02.2015) ~~~~~~~~~~~~~~~~ * ClusterForm.Meta formsets can now be specified as a dict to allow extra properties to be set on the underlying form. * Added order_by() method to FakeQuerySet * Fix: Child object ordering is now applied without needing to save to the database 0.4 (04.09.2014) ~~~~~~~~~~~~~~~~ * Django 1.7 compatibility * Fix: Datetimes are converted to UTC on serialisation and to local time on deserialisation, to match Django's behaviour when accessing the database * Fix: ParentalKey relations to a model's superclass are now picked up correctly by that model * Fix: Custom Media classes on ClusterForm now behave correctly 0.3 (17.06.2014) ~~~~~~~~~~~~~~~~ * Added exists(), first() and last() methods on FakeQuerySet * Fix: Model ordering is applied when adding items to DeferringRelatedManager 0.2 (22.05.2014) ~~~~~~~~~~~~~~~~ * Python 2.6 compatibility * Python 3 compatibility * Django 1.7 beta compatibility * Added support for prefetch_related on DeferringRelatedManager 0.1 (05.02.2014) ~~~~~~~~~~~~~~~~ * Initial release. django-modelcluster-5.0.1/LICENSE000066400000000000000000000030161360467766700165120ustar00rootroot00000000000000Copyright (c) 2014-2018 Torchbox Ltd and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of Torchbox nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. django-modelcluster-5.0.1/MANIFEST.in000066400000000000000000000001151360467766700172400ustar00rootroot00000000000000include LICENSE README.rst CHANGELOG.txt recursive-include modelcluster *.py django-modelcluster-5.0.1/README.rst000066400000000000000000000120531360467766700171750ustar00rootroot00000000000000.. image:: https://travis-ci.org/wagtail/django-modelcluster.svg?branch=master :target: https://travis-ci.org/wagtail/django-modelcluster django-modelcluster =================== If you had a data model like this: .. code-block:: python class Band(models.Model): name = models.CharField(max_length=255) class BandMember(models.Model): band = models.ForeignKey('Band', related_name='members', on_delete=models.CASCADE) name = models.CharField(max_length=255) wouldn't it be nice if you could construct bundles of objects like this, independently of the database: .. code-block:: python beatles = Band(name='The Beatles') beatles.members = [ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ] Unfortunately, you can't. Objects need to exist in the database for foreign key relations to work: .. code-block:: python IntegrityError: null value in column "band_id" violates not-null constraint 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. **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: .. code-block:: python from modelcluster.models import ClusterableModel from modelcluster.fields import ParentalKey class Band(ClusterableModel): name = models.CharField(max_length=255) class BandMember(models.Model): band = ParentalKey('Band', related_name='members', on_delete=models.CASCADE) name = models.CharField(max_length=255) >>> beatles = Band(name='The Beatles') >>> beatles.members = [ ... BandMember(name='John Lennon'), ... BandMember(name='Paul McCartney'), ... ] >>> [member.name for member in beatles.members.all()] ['John Lennon', 'Paul McCartney'] >>> beatles.members.add(BandMember(name='George Harrison')) >>> beatles.members.count() 3 >>> beatles.save() # only now are the records written to the database For more examples, see the unit tests. Many-to-many relations ---------------------- For many-to-many relations, a corresponding *ParentalManyToManyField* is available: .. code-block:: python from modelcluster.models import ClusterableModel from modelcluster.fields import ParentalManyToManyField class Movie(ClusterableModel): title = models.CharField(max_length=255) actors = ParentalManyToManyField('Actor', related_name='movies') class Actor(models.Model): name = models.CharField(max_length=255) >>> harrison_ford = Actor.objects.create(name='Harrison Ford') >>> carrie_fisher = Actor.objects.create(name='Carrie Fisher') >>> star_wars = Movie(title='Star Wars') >>> star_wars.actors = [harrison_ford, carrie_fisher] >>> blade_runner = Movie(title='Blade Runner') >>> blade_runner.actors.add(harrison_ford) >>> star_wars.actors.count() 2 >>> [movie.title for movie in harrison_ford.movies.all()] # the Movie records are not in the database yet [] >>> star_wars.save() # Star Wars now exists in the database (along with the 'actor' relations) >>> [movie.title for movie in harrison_ford.movies.all()] ['Star Wars'] 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.) Introspection ------------- 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: .. code-block:: python >>> from modelcluster.models import get_all_child_relations >>> get_all_child_relations(Band) [, ] This includes relations that are defined on any superclasses of the parent model. To retrieve a list of all ParentalManyToManyFields defined on a parent model, use ``modelcluster.models.get_all_child_m2m_relations``: .. code-block:: python >>> from modelcluster.models import get_all_child_m2m_relations >>> get_all_child_m2m_relations(Movie) [] django-modelcluster-5.0.1/modelcluster/000077500000000000000000000000001360467766700202075ustar00rootroot00000000000000django-modelcluster-5.0.1/modelcluster/__init__.py000066400000000000000000000000001360467766700223060ustar00rootroot00000000000000django-modelcluster-5.0.1/modelcluster/contrib/000077500000000000000000000000001360467766700216475ustar00rootroot00000000000000django-modelcluster-5.0.1/modelcluster/contrib/__init__.py000066400000000000000000000000001360467766700237460ustar00rootroot00000000000000django-modelcluster-5.0.1/modelcluster/contrib/taggit.py000066400000000000000000000112341360467766700235010ustar00rootroot00000000000000from __future__ import unicode_literals from __future__ import absolute_import from taggit import VERSION as TAGGIT_VERSION from taggit.managers import TaggableManager, _TaggableManager from taggit.utils import require_instance_manager from modelcluster.queryset import FakeQuerySet if TAGGIT_VERSION < (0, 20, 0): raise Exception("modelcluster.contrib.taggit requires django-taggit version 0.20 or above") class _ClusterTaggableManager(_TaggableManager): @require_instance_manager def get_tagged_item_manager(self): """Return the manager that handles the relation from this instance to the tagged_item class. If content_object on the tagged_item class is defined as a ParentalKey, this will be a DeferringRelatedManager which allows writing related objects without committing them to the database. """ rel_name = self.through._meta.get_field('content_object').remote_field.get_accessor_name() return getattr(self.instance, rel_name) def get_queryset(self, extra_filters=None): if self.instance is None: # this manager is not associated with a specific model instance # (which probably means it's being invoked within a prefetch_related operation); # this means that we don't have to deal with uncommitted models/tags, and can just # use the standard taggit handler return super(_ClusterTaggableManager, self).get_queryset(extra_filters) else: # FIXME: we ought to have some way of querying the tagged item manager about whether # it has uncommitted changes, and return a real queryset (using the original taggit logic) # if not return FakeQuerySet( self.through.tag_model(), [tagged_item.tag for tagged_item in self.get_tagged_item_manager().all()] ) @require_instance_manager def add(self, *tags): tag_objs = self._to_tag_model_instances(tags) # Now write these to the relation tagged_item_manager = self.get_tagged_item_manager() for tag in tag_objs: if not tagged_item_manager.filter(tag=tag): # make an instance of the self.through model and add it to the relation tagged_item = self.through(tag=tag) tagged_item_manager.add(tagged_item) @require_instance_manager def remove(self, *tags): tagged_item_manager = self.get_tagged_item_manager() tagged_items = [ tagged_item for tagged_item in tagged_item_manager.all() if tagged_item.tag.name in tags ] tagged_item_manager.remove(*tagged_items) @require_instance_manager def set(self, *tags, **kwargs): # Ignore the 'clear' kwarg (which defaults to False) and override it to be always true; # this means that set is implemented as a clear then an add, which was the standard behaviour # prior to django-taggit 0.19 (https://github.com/alex/django-taggit/commit/6542a702b590a5cfb91ea0de218b7f71ffd07c33). # # In this way, we avoid a live database lookup that occurs in the clear=False branch. # # The clear=True behaviour is fine for our purposes; the distinction only exists in django-taggit # to ensure that the correct set of m2m_changed signals is fired, and our reimplementation here # doesn't fire them at all (which makes logical sense, because the whole point of this module is # that the add/remove/set/clear operations don't write to the database). return super(_ClusterTaggableManager, self).set(*tags, clear=True) @require_instance_manager def clear(self): self.get_tagged_item_manager().clear() class ClusterTaggableManager(TaggableManager): _need_commit_after_assignment = True def __get__(self, instance, model): # override TaggableManager's requirement for instance to have a primary key # before we can access its tags manager = _ClusterTaggableManager( through=self.through, model=model, instance=instance, prefetch_cache_name=self.name ) return manager def value_from_object(self, instance): # retrieve the queryset via the related manager on the content object, # to accommodate the possibility of this having uncommitted changes relative to # the live database rel_name = self.through._meta.get_field('content_object').remote_field.get_accessor_name() ret = getattr(instance, rel_name).all() if TAGGIT_VERSION >= (1, ): # expects a Tag list instead of TaggedItem List ret = [tagged_item.tag for tagged_item in ret] return ret django-modelcluster-5.0.1/modelcluster/fields.py000066400000000000000000000460661360467766700220430ustar00rootroot00000000000000from __future__ import unicode_literals from django.core import checks from django.db import IntegrityError, router from django.db.models import CASCADE from django.db.models.fields.related import ForeignKey, ManyToManyField from django.utils.functional import cached_property from django.db.models.fields.related import ReverseManyToOneDescriptor, ManyToManyDescriptor from modelcluster.utils import sort_by_fields from modelcluster.queryset import FakeQuerySet def create_deferring_foreign_related_manager(related, original_manager_cls): """ Create a DeferringRelatedManager class that wraps an ordinary RelatedManager with 'deferring' behaviour: any updates to the object set (via e.g. add() or clear()) are written to a holding area rather than committed to the database immediately. Writing to the database is deferred until the model is saved. """ relation_name = related.get_accessor_name() rel_field = related.field rel_model = related.related_model superclass = rel_model._default_manager.__class__ class DeferringRelatedManager(superclass): def __init__(self, instance): super(DeferringRelatedManager, self).__init__() self.model = rel_model self.instance = instance def _get_cluster_related_objects(self): # Helper to retrieve the instance's _cluster_related_objects dict, # creating it if it does not already exist try: return self.instance._cluster_related_objects except AttributeError: cluster_related_objects = {} self.instance._cluster_related_objects = cluster_related_objects return cluster_related_objects def get_live_query_set(self): # deprecated; renamed to get_live_queryset to match the move from # get_query_set to get_queryset in Django 1.6 return self.get_live_queryset() def get_live_queryset(self): """ return the original manager's queryset, which reflects the live database """ return original_manager_cls(self.instance).get_queryset() def get_queryset(self): """ return the current object set with any updates applied, wrapped up in a FakeQuerySet if it doesn't match the database state """ try: results = self.instance._cluster_related_objects[relation_name] except (AttributeError, KeyError): return self.get_live_queryset() return FakeQuerySet(related.related_model, results) def _apply_rel_filters(self, queryset): # Implemented as empty for compatibility sake # But there is probably a better implementation of this function return queryset._next_is_sticky() def get_prefetch_queryset(self, instances, queryset=None): if queryset is None: db = self._db or router.db_for_read(self.model, instance=instances[0]) queryset = super(DeferringRelatedManager, self).get_queryset().using(db) rel_obj_attr = rel_field.get_local_related_value instance_attr = rel_field.get_foreign_related_value instances_dict = dict((instance_attr(inst), inst) for inst in instances) query = {'%s__in' % rel_field.name: instances} qs = queryset.filter(**query) # Since we just bypassed this class' get_queryset(), we must manage # the reverse relation manually. for rel_obj in qs: instance = instances_dict[rel_obj_attr(rel_obj)] setattr(rel_obj, rel_field.name, instance) cache_name = rel_field.related_query_name() return qs, rel_obj_attr, instance_attr, False, cache_name, False def get_object_list(self): """ return the mutable list that forms the current in-memory state of this relation. If there is no such list (i.e. the manager is returning querysets from the live database instead), one is created, populating it with the live database state """ cluster_related_objects = self._get_cluster_related_objects() try: object_list = cluster_related_objects[relation_name] except KeyError: object_list = list(self.get_live_queryset()) cluster_related_objects[relation_name] = object_list return object_list def add(self, *new_items): """ Add the passed items to the stored object set, but do not commit them to the database """ items = self.get_object_list() for target in new_items: item_matched = False for i, item in enumerate(items): if item == target: # Replace the matched item with the new one. This ensures that any # modifications to that item's fields take effect within the recordset - # i.e. we can perform a virtual UPDATE to an object in the list # by calling add(updated_object). Which is semantically a bit dubious, # but it does the job... items[i] = target item_matched = True break if not item_matched: items.append(target) # update the foreign key on the added item to point back to the parent instance setattr(target, related.field.name, self.instance) # Sort list if rel_model._meta.ordering and len(items) > 1: sort_by_fields(items, rel_model._meta.ordering) def remove(self, *items_to_remove): """ Remove the passed items from the stored object set, but do not commit the change to the database """ items = self.get_object_list() # filter items list in place: see http://stackoverflow.com/a/1208792/1853523 items[:] = [item for item in items if item not in items_to_remove] def create(self, **kwargs): items = self.get_object_list() new_item = related.related_model(**kwargs) items.append(new_item) return new_item def clear(self): """ Clear the stored object set, without affecting the database """ self.set([]) def set(self, objs, bulk=True, clear=False): # cast objs to a list so that: # 1) we can call len() on it (which we can't do on, say, a queryset) # 2) if we need to sort it, we can do so without mutating the original objs = list(objs) cluster_related_objects = self._get_cluster_related_objects() for obj in objs: # update the foreign key on the added item to point back to the parent instance setattr(obj, related.field.name, self.instance) # Clone and sort the 'objs' list, if necessary if rel_model._meta.ordering and len(objs) > 1: sort_by_fields(objs, rel_model._meta.ordering) cluster_related_objects[relation_name] = objs def commit(self): """ Apply any changes made to the stored object set to the database. Any objects removed from the initial set will be deleted entirely from the database. """ if self.instance.pk is None: raise IntegrityError("Cannot commit relation %r on an unsaved model" % relation_name) try: final_items = self.instance._cluster_related_objects[relation_name] except (AttributeError, KeyError): # _cluster_related_objects entry never created => no changes to make return original_manager = original_manager_cls(self.instance) live_items = list(original_manager.get_queryset()) for item in live_items: if item not in final_items: item.delete() for item in final_items: # Django 1.9+ bulk updates items by default which assumes # that they have already been saved to the database. # Disable this behaviour. # https://code.djangoproject.com/ticket/18556 # https://github.com/django/django/commit/adc0c4fbac98f9cb975e8fa8220323b2de638b46 original_manager.add(item, bulk=False) # purge the _cluster_related_objects entry, so we switch back to live SQL del self.instance._cluster_related_objects[relation_name] return DeferringRelatedManager class ChildObjectsDescriptor(ReverseManyToOneDescriptor): def __get__(self, instance, instance_type=None): if instance is None: return self return self.child_object_manager_cls(instance) def __set__(self, instance, value): manager = self.__get__(instance) manager.set(value) @cached_property def child_object_manager_cls(self): return create_deferring_foreign_related_manager(self.rel, self.related_manager_cls) class ParentalKey(ForeignKey): related_accessor_class = ChildObjectsDescriptor def __init__(self, *args, **kwargs): kwargs.setdefault('on_delete', CASCADE) super(ParentalKey, self).__init__(*args, **kwargs) def check(self, **kwargs): from modelcluster.models import ClusterableModel errors = super(ParentalKey, self).check(**kwargs) # Check that the destination model is a subclass of ClusterableModel. # If self.rel.to is a string at this point, it means that Django has been unable # to resolve it as a model name; if so, skip this test so that Django's own # system checks can report the appropriate error if isinstance(self.remote_field.model, type) and not issubclass(self.remote_field.model, ClusterableModel): errors.append( checks.Error( 'ParentalKey must point to a subclass of ClusterableModel.', hint='Change {model_name} into a ClusterableModel or use a ForeignKey instead.'.format( model_name=self.remote_field.model._meta.app_label + '.' + self.remote_field.model.__name__, ), obj=self, id='modelcluster.E001', ) ) # ParentalKeys must have an accessor name (#49) if self.remote_field.get_accessor_name() == '+': errors.append( checks.Error( "related_name='+' is not allowed on ParentalKey fields", hint="Either change it to a valid name or remove it", obj=self, id='modelcluster.E002', ) ) return errors def create_deferring_forward_many_to_many_manager(rel, original_manager_cls): relation_name = rel.field.name rel_model = rel.model superclass = rel_model._default_manager.__class__ class DeferringManyRelatedManager(superclass): def __init__(self, instance=None): super(DeferringManyRelatedManager, self).__init__() self.model = rel_model self.instance = instance def get_original_manager(self): return original_manager_cls(self.instance) def get_live_queryset(self): """ return the original manager's queryset, which reflects the live database """ return self.get_original_manager().get_queryset() def _get_cluster_related_objects(self): # Helper to retrieve the instance's _cluster_related_objects dict, # creating it if it does not already exist try: return self.instance._cluster_related_objects except AttributeError: cluster_related_objects = {} self.instance._cluster_related_objects = cluster_related_objects return cluster_related_objects def get_queryset(self): """ return the current object set with any updates applied, wrapped up in a FakeQuerySet if it doesn't match the database state """ try: results = self.instance._cluster_related_objects[relation_name] except (AttributeError, KeyError): if self.instance.pk: return self.get_live_queryset() else: # the standard M2M manager fails on unsaved instances, # so bypass it and return an empty queryset return rel_model.objects.none() return FakeQuerySet(rel_model, results) def get_object_list(self): """ return the mutable list that forms the current in-memory state of this relation. If there is no such list (i.e. the manager is returning querysets from the live database instead), one is created, populating it with the live database state """ cluster_related_objects = self._get_cluster_related_objects() try: object_list = cluster_related_objects[relation_name] except KeyError: object_list = list(self.get_live_queryset()) cluster_related_objects[relation_name] = object_list return object_list def add(self, *new_items): """ Add the passed items to the stored object set, but do not commit them to the database """ items = self.get_object_list() for target in new_items: if target.pk is None: raise ValueError('"%r" needs to have a primary key value before ' 'it can be added to a parental many-to-many relation.' % target) item_matched = False for i, item in enumerate(items): if item == target: # Replace the matched item with the new one. This ensures that any # modifications to that item's fields take effect within the recordset - # i.e. we can perform a virtual UPDATE to an object in the list # by calling add(updated_object). Which is semantically a bit dubious, # but it does the job... items[i] = target item_matched = True break if not item_matched: items.append(target) # Sort list if rel_model._meta.ordering and len(items) > 1: sort_by_fields(items, rel_model._meta.ordering) def clear(self): """ Clear the stored object set, without affecting the database """ self.set([]) def set(self, objs, bulk=True, clear=False): # cast objs to a list so that: # 1) we can call len() on it (which we can't do on, say, a queryset) # 2) if we need to sort it, we can do so without mutating the original objs = list(objs) if objs and not isinstance(objs[0], rel_model): # assume objs is a list of pks (like when loading data from a # fixture), and allow the orignal manager to handle things original_manager = self.get_original_manager() original_manager.set(objs) return cluster_related_objects = self._get_cluster_related_objects() # Clone and sort the 'objs' list, if necessary if rel_model._meta.ordering and len(objs) > 1: sort_by_fields(objs, rel_model._meta.ordering) cluster_related_objects[relation_name] = objs def remove(self, *items_to_remove): """ Remove the passed items from the stored object set, but do not commit the change to the database """ items = self.get_object_list() # filter items list in place: see http://stackoverflow.com/a/1208792/1853523 items[:] = [item for item in items if item not in items_to_remove] def commit(self): """ Apply any changes made to the stored object set to the database. """ if not self.instance.pk: raise IntegrityError("Cannot commit relation %r on an unsaved model" % relation_name) try: final_items = self.instance._cluster_related_objects[relation_name] except (AttributeError, KeyError): # _cluster_related_objects entry never created => no changes to make return original_manager = self.get_original_manager() live_items = list(original_manager.get_queryset()) items_to_remove = [item for item in live_items if item not in final_items] items_to_add = [item for item in final_items if item not in live_items] if items_to_remove: original_manager.remove(*items_to_remove) if items_to_add: original_manager.add(*items_to_add) # purge the _cluster_related_objects entry, so we switch back to live SQL del self.instance._cluster_related_objects[relation_name] return DeferringManyRelatedManager class ParentalManyToManyDescriptor(ManyToManyDescriptor): def __get__(self, instance, instance_type=None): if instance is None: return self return self.child_object_manager_cls(instance) def __set__(self, instance, value): manager = self.__get__(instance) manager.set(value) @cached_property def child_object_manager_cls(self): rel = self.rel return create_deferring_forward_many_to_many_manager(rel, self.related_manager_cls) class ParentalManyToManyField(ManyToManyField): related_accessor_class = ParentalManyToManyDescriptor _need_commit_after_assignment = True def contribute_to_class(self, cls, name, **kwargs): # ManyToManyField does not (as of Django 1.10) respect related_accessor_class, # but hard-codes ManyToManyDescriptor instead: # https://github.com/django/django/blob/6157cd6da1b27716e8f3d1ed692a6e33d970ae46/django/db/models/fields/related.py#L1538 # So, we'll let the original contribute_to_class do its thing, and then overwrite # the accessor... super(ParentalManyToManyField, self).contribute_to_class(cls, name, **kwargs) setattr(cls, self.name, self.related_accessor_class(self.remote_field)) def value_from_object(self, obj): # In Django >=1.10, ManyToManyField.value_from_object special-cases objects with no PK, # returning an empty list on the basis that unsaved objects can't have related objects. # Remove that special case. return getattr(obj, self.attname).all() django-modelcluster-5.0.1/modelcluster/forms.py000066400000000000000000000403411360467766700217110ustar00rootroot00000000000000from __future__ import unicode_literals from django.forms import ValidationError from django.core.exceptions import NON_FIELD_ERRORS from django.forms.formsets import TOTAL_FORM_COUNT from django.forms.models import ( BaseModelFormSet, modelformset_factory, ModelForm, _get_foreign_key, ModelFormMetaclass, ModelFormOptions ) from django.db.models.fields.related import ForeignObjectRel from modelcluster.models import get_all_child_relations class BaseTransientModelFormSet(BaseModelFormSet): """ A ModelFormSet that doesn't assume that all its initial data instances exist in the db """ def _construct_form(self, i, **kwargs): # Need to override _construct_form to avoid calling to_python on an empty string PK value if self.is_bound and i < self.initial_form_count(): pk_key = "%s-%s" % (self.add_prefix(i), self.model._meta.pk.name) pk = self.data[pk_key] if pk == '': kwargs['instance'] = self.model() else: pk_field = self.model._meta.pk to_python = self._get_to_python(pk_field) pk = to_python(pk) kwargs['instance'] = self._existing_object(pk) if i < self.initial_form_count() and 'instance' not in kwargs: kwargs['instance'] = self.get_queryset()[i] if i >= self.initial_form_count() and self.initial_extra: # Set initial values for extra forms try: kwargs['initial'] = self.initial_extra[i - self.initial_form_count()] except IndexError: pass # bypass BaseModelFormSet's own _construct_form return super(BaseModelFormSet, self)._construct_form(i, **kwargs) def save_existing_objects(self, commit=True): # Need to override _construct_form so that it doesn't skip over initial forms whose instance # has a blank PK (which is taken as an indication that the form was constructed with an # instance not present in our queryset) self.changed_objects = [] self.deleted_objects = [] if not self.initial_forms: return [] saved_instances = [] forms_to_delete = self.deleted_forms for form in self.initial_forms: obj = form.instance if form in forms_to_delete: if obj.pk is None: # no action to be taken to delete an object which isn't in the database continue self.deleted_objects.append(obj) self.delete_existing(obj, commit=commit) elif form.has_changed(): self.changed_objects.append((obj, form.changed_data)) saved_instances.append(self.save_existing(form, obj, commit=commit)) if not commit: self.saved_forms.append(form) return saved_instances def transientmodelformset_factory(model, formset=BaseTransientModelFormSet, **kwargs): return modelformset_factory(model, formset=formset, **kwargs) class BaseChildFormSet(BaseTransientModelFormSet): def __init__(self, data=None, files=None, instance=None, queryset=None, **kwargs): if instance is None: self.instance = self.fk.remote_field.model() else: self.instance = instance self.rel_name = ForeignObjectRel(self.fk, self.fk.remote_field.model, related_name=self.fk.remote_field.related_name).get_accessor_name() if queryset is None: queryset = getattr(self.instance, self.rel_name).all() super(BaseChildFormSet, self).__init__(data, files, queryset=queryset, **kwargs) def save(self, commit=True): # The base ModelFormSet's save(commit=False) will populate the lists # self.changed_objects, self.deleted_objects and self.new_objects; # use these to perform the appropriate updates on the relation's manager. saved_instances = super(BaseChildFormSet, self).save(commit=False) manager = getattr(self.instance, self.rel_name) # if model has a sort_order_field defined, assign order indexes to the attribute # named in it if self.can_order and hasattr(self.model, 'sort_order_field'): sort_order_field = getattr(self.model, 'sort_order_field') for i, form in enumerate(self.ordered_forms): setattr(form.instance, sort_order_field, i) # If the manager has existing instances with a blank ID, we have no way of knowing # whether these correspond to items in the submitted data. We'll assume that they do, # as that's the most common case (i.e. the formset contains the full set of child objects, # not just a selection of additions / updates) and so we delete all ID-less objects here # on the basis that they will be re-added by the formset saving mechanism. no_id_instances = [obj for obj in manager.all() if obj.pk is None] if no_id_instances: manager.remove(*no_id_instances) manager.add(*saved_instances) manager.remove(*self.deleted_objects) self.save_m2m() # ensures any parental-m2m fields are saved. if commit: manager.commit() return saved_instances def clean(self, *args, **kwargs): self.validate_unique() return super(BaseChildFormSet, self).clean(*args, **kwargs) def validate_unique(self): '''This clean method will check for unique_together condition''' # Collect unique_checks and to run from all the forms. all_unique_checks = set() all_date_checks = set() forms_to_delete = self.deleted_forms valid_forms = [form for form in self.forms if form.is_valid() and form not in forms_to_delete] for form in valid_forms: unique_checks, date_checks = form.instance._get_unique_checks() all_unique_checks.update(unique_checks) all_date_checks.update(date_checks) errors = [] # Do each of the unique checks (unique and unique_together) for uclass, unique_check in all_unique_checks: seen_data = set() for form in valid_forms: # Get the data for the set of fields that must be unique among the forms. row_data = ( field if field in self.unique_fields else form.cleaned_data[field] for field in unique_check if field in form.cleaned_data ) # Reduce Model instances to their primary key values row_data = tuple(d._get_pk_val() if hasattr(d, '_get_pk_val') else d for d in row_data) if row_data and None not in row_data: # if we've already seen it then we have a uniqueness failure if row_data in seen_data: # poke error messages into the right places and mark # the form as invalid errors.append(self.get_unique_error_message(unique_check)) form._errors[NON_FIELD_ERRORS] = self.error_class([self.get_form_error()]) # remove the data from the cleaned_data dict since it was invalid for field in unique_check: if field in form.cleaned_data: del form.cleaned_data[field] # mark the data as seen seen_data.add(row_data) if errors: raise ValidationError(errors) def childformset_factory( parent_model, model, form=ModelForm, formset=BaseChildFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, validate_max=False, formfield_callback=None, widgets=None, min_num=None, validate_min=False ): fk = _get_foreign_key(parent_model, model, fk_name=fk_name) # enforce a max_num=1 when the foreign key to the parent model is unique. if fk.unique: max_num = 1 validate_max = True if exclude is None: exclude = [] exclude += [fk.name] kwargs = { 'form': form, 'formfield_callback': formfield_callback, 'formset': formset, 'extra': extra, 'can_delete': can_delete, # if the model supplies a sort_order_field, enable ordering regardless of # the current setting of can_order 'can_order': (can_order or hasattr(model, 'sort_order_field')), 'fields': fields, 'exclude': exclude, 'max_num': max_num, 'validate_max': validate_max, 'widgets': widgets, 'min_num': min_num, 'validate_min': validate_min, } FormSet = transientmodelformset_factory(model, **kwargs) FormSet.fk = fk return FormSet class ClusterFormOptions(ModelFormOptions): def __init__(self, options=None): super(ClusterFormOptions, self).__init__(options=options) self.formsets = getattr(options, 'formsets', None) self.exclude_formsets = getattr(options, 'exclude_formsets', None) class ClusterFormMetaclass(ModelFormMetaclass): extra_form_count = 3 @classmethod def child_form(cls): return ClusterForm def __new__(cls, name, bases, attrs): try: parents = [b for b in bases if issubclass(b, ClusterForm)] except NameError: # We are defining ClusterForm itself. parents = None # grab any formfield_callback that happens to be defined in attrs - # so that we can pass it on to child formsets - before ModelFormMetaclass deletes it. # BAD METACLASS NO BISCUIT. formfield_callback = attrs.get('formfield_callback') new_class = super(ClusterFormMetaclass, cls).__new__(cls, name, bases, attrs) if not parents: return new_class # ModelFormMetaclass will have set up new_class._meta as a ModelFormOptions instance; # replace that with ClusterFormOptions so that we can access _meta.formsets opts = new_class._meta = ClusterFormOptions(getattr(new_class, 'Meta', None)) if opts.model: formsets = {} for rel in get_all_child_relations(opts.model): # to build a childformset class from this relation, we need to specify: # - the base model (opts.model) # - the child model (rel.field.model) # - the fk_name from the child model to the base (rel.field.name) rel_name = rel.get_accessor_name() # apply 'formsets' and 'exclude_formsets' rules from meta if opts.formsets is not None and rel_name not in opts.formsets: continue if opts.exclude_formsets and rel_name in opts.exclude_formsets: continue try: widgets = opts.widgets.get(rel_name) except AttributeError: # thrown if opts.widgets is None widgets = None kwargs = { 'extra': cls.extra_form_count, 'formfield_callback': formfield_callback, 'fk_name': rel.field.name, 'widgets': widgets } # see if opts.formsets looks like a dict; if so, allow the value # to override kwargs try: kwargs.update(opts.formsets.get(rel_name)) except AttributeError: pass formset = childformset_factory(opts.model, rel.field.model, form=cls.child_form(), **kwargs) formsets[rel_name] = formset new_class.formsets = formsets new_class._has_explicit_formsets = (opts.formsets is not None or opts.exclude_formsets is not None) return new_class class ClusterForm(ModelForm, metaclass=ClusterFormMetaclass): def __init__(self, data=None, files=None, instance=None, prefix=None, **kwargs): super(ClusterForm, self).__init__(data, files, instance=instance, prefix=prefix, **kwargs) self.formsets = {} for rel_name, formset_class in self.__class__.formsets.items(): if prefix: formset_prefix = "%s-%s" % (prefix, rel_name) else: formset_prefix = rel_name self.formsets[rel_name] = formset_class(data, files, instance=instance, prefix=formset_prefix) if self.is_bound and not self._has_explicit_formsets: # check which formsets have actually been provided as part of the form submission - # if no `formsets` or `exclude_formsets` was specified, we allow them to be omitted # (https://github.com/wagtail/wagtail/issues/5414#issuecomment-567468127). self._posted_formsets = [ formset for formset in self.formsets.values() if '%s-%s' % (formset.prefix, TOTAL_FORM_COUNT) in self.data ] else: # expect all defined formsets to be part of the post self._posted_formsets = self.formsets.values() def as_p(self): form_as_p = super(ClusterForm, self).as_p() return form_as_p + ''.join([formset.as_p() for formset in self.formsets.values()]) def is_valid(self): form_is_valid = super(ClusterForm, self).is_valid() formsets_are_valid = all(formset.is_valid() for formset in self._posted_formsets) return form_is_valid and formsets_are_valid def is_multipart(self): return ( super(ClusterForm, self).is_multipart() or any(formset.is_multipart() for formset in self.formsets.values()) ) @property def media(self): media = super(ClusterForm, self).media for formset in self.formsets.values(): media = media + formset.media return media def save(self, commit=True): # do we have any fields that expect us to call save_m2m immediately? save_m2m_now = False exclude = self._meta.exclude fields = self._meta.fields for f in self.instance._meta.get_fields(): if fields and f.name not in fields: continue if exclude and f.name in exclude: continue if getattr(f, '_need_commit_after_assignment', False): save_m2m_now = True break instance = super(ClusterForm, self).save(commit=(commit and not save_m2m_now)) # The M2M-like fields designed for use with ClusterForm (currently # ParentalManyToManyField and ClusterTaggableManager) will manage their own in-memory # relations, and not immediately write to the database when we assign to them. # For these fields (identified by the _need_commit_after_assignment # flag), save_m2m() is a safe operation that does not affect the database and is thus # valid for commit=False. In the commit=True case, committing to the database happens # in the subsequent instance.save (so this needs to happen after save_m2m to ensure # we have the updated relation data in place). # For annoying legacy reasons we sometimes need to accommodate 'classic' M2M fields # (particularly taggit.TaggableManager) within ClusterForm. These fields # generally do require our instance to exist in the database at the point we call # save_m2m() - for this reason, we only proceed with the customisation described above # (i.e. postpone the instance.save() operation until after save_m2m) if there's a # _need_commit_after_assignment field on the form that demands it. if save_m2m_now: self.save_m2m() if commit: instance.save() for formset in self._posted_formsets: formset.instance = instance formset.save(commit=commit) return instance def has_changed(self): """Return True if data differs from initial.""" # Need to recurse over nested formsets so that the form is saved if there are changes # to child forms but not the parent if self.formsets: for formset in self._posted_formsets: for form in formset.forms: if form.has_changed(): return True return bool(self.changed_data) django-modelcluster-5.0.1/modelcluster/models.py000066400000000000000000000252571360467766700220570ustar00rootroot00000000000000from __future__ import unicode_literals import json import datetime from django.core.exceptions import FieldDoesNotExist from django.db import models from django.db.models.fields.related import ForeignObjectRel from django.utils.encoding import is_protected_type from django.core.serializers.json import DjangoJSONEncoder from django.conf import settings from django.utils import timezone from modelcluster.fields import ParentalKey, ParentalManyToManyField def get_field_value(field, model): if field.remote_field is None: value = field.pre_save(model, add=model.pk is None) # Make datetimes timezone aware # https://github.com/django/django/blob/master/django/db/models/fields/__init__.py#L1394-L1403 if isinstance(value, datetime.datetime) and settings.USE_TZ: if timezone.is_naive(value): default_timezone = timezone.get_default_timezone() value = timezone.make_aware(value, default_timezone).astimezone(timezone.utc) # convert to UTC value = timezone.localtime(value, timezone.utc) if is_protected_type(value): return value else: return field.value_to_string(model) else: return getattr(model, field.get_attname()) def get_serializable_data_for_fields(model): """ Return a serialised version of the model's fields which exist as local database columns (i.e. excluding m2m and incoming foreign key relations) """ pk_field = model._meta.pk # If model is a child via multitable inheritance, use parent's pk while pk_field.remote_field and pk_field.remote_field.parent_link: pk_field = pk_field.remote_field.model._meta.pk obj = {'pk': get_field_value(pk_field, model)} for field in model._meta.fields: if field.serialize: obj[field.name] = get_field_value(field, model) return obj def model_from_serializable_data(model, data, check_fks=True, strict_fks=False): pk_field = model._meta.pk # If model is a child via multitable inheritance, use parent's pk while pk_field.remote_field and pk_field.remote_field.parent_link: pk_field = pk_field.remote_field.model._meta.pk kwargs = {pk_field.attname: data['pk']} for field_name, field_value in data.items(): try: field = model._meta.get_field(field_name) except FieldDoesNotExist: continue # Filter out reverse relations if isinstance(field, ForeignObjectRel): continue if field.remote_field and isinstance(field.remote_field, models.ManyToManyRel): related_objects = field.remote_field.model._default_manager.filter(pk__in=field_value) kwargs[field.attname] = list(related_objects) elif field.remote_field and isinstance(field.remote_field, models.ManyToOneRel): if field_value is None: kwargs[field.attname] = None else: clean_value = field.remote_field.model._meta.get_field(field.remote_field.field_name).to_python(field_value) kwargs[field.attname] = clean_value if check_fks: try: field.remote_field.model._default_manager.get(**{field.remote_field.field_name: clean_value}) except field.remote_field.model.DoesNotExist: if field.remote_field.on_delete == models.DO_NOTHING: pass elif field.remote_field.on_delete == models.CASCADE: if strict_fks: return None else: kwargs[field.attname] = None elif field.remote_field.on_delete == models.SET_NULL: kwargs[field.attname] = None else: raise Exception("can't currently handle on_delete types other than CASCADE, SET_NULL and DO_NOTHING") else: value = field.to_python(field_value) # Make sure datetimes are converted to localtime if isinstance(field, models.DateTimeField) and settings.USE_TZ and value is not None: default_timezone = timezone.get_default_timezone() if timezone.is_aware(value): value = timezone.localtime(value, default_timezone) else: value = timezone.make_aware(value, default_timezone) kwargs[field.name] = value obj = model(**kwargs) if data['pk'] is not None: # Set state to indicate that this object has come from the database, so that # ModelForm validation doesn't try to enforce a uniqueness check on the primary key obj._state.adding = False return obj def get_all_child_relations(model): """ Return a list of RelatedObject records for child relations of the given model, including ones attached to ancestors of the model """ return [ field for field in model._meta.get_fields() if isinstance(field.remote_field, ParentalKey) ] def get_all_child_m2m_relations(model): """ Return a list of ParentalManyToManyFields on the given model, including ones attached to ancestors of the model """ return [ field for field in model._meta.get_fields() if isinstance(field, ParentalManyToManyField) ] class ClusterableModel(models.Model): def __init__(self, *args, **kwargs): """ Extend the standard model constructor to allow child object lists to be passed in via kwargs """ child_relation_names = ( [rel.get_accessor_name() for rel in get_all_child_relations(self)] + [field.name for field in get_all_child_m2m_relations(self)] ) if any(name in kwargs for name in child_relation_names): # One or more child relation values is being passed in the constructor; need to # separate these from the standard field kwargs to be passed to 'super' kwargs_for_super = kwargs.copy() relation_assignments = {} for rel_name in child_relation_names: if rel_name in kwargs: relation_assignments[rel_name] = kwargs_for_super.pop(rel_name) super(ClusterableModel, self).__init__(*args, **kwargs_for_super) for (field_name, related_instances) in relation_assignments.items(): setattr(self, field_name, related_instances) else: super(ClusterableModel, self).__init__(*args, **kwargs) def save(self, **kwargs): """ Save the model and commit all child relations. """ child_relation_names = [rel.get_accessor_name() for rel in get_all_child_relations(self)] child_m2m_field_names = [field.name for field in get_all_child_m2m_relations(self)] update_fields = kwargs.pop('update_fields', None) if update_fields is None: real_update_fields = None relations_to_commit = child_relation_names m2m_fields_to_commit = child_m2m_field_names else: real_update_fields = [] relations_to_commit = [] m2m_fields_to_commit = [] for field in update_fields: if field in child_relation_names: relations_to_commit.append(field) elif field in child_m2m_field_names: m2m_fields_to_commit.append(field) else: real_update_fields.append(field) super(ClusterableModel, self).save(update_fields=real_update_fields, **kwargs) for relation in relations_to_commit: getattr(self, relation).commit() for field in m2m_fields_to_commit: getattr(self, field).commit() def serializable_data(self): obj = get_serializable_data_for_fields(self) for rel in get_all_child_relations(self): rel_name = rel.get_accessor_name() children = getattr(self, rel_name).all() if hasattr(rel.related_model, 'serializable_data'): obj[rel_name] = [child.serializable_data() for child in children] else: obj[rel_name] = [get_serializable_data_for_fields(child) for child in children] for field in get_all_child_m2m_relations(self): if field.serialize: children = getattr(self, field.name).all() obj[field.name] = [child.pk for child in children] return obj def to_json(self): return json.dumps(self.serializable_data(), cls=DjangoJSONEncoder) @classmethod def from_serializable_data(cls, data, check_fks=True, strict_fks=False): """ Build an instance of this model from the JSON-like structure passed in, recursing into related objects as required. If check_fks is true, it will check whether referenced foreign keys still exist in the database. - dangling foreign keys on related objects are dealt with by either nullifying the key or dropping the related object, according to the 'on_delete' setting. - dangling foreign keys on the base object will be nullified, unless strict_fks is true, in which case any dangling foreign keys with on_delete=CASCADE will cause None to be returned for the entire object. """ obj = model_from_serializable_data(cls, data, check_fks=check_fks, strict_fks=strict_fks) if obj is None: return None child_relations = get_all_child_relations(cls) for rel in child_relations: rel_name = rel.get_accessor_name() try: child_data_list = data[rel_name] except KeyError: continue related_model = rel.related_model if hasattr(related_model, 'from_serializable_data'): children = [ related_model.from_serializable_data(child_data, check_fks=check_fks, strict_fks=True) for child_data in child_data_list ] else: children = [ model_from_serializable_data(related_model, child_data, check_fks=check_fks, strict_fks=True) for child_data in child_data_list ] children = filter(lambda child: child is not None, children) setattr(obj, rel_name, children) return obj @classmethod def from_json(cls, json_data, check_fks=True, strict_fks=False): return cls.from_serializable_data(json.loads(json_data), check_fks=check_fks, strict_fks=strict_fks) class Meta: abstract = True django-modelcluster-5.0.1/modelcluster/queryset.py000066400000000000000000000340071360467766700224460ustar00rootroot00000000000000from __future__ import unicode_literals import datetime import re from django.db.models import Model, prefetch_related_objects from modelcluster.utils import sort_by_fields # Constructor for test functions that determine whether an object passes some boolean condition def test_exact(model, attribute_name, value): if isinstance(value, Model): if value.pk is None: # comparing against an unsaved model, so objects need to match by reference return lambda obj: getattr(obj, attribute_name) is value else: # comparing against a saved model; objects need to match by type and ID. # Additionally, where model inheritance is involved, we need to treat it as a # positive match if one is a subclass of the other def _test(obj): other_value = getattr(obj, attribute_name) if not (isinstance(value, other_value.__class__) or isinstance(other_value, value.__class__)): return False return value.pk == other_value.pk return _test else: field = model._meta.get_field(attribute_name) # convert value to the correct python type for this field typed_value = field.to_python(value) # just a plain Python value = do a normal equality check return lambda obj: getattr(obj, attribute_name) == typed_value def test_iexact(model, attribute_name, match_value): field = model._meta.get_field(attribute_name) match_value = field.to_python(match_value) if match_value is None: return lambda obj: getattr(obj, attribute_name) is None else: match_value = match_value.upper() def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.upper() == match_value return _test def test_contains(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and match_value in val return _test def test_icontains(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value).upper() def _test(obj): val = getattr(obj, attribute_name) return val is not None and match_value in val.upper() return _test def test_lt(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val < match_value return _test def test_lte(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val <= match_value return _test def test_gt(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val > match_value return _test def test_gte(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val >= match_value return _test def test_in(model, attribute_name, value_list): field = model._meta.get_field(attribute_name) match_values = set(field.to_python(val) for val in value_list) return lambda obj: getattr(obj, attribute_name) in match_values def test_startswith(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.startswith(match_value) return _test def test_istartswith(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value).upper() def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.upper().startswith(match_value) return _test def test_endswith(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.endswith(match_value) return _test def test_iendswith(model, attribute_name, value): field = model._meta.get_field(attribute_name) match_value = field.to_python(value).upper() def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.upper().endswith(match_value) return _test def test_range(model, attribute_name, range_val): field = model._meta.get_field(attribute_name) start_val = field.to_python(range_val[0]) end_val = field.to_python(range_val[1]) def _test(obj): val = getattr(obj, attribute_name) return (val is not None and val >= start_val and val <= end_val) return _test def test_date(model, attribute_name, match_value): def _test(obj): val = getattr(obj, attribute_name) if isinstance(val, datetime.datetime): return val.date() == match_value else: return val == match_value return _test def test_year(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.year == match_value return _test def test_month(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.month == match_value return _test def test_day(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.day == match_value return _test def test_week(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.isocalendar()[1] == match_value return _test def test_week_day(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.isoweekday() % 7 + 1 == match_value return _test def test_quarter(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and int((val.month - 1) / 3) + 1 == match_value return _test def test_time(model, attribute_name, match_value): def _test(obj): val = getattr(obj, attribute_name) if isinstance(val, datetime.datetime): return val.time() == match_value else: return val == match_value return _test def test_hour(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.hour == match_value return _test def test_minute(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.minute == match_value return _test def test_second(model, attribute_name, match_value): match_value = int(match_value) def _test(obj): val = getattr(obj, attribute_name) return val is not None and val.second == match_value return _test def test_isnull(model, attribute_name, sense): if sense: return lambda obj: getattr(obj, attribute_name) is None else: return lambda obj: getattr(obj, attribute_name) is not None def test_regex(model, attribute_name, regex_string): regex = re.compile(regex_string) def _test(obj): val = getattr(obj, attribute_name) return val is not None and regex.search(val) return _test def test_iregex(model, attribute_name, regex_string): regex = re.compile(regex_string, re.I) def _test(obj): val = getattr(obj, attribute_name) return val is not None and regex.search(val) return _test FILTER_EXPRESSION_TOKENS = { 'exact': test_exact, 'iexact': test_iexact, 'contains': test_contains, 'icontains': test_icontains, 'lt': test_lt, 'lte': test_lte, 'gt': test_gt, 'gte': test_gte, 'in': test_in, 'startswith': test_startswith, 'istartswith': test_istartswith, 'endswith': test_endswith, 'iendswith': test_iendswith, 'range': test_range, 'date': test_date, 'year': test_year, 'month': test_month, 'day': test_day, 'week': test_week, 'week_day': test_week_day, 'quarter': test_quarter, 'time': test_time, 'hour': test_hour, 'minute': test_minute, 'second': test_second, 'isnull': test_isnull, 'regex': test_regex, 'iregex': test_iregex, } def _build_test_function_from_filter(model, key_clauses, val): # Translate a filter kwarg rule (e.g. foo__bar__exact=123) into a function which can # take a model instance and return a boolean indicating whether it passes the rule if len(key_clauses) == 1: # key is a single clause; treat as an exact match test return test_exact(model, key_clauses[0], val) elif len(key_clauses) == 2 and key_clauses[1] in FILTER_EXPRESSION_TOKENS: # second clause indicates the type of test constructor = FILTER_EXPRESSION_TOKENS[key_clauses[1]] return constructor(model, key_clauses[0], val) else: raise NotImplementedError("Filter expression not supported: %s" % '__'.join(key_clauses)) class FakeQuerySet(object): def __init__(self, model, results): self.model = model self.results = results def all(self): return self def _get_filters(self, **kwargs): # a list of test functions; objects must pass all tests to be included # in the filtered list filters = [] for key, val in kwargs.items(): key_clauses = key.split('__') filters.append( _build_test_function_from_filter(self.model, key_clauses, val) ) return filters def filter(self, **kwargs): filters = self._get_filters(**kwargs) filtered_results = [ obj for obj in self.results if all([test(obj) for test in filters]) ] return FakeQuerySet(self.model, filtered_results) def exclude(self, **kwargs): filters = self._get_filters(**kwargs) filtered_results = [ obj for obj in self.results if not all([test(obj) for test in filters]) ] return FakeQuerySet(self.model, filtered_results) def get(self, **kwargs): results = self.filter(**kwargs) result_count = results.count() if result_count == 0: raise self.model.DoesNotExist("%s matching query does not exist." % self.model._meta.object_name) elif result_count == 1: return results[0] else: raise self.model.MultipleObjectsReturned( "get() returned more than one %s -- it returned %s!" % (self.model._meta.object_name, result_count) ) def count(self): return len(self.results) def exists(self): return bool(self.results) def first(self): if self.results: return self.results[0] def last(self): if self.results: return self.results[-1] def select_related(self, *args): # has no meaningful effect on non-db querysets return self def prefetch_related(self, *args): prefetch_related_objects(self.results, *args) return self def values_list(self, *fields, **kwargs): # FIXME: values_list should return an object that behaves like both a queryset and a list, # so that we can do things like Foo.objects.values_list('id').order_by('id') flat = kwargs.get('flat') # TODO: throw TypeError if other kwargs are present if not fields: # return a tuple of all fields field_names = [field.name for field in self.model._meta.fields] return [ tuple([getattr(obj, field_name) for field_name in field_names]) for obj in self.results ] if flat: if len(fields) > 1: raise TypeError("'flat' is not valid when values_list is called with more than one field.") field_name = fields[0] return [getattr(obj, field_name) for obj in self.results] else: return [ tuple([getattr(obj, field_name) for field_name in fields]) for obj in self.results ] def order_by(self, *fields): results = self.results[:] # make a copy of results sort_by_fields(results, fields) return FakeQuerySet(self.model, results) # a standard QuerySet will store the results in _result_cache on running the query; # this is effectively the same as self.results on a FakeQuerySet, and so we'll make # _result_cache an alias of self.results for the benefit of Django internals that # exploit it def _get_result_cache(self): return self.results def _set_result_cache(self, val): self.results = list(val) _result_cache = property(_get_result_cache, _set_result_cache) def __getitem__(self, k): return self.results[k] def __iter__(self): return self.results.__iter__() def __nonzero__(self): return bool(self.results) def __repr__(self): return repr(list(self)) def __len__(self): return len(self.results) ordered = True # results are returned in a consistent order django-modelcluster-5.0.1/modelcluster/tags.py000066400000000000000000000003011360467766700215110ustar00rootroot00000000000000import warnings from modelcluster.contrib.taggit import * # NOQA warnings.warn( "The modelcluster.tags module has been moved to " "modelcluster.contrib.taggit", DeprecationWarning) django-modelcluster-5.0.1/modelcluster/utils.py000066400000000000000000000016171360467766700217260ustar00rootroot00000000000000def sort_by_fields(items, fields): """ Sort a list of objects on the given fields. The field list works analogously to queryset.order_by(*fields): each field is either a property of the object, or is prefixed by '-' (e.g. '-name') to indicate reverse ordering. """ # To get the desired behaviour, we need to order by keys in reverse order # See: https://docs.python.org/2/howto/sorting.html#sort-stability-and-complex-sorts for key in reversed(fields): # Check if this key has been reversed reverse = False if key[0] == '-': reverse = True key = key[1:] # Sort # Use a tuple of (v is not None, v) as the key, to ensure that None sorts before other values, # as comparing directly with None breaks on python3 items.sort(key=lambda x: (getattr(x, key) is not None, getattr(x, key)), reverse=reverse) django-modelcluster-5.0.1/runtests.py000077500000000000000000000006761360467766700177620ustar00rootroot00000000000000#!/usr/bin/env python import os import shutil import sys from django.core.management import execute_from_command_line from django.conf import settings os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' def runtests(): argv = sys.argv[:1] + ['test'] + sys.argv[1:] try: execute_from_command_line(argv) finally: shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) if __name__ == '__main__': runtests() django-modelcluster-5.0.1/setup.cfg000066400000000000000000000000771360467766700173320ustar00rootroot00000000000000[bdist_wheel] universal = 1 [metadata] license_file = LICENSE django-modelcluster-5.0.1/setup.py000066400000000000000000000024431360467766700172220ustar00rootroot00000000000000#!/usr/bin/env python try: from setuptools import setup, find_packages except ImportError: from distutils.core import setup setup( name='django-modelcluster', version='5.0.1', description="Django extension to allow working with 'clusters' of models as a single unit, independently of the database", author='Matthew Westcott', author_email='matthew.westcott@torchbox.com', url='https://github.com/wagtail/django-modelcluster', packages=find_packages(exclude=('tests*',)), license='BSD', long_description=open('README.rst').read(), python_requires=">=3.5", install_requires=[ "pytz>=2015.2", ], extras_require={ 'taggit': ['django-taggit>=0.20'], }, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3 :: Only', 'Framework :: Django', ], ) django-modelcluster-5.0.1/shell.py000077500000000000000000000004671360467766700172000ustar00rootroot00000000000000#!/usr/bin/env python import os import sys from django.core.management import execute_from_command_line os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' def runshell(): argv = sys.argv[:1] + ['shell'] + sys.argv[1:] execute_from_command_line(argv) if __name__ == '__main__': runshell() django-modelcluster-5.0.1/tests/000077500000000000000000000000001360467766700166475ustar00rootroot00000000000000django-modelcluster-5.0.1/tests/__init__.py000066400000000000000000000000001360467766700207460ustar00rootroot00000000000000django-modelcluster-5.0.1/tests/fixtures/000077500000000000000000000000001360467766700205205ustar00rootroot00000000000000django-modelcluster-5.0.1/tests/fixtures/parentalmanytomany-to-ordered-model.json000066400000000000000000000010031360467766700304700ustar00rootroot00000000000000[ { "model": "tests.house", "pk": 1, "fields": { "name": "Weekend home", "address": "1 Homely Drive, Hometown", "owner": null, "main_room": null } }, { "model": "tests.house", "pk": 2, "fields": { "name": "Midweek home", "address": "1 Business Park, Worktown", "owner": null, "main_room": null } }, { "model": "tests.person", "pk": 1, "fields": { "name": "Mr Two Houses", "houses": [1, 2] } } ] django-modelcluster-5.0.1/tests/migrations/000077500000000000000000000000001360467766700210235ustar00rootroot00000000000000django-modelcluster-5.0.1/tests/migrations/0001_initial.py000066400000000000000000000157401360467766700234750ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals from django.db import models, migrations import modelcluster.fields import django.db.models.deletion import modelcluster.contrib.taggit class Migration(migrations.Migration): dependencies = [ ('taggit', '0001_initial'), ] operations = [ migrations.CreateModel( name='Album', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ('release_date', models.DateField(null=True, blank=True)), ('sort_order', models.IntegerField(null=True, editable=False, blank=True)), ], options={ 'ordering': ['sort_order'], }, ), migrations.CreateModel( name='Band', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='BandMember', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ('band', modelcluster.fields.ParentalKey(related_name='members', to='tests.Band', on_delete=django.db.models.deletion.CASCADE)), ], ), migrations.CreateModel( name='Chef', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ], ), migrations.CreateModel( name='Dish', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ], ), migrations.CreateModel( name='Log', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('time', models.DateTimeField(null=True, blank=True)), ('data', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MenuItem', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('price', models.DecimalField(max_digits=6, decimal_places=2)), ('dish', models.ForeignKey(related_name='+', to='tests.Dish', on_delete=django.db.models.deletion.CASCADE)), ], ), migrations.CreateModel( name='Place', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='Review', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('author', models.CharField(max_length=255)), ('body', models.TextField()), ], ), migrations.CreateModel( name='TaggedPlace', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='Wine', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ], ), migrations.CreateModel( name='Restaurant', fields=[ ('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)), ('serves_hot_dogs', models.BooleanField(default=False)), ('proprietor', models.ForeignKey(related_name='restaurants', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='tests.Chef', null=True)), ], options={ 'abstract': False, }, bases=('tests.place',), ), migrations.CreateModel( name='Document', fields=[ ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), ('title', models.CharField(max_length=255)), ('file', models.FileField(upload_to='documents')), ], options={ 'abstract': False, }, ), migrations.AddField( model_name='taggedplace', name='content_object', field=modelcluster.fields.ParentalKey(related_name='tagged_items', to='tests.Place', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='taggedplace', name='tag', field=models.ForeignKey(related_name='tests_taggedplace_items', to='taggit.Tag', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='review', name='place', field=modelcluster.fields.ParentalKey(related_name='reviews', to='tests.Place', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='place', name='tags', field=modelcluster.contrib.taggit.ClusterTaggableManager(to='taggit.Tag', through='tests.TaggedPlace', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags'), ), migrations.AddField( model_name='menuitem', name='recommended_wine', field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='tests.Wine', null=True), ), migrations.AddField( model_name='album', name='band', field=modelcluster.fields.ParentalKey(related_name='albums', to='tests.Band', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='menuitem', name='restaurant', field=modelcluster.fields.ParentalKey(related_name='menu_items', to='tests.Restaurant', on_delete=django.db.models.deletion.CASCADE), ), ] django-modelcluster-5.0.1/tests/migrations/0002_add_m2m_models.py000066400000000000000000000033261360467766700247100ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-09 23:32 from __future__ import unicode_literals from django.db import migrations, models import modelcluster.fields class Migration(migrations.Migration): dependencies = [ ('tests', '0001_initial'), ] operations = [ migrations.CreateModel( name='Article', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='Author', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=255)), ], options={ 'ordering': ['name'] }, ), migrations.CreateModel( name='Category', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=255)), ], ), migrations.AddField( model_name='article', name='authors', field=modelcluster.fields.ParentalManyToManyField(related_name='articles_by_author', to='tests.Author'), ), migrations.AddField( model_name='article', name='categories', field=modelcluster.fields.ParentalManyToManyField(related_name='articles_by_category', to='tests.Category'), ), ] django-modelcluster-5.0.1/tests/migrations/0003_gallery_galleryimage.py000066400000000000000000000021671360467766700262260ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-03-09 16:13 from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion import modelcluster.fields class Migration(migrations.Migration): dependencies = [ ('tests', '0002_add_m2m_models'), ] operations = [ migrations.CreateModel( name='Gallery', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='GalleryImage', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('image', models.FileField(upload_to='')), ('gallery', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='tests.Gallery')), ], ), ] django-modelcluster-5.0.1/tests/migrations/0004_auto_20170406_1734.py000066400000000000000000000031041360467766700244470ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-04-06 22:34 from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion import taggit.managers class Migration(migrations.Migration): dependencies = [ ('taggit', '0002_auto_20150616_2121'), ('tests', '0003_gallery_galleryimage'), ] operations = [ migrations.CreateModel( name='NonClusterPlace', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=255)), ], ), migrations.CreateModel( name='TaggedNonClusterPlace', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='tests.NonClusterPlace')), ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_taggednonclusterplace_items', to='taggit.Tag')), ], options={ 'abstract': False, }, ), migrations.AddField( model_name='nonclusterplace', name='tags', field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='tests.TaggedNonClusterPlace', to='taggit.Tag', verbose_name='Tags'), ), ] django-modelcluster-5.0.1/tests/migrations/0005_article_fk_to_newspaper.py000066400000000000000000000016721360467766700267400ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-05 12:48 from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion import modelcluster.fields class Migration(migrations.Migration): dependencies = [ ('tests', '0004_auto_20170406_1734'), ] operations = [ migrations.CreateModel( name='NewsPaper', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.AddField( model_name='article', name='paper', field=modelcluster.fields.ParentalKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tests.NewsPaper'), ), ] django-modelcluster-5.0.1/tests/migrations/0006_auto_20171109_0614.py000066400000000000000000000025051360467766700244520ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-11-09 12:14 from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion import modelcluster.contrib.taggit import modelcluster.fields class Migration(migrations.Migration): dependencies = [ ('taggit', '0002_auto_20150616_2121'), ('tests', '0005_article_fk_to_newspaper'), ] operations = [ migrations.CreateModel( name='TaggedArticle', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='tests.Article')), ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_taggedarticle_items', to='taggit.Tag')), ], options={ 'abstract': False, }, ), migrations.AddField( model_name='article', name='tags', field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='tests.TaggedArticle', to='taggit.Tag', verbose_name='Tags'), ), ] django-modelcluster-5.0.1/tests/migrations/0007_add_bandmember_favourite_restaurant.py000066400000000000000000000020531360467766700313030ustar00rootroot00000000000000# Generated by Django 2.1 on 2018-08-07 15:51 from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ ('tests', '0006_auto_20171109_0614'), ] operations = [ migrations.CreateModel( name='SeafoodRestaurant', fields=[ ('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')), ], options={ 'abstract': False, }, bases=('tests.restaurant',), ), migrations.AddField( model_name='bandmember', name='favourite_restaurant', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tests.Restaurant'), ), migrations.AlterUniqueTogether( name='bandmember', unique_together={('band', 'name')}, ), ] django-modelcluster-5.0.1/tests/migrations/0008_prefetch_related_tests.py000066400000000000000000000037201360467766700265700ustar00rootroot00000000000000# Generated by Django 2.0.3 on 2018-05-19 11:02 from django.db import migrations, models import django.db.models.deletion import modelcluster.fields class Migration(migrations.Migration): dependencies = [ ('tests', '0007_add_bandmember_favourite_restaurant'), ] operations = [ migrations.CreateModel( name='House', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=50)), ('address', models.CharField(max_length=255)), ], options={ 'ordering': ['id'], }, ), migrations.CreateModel( name='Person', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=50)), ('houses', modelcluster.fields.ParentalManyToManyField(related_name='occupants', to='tests.House')), ], options={ 'ordering': ['id'], }, ), migrations.CreateModel( name='Room', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=50)), ], options={ 'ordering': ['id'], }, ), migrations.AddField( model_name='house', name='main_room', field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='main_room_of', to='tests.Room'), ), migrations.AddField( model_name='house', name='owner', field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='tests.Person'), ), ] django-modelcluster-5.0.1/tests/migrations/0009_article_related_articles.py000066400000000000000000000014511360467766700270570ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by Django 1.11 on 2018-04-20 10:39 from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion import modelcluster.fields class Migration(migrations.Migration): dependencies = [ ('tests', '0008_prefetch_related_tests'), ] operations = [ migrations.AddField( model_name='article', name='related_articles', field=modelcluster.fields.ParentalManyToManyField(blank=True, related_name='_article_related_articles_+', serialize=False, to='tests.Article'), ), migrations.AddField( model_name='article', name='view_count', field=models.IntegerField(blank=True, null=True, serialize=False), ), ] django-modelcluster-5.0.1/tests/migrations/0010_song.py000066400000000000000000000015761360467766700230140ustar00rootroot00000000000000# Generated by Django 2.1.5 on 2019-01-17 21:49 from django.db import migrations, models import django.db.models.deletion import modelcluster.fields class Migration(migrations.Migration): dependencies = [ ('tests', '0009_article_related_articles'), ] operations = [ migrations.CreateModel( name='Song', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=255)), ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), ('album', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='songs', to='tests.Album')), ], options={ 'ordering': ['sort_order'], }, ), ] django-modelcluster-5.0.1/tests/migrations/__init__.py000066400000000000000000000000001360467766700231220ustar00rootroot00000000000000django-modelcluster-5.0.1/tests/models.py000066400000000000000000000150311360467766700205040ustar00rootroot00000000000000from __future__ import unicode_literals from django.db import models from modelcluster.contrib.taggit import ClusterTaggableManager from taggit.managers import TaggableManager from taggit.models import TaggedItemBase from modelcluster.fields import ParentalKey, ParentalManyToManyField from modelcluster.models import ClusterableModel class Band(ClusterableModel): name = models.CharField(max_length=255) def __str__(self): return self.name class BandMember(models.Model): band = ParentalKey('Band', related_name='members', on_delete=models.CASCADE) name = models.CharField(max_length=255) favourite_restaurant = models.ForeignKey('Restaurant', null=True, blank=True, on_delete=models.SET_NULL) def __str__(self): return self.name class Meta: unique_together = [ ['band', 'name'] ] class Album(ClusterableModel): band = ParentalKey('Band', related_name='albums') name = models.CharField(max_length=255) release_date = models.DateField(null=True, blank=True) sort_order = models.IntegerField(null=True, blank=True, editable=False) sort_order_field = 'sort_order' def __str__(self): return self.name class Meta: ordering = ['sort_order'] class Song(models.Model): album = ParentalKey('Album', related_name='songs') name = models.CharField(max_length=255) sort_order = models.IntegerField(null=True, blank=True, editable=False) sort_order_field = 'sort_order' def __str__(self): return self.name class Meta: ordering = ['sort_order'] class TaggedPlace(TaggedItemBase): content_object = ParentalKey('Place', related_name='tagged_items', on_delete=models.CASCADE) class Place(ClusterableModel): name = models.CharField(max_length=255) tags = ClusterTaggableManager(through=TaggedPlace, blank=True) def __str__(self): return self.name class Restaurant(Place): serves_hot_dogs = models.BooleanField(default=False) proprietor = models.ForeignKey('Chef', null=True, blank=True, on_delete=models.SET_NULL, related_name='restaurants') class SeafoodRestaurant(Restaurant): pass class TaggedNonClusterPlace(TaggedItemBase): content_object = models.ForeignKey('NonClusterPlace', related_name='tagged_items', on_delete=models.CASCADE) class NonClusterPlace(models.Model): """ For backwards compatibility we need ClusterModel to work with plain TaggableManagers (as opposed to ClusterTaggableManager), albeit without the in-memory relation behaviour """ name = models.CharField(max_length=255) tags = TaggableManager(through=TaggedNonClusterPlace, blank=True) def __str__(self): return self.name class Dish(models.Model): name = models.CharField(max_length=255) def __str__(self): return self.name class Wine(models.Model): name = models.CharField(max_length=255) def __str__(self): return self.name class Chef(models.Model): name = models.CharField(max_length=255) def __str__(self): return self.name class MenuItem(models.Model): restaurant = ParentalKey('Restaurant', related_name='menu_items', on_delete=models.CASCADE) dish = models.ForeignKey('Dish', related_name='+', on_delete=models.CASCADE) price = models.DecimalField(max_digits=6, decimal_places=2) recommended_wine = models.ForeignKey('Wine', null=True, blank=True, on_delete=models.SET_NULL, related_name='+') def __str__(self): return "%s - %f" % (self.dish, self.price) class Review(models.Model): place = ParentalKey('Place', related_name='reviews', on_delete=models.CASCADE) author = models.CharField(max_length=255) body = models.TextField() def __str__(self): return "%s on %s" % (self.author, self.place.name) class Log(ClusterableModel): time = models.DateTimeField(blank=True, null=True) data = models.CharField(max_length=255) def __str__(self): return "[%s] %s" % (self.time.isoformat(), self.data) class Document(ClusterableModel): title = models.CharField(max_length=255) file = models.FileField(upload_to='documents') def __str__(self): return self.title class NewsPaper(ClusterableModel): title = models.CharField(max_length=255) def __str__(self): return self.title class TaggedArticle(TaggedItemBase): content_object = ParentalKey('Article', related_name='tagged_items', on_delete=models.CASCADE) class Article(ClusterableModel): paper = ParentalKey(NewsPaper, blank=True, null=True, on_delete=models.CASCADE) title = models.CharField(max_length=255) authors = ParentalManyToManyField('Author', related_name='articles_by_author') categories = ParentalManyToManyField('Category', related_name='articles_by_category') tags = ClusterTaggableManager(through=TaggedArticle, blank=True) related_articles = ParentalManyToManyField('self', serialize=False, blank=True) view_count = models.IntegerField(null=True, blank=True, serialize=False) def __str__(self): return self.title class Author(models.Model): name = models.CharField(max_length=255) def __str__(self): return self.name class Meta: ordering = ['name'] class Category(models.Model): name = models.CharField(max_length=255) def __str__(self): return self.name class Gallery(ClusterableModel): title = models.CharField(max_length=255) def __str__(self): return self.title class GalleryImage(models.Model): gallery = ParentalKey(Gallery, related_name='images', on_delete=models.CASCADE) image = models.FileField() # Models for fakequeryset prefetch_related test class House(models.Model): name = models.CharField(max_length=50) address = models.CharField(max_length=255) owner = models.ForeignKey('Person', models.SET_NULL, null=True) main_room = models.OneToOneField('Room', models.SET_NULL, related_name='main_room_of', null=True) class Meta: ordering = ['id'] class Room(models.Model): name = models.CharField(max_length=50) class Meta: ordering = ['id'] class Person(ClusterableModel): name = models.CharField(max_length=50) houses = ParentalManyToManyField(House, related_name='occupants') @property def primary_house(self): # Assume business logic forces every person to have at least one house. return sorted(self.houses.all(), key=lambda house: -house.rooms.count())[0] @property def all_houses(self): return list(self.houses.all()) class Meta: ordering = ['id'] django-modelcluster-5.0.1/tests/settings.py000066400000000000000000000020511360467766700210570ustar00rootroot00000000000000import os MODELCLUSTER_ROOT = os.path.dirname(os.path.dirname(__file__)) MEDIA_ROOT = os.path.join(MODELCLUSTER_ROOT, 'test-media') DATABASES = { 'default': { 'ENGINE': os.environ.get('DATABASE_ENGINE', 'django.db.backends.sqlite3'), 'NAME': os.environ.get('DATABASE_NAME', 'modelcluster'), 'USER': os.environ.get('DATABASE_USER', None), 'PASSWORD': os.environ.get('DATABASE_PASS', None), 'HOST': os.environ.get('DATABASE_HOST', None), } } SECRET_KEY = 'not needed' INSTALLED_APPS = [ 'modelcluster', 'django.contrib.contenttypes', 'taggit', 'tests', ] MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) USE_TZ = True TIME_ZONE = 'America/Chicago' ROOT_URLCONF = 'tests.urls' django-modelcluster-5.0.1/tests/tests/000077500000000000000000000000001360467766700200115ustar00rootroot00000000000000django-modelcluster-5.0.1/tests/tests/__init__.py000066400000000000000000000000001360467766700221100ustar00rootroot00000000000000django-modelcluster-5.0.1/tests/tests/test_cluster.py000066400000000000000000001070041360467766700231050ustar00rootroot00000000000000from __future__ import unicode_literals import datetime import itertools from django.test import TestCase from django.db import IntegrityError from modelcluster.models import get_all_child_relations from modelcluster.queryset import FakeQuerySet from tests.models import Band, BandMember, Place, Restaurant, SeafoodRestaurant, Review, Album, \ Article, Author, Category, Person, Room, House, Log class ClusterTest(TestCase): def test_can_create_cluster(self): beatles = Band(name='The Beatles') self.assertEqual(0, beatles.members.count()) beatles.members = [ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ] # we should be able to query this relation using (some) queryset methods self.assertEqual(2, beatles.members.count()) self.assertEqual('John Lennon', beatles.members.all()[0].name) self.assertEqual('Paul McCartney', beatles.members.filter(name='Paul McCartney')[0].name) self.assertEqual('Paul McCartney', beatles.members.filter(name__exact='Paul McCartney')[0].name) self.assertEqual('Paul McCartney', beatles.members.filter(name__iexact='paul mccartNEY')[0].name) self.assertEqual(0, beatles.members.filter(name__lt='B').count()) self.assertEqual(1, beatles.members.filter(name__lt='M').count()) self.assertEqual('John Lennon', beatles.members.filter(name__lt='M')[0].name) self.assertEqual(1, beatles.members.filter(name__lt='Paul McCartney').count()) self.assertEqual('John Lennon', beatles.members.filter(name__lt='Paul McCartney')[0].name) self.assertEqual(2, beatles.members.filter(name__lt='Z').count()) self.assertEqual(0, beatles.members.filter(name__lte='B').count()) self.assertEqual(1, beatles.members.filter(name__lte='M').count()) self.assertEqual('John Lennon', beatles.members.filter(name__lte='M')[0].name) self.assertEqual(2, beatles.members.filter(name__lte='Paul McCartney').count()) self.assertEqual(2, beatles.members.filter(name__lte='Z').count()) self.assertEqual(2, beatles.members.filter(name__gt='B').count()) self.assertEqual(1, beatles.members.filter(name__gt='M').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__gt='M')[0].name) self.assertEqual(0, beatles.members.filter(name__gt='Paul McCartney').count()) self.assertEqual(2, beatles.members.filter(name__gte='B').count()) self.assertEqual(1, beatles.members.filter(name__gte='M').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__gte='M')[0].name) self.assertEqual(1, beatles.members.filter(name__gte='Paul McCartney').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__gte='Paul McCartney')[0].name) self.assertEqual(0, beatles.members.filter(name__gte='Z').count()) self.assertEqual(1, beatles.members.filter(name__contains='Cart').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__contains='Cart')[0].name) self.assertEqual(1, beatles.members.filter(name__icontains='carT').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__icontains='carT')[0].name) self.assertEqual(1, beatles.members.filter(name__in=['Paul McCartney', 'Linda McCartney']).count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__in=['Paul McCartney', 'Linda McCartney'])[0].name) self.assertEqual(1, beatles.members.filter(name__startswith='Paul').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__startswith='Paul')[0].name) self.assertEqual(1, beatles.members.filter(name__istartswith='pauL').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__istartswith='pauL')[0].name) self.assertEqual(1, beatles.members.filter(name__endswith='ney').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__endswith='ney')[0].name) self.assertEqual(1, beatles.members.filter(name__iendswith='Ney').count()) self.assertEqual('Paul McCartney', beatles.members.filter(name__iendswith='Ney')[0].name) self.assertEqual('Paul McCartney', beatles.members.get(name='Paul McCartney').name) self.assertEqual('Paul McCartney', beatles.members.get(name__exact='Paul McCartney').name) self.assertEqual('Paul McCartney', beatles.members.get(name__iexact='paul mccartNEY').name) self.assertEqual('John Lennon', beatles.members.get(name__lt='Paul McCartney').name) self.assertEqual('John Lennon', beatles.members.get(name__lte='M').name) self.assertEqual('Paul McCartney', beatles.members.get(name__gt='M').name) self.assertEqual('Paul McCartney', beatles.members.get(name__gte='Paul McCartney').name) self.assertEqual('Paul McCartney', beatles.members.get(name__contains='Cart').name) self.assertEqual('Paul McCartney', beatles.members.get(name__icontains='carT').name) self.assertEqual('Paul McCartney', beatles.members.get(name__in=['Paul McCartney', 'Linda McCartney']).name) self.assertEqual('Paul McCartney', beatles.members.get(name__startswith='Paul').name) self.assertEqual('Paul McCartney', beatles.members.get(name__istartswith='pauL').name) self.assertEqual('Paul McCartney', beatles.members.get(name__endswith='ney').name) self.assertEqual('Paul McCartney', beatles.members.get(name__iendswith='Ney').name) self.assertEqual('John Lennon', beatles.members.get(name__regex=r'n{2}').name) self.assertEqual('John Lennon', beatles.members.get(name__iregex=r'N{2}').name) self.assertRaises(BandMember.DoesNotExist, lambda: beatles.members.get(name='Reginald Dwight')) self.assertRaises(BandMember.MultipleObjectsReturned, lambda: beatles.members.get()) self.assertEqual([('Paul McCartney',)], beatles.members.filter(name='Paul McCartney').values_list('name')) self.assertEqual(['Paul McCartney'], beatles.members.filter(name='Paul McCartney').values_list('name', flat=True)) # quick-and-dirty check that we can invoke values_list with empty args list beatles.members.filter(name='Paul McCartney').values_list() self.assertTrue(beatles.members.filter(name='Paul McCartney').exists()) self.assertFalse(beatles.members.filter(name='Reginald Dwight').exists()) self.assertEqual('John Lennon', beatles.members.first().name) self.assertEqual('Paul McCartney', beatles.members.last().name) self.assertTrue('John Lennon', beatles.members.order_by('name').first()) self.assertTrue('Paul McCartney', beatles.members.order_by('-name').first()) # these should not exist in the database yet self.assertFalse(Band.objects.filter(name='The Beatles').exists()) self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) beatles.save() # this should create database entries self.assertTrue(Band.objects.filter(name='The Beatles').exists()) self.assertTrue(BandMember.objects.filter(name='John Lennon').exists()) john_lennon = BandMember.objects.get(name='John Lennon') beatles.members = [john_lennon] # reassigning should take effect on the in-memory record self.assertEqual(1, beatles.members.count()) # but not the database self.assertEqual(2, Band.objects.get(name='The Beatles').members.count()) beatles.save() # now updated in the database self.assertEqual(1, Band.objects.get(name='The Beatles').members.count()) self.assertEqual(1, BandMember.objects.filter(name='John Lennon').count()) # removed member should be deleted from the db entirely self.assertEqual(0, BandMember.objects.filter(name='Paul McCartney').count()) # queries on beatles.members should now revert to SQL self.assertTrue(beatles.members.extra(where=["tests_bandmember.name='John Lennon'"]).exists()) def test_related_manager_assignment_ops(self): beatles = Band(name='The Beatles') john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') beatles.members.add(john) self.assertEqual(1, beatles.members.count()) beatles.members.add(paul) self.assertEqual(2, beatles.members.count()) # ensure that duplicates are filtered beatles.members.add(paul) self.assertEqual(2, beatles.members.count()) beatles.members.remove(john) self.assertEqual(1, beatles.members.count()) self.assertEqual(paul, beatles.members.all()[0]) george = beatles.members.create(name='George Harrison') self.assertEqual(2, beatles.members.count()) self.assertEqual('George Harrison', george.name) beatles.members.set([john]) self.assertEqual(1, beatles.members.count()) self.assertEqual(john, beatles.members.all()[0]) def test_can_pass_child_relations_as_constructor_kwargs(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) self.assertEqual(2, beatles.members.count()) self.assertEqual(beatles, beatles.members.all()[0].band) def test_can_access_child_relations_of_superclass(self): fat_duck = Restaurant(name='The Fat Duck', serves_hot_dogs=False, reviews=[ Review(author='Michael Winner', body='Rubbish.') ]) self.assertEqual(1, fat_duck.reviews.count()) self.assertEqual(fat_duck.reviews.first().author, 'Michael Winner') self.assertEqual(fat_duck, fat_duck.reviews.all()[0].place) fat_duck.save() # ensure relations have been saved to the database fat_duck = Restaurant.objects.get(id=fat_duck.id) self.assertEqual(1, fat_duck.reviews.count()) self.assertEqual(fat_duck.reviews.first().author, 'Michael Winner') def test_can_only_commit_on_saved_parent(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) self.assertRaises(IntegrityError, lambda: beatles.members.commit()) beatles.save() beatles.members.commit() def test_integrity_error_with_none_pk(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) beatles.save() beatles.pk = None self.assertRaises(IntegrityError, lambda: beatles.members.commit()) # this should work fine, as Django will end up cloning this entity beatles.save() self.assertEqual(Band.objects.get(pk=beatles.pk).name, 'The Beatles') def test_model_with_zero_pk(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) beatles.save() beatles.pk = 0 beatles.members.commit() beatles.save() self.assertEqual(Band.objects.get(pk=0).name, 'The Beatles') def test_save_with_update_fields(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ], albums=[ Album(name='Please Please Me', sort_order=1), Album(name='With The Beatles', sort_order=2), Album(name='Abbey Road', sort_order=3), ]) beatles.save() # modify both relations, but only commit the change to members beatles.members.clear() beatles.albums.clear() beatles.name = 'The Rutles' beatles.save(update_fields=['name', 'members']) updated_beatles = Band.objects.get(pk=beatles.pk) self.assertEqual(updated_beatles.name, 'The Rutles') self.assertEqual(updated_beatles.members.count(), 0) self.assertEqual(updated_beatles.albums.count(), 3) def test_queryset_filtering(self): beatles = Band(name='The Beatles', members=[ BandMember(id=1, name='John Lennon'), BandMember(id=2, name='Paul McCartney'), ]) self.assertEqual('Paul McCartney', beatles.members.get(id=2).name) self.assertEqual('Paul McCartney', beatles.members.get(id='2').name) self.assertEqual(1, beatles.members.filter(name='Paul McCartney').count()) # also need to be able to filter on foreign fields that return a model instance # rather than a simple python value self.assertEqual(2, beatles.members.filter(band=beatles).count()) # and ensure that the comparison is not treating all unsaved instances as identical rutles = Band(name='The Rutles') self.assertEqual(0, beatles.members.filter(band=rutles).count()) # and the comparison must be on the model instance's ID where available, # not by reference beatles.save() beatles.members.add(BandMember(id=3, name='George Harrison')) # modify the relation so that we're not to a plain database-backed queryset also_beatles = Band.objects.get(id=beatles.id) self.assertEqual(3, beatles.members.filter(band=also_beatles).count()) def test_queryset_filtering_on_models_with_inheritance(self): strawberry_fields = Restaurant.objects.create(name='Strawberry Fields') the_yellow_submarine = SeafoodRestaurant.objects.create(name='The Yellow Submarine') john = BandMember(name='John Lennon', favourite_restaurant=strawberry_fields) ringo = BandMember(name='Ringo Starr', favourite_restaurant=Restaurant.objects.get(name='The Yellow Submarine')) beatles = Band(name='The Beatles', members=[john, ringo]) # queried instance is less specific self.assertEqual( list(beatles.members.filter(favourite_restaurant=Place.objects.get(name='Strawberry Fields'))), [john] ) # queried instance is more specific self.assertEqual( list(beatles.members.filter(favourite_restaurant=the_yellow_submarine)), [ringo] ) def test_queryset_exclude_filtering(self): beatles = Band(name='The Beatles', members=[ BandMember(id=1, name='John Lennon'), BandMember(id=2, name='Paul McCartney'), ]) self.assertEqual(1, beatles.members.exclude(name='Paul McCartney').count()) self.assertEqual('John Lennon', beatles.members.exclude(name='Paul McCartney').first().name) self.assertEqual(1, beatles.members.exclude(name__exact='Paul McCartney').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__exact='Paul McCartney').first().name) self.assertEqual(1, beatles.members.exclude(name__iexact='paul mccartNEY').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__iexact='paul mccartNEY').first().name) self.assertEqual(1, beatles.members.exclude(name__lt='M').count()) self.assertEqual('Paul McCartney', beatles.members.exclude(name__lt='M').first().name) self.assertEqual(1, beatles.members.exclude(name__lt='Paul McCartney').count()) self.assertEqual('Paul McCartney', beatles.members.exclude(name__lt='Paul McCartney').first().name) self.assertEqual(1, beatles.members.exclude(name__lte='John Lennon').count()) self.assertEqual('Paul McCartney', beatles.members.exclude(name__lte='John Lennon').first().name) self.assertEqual(1, beatles.members.exclude(name__gt='M').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__gt='M').first().name) self.assertEqual(1, beatles.members.exclude(name__gte='Paul McCartney').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__gte='Paul McCartney').first().name) self.assertEqual(1, beatles.members.exclude(name__contains='Cart').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__contains='Cart').first().name) self.assertEqual(1, beatles.members.exclude(name__icontains='carT').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__icontains='carT').first().name) self.assertEqual(1, beatles.members.exclude(name__in=['Paul McCartney', 'Linda McCartney']).count()) self.assertEqual('John Lennon', beatles.members.exclude(name__in=['Paul McCartney', 'Linda McCartney'])[0].name) self.assertEqual(1, beatles.members.exclude(name__startswith='Paul').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__startswith='Paul').first().name) self.assertEqual(1, beatles.members.exclude(name__istartswith='pauL').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__istartswith='pauL').first().name) self.assertEqual(1, beatles.members.exclude(name__endswith='ney').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__endswith='ney').first().name) self.assertEqual(1, beatles.members.exclude(name__iendswith='Ney').count()) self.assertEqual('John Lennon', beatles.members.exclude(name__iendswith='Ney').first().name) def test_queryset_filter_with_nulls(self): tmbg = Band(name="They Might Be Giants", albums=[ Album(name="Flood", release_date=datetime.date(1990, 1, 1)), Album(name="John Henry", release_date=datetime.date(1994, 7, 21)), Album(name="Factory Showroom", release_date=datetime.date(1996, 3, 30)), Album(name="", release_date=None), Album(name=None, release_date=None), ]) self.assertEqual(tmbg.albums.get(name="Flood").name, "Flood") self.assertEqual(tmbg.albums.get(name="").name, "") self.assertEqual(tmbg.albums.get(name=None).name, None) self.assertEqual(tmbg.albums.get(name__exact="Flood").name, "Flood") self.assertEqual(tmbg.albums.get(name__exact="").name, "") self.assertEqual(tmbg.albums.get(name__exact=None).name, None) self.assertEqual(tmbg.albums.get(name__iexact="flood").name, "Flood") self.assertEqual(tmbg.albums.get(name__iexact="").name, "") self.assertEqual(tmbg.albums.get(name__iexact=None).name, None) self.assertEqual(tmbg.albums.get(name__contains="loo").name, "Flood") self.assertEqual(tmbg.albums.get(name__icontains="LOO").name, "Flood") self.assertEqual(tmbg.albums.get(name__startswith="Flo").name, "Flood") self.assertEqual(tmbg.albums.get(name__istartswith="flO").name, "Flood") self.assertEqual(tmbg.albums.get(name__endswith="ood").name, "Flood") self.assertEqual(tmbg.albums.get(name__iendswith="Ood").name, "Flood") self.assertEqual(tmbg.albums.get(name__lt="A").name, "") self.assertEqual(tmbg.albums.get(name__lte="A").name, "") self.assertEqual(tmbg.albums.get(name__gt="J").name, "John Henry") self.assertEqual(tmbg.albums.get(name__gte="J").name, "John Henry") self.assertEqual(tmbg.albums.get(name__in=["Flood", "Mink Car"]).name, "Flood") self.assertEqual(tmbg.albums.get(name__in=["", "Mink Car"]).name, "") self.assertEqual(tmbg.albums.get(name__in=[None, "Mink Car"]).name, None) self.assertEqual(tmbg.albums.filter(name__isnull=True).count(), 1) self.assertEqual(tmbg.albums.filter(name__isnull=False).count(), 4) self.assertEqual(tmbg.albums.get(name__regex=r'l..d').name, "Flood") self.assertEqual(tmbg.albums.get(name__iregex=r'f..o').name, "Flood") def test_date_filters(self): tmbg = Band(name="They Might Be Giants", albums=[ Album(name="Flood", release_date=datetime.date(1990, 1, 1)), Album(name="John Henry", release_date=datetime.date(1994, 7, 21)), Album(name="Factory Showroom", release_date=datetime.date(1996, 3, 30)), Album(name="The Complete Dial-A-Song", release_date=None), ]) logs = FakeQuerySet(Log, [ Log(time=datetime.datetime(1979, 7, 1, 1, 1, 1), data="nobody died"), Log(time=datetime.datetime(1980, 2, 2, 2, 2, 2), data="one person died"), Log(time=None, data="nothing happened") ]) self.assertEqual( tmbg.albums.get(release_date__range=(datetime.date(1994, 1, 1), datetime.date(1994, 12, 31))).name, "John Henry" ) self.assertEqual( logs.get(time__range=(datetime.datetime(1980, 1, 1, 1, 1, 1), datetime.datetime(1980, 12, 31, 23, 59, 59))).data, "one person died" ) self.assertEqual( tmbg.albums.get(release_date__date=datetime.date(1994, 7, 21)).name, "John Henry" ) self.assertEqual( logs.get(time__date=datetime.date(1980, 2, 2)).data, "one person died" ) self.assertEqual( tmbg.albums.get(release_date__year='1994').name, "John Henry" ) self.assertEqual( logs.get(time__year=1980).data, "one person died" ) self.assertEqual( tmbg.albums.get(release_date__month=7).name, "John Henry" ) self.assertEqual( logs.get(time__month='2').data, "one person died" ) self.assertEqual( tmbg.albums.get(release_date__day='21').name, "John Henry" ) self.assertEqual( logs.get(time__day=2).data, "one person died" ) self.assertEqual( tmbg.albums.get(release_date__week=29).name, "John Henry" ) self.assertEqual( logs.get(time__week='5').data, "one person died" ) self.assertEqual( tmbg.albums.get(release_date__week_day=5).name, "John Henry" ) self.assertEqual( logs.get(time__week_day=7).data, "one person died" ) self.assertEqual( tmbg.albums.get(release_date__quarter=3).name, "John Henry" ) self.assertEqual( logs.get(time__quarter=1).data, "one person died" ) self.assertEqual( logs.get(time__time=datetime.time(2, 2, 2)).data, "one person died" ) self.assertEqual( logs.get(time__hour=2).data, "one person died" ) self.assertEqual( logs.get(time__minute='2').data, "one person died" ) self.assertEqual( logs.get(time__second=2).data, "one person died" ) def test_prefetch_related(self): Band.objects.create(name='The Beatles', members=[ BandMember(id=1, name='John Lennon'), BandMember(id=2, name='Paul McCartney'), ]) with self.assertNumQueries(2): lists = [list(band.members.all()) for band in Band.objects.prefetch_related('members')] normal_lists = [list(band.members.all()) for band in Band.objects.all()] self.assertEqual(lists, normal_lists) def test_prefetch_related_with_custom_queryset(self): from django.db.models import Prefetch Band.objects.create(name='The Beatles', members=[ BandMember(id=1, name='John Lennon'), BandMember(id=2, name='Paul McCartney'), ]) with self.assertNumQueries(2): lists = [ list(band.members.all()) for band in Band.objects.prefetch_related( Prefetch('members', queryset=BandMember.objects.filter(name__startswith='Paul')) ) ] normal_lists = [list(band.members.filter(name__startswith='Paul')) for band in Band.objects.all()] self.assertEqual(lists, normal_lists) def test_order_by_with_multiple_fields(self): beatles = Band(name='The Beatles', albums=[ Album(name='Please Please Me', sort_order=2), Album(name='With The Beatles', sort_order=1), Album(name='Abbey Road', sort_order=2), ]) albums = [album.name for album in beatles.albums.order_by('sort_order', 'name')] self.assertEqual(['With The Beatles', 'Abbey Road', 'Please Please Me'], albums) albums = [album.name for album in beatles.albums.order_by('sort_order', '-name')] self.assertEqual(['With The Beatles', 'Please Please Me', 'Abbey Road'], albums) def test_meta_ordering(self): beatles = Band(name='The Beatles', albums=[ Album(name='Please Please Me', sort_order=2), Album(name='With The Beatles', sort_order=1), Album(name='Abbey Road', sort_order=3), ]) # in the absence of an explicit order_by clause, it should use the ordering as defined # in Album.Meta, which is 'sort_order' albums = [album.name for album in beatles.albums.all()] self.assertEqual(['With The Beatles', 'Please Please Me', 'Abbey Road'], albums) def test_parental_key_checks_clusterable_model(self): from django.core import checks from django.db import models from modelcluster.fields import ParentalKey class Instrument(models.Model): # Oops, BandMember is not a Clusterable model member = ParentalKey(BandMember, on_delete=models.CASCADE) class Meta: # Prevent Django from thinking this is in the database # This shouldn't affect the test abstract = True # Check for error errors = Instrument.check() self.assertEqual(1, len(errors)) # Check the error itself error = errors[0] self.assertIsInstance(error, checks.Error) self.assertEqual(error.id, 'modelcluster.E001') self.assertEqual(error.obj, Instrument.member.field) self.assertEqual(error.msg, 'ParentalKey must point to a subclass of ClusterableModel.') self.assertEqual(error.hint, 'Change tests.BandMember into a ClusterableModel or use a ForeignKey instead.') def test_parental_key_checks_related_name_is_not_plus(self): from django.core import checks from django.db import models from modelcluster.fields import ParentalKey class Instrument(models.Model): # Oops, related_name='+' is not allowed band = ParentalKey(Band, related_name='+', on_delete=models.CASCADE) class Meta: # Prevent Django from thinking this is in the database # This shouldn't affect the test abstract = True # Check for error errors = Instrument.check() self.assertEqual(1, len(errors)) # Check the error itself error = errors[0] self.assertIsInstance(error, checks.Error) self.assertEqual(error.id, 'modelcluster.E002') self.assertEqual(error.obj, Instrument.band.field) self.assertEqual(error.msg, "related_name='+' is not allowed on ParentalKey fields") self.assertEqual(error.hint, "Either change it to a valid name or remove it") def test_parental_key_checks_target_is_resolved_as_class(self): from django.core import checks from django.db import models from modelcluster.fields import ParentalKey class Instrument(models.Model): banana = ParentalKey('Banana', on_delete=models.CASCADE) class Meta: # Prevent Django from thinking this is in the database # This shouldn't affect the test abstract = True # Check for error errors = Instrument.check() self.assertEqual(1, len(errors)) # Check the error itself error = errors[0] self.assertIsInstance(error, checks.Error) self.assertEqual(error.id, 'fields.E300') self.assertEqual(error.obj, Instrument.banana.field) self.assertEqual(error.msg, "Field defines a relation with model 'Banana', which is either not installed, or is abstract.") class GetAllChildRelationsTest(TestCase): def test_get_all_child_relations(self): self.assertEqual( set([rel.name for rel in get_all_child_relations(Restaurant)]), set(['tagged_items', 'reviews', 'menu_items']) ) class ParentalM2MTest(TestCase): def setUp(self): self.article = Article(title="Test Title") self.author_1 = Author.objects.create(name="Author 1") self.author_2 = Author.objects.create(name="Author 2") self.article.authors = [self.author_1, self.author_2] self.category_1 = Category.objects.create(name="Category 1") self.category_2 = Category.objects.create(name="Category 2") self.article.categories = [self.category_1, self.category_2] def test_uninitialised_m2m_relation(self): # Reading an m2m relation of a newly created object should return an empty queryset new_article = Article(title="Test title") self.assertEqual([], list(new_article.authors.all())) self.assertEqual(new_article.authors.count(), 0) # the manager should have a 'model' property pointing to the target model self.assertEqual(Author, new_article.authors.model) def test_parentalm2mfield(self): # Article should not exist in the database yet self.assertFalse(Article.objects.filter(title='Test Title').exists()) # Test lookup on parental M2M relation self.assertEqual( ['Author 1', 'Author 2'], [author.name for author in self.article.authors.order_by('name')] ) self.assertEqual(self.article.authors.count(), 2) # the manager should have a 'model' property pointing to the target model self.assertEqual(Author, self.article.authors.model) # Test adding to the relation author_3 = Author.objects.create(name="Author 3") self.article.authors.add(author_3) self.assertEqual( ['Author 1', 'Author 2', 'Author 3'], [author.name for author in self.article.authors.all().order_by('name')] ) self.assertEqual(self.article.authors.count(), 3) # Test removing from the relation self.article.authors.remove(author_3) self.assertEqual( ['Author 1', 'Author 2'], [author.name for author in self.article.authors.order_by('name')] ) self.assertEqual(self.article.authors.count(), 2) # Test clearing the relation self.article.authors.clear() self.assertEqual( [], [author.name for author in self.article.authors.order_by('name')] ) self.assertEqual(self.article.authors.count(), 0) # Test the 'set' operation self.article.authors.set([self.author_2]) self.assertEqual(self.article.authors.count(), 1) self.assertEqual( ['Author 2'], [author.name for author in self.article.authors.order_by('name')] ) # Test saving to / restoring from DB self.article.authors = [self.author_1, self.author_2] self.article.save() self.article = Article.objects.get(title="Test Title") self.assertEqual( ['Author 1', 'Author 2'], [author.name for author in self.article.authors.order_by('name')] ) self.assertEqual(self.article.authors.count(), 2) def test_constructor(self): # Test passing values for M2M relations as kwargs to the constructor article2 = Article( title="Test article 2", authors=[self.author_1], categories=[self.category_2], ) self.assertEqual( ['Author 1'], [author.name for author in article2.authors.order_by('name')] ) self.assertEqual(article2.authors.count(), 1) def test_ordering(self): # our fake querysets should respect the ordering defined on the target model bela_bartok = Author.objects.create(name='Bela Bartok') graham_greene = Author.objects.create(name='Graham Greene') janis_joplin = Author.objects.create(name='Janis Joplin') simon_sharma = Author.objects.create(name='Simon Sharma') william_wordsworth = Author.objects.create(name='William Wordsworth') article3 = Article(title="Test article 3") article3.authors = [ janis_joplin, william_wordsworth, bela_bartok, simon_sharma, graham_greene ] self.assertEqual( list(article3.authors.all()), [bela_bartok, graham_greene, janis_joplin, simon_sharma, william_wordsworth] ) def test_save_m2m_with_update_fields(self): self.article.save() # modify both relations, but only commit the change to authors self.article.authors.clear() self.article.categories.clear() self.article.title = 'Updated title' self.article.save(update_fields=['title', 'authors']) self.updated_article = Article.objects.get(pk=self.article.pk) self.assertEqual(self.updated_article.title, 'Updated title') self.assertEqual(self.updated_article.authors.count(), 0) self.assertEqual(self.updated_article.categories.count(), 2) def test_reverse_m2m_field(self): # article is unsaved, so should not be returned by the reverse relation on author self.assertEqual(self.author_1.articles_by_author.count(), 0) self.article.save() # should now be able to look up on the reverse relation self.assertEqual(self.author_1.articles_by_author.count(), 1) self.assertEqual(self.author_1.articles_by_author.get(), self.article) article_2 = Article(title="Test Title 2") article_2.authors = [self.author_1] article_2.save() self.assertEqual(self.author_1.articles_by_author.all().count(), 2) self.assertEqual( list(self.author_1.articles_by_author.order_by('title').values_list('title', flat=True)), ['Test Title', 'Test Title 2'] ) def test_value_from_object(self): authors_field = Article._meta.get_field('authors') self.assertEqual( set(authors_field.value_from_object(self.article)), set([self.author_1, self.author_2]) ) self.article.save() self.assertEqual( set(authors_field.value_from_object(self.article)), set([self.author_1, self.author_2]) ) class PrefetchRelatedTest(TestCase): def test_fakequeryset_prefetch_related(self): person1 = Person.objects.create(name='Joe') person2 = Person.objects.create(name='Mary') # Set main_room for each house before creating the next one for # databases where supports_nullable_unique_constraints is False. house1 = House.objects.create(name='House 1', address='123 Main St', owner=person1) room1_1 = Room.objects.create(name='Dining room') room1_2 = Room.objects.create(name='Lounge') room1_3 = Room.objects.create(name='Kitchen') house1.main_room = room1_1 house1.save() house2 = House(name='House 2', address='45 Side St', owner=person1) room2_1 = Room.objects.create(name='Eating room') room2_2 = Room.objects.create(name='TV Room') room2_3 = Room.objects.create(name='Bathroom') house2.main_room = room2_1 person1.houses = itertools.chain(House.objects.all(), [house2]) houses = person1.houses.all() with self.assertNumQueries(1): qs = person1.houses.prefetch_related('main_room') with self.assertNumQueries(0): main_rooms = [ house.main_room for house in person1.houses.all() ] self.assertEqual(len(main_rooms), 2) django-modelcluster-5.0.1/tests/tests/test_cluster_form.py000066400000000000000000001076271360467766700241430ustar00rootroot00000000000000from __future__ import unicode_literals import unittest from django.core.exceptions import ValidationError from django.test import TestCase from tests.models import Band, BandMember, Album, Restaurant, Article, Author, Document, Gallery, Song from modelcluster.forms import ClusterForm from django.forms import Textarea, CharField from django.forms.widgets import TextInput, FileInput import datetime class ClusterFormTest(TestCase): def test_cluster_form(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] self.assertTrue(BandForm.formsets) beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) form = BandForm(instance=beatles) self.assertEqual(5, len(form.formsets['members'].forms)) self.assertTrue('albums' in form.as_p()) def test_empty_cluster_form(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm() self.assertEqual(3, len(form.formsets['members'].forms)) def test_incoming_form_data(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] beatles = Band(name='The Beatles', members=[ BandMember(name='George Harrison'), ]) form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 4, 'members-INITIAL_FORMS': 1, 'members-MAX_NUM_FORMS': 1000, 'members-0-name': 'George Harrison', 'members-0-DELETE': 'members-0-DELETE', 'members-0-id': '', 'members-1-name': 'John Lennon', 'members-1-id': '', 'members-2-name': 'Paul McCartney', 'members-2-id': '', 'members-3-name': '', 'members-3-id': '', 'albums-TOTAL_FORMS': 0, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, }, instance=beatles) self.assertTrue(form.is_valid()) result = form.save(commit=False) self.assertEqual(result, beatles) self.assertEqual(2, beatles.members.count()) self.assertEqual('John Lennon', beatles.members.all()[0].name) # should not exist in the database yet self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) beatles.save() # this should create database entries self.assertTrue(Band.objects.filter(name='The Beatles').exists()) self.assertTrue(BandMember.objects.filter(name='John Lennon').exists()) def test_explicit_formset_list(self): class BandForm(ClusterForm): class Meta: model = Band formsets = ('members',) fields = ['name'] form = BandForm() self.assertTrue(form.formsets.get('members')) self.assertFalse(form.formsets.get('albums')) self.assertTrue('members' in form.as_p()) self.assertFalse('albums' in form.as_p()) def test_excluded_formset_list(self): class BandForm(ClusterForm): class Meta: model = Band exclude_formsets = ('albums',) fields = ['name'] form = BandForm() self.assertTrue(form.formsets.get('members')) self.assertFalse(form.formsets.get('albums')) self.assertTrue('members' in form.as_p()) self.assertFalse('albums' in form.as_p()) def test_widget_overrides(self): class BandForm(ClusterForm): class Meta: model = Band widgets = { 'name': Textarea(), 'members': { 'name': Textarea() } } fields = ['name'] form = BandForm() self.assertEqual(Textarea, type(form['name'].field.widget)) self.assertEqual(Textarea, type(form.formsets['members'].forms[0]['name'].field.widget)) def test_explicit_formset_dict(self): class BandForm(ClusterForm): class Meta: model = Band formsets = { 'albums': {'fields': ['name'], 'widgets': {'name': Textarea()}} } fields = ['name'] form = BandForm() self.assertTrue(form.formsets.get('albums')) self.assertFalse(form.formsets.get('members')) self.assertTrue('albums' in form.as_p()) self.assertFalse('members' in form.as_p()) self.assertIn('name', form.formsets['albums'].forms[0].fields) self.assertNotIn('release_date', form.formsets['albums'].forms[0].fields) self.assertEqual(Textarea, type(form.formsets['albums'].forms[0]['name'].field.widget)) def test_formfield_callback(self): def formfield_for_dbfield(db_field, **kwargs): # a particularly stupid formfield_callback that just uses Textarea for everything return CharField(widget=Textarea, **kwargs) class BandFormWithFFC(ClusterForm): formfield_callback = formfield_for_dbfield class Meta: model = Band fields = ['name'] form = BandFormWithFFC() self.assertEqual(Textarea, type(form['name'].field.widget)) self.assertEqual(Textarea, type(form.formsets['members'].forms[0]['name'].field.widget)) def test_saved_items(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') beatles = Band(name='The Beatles', members=[john, paul]) beatles.save() self.assertTrue(john.id) self.assertTrue(paul.id) form = BandForm({ 'name': "The New Beatles", 'members-TOTAL_FORMS': 4, 'members-INITIAL_FORMS': 2, 'members-MAX_NUM_FORMS': 1000, 'members-0-name': john.name, 'members-0-DELETE': 'members-0-DELETE', 'members-0-id': john.id, 'members-1-name': paul.name, 'members-1-id': paul.id, 'members-2-name': 'George Harrison', 'members-2-id': '', 'members-3-name': '', 'members-3-id': '', 'albums-TOTAL_FORMS': 0, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, }, instance=beatles) self.assertTrue(form.is_valid()) form.save() new_beatles = Band.objects.get(id=beatles.id) self.assertEqual('The New Beatles', new_beatles.name) self.assertTrue(BandMember.objects.filter(name='George Harrison').exists()) self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) def test_can_omit_formset_from_submission(self): """ If no explicit `formsets` parameter has been given, any formsets missing from the submission should be skipped over. https://github.com/wagtail/wagtail/issues/5414#issuecomment-567468127 """ class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') abbey_road = Album(name='Abbey Road') beatles = Band(name='The Beatles', members=[john, paul], albums=[abbey_road]) beatles.save() form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 3, 'members-INITIAL_FORMS': 2, 'members-MAX_NUM_FORMS': 1000, 'members-0-name': john.name, 'members-0-DELETE': 'members-0-DELETE', 'members-0-id': john.id, 'members-1-name': paul.name, 'members-1-id': paul.id, 'members-2-name': 'George Harrison', 'members-2-id': '', }, instance=beatles) self.assertTrue(form.is_valid()) form.save() beatles = Band.objects.get(id=beatles.id) self.assertEqual(1, beatles.albums.count()) self.assertTrue(BandMember.objects.filter(name='George Harrison').exists()) self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) def test_cannot_omit_explicit_formset_from_submission(self): """ If an explicit `formsets` parameter has been given, formsets missing from a form submission should raise a ValidationError as normal """ class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] formsets = ['members', 'albums'] john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') abbey_road = Album(name='Abbey Road') beatles = Band(name='The Beatles', members=[john, paul], albums=[abbey_road]) beatles.save() form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 3, 'members-INITIAL_FORMS': 2, 'members-MAX_NUM_FORMS': 1000, 'members-0-name': john.name, 'members-0-DELETE': 'members-0-DELETE', 'members-0-id': john.id, 'members-1-name': paul.name, 'members-1-id': paul.id, 'members-2-name': 'George Harrison', 'members-2-id': '', }, instance=beatles) with self.assertRaises(ValidationError): form.is_valid() def test_saved_items_with_non_db_relation(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') beatles = Band(name='The Beatles', members=[john, paul]) beatles.save() # pack and unpack the record so that we're working with a non-db-backed queryset new_beatles = Band.from_json(beatles.to_json()) form = BandForm({ 'name': "The New Beatles", 'members-TOTAL_FORMS': 4, 'members-INITIAL_FORMS': 2, 'members-MAX_NUM_FORMS': 1000, 'members-0-name': john.name, 'members-0-DELETE': 'members-0-DELETE', 'members-0-id': john.id, 'members-1-name': paul.name, 'members-1-id': paul.id, 'members-2-name': 'George Harrison', 'members-2-id': '', 'members-3-name': '', 'members-3-id': '', 'albums-TOTAL_FORMS': 0, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, }, instance=new_beatles) self.assertTrue(form.is_valid()) form.save() new_beatles = Band.objects.get(id=beatles.id) self.assertEqual('The New Beatles', new_beatles.name) self.assertTrue(BandMember.objects.filter(name='George Harrison').exists()) self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) def test_creation(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 4, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'members-0-name': 'John Lennon', 'members-0-id': '', 'members-1-name': 'Paul McCartney', 'members-1-id': '', 'members-2-name': 'Pete Best', 'members-2-DELETE': 'members-0-DELETE', 'members-2-id': '', 'members-3-name': '', 'members-3-id': '', 'albums-TOTAL_FORMS': 0, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, }) self.assertTrue(form.is_valid()) beatles = form.save() self.assertTrue(beatles.id) self.assertEqual('The Beatles', beatles.name) self.assertEqual('The Beatles', Band.objects.get(id=beatles.id).name) self.assertEqual(2, beatles.members.count()) self.assertTrue(BandMember.objects.filter(name='John Lennon').exists()) self.assertFalse(BandMember.objects.filter(name='Pete Best').exists()) def test_sort_order_is_output_on_form(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm() form_html = form.as_p() self.assertTrue('albums-0-ORDER' in form_html) self.assertFalse('members-0-ORDER' in form_html) def test_sort_order_is_committed(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 2, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': 'With The Beatles', 'albums-0-id': '', 'albums-0-ORDER': 2, 'albums-0-songs-TOTAL_FORMS': 0, 'albums-0-songs-INITIAL_FORMS': 0, 'albums-0-songs-MAX_NUM_FORMS': 1000, 'albums-1-name': 'Please Please Me', 'albums-1-id': '', 'albums-1-ORDER': 1, 'albums-1-songs-TOTAL_FORMS': 0, 'albums-1-songs-INITIAL_FORMS': 0, 'albums-1-songs-MAX_NUM_FORMS': 1000, }) self.assertTrue(form.is_valid()) beatles = form.save() self.assertEqual('Please Please Me', beatles.albums.all()[0].name) self.assertEqual('With The Beatles', beatles.albums.all()[1].name) def test_ignore_validation_on_deleted_items(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] please_please_me = Album(name='Please Please Me', release_date=datetime.date(1963, 3, 22)) beatles = Band(name='The Beatles', albums=[please_please_me]) beatles.save() form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 1, 'albums-INITIAL_FORMS': 1, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': 'With The Beatles', 'albums-0-release_date': '1963-02-31', # invalid date 'albums-0-id': please_please_me.id, 'albums-0-ORDER': 1, 'albums-0-songs-TOTAL_FORMS': 0, 'albums-0-songs-INITIAL_FORMS': 0, 'albums-0-songs-MAX_NUM_FORMS': 1000, }, instance=beatles) self.assertFalse(form.is_valid()) form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 1, 'albums-INITIAL_FORMS': 1, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': 'With The Beatles', 'albums-0-release_date': '1963-02-31', # invalid date 'albums-0-id': please_please_me.id, 'albums-0-ORDER': 1, 'albums-0-DELETE': 'albums-0-DELETE', 'albums-0-songs-TOTAL_FORMS': 0, 'albums-0-songs-INITIAL_FORMS': 0, 'albums-0-songs-MAX_NUM_FORMS': 1000, }, instance=beatles) self.assertTrue(form.is_valid()) form.save(commit=False) self.assertEqual(0, beatles.albums.count()) self.assertEqual(1, Band.objects.get(id=beatles.id).albums.count()) beatles.save() self.assertEqual(0, Band.objects.get(id=beatles.id).albums.count()) def test_cluster_form_without_formsets(self): class BandForm(ClusterForm): class Meta: model = Band formsets = () fields = ['name'] beatles = Band(name='The Beatles') beatles.save() form = BandForm({ 'name': "The New Beatles", }, instance=beatles) self.assertTrue(form.is_valid()) form.save(commit=False) self.assertEqual(1, Band.objects.filter(name='The Beatles').count()) beatles.save() self.assertEqual(0, Band.objects.filter(name='The Beatles').count()) def test_formsets_from_model_superclass_are_exposed(self): class RestaurantForm(ClusterForm): class Meta: model = Restaurant fields = ['name', 'tags', 'serves_hot_dogs', 'proprietor'] self.assertIn('reviews', RestaurantForm.formsets) form = RestaurantForm({ 'name': 'The Fat Duck', 'menu_items-TOTAL_FORMS': 0, 'menu_items-INITIAL_FORMS': 0, 'menu_items-MAX_NUM_FORMS': 1000, 'reviews-TOTAL_FORMS': 1, 'reviews-INITIAL_FORMS': 1, 'reviews-MAX_NUM_FORMS': 1000, 'reviews-0-id': '', 'reviews-0-author': 'Michael Winner', 'reviews-0-body': 'Rubbish.', 'tagged_items-TOTAL_FORMS': 0, 'tagged_items-INITIAL_FORMS': 0, 'tagged_items-MAX_NUM_FORMS': 1000, }) self.assertTrue(form.is_valid()) instance = form.save(commit=False) self.assertEqual(instance.reviews.count(), 1) self.assertEqual(instance.reviews.first().author, 'Michael Winner') def test_formsets_from_model_superclass_with_explicit_formsets_def(self): class RestaurantForm(ClusterForm): class Meta: model = Restaurant formsets = ('menu_items', 'reviews') fields = ['name', 'tags', 'serves_hot_dogs', 'proprietor'] self.assertIn('reviews', RestaurantForm.formsets) form = RestaurantForm({ 'name': 'The Fat Duck', 'menu_items-TOTAL_FORMS': 0, 'menu_items-INITIAL_FORMS': 0, 'menu_items-MAX_NUM_FORMS': 1000, 'reviews-TOTAL_FORMS': 1, 'reviews-INITIAL_FORMS': 1, 'reviews-MAX_NUM_FORMS': 1000, 'reviews-0-id': '', 'reviews-0-author': 'Michael Winner', 'reviews-0-body': 'Rubbish.', }) self.assertTrue(form.is_valid()) instance = form.save(commit=False) self.assertEqual(instance.reviews.count(), 1) self.assertEqual(instance.reviews.first().author, 'Michael Winner') def test_widgets_with_media(self): class WidgetWithMedia(TextInput): class Media: js = ['test.js'] css = {'all': ['test.css']} class FormWithWidgetMedia(ClusterForm): class Meta: model = Restaurant fields = ['name', 'tags', 'serves_hot_dogs', 'proprietor'] widgets = { 'name': WidgetWithMedia } form = FormWithWidgetMedia() self.assertIn('test.js', str(form.media['js'])) self.assertIn('test.css', str(form.media['css'])) def test_widgets_with_media_on_child_form(self): """ The media property of ClusterForm should pick up media defined on child forms too """ class FancyTextInput(TextInput): class Media: js = ['fancy-text-input.js'] class FancyFileUploader(FileInput): class Media: js = ['fancy-file-uploader.js'] class FormWithWidgetMedia(ClusterForm): class Meta: model = Gallery fields = ['title'] widgets = { 'title': FancyTextInput, } formsets = { 'images': { 'fields': ['image'], 'widgets': {'image': FancyFileUploader} } } form = FormWithWidgetMedia() self.assertIn('fancy-text-input.js', str(form.media['js'])) self.assertIn('fancy-file-uploader.js', str(form.media['js'])) def test_is_multipart_on_parent_form(self): """ is_multipart should be True if a field requiring multipart submission exists on the parent form """ class BandForm(ClusterForm): class Meta: model = Band formsets = ['members'] fields = ['name'] class DocumentForm(ClusterForm): class Meta: model = Document fields = ['title', 'file'] band_form = BandForm() self.assertFalse(band_form.is_multipart()) document_form = DocumentForm() self.assertTrue(document_form.is_multipart()) def test_is_multipart_on_child_form(self): """ is_multipart should be True if a field requiring multipart submission exists on the child form """ class GalleryForm(ClusterForm): class Meta: model = Gallery formsets = ['images'] fields = ['title'] gallery_form = GalleryForm() self.assertTrue(gallery_form.is_multipart()) def test_unique_together(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 2, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'members-0-name': 'John Lennon', 'members-0-id': '', 'members-1-name': 'John Lennon', 'members-1-id': '', 'albums-TOTAL_FORMS': 0, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, }) self.assertFalse(form.is_valid()) class FormWithM2MTest(TestCase): def setUp(self): self.james_joyce = Author.objects.create(name='James Joyce') self.charles_dickens = Author.objects.create(name='Charles Dickens') self.article = Article.objects.create( title='Test article', authors=[self.james_joyce], ) def test_render_form_with_m2m(self): class ArticleForm(ClusterForm): class Meta: model = Article fields = ['title', 'authors'] form = ArticleForm(instance=self.article) html = form.as_p() self.assertIn('Test article', html) self.article.authors.add(self.charles_dickens) form = ArticleForm(instance=self.article) html = form.as_p() self.assertIn('Test article', html) def test_save_form_with_m2m(self): class ArticleForm(ClusterForm): class Meta: model = Article fields = ['title', 'authors'] formsets = [] form = ArticleForm({ 'title': 'Updated test article', 'authors': [self.charles_dickens.id] }, instance=self.article) self.assertTrue(form.is_valid()) form.save() # changes should take effect on both the in-memory instance and the database self.assertEqual(self.article.title, 'Updated test article') self.assertEqual(list(self.article.authors.all()), [self.charles_dickens]) updated_article = Article.objects.get(pk=self.article.pk) self.assertEqual(updated_article.title, 'Updated test article') self.assertEqual(list(updated_article.authors.all()), [self.charles_dickens]) def test_save_form_uncommitted_with_m2m(self): class ArticleForm(ClusterForm): class Meta: model = Article fields = ['title', 'authors'] formsets = [] form = ArticleForm({ 'title': 'Updated test article', 'authors': [self.charles_dickens.id], }, instance=self.article) self.assertTrue(form.is_valid()) form.save(commit=False) # the in-memory instance should have 'title' and 'authors' updated, self.assertEqual(self.article.title, 'Updated test article') self.assertEqual(list(self.article.authors.all()), [self.charles_dickens]) # the database record should be unchanged db_article = Article.objects.get(pk=self.article.pk) self.assertEqual(db_article.title, 'Test article') self.assertEqual(list(db_article.authors.all()), [self.james_joyce]) # model.save commits the record to the db self.article.save() db_article = Article.objects.get(pk=self.article.pk) self.assertEqual(db_article.title, 'Updated test article') self.assertEqual(list(db_article.authors.all()), [self.charles_dickens]) class NestedClusterFormTest(TestCase): def test_nested_formsets(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] self.assertTrue(BandForm.formsets) beatles = Band(name='The Beatles', albums=[ Album(name='Please Please Me', songs=[ Song(name='I Saw Her Standing There'), Song(name='Misery') ]), ]) form = BandForm(instance=beatles) self.assertEqual(4, len(form.formsets['albums'].forms)) self.assertEqual(5, len(form.formsets['albums'].forms[0].formsets['songs'])) self.assertTrue('songs' in form.as_p()) def test_empty_nested_formsets(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm() self.assertEqual(3, len(form.formsets['albums'].forms)) self.assertEqual(3, len(form.formsets['albums'].forms[0].formsets['songs'].forms)) def test_incoming_form_data(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] beatles = Band(name='The Beatles', albums=[ Album(name='Please Please Me', songs=[ Song(name='I Saw Her Standing There') ]), ]) form = BandForm({ 'name': 'The Beatles', 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 1, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': 'Please Please Me', 'albums-0-id': '', 'albums-0-ORDER': 1, 'albums-0-songs-TOTAL_FORMS': 2, 'albums-0-songs-INITIAL_FORMS': 1, 'albums-0-songs-MAX_NUM_FORMS': 1000, 'albums-0-songs-0-name': 'I Saw Her Standing There', 'albums-0-songs-0-DELETE': 'albums-0-songs-0-DELETE', 'albums-0-songs-0-id': '', 'albums-0-songs-1-name': 'Misery', 'albums-0-songs-1-id': '', }, instance=beatles) self.assertTrue(form.is_valid()) result = form.save(commit=False) self.assertEqual(result, beatles) self.assertEqual(1, beatles.albums.count()) self.assertEqual('Please Please Me', beatles.albums.first().name) self.assertEqual(1, beatles.albums.first().songs.all().count()) self.assertEqual('Misery', beatles.albums.first().songs.first().name) # should not exist in the database yet self.assertFalse(Album.objects.filter(name='Please Please Me').exists()) self.assertFalse(Song.objects.filter(name='Misery').exists()) beatles.save() # this should create database entries self.assertTrue(Band.objects.filter(name='The Beatles').exists()) self.assertTrue(Album.objects.filter(name='Please Please Me').exists()) self.assertTrue(Song.objects.filter(name='Misery').exists()) self.assertFalse(Song.objects.filter(name='I Saw Her Standing There').exists()) @unittest.skip('Explicit nested formsets not yet enabled') def test_explicit_nested_formset_list(self): class BandForm(ClusterForm): class Meta: model = Band formsets = { 'albums': {'formsets': ['songs']} } fields = ['name'] form = BandForm() self.assertTrue(form.formsets.get('albums')) self.assertTrue(form.formsets.get('albums').forms[0].formsets['songs']) self.assertTrue('albums' in form.as_p()) self.assertTrue('songs' in form.as_p()) @unittest.skip('Excluded nested formsets not yet enabled') def test_excluded_nested_formset_list(self): class BandForm(ClusterForm): class Meta: model = Band formsets = { 'albums': {'exclude_formsets': ['songs']} } fields = ['name'] form = BandForm() self.assertTrue(form.formsets.get('albums')) self.assertTrue('albums' in form.as_p()) self.assertFalse('songs' in form.as_p()) def test_saved_items(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] first_song = Song(name='I Saw Her Standing There') second_song = Song(name='Misery') album = Album(name='Please Please Me', songs=[first_song, second_song]) beatles = Band(name='The Beatles', albums=[album]) beatles.save() self.assertTrue(album.id) self.assertTrue(first_song.id) self.assertTrue(second_song.id) form = BandForm({ 'name': 'The Beatles', 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 1, 'albums-INITIAL_FORMS': 1, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': album.name, 'albums-0-id': album.id, 'albums-0-ORDER': 1, 'albums-0-songs-TOTAL_FORMS': 4, 'albums-0-songs-INITIAL_FORMS': 2, 'albums-0-songs-MAX_NUM_FORMS': 1000, 'albums-0-songs-0-name': first_song.name, 'albums-0-songs-0-DELETE': 'albums-0-songs-0-DELETE', 'albums-0-songs-0-id': first_song.id, 'albums-0-songs-1-name': second_song.name, 'albums-0-songs-1-id': second_song.id, 'albums-0-songs-2-name': 'Anna', 'albums-0-songs-2-id': '', 'albums-0-songs-3-name': '', 'albums-0-songs-3-id': '', }, instance=beatles) self.assertTrue(form.is_valid()) form.save() self.assertTrue(Song.objects.filter(name='Anna').exists()) self.assertTrue(Song.objects.filter(name='Misery').exists()) self.assertFalse(Song.objects.filter(name='I Saw Her Standing There').exists()) def test_saved_items_with_non_db_relation(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] first_song = Song(name='I Saw Her Standing There') second_song = Song(name='Misery') album = Album(name='Please Please Me', songs=[first_song, second_song]) beatles = Band(name='The Beatles', albums=[album]) beatles.save() # pack and unpack the record so that we're working with a non-db-backed queryset new_beatles = Band.from_json(beatles.to_json()) form = BandForm({ 'name': 'The Beatles', 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 1, 'albums-INITIAL_FORMS': 1, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': album.name, 'albums-0-id': album.id, 'albums-0-ORDER': 1, 'albums-0-songs-TOTAL_FORMS': 4, 'albums-0-songs-INITIAL_FORMS': 2, 'albums-0-songs-MAX_NUM_FORMS': 1000, 'albums-0-songs-0-name': first_song.name, 'albums-0-songs-0-DELETE': 'albums-0-songs-0-DELETE', 'albums-0-songs-0-id': first_song.id, 'albums-0-songs-1-name': second_song.name, 'albums-0-songs-1-id': second_song.id, 'albums-0-songs-2-name': 'Anna', 'albums-0-songs-2-id': '', 'albums-0-songs-3-name': '', 'albums-0-songs-3-id': '', }, instance=new_beatles) self.assertTrue(form.is_valid()) form.save() self.assertTrue(Song.objects.filter(name='Anna').exists()) self.assertFalse(Song.objects.filter(name='I Saw Her Standing There').exists()) def test_creation(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 1, 'albums-INITIAL_FORMS': 1, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': 'Please Please Me', 'albums-0-id': '', 'albums-0-ORDER': 1, 'albums-0-songs-TOTAL_FORMS': 4, 'albums-0-songs-INITIAL_FORMS': 2, 'albums-0-songs-MAX_NUM_FORMS': 1000, 'albums-0-songs-0-name': 'I Saw Her Standing There', 'albums-0-songs-0-id': '', 'albums-0-songs-1-name': 'Misery', 'albums-0-songs-1-id': '', 'albums-0-songs-2-name': 'Anna', 'albums-0-songs-2-DELETE': 'albums-0-songs-2-DELETE', 'albums-0-songs-2-id': '', 'albums-0-songs-3-name': '', 'albums-0-songs-3-id': '', }) self.assertTrue(form.is_valid()) beatles = form.save() self.assertTrue(beatles.id) self.assertEqual('The Beatles', beatles.name) self.assertEqual('The Beatles', Band.objects.get(id=beatles.id).name) self.assertEqual(1, beatles.albums.count()) self.assertEqual(2, beatles.albums.first().songs.count()) self.assertTrue(Song.objects.filter(name='I Saw Her Standing There').exists()) self.assertFalse(Song.objects.filter(name='Anna').exists()) def test_sort_order_is_output_on_form(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm() form_html = form.as_p() self.assertTrue('albums-0-ORDER' in form_html) self.assertTrue('albums-0-songs-0-ORDER' in form_html) def test_sort_order_is_committed(self): class BandForm(ClusterForm): class Meta: model = Band fields = ['name'] form = BandForm({ 'name': "The Beatles", 'members-TOTAL_FORMS': 0, 'members-INITIAL_FORMS': 0, 'members-MAX_NUM_FORMS': 1000, 'albums-TOTAL_FORMS': 1, 'albums-INITIAL_FORMS': 0, 'albums-MAX_NUM_FORMS': 1000, 'albums-0-name': 'Please Please Me', 'albums-0-id': '', 'albums-0-ORDER': 1, 'albums-0-songs-TOTAL_FORMS': 2, 'albums-0-songs-INITIAL_FORMS': 0, 'albums-0-songs-MAX_NUM_FORMS': 1000, 'albums-0-songs-0-name': 'Misery', 'albums-0-songs-0-id': '', 'albums-0-songs-0-ORDER': 2, 'albums-0-songs-1-name': 'I Saw Her Standing There', 'albums-0-songs-1-id': '', 'albums-0-songs-1-ORDER': 1, }) self.assertTrue(form.is_valid()) beatles = form.save() self.assertEqual('I Saw Her Standing There', beatles.albums.first().songs.all()[0].name) self.assertEqual('Misery', beatles.albums.first().songs.all()[1].name) django-modelcluster-5.0.1/tests/tests/test_fixture_loading.py000066400000000000000000000010771360467766700246120ustar00rootroot00000000000000from __future__ import unicode_literals from django.test import TestCase from tests.models import Person class TestLoadsParentalManyToManyToOrderedModel(TestCase): fixtures = ["parentalmanytomany-to-ordered-model.json"] def test_data_loads_from_fixture(self): """ The main test here is that the fixture loads without errors. The code below code then confirms that the relationship was set correctly. """ person = Person.objects.get(id=1) self.assertEqual(list(person.houses.values_list("id", flat=True)), [1, 2]) django-modelcluster-5.0.1/tests/tests/test_formset.py000066400000000000000000000531421360467766700231060ustar00rootroot00000000000000from __future__ import unicode_literals from django.test import TestCase from modelcluster.forms import ClusterForm, transientmodelformset_factory, childformset_factory from tests.models import NewsPaper, Article, Author, Band, BandMember, Album, Song class TransientFormsetTest(TestCase): BandMembersFormset = transientmodelformset_factory(BandMember, exclude=['band'], extra=3, can_delete=True) def test_can_create_formset(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) band_members_formset = self.BandMembersFormset(queryset=beatles.members.all()) self.assertEqual(5, len(band_members_formset.forms)) self.assertEqual('John Lennon', band_members_formset.forms[0].instance.name) def test_incoming_formset_data(self): beatles = Band(name='The Beatles', members=[ BandMember(name='George Harrison'), ]) band_members_formset = self.BandMembersFormset({ 'form-TOTAL_FORMS': 3, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': '', 'form-1-name': 'Paul McCartney', 'form-1-id': '', 'form-2-name': '', 'form-2-id': '', }, queryset=beatles.members.all()) self.assertTrue(band_members_formset.is_valid()) members = band_members_formset.save(commit=False) self.assertEqual(2, len(members)) self.assertEqual('John Lennon', members[0].name) # should not exist in the database yet self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) def test_save_commit_false(self): john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') ringo = BandMember(name='Richard Starkey') beatles = Band(name='The Beatles', members=[ john, paul, ringo ]) beatles.save() john_id, paul_id, ringo_id = john.id, paul.id, ringo.id self.assertTrue(john_id) self.assertTrue(paul_id) band_members_formset = self.BandMembersFormset({ 'form-TOTAL_FORMS': 5, 'form-INITIAL_FORMS': 3, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-DELETE': 'form-0-DELETE', 'form-0-id': john_id, 'form-1-name': 'Paul McCartney', 'form-1-id': paul_id, 'form-2-name': 'Ringo Starr', # changing data of an existing record 'form-2-id': ringo_id, 'form-3-name': '', 'form-3-id': '', 'form-4-name': 'George Harrison', # Adding a record 'form-4-id': '', }, queryset=beatles.members.all()) self.assertTrue(band_members_formset.is_valid()) updated_members = band_members_formset.save(commit=False) self.assertEqual(2, len(updated_members)) self.assertEqual('Ringo Starr', updated_members[0].name) self.assertEqual(ringo_id, updated_members[0].id) # should not be updated in the db yet self.assertEqual('Richard Starkey', BandMember.objects.get(id=ringo_id).name) self.assertEqual('George Harrison', updated_members[1].name) self.assertFalse(updated_members[1].id) # no ID yet def test_save_commit_true(self): john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') ringo = BandMember(name='Richard Starkey') beatles = Band(name='The Beatles', members=[ john, paul, ringo ]) beatles.save() john_id, paul_id, ringo_id = john.id, paul.id, ringo.id self.assertTrue(john_id) self.assertTrue(paul_id) band_members_formset = self.BandMembersFormset({ 'form-TOTAL_FORMS': 4, 'form-INITIAL_FORMS': 3, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-DELETE': 'form-0-DELETE', 'form-0-id': john_id, 'form-1-name': 'Paul McCartney', 'form-1-id': paul_id, 'form-2-name': 'Ringo Starr', # changing data of an existing record 'form-2-id': ringo_id, 'form-3-name': '', 'form-3-id': '', }, queryset=beatles.members.all()) self.assertTrue(band_members_formset.is_valid()) updated_members = band_members_formset.save() self.assertEqual(1, len(updated_members)) self.assertEqual('Ringo Starr', updated_members[0].name) self.assertEqual(ringo_id, updated_members[0].id) self.assertFalse(BandMember.objects.filter(id=john_id).exists()) self.assertEqual('Paul McCartney', BandMember.objects.get(id=paul_id).name) self.assertEqual(beatles.id, BandMember.objects.get(id=paul_id).band_id) self.assertEqual('Ringo Starr', BandMember.objects.get(id=ringo_id).name) self.assertEqual(beatles.id, BandMember.objects.get(id=ringo_id).band_id) class ChildFormsetTest(TestCase): def test_can_create_formset(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) BandMembersFormset = childformset_factory(Band, BandMember, extra=3) band_members_formset = BandMembersFormset(instance=beatles) self.assertEqual(5, len(band_members_formset.forms)) self.assertEqual('John Lennon', band_members_formset.forms[0].instance.name) def test_empty_formset(self): BandMembersFormset = childformset_factory(Band, BandMember, extra=3) band_members_formset = BandMembersFormset() self.assertEqual(3, len(band_members_formset.forms)) def test_save_commit_false(self): john = BandMember(name='John Lennon') paul = BandMember(name='Paul McCartney') ringo = BandMember(name='Richard Starkey') beatles = Band(name='The Beatles', members=[ john, paul, ringo ]) beatles.save() john_id, paul_id, ringo_id = john.id, paul.id, ringo.id BandMembersFormset = childformset_factory(Band, BandMember, extra=3) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 5, 'form-INITIAL_FORMS': 3, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-DELETE': 'form-0-DELETE', 'form-0-id': john_id, 'form-1-name': 'Paul McCartney', 'form-1-id': paul_id, 'form-2-name': 'Ringo Starr', # changing data of an existing record 'form-2-id': ringo_id, 'form-3-name': '', 'form-3-id': '', 'form-4-name': 'George Harrison', # adding a record 'form-4-id': '', }, instance=beatles) self.assertTrue(band_members_formset.is_valid()) updated_members = band_members_formset.save(commit=False) # updated_members should only include the items that have been changed and not deleted self.assertEqual(2, len(updated_members)) self.assertEqual('Ringo Starr', updated_members[0].name) self.assertEqual(ringo_id, updated_members[0].id) self.assertEqual('George Harrison', updated_members[1].name) self.assertEqual(None, updated_members[1].id) # Changes should not be committed to the db yet self.assertTrue(BandMember.objects.filter(name='John Lennon', id=john_id).exists()) self.assertEqual('Richard Starkey', BandMember.objects.get(id=ringo_id).name) self.assertFalse(BandMember.objects.filter(name='George Harrison').exists()) beatles.members.commit() # this should create/update/delete database entries self.assertEqual('Ringo Starr', BandMember.objects.get(id=ringo_id).name) self.assertTrue(BandMember.objects.filter(name='George Harrison').exists()) self.assertFalse(BandMember.objects.filter(name='John Lennon').exists()) def test_child_updates_without_ids(self): john = BandMember(name='John Lennon') beatles = Band(name='The Beatles', members=[ john ]) beatles.save() john_id = john.id paul = BandMember(name='Paul McCartney') beatles.members.add(paul) BandMembersFormset = childformset_factory(Band, BandMember, extra=3) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 2, 'form-INITIAL_FORMS': 2, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': john_id, 'form-1-name': 'Paul McCartney', # NB no way to know programmatically that this form corresponds to the 'paul' object 'form-1-id': '', }, instance=beatles) self.assertTrue(band_members_formset.is_valid()) band_members_formset.save(commit=False) self.assertEqual(2, beatles.members.count()) def test_max_num_ignored_in_validation_when_validate_max_false(self): BandMembersFormset = childformset_factory(Band, BandMember, max_num=2) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 3, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': '', 'form-1-name': 'Paul McCartney', 'form-1-id': '', 'form-2-name': 'Ringo Starr', 'form-2-id': '', }) self.assertTrue(band_members_formset.is_valid()) def test_max_num_fail_validation(self): BandMembersFormset = childformset_factory(Band, BandMember, max_num=2, validate_max=True) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 3, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': '', 'form-1-name': 'Paul McCartney', 'form-1-id': '', 'form-2-name': 'Ringo Starr', 'form-2-id': '', }) self.assertFalse(band_members_formset.is_valid()) self.assertEqual(band_members_formset.non_form_errors()[0], "Please submit 2 or fewer forms.") def test_max_num_pass_validation(self): BandMembersFormset = childformset_factory(Band, BandMember, max_num=2, validate_max=True) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 2, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': '', 'form-1-name': 'Paul McCartney', 'form-1-id': '', }) self.assertTrue(band_members_formset.is_valid()) def test_min_num_ignored_in_validation_when_validate_max_false(self): BandMembersFormset = childformset_factory(Band, BandMember, min_num=2) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': '', }) self.assertTrue(band_members_formset.is_valid()) def test_min_num_fail_validation(self): BandMembersFormset = childformset_factory(Band, BandMember, min_num=2, validate_min=True) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': '', }) self.assertFalse(band_members_formset.is_valid()) self.assertEqual(band_members_formset.non_form_errors()[0], "Please submit 2 or more forms.") def test_min_num_pass_validation(self): BandMembersFormset = childformset_factory(Band, BandMember, min_num=2, validate_min=True) band_members_formset = BandMembersFormset({ 'form-TOTAL_FORMS': 2, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'John Lennon', 'form-0-id': '', 'form-1-name': 'Paul McCartney', 'form-1-id': '', }) self.assertTrue(band_members_formset.is_valid()) class ChildFormsetWithM2MTest(TestCase): def setUp(self): self.james_joyce = Author.objects.create(name='James Joyce') self.charles_dickens = Author.objects.create(name='Charles Dickens') self.paper = NewsPaper.objects.create(title='the daily record') self.article = Article.objects.create( paper=self.paper, title='Test article', authors=[self.james_joyce], ) ArticleFormset = childformset_factory(NewsPaper, Article, exclude=['categories', 'tags'], extra=3) self.formset = ArticleFormset({ 'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 10, 'form-0-id': self.article.id, 'form-0-title': self.article.title, 'form-0-authors': [self.james_joyce.id, self.charles_dickens.id], }, instance=self.paper) ArticleTagsFormset = childformset_factory(NewsPaper, Article, exclude=['categories', 'authors'], extra=3) self.tags_formset = ArticleTagsFormset({ 'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 10, 'form-0-id': self.article.id, 'form-0-title': self.article.title, 'form-0-tags': 'tag1, tagtwo', }, instance=self.paper) def test_save_with_commit_false(self): self.assertTrue(self.formset.is_valid()) saved_articles = self.formset.save(commit=False) updated_article = saved_articles[0] # in memory self.assertIn(self.james_joyce, updated_article.authors.all()) self.assertIn(self.charles_dickens, updated_article.authors.all()) # in db db_article = Article.objects.get(id=self.article.id) self.assertIn(self.james_joyce, db_article.authors.all()) self.assertNotIn(self.charles_dickens, db_article.authors.all()) def test_save_with_commit_true(self): self.assertTrue(self.formset.is_valid()) saved_articles = self.formset.save(commit=True) updated_article = saved_articles[0] # in db db_article = Article.objects.get(id=self.article.id) self.assertIn(self.james_joyce, db_article.authors.all()) self.assertIn(self.charles_dickens, db_article.authors.all()) # in memory self.assertIn(self.james_joyce, updated_article.authors.all()) self.assertIn(self.charles_dickens, updated_article.authors.all()) def test_tags_save_with_commit_false(self): self.assertTrue(self.tags_formset.is_valid()) saved_articles = self.tags_formset.save(commit=False) updated_article = saved_articles[0] # in memory self.assertIn('tag1', [t.slug for t in updated_article.tags.all()]) self.assertIn('tagtwo', [t.slug for t in updated_article.tags.all()]) # in db db_article = Article.objects.get(id=self.article.id) self.assertNotIn('tag1', [t.slug for t in db_article.tags.all()]) self.assertNotIn('tagtwo', [t.slug for t in db_article.tags.all()]) def test_tags_save_with_commit_true(self): self.assertTrue(self.tags_formset.is_valid()) saved_articles = self.tags_formset.save(commit=True) updated_article = saved_articles[0] # in db db_article = Article.objects.get(id=self.article.id) self.assertIn('tag1', [t.slug for t in db_article.tags.all()]) self.assertIn('tagtwo', [t.slug for t in db_article.tags.all()]) # in memory self.assertIn('tag1', [t.slug for t in updated_article.tags.all()]) self.assertIn('tagtwo', [t.slug for t in updated_article.tags.all()]) class OrderedFormsetTest(TestCase): def test_saving_formset_preserves_order(self): AlbumsFormset = childformset_factory(Band, Album, extra=3, can_order=True) beatles = Band(name='The Beatles') albums_formset = AlbumsFormset({ 'form-TOTAL_FORMS': 2, 'form-INITIAL_FORMS': 0, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'With The Beatles', 'form-0-id': '', 'form-0-ORDER': '2', 'form-1-name': 'Please Please Me', 'form-1-id': '', 'form-1-ORDER': '1', }, instance=beatles) self.assertTrue(albums_formset.is_valid()) albums_formset.save(commit=False) album_names = [album.name for album in beatles.albums.all()] self.assertEqual(['Please Please Me', 'With The Beatles'], album_names) class NestedChildFormsetTest(TestCase): def test_can_create_formset(self): beatles = Band(name='The Beatles', albums=[ Album(name='Please Please Me', songs=[ Song(name='I Saw Her Standing There'), Song(name='Misery') ]) ]) AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) albums_formset = AlbumsFormset(instance=beatles) self.assertEqual(4, len(albums_formset.forms)) self.assertEqual('Please Please Me', albums_formset.forms[0].instance.name) self.assertEqual(5, len(albums_formset.forms[0].formsets['songs'].forms)) self.assertEqual( 'I Saw Her Standing There', albums_formset.forms[0].formsets['songs'].forms[0].instance.name ) def test_empty_formset(self): AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) albums_formset = AlbumsFormset() self.assertEqual(3, len(albums_formset.forms)) self.assertEqual(3, len(albums_formset.forms[0].formsets['songs'].forms)) def test_save_commit_false(self): first_song = Song(name='I Saw Her Standing There') second_song = Song(name='Mystery') album = Album(name='Please Please Me', songs=[first_song, second_song]) beatles = Band(name='The Beatles', albums=[album]) beatles.save() first_song_id, second_song_id = first_song.id, second_song.id AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) albums_formset = AlbumsFormset({ 'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'Please Please Me', 'form-0-id': album.id, 'form-0-songs-TOTAL_FORMS': 4, 'form-0-songs-INITIAL_FORMS': 2, 'form-0-songs-MAX_NUM_FORMS': 1000, 'form-0-songs-0-name': 'I Saw Her Standing There', 'form-0-songs-0-DELETE': 'form-0-songs-0-DELETE', 'form-0-songs-0-id': first_song_id, 'form-0-songs-1-name': 'Misery', # changing data of an existing record 'form-0-songs-1-id': second_song_id, 'form-0-songs-2-name': '', 'form-0-songs-2-id': '', 'form-0-songs-3-name': 'Chains', # adding a record 'form-0-songs-3-id': '', }, instance=beatles) self.assertTrue(albums_formset.is_valid()) updated_albums = albums_formset.save(commit=False) # updated_members should only include the items that have been changed and not deleted self.assertEqual(1, len(updated_albums)) self.assertEqual('Please Please Me', updated_albums[0].name) self.assertEqual(2, updated_albums[0].songs.count()) self.assertEqual('Misery', updated_albums[0].songs.first().name) self.assertEqual(second_song_id, updated_albums[0].songs.first().id) self.assertEqual('Chains', updated_albums[0].songs.all()[1].name) self.assertEqual(None, updated_albums[0].songs.all()[1].id) # Changes should not be committed to the db yet self.assertTrue(Song.objects.filter(name='I Saw Her Standing There', id=first_song_id).exists()) self.assertEqual('Mystery', Song.objects.get(id=second_song_id).name) self.assertFalse(Song.objects.filter(name='Chains').exists()) beatles.albums.first().songs.commit() # this should create/update/delete database entries self.assertEqual('Misery', Song.objects.get(id=second_song_id).name) self.assertTrue(Song.objects.filter(name='Chains').exists()) self.assertFalse(Song.objects.filter(name='I Saw Her Standing There').exists()) def test_child_updates_without_ids(self): first_song = Song(name='I Saw Her Standing There') album = Album(name='Please Please Me', songs=[first_song]) beatles = Band(name='The Beatles', albums=[album]) beatles.save() first_song_id = first_song.id second_song = Song(name='Misery') album.songs.add(second_song) AlbumsFormset = childformset_factory(Band, Album, form=ClusterForm, extra=3) albums_formset = AlbumsFormset({ 'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 1, 'form-MAX_NUM_FORMS': 1000, 'form-0-name': 'Please Please Me', 'form-0-id': album.id, 'form-0-songs-TOTAL_FORMS': 2, 'form-0-songs-INITIAL_FORMS': 2, 'form-0-songs-MAX_NUM_FORMS': 1000, 'form-0-songs-0-name': 'I Saw Her Standing There', 'form-0-songs-0-id': first_song_id, 'form-0-songs-1-name': 'Misery', 'form-0-songs-1-id': '', }, instance=beatles) self.assertTrue(albums_formset.is_valid()) albums_formset.save(commit=False) self.assertEqual(2, beatles.albums.first().songs.count()) django-modelcluster-5.0.1/tests/tests/test_serialize.py000066400000000000000000000310051360467766700234100ustar00rootroot00000000000000from __future__ import unicode_literals import json import datetime from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.utils import timezone from tests.models import Band, BandMember, Album, Restaurant, Dish, MenuItem, Chef, Wine, \ Review, Log, Document, Article, Author, Category class SerializeTest(TestCase): def test_serialize(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ]) expected = {'pk': None, 'albums': [], 'name': 'The Beatles', 'members': [ {'pk': None, 'name': 'John Lennon', 'band': None, 'favourite_restaurant': None}, {'pk': None, 'name': 'Paul McCartney', 'band': None, 'favourite_restaurant': None} ]} self.assertEqual(expected, beatles.serializable_data()) def test_serialize_m2m(self): george_orwell = Author.objects.create(name='George Orwell') charles_dickens = Author.objects.create(name='Charles Dickens') article = Article( title='Down and Out in Paris and London', authors=[george_orwell, charles_dickens], ) article_serialised = article.serializable_data() self.assertEqual(article_serialised['title'], 'Down and Out in Paris and London') self.assertIn(george_orwell.pk, article_serialised['authors']) self.assertEqual(article_serialised['categories'], []) def test_serialize_json_with_dates(self): beatles = Band(name='The Beatles', members=[ BandMember(name='John Lennon'), BandMember(name='Paul McCartney'), ], albums=[ Album(name='Rubber Soul', release_date=datetime.date(1965, 12, 3)) ]) beatles_json = beatles.to_json() self.assertTrue("John Lennon" in beatles_json) self.assertTrue("1965-12-03" in beatles_json) unpacked_beatles = Band.from_json(beatles_json) self.assertEqual(datetime.date(1965, 12, 3), unpacked_beatles.albums.all()[0].release_date) def test_deserialize(self): beatles = Band.from_serializable_data({ 'pk': 9, 'albums': [], 'name': 'The Beatles', 'members': [ {'pk': None, 'name': 'John Lennon', 'band': None}, {'pk': None, 'name': 'Paul McCartney', 'band': None}, ] }) self.assertEqual(9, beatles.id) self.assertEqual('The Beatles', beatles.name) self.assertEqual(2, beatles.members.count()) self.assertEqual(BandMember, beatles.members.all()[0].__class__) def test_deserialize_m2m(self): authors = {} categories = {} for i in range(1, 6): authors[i] = Author.objects.create(name="Author %d" % i) categories[i] = Category.objects.create(name="Category %d" % i) article = Article.from_serializable_data({ 'pk': 1, 'title': 'Article Title 1', 'authors': [authors[1].pk, authors[2].pk], 'categories': [categories[2].pk, categories[3].pk, categories[4].pk] }) self.assertEqual(article.id, 1) self.assertEqual(article.title, 'Article Title 1') self.assertEqual(article.authors.count(), 2) self.assertEqual( [author.name for author in article.authors.order_by('name')], ['Author 1', 'Author 2'] ) self.assertEqual(article.categories.count(), 3) def test_deserialize_json(self): 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}]}') self.assertEqual(9, beatles.id) self.assertEqual('The Beatles', beatles.name) self.assertEqual(2, beatles.members.count()) self.assertEqual(BandMember, beatles.members.all()[0].__class__) def test_serialize_with_multi_table_inheritance(self): fat_duck = Restaurant(name='The Fat Duck', serves_hot_dogs=False, reviews=[ Review(author='Michael Winner', body='Rubbish.') ]) data = json.loads(fat_duck.to_json()) self.assertEqual(data['name'], 'The Fat Duck') self.assertEqual(data['serves_hot_dogs'], False) self.assertEqual(data['reviews'][0]['author'], 'Michael Winner') def test_deserialize_with_multi_table_inheritance(self): fat_duck = Restaurant.from_json('{"pk": 42, "name": "The Fat Duck", "serves_hot_dogs": false, "reviews": [{"pk": null, "author": "Michael Winner", "body": "Rubbish."}]}') self.assertEqual(fat_duck.id, 42) self.assertEqual(fat_duck.name, "The Fat Duck") self.assertEqual(fat_duck.serves_hot_dogs, False) self.assertEqual(fat_duck.reviews.all()[0].author, "Michael Winner") def test_dangling_foreign_keys(self): heston_blumenthal = Chef.objects.create(name="Heston Blumenthal") snail_ice_cream = Dish.objects.create(name="Snail ice cream") chateauneuf = Wine.objects.create(name="Chateauneuf-du-Pape 1979") fat_duck = Restaurant(name="The Fat Duck", proprietor=heston_blumenthal, serves_hot_dogs=False, menu_items=[ MenuItem(dish=snail_ice_cream, price='20.00', recommended_wine=chateauneuf) ]) fat_duck_json = fat_duck.to_json() fat_duck = Restaurant.from_json(fat_duck_json) self.assertEqual("Heston Blumenthal", fat_duck.proprietor.name) self.assertEqual("Chateauneuf-du-Pape 1979", fat_duck.menu_items.all()[0].recommended_wine.name) heston_blumenthal.delete() fat_duck = Restaurant.from_json(fat_duck_json) # the deserialised record should recognise that the heston_blumenthal record is now missing self.assertEqual(None, fat_duck.proprietor) self.assertEqual("Chateauneuf-du-Pape 1979", fat_duck.menu_items.all()[0].recommended_wine.name) chateauneuf.delete() # oh dear, looks like we just drank the last bottle fat_duck = Restaurant.from_json(fat_duck_json) # the deserialised record should now have a null recommended_wine field self.assertEqual(None, fat_duck.menu_items.all()[0].recommended_wine) snail_ice_cream.delete() # NOM NOM NOM fat_duck = Restaurant.from_json(fat_duck_json) # the menu item should now be dropped entirely (because the foreign key to Dish has on_delete=CASCADE) self.assertEqual(0, fat_duck.menu_items.count()) def test_deserialize_with_sort_order(self): 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": []}') self.assertEqual(2, beatles.albums.count()) # Make sure the albums were ordered correctly self.assertEqual("Please Please Me", beatles.albums.all()[0].name) self.assertEqual("With The Beatles", beatles.albums.all()[1].name) def test_deserialize_with_reversed_sort_order(self): Album._meta.ordering = ['-sort_order'] 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": []}') Album._meta.ordering = ['sort_order'] self.assertEqual(2, beatles.albums.count()) # Make sure the albums were ordered correctly self.assertEqual("With The Beatles", beatles.albums.all()[0].name) self.assertEqual("Please Please Me", beatles.albums.all()[1].name) def test_deserialize_with_multiple_sort_order(self): Album._meta.ordering = ['sort_order', 'name'] 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": []}') Album._meta.ordering = ['sort_order'] self.assertEqual(3, beatles.albums.count()) # Make sure the albums were ordered correctly self.assertEqual(2, beatles.albums.all()[0].pk) self.assertEqual(1, beatles.albums.all()[1].pk) self.assertEqual(3, beatles.albums.all()[2].pk) WAGTAIL_05_RELEASE_DATETIME = datetime.datetime(2014, 8, 1, 11, 1, 42) def test_serialise_with_naive_datetime(self): """ This tests that naive datetimes are saved as UTC """ # Time is in America/Chicago time log = Log(time=self.WAGTAIL_05_RELEASE_DATETIME, data="Wagtail 0.5 released") log_json = json.loads(log.to_json()) # Now check that the time is stored correctly with the timezone information at the end self.assertEqual(log_json['time'], '2014-08-01T16:01:42Z') def test_serialise_with_aware_datetime(self): """ This tests that aware datetimes are converted to as UTC """ # make an aware datetime, consisting of WAGTAIL_05_RELEASE_DATETIME # in a timezone 1hr west of UTC one_hour_west = timezone.get_fixed_timezone(-60) local_time = timezone.make_aware(self.WAGTAIL_05_RELEASE_DATETIME, one_hour_west) log = Log(time=local_time, data="Wagtail 0.5 released") log_json = json.loads(log.to_json()) # Now check that the time is stored correctly with the timezone information at the end self.assertEqual(log_json['time'], '2014-08-01T12:01:42Z') def test_deserialise_with_utc_datetime(self): """ This tests that a datetimes saved as UTC are converted back correctly """ # Time is in UTC log = Log.from_json('{"data": "Wagtail 0.5 released", "time": "2014-08-01T16:01:42Z", "pk": null}') # Naive and aware timezones cannot be compared so make the release date timezone-aware before comparison expected_time = timezone.make_aware(self.WAGTAIL_05_RELEASE_DATETIME, timezone.get_default_timezone()) # Check that the datetime is correct and was converted back into the correct timezone self.assertEqual(log.time, expected_time) self.assertEqual(log.time.tzinfo, expected_time.tzinfo) def test_deserialise_with_local_datetime(self): """ This tests that a datetime without timezone information is interpreted as a local time """ log = Log.from_json('{"data": "Wagtail 0.5 released", "time": "2014-08-01T11:01:42", "pk": null}') expected_time = timezone.make_aware(self.WAGTAIL_05_RELEASE_DATETIME, timezone.get_default_timezone()) self.assertEqual(log.time, expected_time) self.assertEqual(log.time.tzinfo, expected_time.tzinfo) def test_serialise_with_null_datetime(self): log = Log(time=None, data="Someone scanned a QR code") log_json = json.loads(log.to_json()) self.assertEqual(log_json['time'], None) def test_deserialise_with_null_datetime(self): log = Log.from_json('{"data": "Someone scanned a QR code", "time": null, "pk": null}') self.assertEqual(log.time, None) def test_serialise_saves_file_fields(self): doc = Document(title='Hello') doc.file = SimpleUploadedFile('hello.txt', b'Hello world') doc_json = doc.to_json() new_doc = Document.from_json(doc_json) self.assertEqual(new_doc.file.read(), b'Hello world') def test_ignored_relations(self): george_orwell = Author.objects.create(name='George Orwell') charles_dickens = Author.objects.create(name='Charles Dickens') rel_article = Article( title='Round and round wherever', authors=[george_orwell], ) article = Article( title='Down and Out in Paris and London', authors=[george_orwell, charles_dickens], related_articles=[rel_article], view_count=123 ) article_serialised = article.serializable_data() # check that related_articles and view_count are not serialized (marked with serialize=False) self.assertNotIn('related_articles', article_serialised) self.assertNotIn('view_count', article_serialised) rel_article.save() article.save() article_json = article.to_json() restored_article = Article.from_json(article_json) restored_article.save() restored_article = Article.objects.get(pk=restored_article.pk) # check that related_articles and view_count hasn't been touched self.assertIn(rel_article, restored_article.related_articles.all()) django-modelcluster-5.0.1/tests/tests/test_tag.py000066400000000000000000000154021360467766700221770ustar00rootroot00000000000000from __future__ import unicode_literals import unittest from django.test import TestCase, override_settings from taggit import VERSION as TAGGIT_VERSION from taggit.models import Tag from modelcluster.forms import ClusterForm from tests.models import NonClusterPlace, Place, TaggedPlace class TagTest(TestCase): def test_can_access_tags_on_unsaved_instance(self): mission_burrito = Place(name='Mission Burrito') self.assertEqual(0, mission_burrito.tags.count()) mission_burrito.tags.add('mexican', 'burrito') self.assertEqual(2, mission_burrito.tags.count()) self.assertEqual(Tag, mission_burrito.tags.all()[0].__class__) self.assertTrue([tag for tag in mission_burrito.tags.all() if tag.name == 'mexican']) mission_burrito.save() self.assertEqual(2, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) mission_burrito.tags.remove('burrito') self.assertEqual(1, mission_burrito.tags.count()) # should not affect database until we save self.assertEqual(2, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) mission_burrito.save() self.assertEqual(1, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) mission_burrito.tags.clear() self.assertEqual(0, mission_burrito.tags.count()) # should not affect database until we save self.assertEqual(1, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) mission_burrito.save() self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) mission_burrito.tags.set('mexican', 'burrito') self.assertEqual(2, mission_burrito.tags.count()) self.assertEqual(0, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) mission_burrito.save() self.assertEqual(2, TaggedPlace.objects.filter(content_object_id=mission_burrito.id).count()) def test_prefetch_tags_doesnt_break(self): mission_burrito = Place(name='Mission Burrito') mission_burrito.tags.add('mexican', 'burrito') mission_burrito.save() atomic_burger = Place(name='Atomic Burger') atomic_burger.tags.add('burger') atomic_burger.save() places = list(Place.objects.order_by('name').prefetch_related('tags')) self.assertEqual(places[0].name, 'Atomic Burger') self.assertEqual(places[0].tags.first().name, 'burger') @unittest.expectedFailure def test_prefetch_tags_actually_prefetches(self): mission_burrito = Place(name='Mission Burrito') mission_burrito.tags.add('mexican', 'burrito') mission_burrito.save() atomic_burger = Place(name='Atomic Burger') atomic_burger.tags.add('burger') atomic_burger.save() with self.assertNumQueries(2): places = list(Place.objects.order_by('name').prefetch_related('tags')) self.assertEqual(places[0].name, 'Atomic Burger') self.assertEqual(places[0].tags.first().name, 'burger') def test_tag_form_field(self): class PlaceForm(ClusterForm): class Meta: model = Place exclude_formsets = ['tagged_items', 'reviews'] fields = ['name', 'tags'] mission_burrito = Place(name='Mission Burrito') mission_burrito.tags.add('mexican', 'burrito') form = PlaceForm(instance=mission_burrito) self.assertEqual(2, len(form['tags'].value())) expected_instance = TaggedPlace if TAGGIT_VERSION < (1,) else Tag self.assertEqual(expected_instance, form['tags'].value()[0].__class__) form = PlaceForm({ 'name': "Mission Burrito", 'tags': "burrito, fajita" }, instance=mission_burrito) self.assertTrue(form.is_valid()) mission_burrito = form.save(commit=False) self.assertTrue(Tag.objects.get(name='burrito') in mission_burrito.tags.all()) self.assertTrue(Tag.objects.get(name='fajita') in mission_burrito.tags.all()) self.assertFalse(Tag.objects.get(name='mexican') in mission_burrito.tags.all()) def test_create_with_tags(self): class PlaceForm(ClusterForm): class Meta: model = Place exclude_formsets = ['tagged_items', 'reviews'] fields = ['name', 'tags'] form = PlaceForm({ 'name': "Mission Burrito", 'tags': "burrito, fajita" }, instance=Place()) self.assertTrue(form.is_valid()) mission_burrito = form.save() reloaded_mission_burrito = Place.objects.get(pk=mission_burrito.pk) self.assertEqual( set(reloaded_mission_burrito.tags.all()), set([Tag.objects.get(name='burrito'), Tag.objects.get(name='fajita')]) ) def test_create_with_tags_with_plain_taggable_manager(self): class PlaceForm(ClusterForm): class Meta: model = NonClusterPlace exclude_formsets = ['tagged_items', 'reviews'] fields = ['name', 'tags'] form = PlaceForm({ 'name': "Mission Burrito", 'tags': "burrito, fajita" }, instance=NonClusterPlace()) self.assertTrue(form.is_valid()) mission_burrito = form.save() reloaded_mission_burrito = NonClusterPlace.objects.get(pk=mission_burrito.pk) self.assertEqual( set(reloaded_mission_burrito.tags.all()), set([Tag.objects.get(name='burrito'), Tag.objects.get(name='fajita')]) ) def test_render_tag_form(self): class PlaceForm(ClusterForm): class Meta: model = Place exclude_formsets = ['tagged_items', 'reviews'] fields = ['name', 'tags'] mission_burrito = Place(name="Mission Burrito") mission_burrito.tags.add('burrito', 'mexican') form = PlaceForm(instance=mission_burrito) form_html = form.as_p() self.assertInHTML('', form_html) @override_settings(TAGGIT_CASE_INSENSITIVE=True) def test_case_insensitive_tags(self): mission_burrito = Place(name='Mission Burrito') mission_burrito.tags.add('burrito') mission_burrito.tags.add('Burrito') self.assertEqual(1, mission_burrito.tags.count()) def test_integers(self): """Adding an integer as a tag should raise a ValueError""" mission_burrito = Place(name='Mission Burrito') with self.assertRaisesRegexp(ValueError, ( r"Cannot add 1 \(<(type|class) 'int'>\). " r"Expected or str.")): mission_burrito.tags.add(1) django-modelcluster-5.0.1/tests/urls.py000066400000000000000000000000211360467766700201770ustar00rootroot00000000000000urlpatterns = [] django-modelcluster-5.0.1/tox.ini000066400000000000000000000013351360467766700170220ustar00rootroot00000000000000[tox] envlist = py{35,36,37}-dj20-{sqlite,postgres}-taggit{0,1} py{35,36,37}-dj21-{sqlite,postgres}-taggit{0,1} py{35,36,37}-dj22-{sqlite,postgres}-taggit{0,1} [testenv] commands=./runtests.py --noinput {posargs} basepython = py35: python3.5 py36: python3.6 py37: python3.7 deps = taggit0: django-taggit>=0.24,<1 taggit1: django-taggit>=1 pytz>=2014.7 dj20: Django>=2.0,<2.1 dj21: Django>=2.1,<2.2 dj22: Django>=2.2,<2.3 dj22stable: git+https://github.com/django/django.git@stable/2.2.x#egg=Django djmaster: git+https://github.com/django/django.git@master#egg=Django postgres: psycopg2>=2.6 setenv = postgres: DATABASE_ENGINE=django.db.backends.postgresql_psycopg2