pax_global_header00006660000000000000000000000064147723767640014541gustar00rootroot0000000000000052 comment=6ae9cd267dbab315b0c4511bf6227aa8255c4e69 django-polymodels-1.8.1/000077500000000000000000000000001477237676400151775ustar00rootroot00000000000000django-polymodels-1.8.1/.coveragerc000066400000000000000000000000751477237676400173220ustar00rootroot00000000000000[run] source = polymodels branch = True relative_files = Truedjango-polymodels-1.8.1/.github/000077500000000000000000000000001477237676400165375ustar00rootroot00000000000000django-polymodels-1.8.1/.github/workflows/000077500000000000000000000000001477237676400205745ustar00rootroot00000000000000django-polymodels-1.8.1/.github/workflows/release.yml000066400000000000000000000016241477237676400227420ustar00rootroot00000000000000name: Release on: push: tags: - '*' jobs: build: if: github.repository == 'charettes/django-polymodels' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install dependencies run: | python -m pip install -U pip python -m pip install -U setuptools twine wheel - name: Build package run: | python setup.py --version python setup.py sdist --format=gztar bdist_wheel twine check dist/* - name: Upload packages to Pypi if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} django-polymodels-1.8.1/.github/workflows/test.yml000066400000000000000000000025431477237676400223020ustar00rootroot00000000000000name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 5 matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Cache uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Tox tests run: | tox -v - name: Coveralls uses: AndreMiras/coveralls-python-action@develop with: parallel: true flag-name: Unit Test coveralls_finish: needs: test runs-on: ubuntu-latest steps: - name: Coveralls Finished uses: AndreMiras/coveralls-python-action@develop with: parallel-finished: true django-polymodels-1.8.1/.gitignore000066400000000000000000000001061477237676400171640ustar00rootroot00000000000000syntax:glob *.py[co] dist/ django_polymodels.egg-info/* .coverage .toxdjango-polymodels-1.8.1/LICENSE000066400000000000000000000020431477237676400162030ustar00rootroot00000000000000Copyright (c) 2015, Simon Charette Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-polymodels-1.8.1/MANIFEST.in000066400000000000000000000000421477237676400167310ustar00rootroot00000000000000include LICENSE include README.rstdjango-polymodels-1.8.1/README.rst000066400000000000000000000116151477237676400166720ustar00rootroot00000000000000################# django-polymodels ################# A django application that provides a simple way to retrieve models type casted to their original ``ContentType``. .. image:: https://github.com/charettes/django-polymodels/workflows/Test/badge.svg :target: https://github.com/charettes/django-polymodels/actions :alt: Build Status .. image:: https://coveralls.io/repos/charettes/django-polymodels/badge.svg?branch=master&service=github :target: https://coveralls.io/github/charettes/django-polymodels?branch=master ************ Installation ************ >>> pip install django-polymodels Make sure ``'django.contrib.contenttypes'`` and ``'polymodels'`` are in your `INSTALLED_APPS` :: INSTALLED_APPS += ('django.contrib.contenttypes', 'polymodels') ***** Usage ***** Subclass ``PolymorphicModel``, an abstract model class. :: from django.db import models from polymodels.models import PolymorphicModel class Animal(PolymorphicModel): name = models.CharField(max_length=255) def __str__(self): return self.name class Mammal(Animal): pass class Dog(Mammal): pass class Reptile(Animal): pass class Snake(Reptile): class Meta: proxy = True Objects are created the same way as usual and their associated ``ContentType`` is saved automatically: >>> animal = Animal.objects.create(name='animal') >>> mammal = Mammal.objects.create(name='mammal') >>> reptile = Reptile.objects.create(name='reptile') >>> snake = Snake.objects.create(name='snake') To retreive *type casted* instances from the ``Animal.objects`` manager you just have to use the ``select_subclasses`` method. >>> Animal.objects.select_subclasses() [, , , ] You can also retreive a subset of the subclasses by passing them as arguments to ``select_subclass``. >>> Animal.objects.select_subclasses(Reptile) [, ] Or directly from subclasses managers. >>> Reptile.objects.select_subclasses(Snake) [] Note that you can also retrieve original results by avoiding the ``select_subclasses`` call. >>> Animal.objects.all() [, , , ] It's also possible to select only instances of the model to which the manager is attached by using the ``exclude_subclasses`` method. >>> Mammal.objects.all() [] Each instance of ``PolymorphicModel`` has a ``type_cast`` method that knows how to convert itself to the correct ``ContentType``. >>> animal_snake = Animal.objects.get(pk=snake.pk) >>> animal_snake.type_cast() >>> animal_snake.type_cast(Reptile) If the ``PolymorphicModel.content_type`` fields conflicts with one of your existing fields you just have to subclass ``polymodels.models.BasePolymorphicModel`` and specify which field *polymodels* should use instead by defining a ``CONTENT_TYPE_FIELD`` attribute on your model. This field must be a ``ForeignKey`` to ``ContentType``. :: from django.contrib.contenttypes.models import ContentType from django.db import models from polymodels.models import BasePolymorphicModel class MyModel(BasePolymorphicModel): CONTENT_TYPE_FIELD = 'polymorphic_ct' polymorphic_ct = models.ForeignKey(ContentType) ************ How it works ************ Under the hood ``select_subclasses`` calls ``seleted_related`` to avoid unnecessary queries and ``filter`` if you pass some classes to it. On queryset iteration, the fetched instanced are converted to their correct type by calling ``BasePolymorphicModel.type_cast``. Note that those lookups are cached on class creation to avoid computing them on every single query. ****************** Note of the author ****************** I'm aware there's already plenty of existing projects tackling the whole **model-inheritance-type-casting-thing** such as `django-polymorphic`_. However I wanted to implement this feature in a lightweight way: no ``__metaclass__`` or ``__init__`` overrides while using django's public API as much as possible. In the end, this was really just an extraction of `django-mutant`_'s own mecanism of handling this since I needed it as a standalone app for another project. .. _django-polymorphic: https://github.com/chrisglass/django_polymorphic .. _django-mutant: https://github.com/charettes/django-mutant ********** Contribute ********** If you happen to encounter a bug or would like to suggest a feature addition please `file an issue`_ or `create a pull request`_ containing **tests**. .. _file an issue: https://github.com/charettes/django-polymodels/issues .. _create a pull request: https://github.com/charettes/django-polymodels/pulls ******* Credits ******* * Inspired by a `post of Jeff Elmores`_. .. _post of Jeff Elmores: http://jeffelmore.org/2010/11/11/automatic-downcasting-of-inherited-models-in-django/ django-polymodels-1.8.1/polymodels/000077500000000000000000000000001477237676400173665ustar00rootroot00000000000000django-polymodels-1.8.1/polymodels/__init__.py000066400000000000000000000000671477237676400215020ustar00rootroot00000000000000VERSION = (1, 8, 1, "final", 0) __version__ = "1.8.1" django-polymodels-1.8.1/polymodels/fields.py000066400000000000000000000150341477237676400212110ustar00rootroot00000000000000from django import forms from django.apps import apps from django.core import checks from django.db.models import ForeignKey, Q from django.db.models.fields import NOT_PROVIDED from django.db.models.fields.related import ( RelatedField, lazy_related_operation, ) from django.utils.deconstruct import deconstructible from django.utils.functional import LazyObject, empty from django.utils.translation import gettext_lazy as _ from .models import BasePolymorphicModel from .utils import get_content_type class LimitChoicesToSubclasses: def __init__(self, field, limit_choices_to): self.field = field self.limit_choices_to = limit_choices_to @property def value(self): subclasses_lookup = self.field.polymorphic_type.subclasses_lookup("pk") limit_choices_to = self.limit_choices_to if limit_choices_to is None: limit_choices_to = subclasses_lookup.copy() elif isinstance(limit_choices_to, dict): limit_choices_to = dict(limit_choices_to, **subclasses_lookup) elif isinstance(limit_choices_to, Q): limit_choices_to = limit_choices_to & Q(**subclasses_lookup) self.__dict__["value"] = limit_choices_to return limit_choices_to def __call__(self): return self.value class LazyPolymorphicTypeQueryset(LazyObject): def __init__(self, remote_field, db): super().__init__() self.__dict__.update(remote_field=remote_field, db=db) def _setup(self): remote_field = self.__dict__.get("remote_field") db = self.__dict__.get("db") self._wrapped = remote_field.model._default_manager.using(db).complex_filter( remote_field.limit_choices_to() ) def __getattr__(self, attr): # ModelChoiceField._set_queryset(queryset) calls queryset.all() on # Django 2.1+ in order to clear possible cached results. # Since no results might have been cached before _setup() is called # it's safe to keep deferring until something else is accessed. if attr == "all" and self._wrapped is empty: return lambda: self return super().__getattr__(attr) @deconstructible class ContentTypeReference: def __init__(self, app_label, model_name): self.app_label = app_label self.model_name = model_name def __eq__(self, other): return isinstance(other, self.__class__) and ( (self.app_label, self.model_name) == (other.app_label, other.model_name) ) def __call__(self): model = apps.get_model(self.app_label, self.model_name) return get_content_type(model).pk def __repr__(self): return "ContentTypeReference(%r, %r)" % (self.app_label, self.model_name) class PolymorphicTypeField(ForeignKey): default_error_messages = { "invalid": _("Specified model is not a subclass of %(model)s.") } description = _("Content type of a subclass of %(type)s") default_kwargs = { "to": "contenttypes.contenttype", "related_name": "+", } def __init__(self, polymorphic_type, *args, **kwargs): self.polymorphic_type = polymorphic_type self.overriden_default = False for kwarg, value in self.default_kwargs.items(): kwargs.setdefault(kwarg, value) kwargs["limit_choices_to"] = LimitChoicesToSubclasses( self, kwargs.pop("limit_choices_to", None) ) super().__init__(*args, **kwargs) def contribute_to_class(self, cls, name): super().contribute_to_class(cls, name) polymorphic_type = self.polymorphic_type if isinstance(polymorphic_type, str) or polymorphic_type._meta.pk is None: def resolve_polymorphic_type(model, related_model, field): field.do_polymorphic_type(related_model) lazy_related_operation( resolve_polymorphic_type, cls, polymorphic_type, field=self ) else: self.do_polymorphic_type(polymorphic_type) def do_polymorphic_type(self, polymorphic_type): if self.default is NOT_PROVIDED and not self.null: opts = polymorphic_type._meta self.default = ContentTypeReference(opts.app_label, opts.model_name) self.overriden_default = True self.polymorphic_type = polymorphic_type self.type = polymorphic_type.__name__ self.error_messages["invalid"] = ( "Specified content type is not of a subclass of %s." % polymorphic_type._meta.object_name ) def check(self, **kwargs): errors = super().check(**kwargs) if isinstance(self.polymorphic_type, str): errors.append( checks.Error( ( "Field defines a relation with model '%s', which " "is either not installed, or is abstract." ) % self.polymorphic_type, id="fields.E300", ) ) elif not issubclass(self.polymorphic_type, BasePolymorphicModel): errors.append( checks.Error( "The %s type is not a subclass of BasePolymorphicModel." % self.polymorphic_type.__name__, id="polymodels.E004", ) ) return errors def formfield(self, **kwargs): db = kwargs.pop("using", None) if isinstance(self.polymorphic_type, str): raise ValueError( "Cannot create form field for %r yet, because its related model %r has not been loaded yet" % (self.name, self.polymorphic_type) ) defaults = { "form_class": forms.ModelChoiceField, "queryset": LazyPolymorphicTypeQueryset(self.remote_field, db), "to_field_name": self.remote_field.field_name, } defaults.update(kwargs) return super(RelatedField, self).formfield(**defaults) def deconstruct(self): name, path, args, kwargs = super().deconstruct() opts = getattr(self.polymorphic_type, "_meta", None) kwargs["polymorphic_type"] = ( "%s.%s" % (opts.app_label, opts.object_name) if opts else self.polymorphic_type ) for kwarg, value in list(kwargs.items()): if self.default_kwargs.get(kwarg) == value: kwargs.pop(kwarg) if self.overriden_default: kwargs.pop("default") kwargs.pop("limit_choices_to", None) return name, path, args, kwargs django-polymodels-1.8.1/polymodels/forms.py000066400000000000000000000017351477237676400210740ustar00rootroot00000000000000from django.forms import models class PolymorphicModelFormMetaclass(models.ModelFormMetaclass): def __new__(cls, name, bases, attrs): form = super().__new__(cls, name, bases, attrs) model = form._meta.model form._meta.polymorphic_forms = {model: form} if model: for base in bases: for mro in base.__mro__: if issubclass(mro, PolymorphicModelForm): mro._meta.polymorphic_forms[model] = form return form def __getitem__(self, model): try: return self._meta.polymorphic_forms[model] except KeyError: raise TypeError("No form registered for %s." % model) class PolymorphicModelForm(models.ModelForm, metaclass=PolymorphicModelFormMetaclass): def __new__(cls, *args, **kwargs): instance = kwargs.get("instance", None) if instance: cls = cls[instance.__class__] return super().__new__(cls) django-polymodels-1.8.1/polymodels/managers.py000066400000000000000000000077551477237676400215530ustar00rootroot00000000000000from functools import partial from operator import methodcaller from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models.query import ModelIterable type_cast_iterator = partial(map, methodcaller("type_cast")) type_cast_prefetch_iterator = partial( map, methodcaller("type_cast", with_prefetched_objects=True) ) class PolymorphicModelIterable(ModelIterable): def __init__(self, queryset, type_cast=True, **kwargs): self.type_cast = type_cast super().__init__(queryset, **kwargs) def __iter__(self): iterator = super().__iter__() if self.type_cast: iterator = type_cast_iterator(iterator) return iterator class PolymorphicQuerySet(models.query.QuerySet): def select_subclasses(self, *models): if issubclass(self._iterable_class, ModelIterable): self._iterable_class = PolymorphicModelIterable related_lookups = set() accessors = self.model.subclass_accessors if models: subclasses = set() for model in models: if not issubclass(model, self.model): raise TypeError("%r is not a subclass of %r" % (model, self.model)) subclasses.update(model.subclass_accessors) # Collect all `select_related` required lookups for subclass in subclasses: # Avoid collecting ourself and proxy subclasses related_lookup = accessors[subclass].related_lookup if related_lookup: related_lookups.add(related_lookup) queryset = self.filter(**self.model.content_type_lookup(*tuple(subclasses))) else: # Collect all `select_related` required relateds for accessor in accessors.values(): # Avoid collecting ourself and proxy subclasses related_lookup = accessor.related_lookup if related_lookup: related_lookups.add(related_lookup) queryset = self if related_lookups: queryset = queryset.select_related(*related_lookups) return queryset def exclude_subclasses(self): return self.filter(**self.model.content_type_lookup()) def _fetch_all(self): # Override _fetch_all in order to disable PolymorphicModelIterable's # type casting when prefetch_related is used because the latter might # crash or disfunction when dealing with a mixed set of objects. prefetch_related_objects = ( self._prefetch_related_lookups and not self._prefetch_done ) type_cast = False if self._result_cache is None: iterable_class = self._iterable_class if issubclass(iterable_class, PolymorphicModelIterable): type_cast = bool(prefetch_related_objects) iterable_class = partial(iterable_class, type_cast=not type_cast) self._result_cache = list(iterable_class(self)) if prefetch_related_objects: self._prefetch_related_objects() if type_cast: self._result_cache = list( type_cast_prefetch_iterator(self._result_cache) ) class PolymorphicManager(models.Manager.from_queryset(PolymorphicQuerySet)): def contribute_to_class(self, model, name): # Avoid circular reference from .models import BasePolymorphicModel if not issubclass(model, BasePolymorphicModel): raise ImproperlyConfigured( "`%s` can only be used on " "`BasePolymorphicModel` subclasses." % self.__class__.__name__ ) return super().contribute_to_class(model, name) def get_queryset(self): queryset = super().get_queryset() model = self.model if model._meta.proxy: # Select only associated model and its subclasses. queryset = queryset.filter(**self.model.subclasses_lookup()) return queryset django-polymodels-1.8.1/polymodels/models.py000066400000000000000000000174351477237676400212350ustar00rootroot00000000000000import threading from collections import defaultdict, namedtuple from operator import attrgetter from django.contrib.contenttypes.models import ContentType from django.core import checks from django.core.exceptions import FieldDoesNotExist from django.db import models, transaction from django.db.models.constants import LOOKUP_SEP from django.db.models.signals import class_prepared from django.utils.functional import cached_property from .managers import PolymorphicManager from .utils import copy_fields, get_content_type, get_content_types class SubclassAccessor( namedtuple("SubclassAccessor", ["attrs", "proxy", "related_lookup"]) ): @staticmethod def _identity(obj): return obj @cached_property def attrgetter(self): if not self.attrs: return self._identity return attrgetter(".".join(self.attrs)) def __call__(self, obj, with_prefetched_objects=False): # Cast to the right concrete model by going up in the # SingleRelatedObjectDescriptor chain casted = self.attrgetter(obj) # If it's a proxy model we make sure to type cast it proxy = self.proxy if proxy: casted = copy_fields(casted, proxy) if with_prefetched_objects: try: casted._prefetched_objects_cache.update(obj._prefetched_objects_cache) except AttributeError: casted._prefetched_objects_cache = obj._prefetched_objects_cache return casted EMPTY_ACCESSOR = SubclassAccessor((), None, "") class SubclassAccessors(defaultdict): def __init__(self): self.model = None self.apps = None def contribute_to_class(self, model, name, **kwargs): self.model = model self.apps = model._meta.apps self.lock = threading.RLock() setattr(model, name, self) # Ideally we would connect to the model.apps.clear_cache() class_prepared.connect(self.class_prepared_receiver, weak=False) def class_prepared_receiver(self, sender, **kwargs): if issubclass(sender, self.model): with self.lock: for parent in sender._meta.parents: self.pop(self.get_model_key(parent._meta), None) def get_model_key(self, opts): return opts.app_label, opts.model_name def __get__(self, instance, owner): if owner is self.model: return self opts = owner._meta model_key = self.get_model_key(opts) return self[model_key] def __missing__(self, model_key): """ Generate the accessors for this model by recursively generating its children accessors and prefixing them. """ owner = self.apps.get_model(*model_key) if not issubclass(owner, self.model): raise KeyError accessors = {owner: EMPTY_ACCESSOR} with self.lock: for model in self.apps.get_models(): opts = model._meta if ( opts.proxy and issubclass(model, owner) and (owner._meta.proxy or opts.concrete_model is owner) ): accessors[model] = SubclassAccessor((), model, "") # Use .get() instead of `in` as proxy inheritance is also # stored in _meta.parents as None. elif opts.parents.get(owner): part = opts.model_name for child, (parts, proxy, _lookup) in self[ self.get_model_key(opts) ].items(): accessors[child] = SubclassAccessor( (part,) + parts, proxy, LOOKUP_SEP.join((part,) + parts) ) return accessors class BasePolymorphicModel(models.Model): class Meta: abstract = True subclass_accessors = SubclassAccessors() def type_cast(self, to=None, with_prefetched_objects=False): if to is None: content_type_id = getattr(self, "%s_id" % self.CONTENT_TYPE_FIELD) to = ContentType.objects.get_for_id(content_type_id).model_class() accessor = self.subclass_accessors[to] return accessor(self, with_prefetched_objects) def save(self, *args, **kwargs): if self._state.adding and getattr(self, self.CONTENT_TYPE_FIELD, None) is None: content_type = get_content_type(self.__class__) setattr(self, self.CONTENT_TYPE_FIELD, content_type) return super().save(*args, **kwargs) def delete(self, using=None, keep_parents=False): kept_parent = None if keep_parents: parent_ptr = next( iter(self._meta.concrete_model._meta.parents.values()), None ) if parent_ptr: kept_parent = getattr(self, parent_ptr.name) if kept_parent: context_manager = transaction.atomic(using=using, savepoint=False) else: context_manager = transaction.mark_for_rollback_on_error(using=using) with context_manager: deletion = super().delete(using=using, keep_parents=keep_parents) if kept_parent: parent_content_type = get_content_type(kept_parent) setattr(kept_parent, self.CONTENT_TYPE_FIELD, parent_content_type) kept_parent.save(update_fields=[self.CONTENT_TYPE_FIELD]) return deletion @classmethod def content_type_lookup(cls, *models, **kwargs): query_name = kwargs.pop("query_name", None) or cls.CONTENT_TYPE_FIELD if models: query_name = "%s__in" % query_name value = set(ct.pk for ct in get_content_types(*models).values()) else: value = get_content_type(cls).pk return {query_name: value} @classmethod def subclasses_lookup(cls, query_name=None): return cls.content_type_lookup( cls, *tuple(cls.subclass_accessors), query_name=query_name ) @classmethod def check(cls, **kwargs): errors = super().check(**kwargs) try: content_type_field_name = getattr(cls, "CONTENT_TYPE_FIELD") except AttributeError: errors.append( checks.Error( "`BasePolymorphicModel` subclasses must define a `CONTENT_TYPE_FIELD`.", hint=None, obj=cls, id="polymodels.E001", ) ) else: try: content_type_field = cls._meta.get_field(content_type_field_name) except FieldDoesNotExist: errors.append( checks.Error( "`CONTENT_TYPE_FIELD` points to an inexistent field '%s'." % content_type_field_name, hint=None, obj=cls, id="polymodels.E002", ) ) else: if ( not isinstance(content_type_field, models.ForeignKey) or content_type_field.remote_field.model is not ContentType ): errors.append( checks.Error( "`%s` must be a `ForeignKey` to `ContentType`." % content_type_field_name, hint=None, obj=content_type_field, id="polymodels.E003", ) ) return errors class PolymorphicModel(BasePolymorphicModel): CONTENT_TYPE_FIELD = "content_type" content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, related_name="+" ) objects = PolymorphicManager() class Meta: abstract = True django-polymodels-1.8.1/polymodels/utils.py000066400000000000000000000012361477237676400211020ustar00rootroot00000000000000from functools import partial from django.contrib.contenttypes.models import ContentType def copy_fields(src, to): """ Returns a new instance of `to_cls` with fields data fetched from `src`. Useful for getting a model proxy instance from concrete model instance or the other way around. Note that we use *arg calling to get a faster model initialization. """ args = tuple(getattr(src, field.attname) for field in src._meta.fields) return to(*args) get_content_type = partial(ContentType.objects.get_for_model, for_concrete_model=False) get_content_types = partial( ContentType.objects.get_for_models, for_concrete_models=False ) django-polymodels-1.8.1/setup.cfg000066400000000000000000000002771477237676400170260ustar00rootroot00000000000000[flake8] max-line-length = 119 [isort] combine_as_imports=true include_trailing_comma=true multi_line_output=3 not_skip=__init__.py [metadata] license-file = LICENSE [wheel] universal = 1 django-polymodels-1.8.1/setup.py000077500000000000000000000026701477237676400167210ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import find_packages, setup from polymodels import __version__ github_url = "https://github.com/charettes/django-polymodels" long_desc = open("README.rst").read() setup( name="django-polymodels", version=__version__, description="Polymorphic models implementation for django", long_description=long_desc, long_description_content_type="text/x-rst", url=github_url, author="Simon Charette", author_email="charette.s@gmail.com", install_requires=("Django>=4.2",), packages=find_packages(exclude=["tests", "tests.*"]), include_package_data=True, license="MIT License", classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", ], ) django-polymodels-1.8.1/tests/000077500000000000000000000000001477237676400163415ustar00rootroot00000000000000django-polymodels-1.8.1/tests/__init__.py000066400000000000000000000000001477237676400204400ustar00rootroot00000000000000django-polymodels-1.8.1/tests/base.py000066400000000000000000000004601477237676400176250ustar00rootroot00000000000000from django.contrib.contenttypes.models import ContentType from django.test.testcases import TestCase from polymodels.models import BasePolymorphicModel class TestCase(TestCase): def tearDown(self): ContentType.objects.clear_cache() BasePolymorphicModel.subclass_accessors.clear() django-polymodels-1.8.1/tests/forms.py000066400000000000000000000006151477237676400200430ustar00rootroot00000000000000from polymodels.forms import PolymorphicModelForm from .models import Animal, BigSnake, Snake class AnimalForm(PolymorphicModelForm): class Meta: fields = ["name"] model = Animal class SnakeForm(AnimalForm): class Meta: fields = ["name"] model = Snake class BigSnakeForm(SnakeForm): class Meta: fields = ["name"] model = BigSnake django-polymodels-1.8.1/tests/migrations/000077500000000000000000000000001477237676400205155ustar00rootroot00000000000000django-polymodels-1.8.1/tests/migrations/0001_initial.py000066400000000000000000000140401477237676400231570ustar00rootroot00000000000000import django.db.models.deletion from django.db import migrations, models import polymodels.fields class Migration(migrations.Migration): initial = True dependencies = [("contenttypes", "0002_remove_content_type_name")] operations = [ migrations.CreateModel( name="Animal", 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.CreateModel( name="Trait", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "content_type", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="+", to="contenttypes.ContentType", ), ), ( "mammal_type", polymodels.fields.PolymorphicTypeField( blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, polymorphic_type="tests.Mammal", ), ), ( "snake_type", polymodels.fields.PolymorphicTypeField( on_delete=django.db.models.deletion.CASCADE, polymorphic_type="tests.Snake", ), ), ( "trait_type", polymodels.fields.PolymorphicTypeField( blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, polymorphic_type="tests.Trait", ), ), ], options={"abstract": False}, ), migrations.CreateModel( name="Zoo", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "zoos", models.ManyToManyField("Animal", related_name="zoos"), ), ], ), migrations.CreateModel( name="Mammal", fields=[ ( "animal_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="tests.Animal", ), ) ], options={"abstract": False}, bases=("tests.animal",), ), migrations.CreateModel( name="Snake", fields=[ ( "animal_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="tests.Animal", ), ), ("length", models.SmallIntegerField()), ("color", models.CharField(blank=True, max_length=100)), ], options={"ordering": ["id"]}, bases=("tests.animal",), ), migrations.AddField( model_name="zoo", name="animals", field=models.ManyToManyField(to="tests.Animal"), ), migrations.AddField( model_name="animal", name="content_type", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="+", to="contenttypes.ContentType", ), ), migrations.CreateModel( name="AcknowledgedTrait", fields=[], options={"proxy": True, "indexes": []}, bases=("tests.trait",), ), migrations.CreateModel( name="Monkey", fields=[ ( "mammal_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="tests.Mammal", ), ), ( "friends", models.ManyToManyField( related_name="_monkey_friends_+", to="tests.Monkey" ), ), ], options={"abstract": False}, bases=("tests.mammal",), ), migrations.CreateModel( name="BigSnake", fields=[], options={"proxy": True, "indexes": []}, bases=("tests.snake",), ), migrations.CreateModel( name="HugeSnake", fields=[], options={"proxy": True, "indexes": []}, bases=("tests.bigsnake",), ), ] django-polymodels-1.8.1/tests/migrations/__init__.py000066400000000000000000000000001477237676400226140ustar00rootroot00000000000000django-polymodels-1.8.1/tests/models.py000066400000000000000000000025221477237676400201770ustar00rootroot00000000000000from django.db import models from polymodels.fields import PolymorphicTypeField from polymodels.models import PolymorphicModel class Zoo(models.Model): animals = models.ManyToManyField("Animal", related_name="zoos") class Animal(PolymorphicModel): name = models.CharField(max_length=50) class Meta: ordering = ["id"] def __str__(self): return self.name class NotInstalledAnimal(Animal): class Meta: app_label = "not_installed" class Mammal(Animal): pass class Monkey(Mammal): friends = models.ManyToManyField("self") class Trait(PolymorphicModel): trait_type = PolymorphicTypeField( "self", on_delete=models.CASCADE, blank=True, null=True ) mammal_type = PolymorphicTypeField( Mammal, on_delete=models.CASCADE, blank=True, null=True ) snake_type = PolymorphicTypeField("Snake", on_delete=models.CASCADE) class AcknowledgedTrait(Trait): class Meta: proxy = True class Reptile(Animal): length = models.SmallIntegerField() class Meta: abstract = True ordering = ["id"] class Snake(Reptile): color = models.CharField(max_length=100, blank=True) class Meta: ordering = ["id"] class BigSnake(Snake): class Meta: proxy = True class HugeSnake(BigSnake): class Meta: proxy = True django-polymodels-1.8.1/tests/settings.py000066400000000000000000000004021477237676400205470ustar00rootroot00000000000000SECRET_KEY = "not-anymore" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", }, } INSTALLED_APPS = [ "django.contrib.contenttypes", "polymodels", "tests", ] DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" django-polymodels-1.8.1/tests/test_fields.py000066400000000000000000000205671477237676400212320ustar00rootroot00000000000000from django.apps.registry import Apps from django.core import checks from django.core.exceptions import ValidationError from django.db import models from django.db.migrations.writer import MigrationWriter from django.db.models.query_utils import Q from polymodels.fields import ContentTypeReference, PolymorphicTypeField from polymodels.models import PolymorphicModel from polymodels.utils import get_content_type from .base import TestCase from .models import AcknowledgedTrait, HugeSnake, Snake, Trait class ContentTypeReferenceTests(TestCase): reference = ContentTypeReference(str("tests"), str("snake")) def test_equality(self): self.assertEqual(self.reference, ContentTypeReference("tests", "snake")) def test_retreival(self): self.assertEqual(self.reference(), get_content_type(Snake).pk) def test_repr(self): self.assertEqual(repr(self.reference), "ContentTypeReference('tests', 'snake')") class PolymorphicTypeFieldTests(TestCase): def test_default_value(self): """ Make sure fields defaults """ trait = Trait.objects.create() self.assertIsNone(trait.trait_type) self.assertIsNone(trait.mammal_type) self.assertEqual(trait.snake_type.model_class(), Snake) def test_limit_choices_to(self): """ Make sure existing `limit_choices_to` are taken into consideration """ field = PolymorphicTypeField(Trait, on_delete=models.CASCADE) remote_field = field.remote_field subclasses_lookup = Trait.subclasses_lookup("pk") self.assertEqual(remote_field.limit_choices_to(), subclasses_lookup) # Test dict() limit_choices_to. limit_choices_to = {"app_label": "polymodels"} field = PolymorphicTypeField( Trait, on_delete=models.CASCADE, limit_choices_to=limit_choices_to ) remote_field = field.remote_field self.assertEqual( remote_field.limit_choices_to(), dict(subclasses_lookup, **limit_choices_to) ) # Test Q() limit_choices_to. field = PolymorphicTypeField( Trait, on_delete=models.CASCADE, limit_choices_to=Q(**limit_choices_to) ) remote_field = field.remote_field self.assertEqual( str(remote_field.limit_choices_to()), str(Q(**limit_choices_to) & Q(**subclasses_lookup)), ) def test_invalid_type(self): trait = Trait.objects.create() snake_type = get_content_type(Snake) trait.mammal_type = snake_type trait.snake_type = snake_type with self.assertRaisesMessage( ValidationError, "Specified content type is not of a subclass of Mammal." ): trait.full_clean() def test_valid_subclass(self): trait = Trait.objects.create() trait.snake_type = get_content_type(HugeSnake) trait.full_clean() def test_valid_proxy_subclass(self): trait = Trait.objects.create() trait.trait_type = get_content_type(AcknowledgedTrait) trait.full_clean() def test_description(self): trait_type = Trait._meta.get_field("trait_type") self.assertEqual( trait_type.description % trait_type.__dict__, "Content type of a subclass of Trait", ) def test_checks(self): test_apps = Apps(["tests", "django.contrib.contenttypes"]) class ContentType(models.Model): class Meta: apps = test_apps app_label = "contenttypes" class CheckModel(PolymorphicModel): valid = PolymorphicTypeField("self", on_delete=models.CASCADE) unresolved = PolymorphicTypeField("unresolved", on_delete=models.CASCADE) non_polymorphic_base = PolymorphicTypeField( "contenttypes.ContentType", on_delete=models.CASCADE ) class Meta: apps = test_apps self.assertEqual(CheckModel._meta.get_field("valid").check(), []) self.assertEqual( CheckModel._meta.get_field("unresolved").check(), [ checks.Error( "Field defines a relation with model 'unresolved', which is either not installed, or is abstract.", id="fields.E300", ), ], ) self.assertEqual( CheckModel._meta.get_field("non_polymorphic_base").check(), [ checks.Error( "The ContentType type is not a subclass of BasePolymorphicModel.", id="polymodels.E004", ), ], ) def test_formfield_issues_no_queries(self): trait_type = Trait._meta.get_field("trait_type") with self.assertNumQueries(0): formfield = trait_type.formfield() self.assertSetEqual( set(formfield.queryset), { get_content_type(Trait), get_content_type(AcknowledgedTrait), }, ) def test_unresolved_relationship_formfield(self): field = PolymorphicTypeField( "Snake", to="app.Unresolved", on_delete=models.CASCADE ) with self.assertRaises(ValueError): field.formfield() def safe_exec(self, string, value=None): scope = {} try: exec(string, globals(), scope) except Exception as e: if value: self.fail( "Could not exec %r (from value %r): %s" % (string.strip(), value, e) ) else: self.fail("Could not exec %r: %s" % (string.strip(), e)) return scope def serialize_round_trip(self, value): string, imports = MigrationWriter.serialize(value) return self.safe_exec( "%s\ntest_value_result = %s" % ("\n".join(imports), string), value )["test_value_result"] def assertDeconstructionEqual(self, field, deconstructed): self.assertEqual(field.deconstruct(), deconstructed) self.assertEqual(field.clone().deconstruct()[1:], deconstructed[1:]) self.assertEqual(self.serialize_round_trip(deconstructed), deconstructed) def test_field_deconstruction(self): test_apps = Apps() class Foo(PolymorphicModel): foo = PolymorphicTypeField("self", on_delete=models.CASCADE) class Meta: apps = test_apps app_label = "polymodels" class Bar(models.Model): foo = PolymorphicTypeField("Foo", on_delete=models.CASCADE) foo_null = PolymorphicTypeField(Foo, on_delete=models.CASCADE, null=True) foo_default = PolymorphicTypeField( Foo, on_delete=models.CASCADE, default=get_content_type(Foo).pk ) class Meta: apps = test_apps app_label = "polymodels" self.assertDeconstructionEqual( Foo._meta.get_field("foo"), ( "foo", "polymodels.fields.PolymorphicTypeField", [], { "polymorphic_type": "polymodels.Foo", "on_delete": models.CASCADE, }, ), ) self.assertDeconstructionEqual( Bar._meta.get_field("foo"), ( "foo", "polymodels.fields.PolymorphicTypeField", [], { "polymorphic_type": "polymodels.Foo", "on_delete": models.CASCADE, }, ), ) self.assertDeconstructionEqual( Bar._meta.get_field("foo_null"), ( "foo_null", "polymodels.fields.PolymorphicTypeField", [], { "polymorphic_type": "polymodels.Foo", "null": True, "on_delete": models.CASCADE, }, ), ) self.assertDeconstructionEqual( Bar._meta.get_field("foo_default"), ( "foo_default", "polymodels.fields.PolymorphicTypeField", [], { "polymorphic_type": "polymodels.Foo", "default": get_content_type(Foo).pk, "on_delete": models.CASCADE, }, ), ) django-polymodels-1.8.1/tests/test_forms.py000066400000000000000000000022771477237676400211100ustar00rootroot00000000000000from .base import TestCase from .forms import AnimalForm, BigSnakeForm, SnakeForm from .models import Animal, BigSnake, Monkey, Snake class PolymorphicModelFormTests(TestCase): def test_invalid_provided_instance(self): monkey = Monkey() with self.assertRaises(TypeError): AnimalForm(instance=monkey) def test_instance_based_form_creation(self): self.assertIsInstance(AnimalForm(instance=Animal()), AnimalForm) self.assertIsInstance(AnimalForm(instance=Snake()), SnakeForm) self.assertIsInstance(AnimalForm(instance=BigSnake()), BigSnakeForm) self.assertIsInstance(SnakeForm(instance=Snake()), SnakeForm) self.assertIsInstance(SnakeForm(instance=BigSnake()), BigSnakeForm) self.assertIsInstance(BigSnakeForm(instance=BigSnake()), BigSnakeForm) def test_default_instance_type(self): form = AnimalForm() self.assertIsInstance(form.instance, Animal) form = SnakeForm() self.assertIsInstance(form.instance, Snake) form = BigSnakeForm() self.assertIsInstance(form.instance, BigSnake) def test_retreival_from_class(self): self.assertEqual(AnimalForm[Snake], SnakeForm) django-polymodels-1.8.1/tests/test_managers.py000066400000000000000000000173611477237676400215570ustar00rootroot00000000000000from django.core.exceptions import ImproperlyConfigured from django.db import models from polymodels.managers import PolymorphicManager from .base import TestCase from .models import Animal, BigSnake, HugeSnake, Mammal, Monkey, Snake, Zoo class PolymorphicQuerySetTest(TestCase): def test_select_subclasses(self): Animal.objects.create(name="animal") Mammal.objects.create(name="mammal") Monkey.objects.create(name="monkey") Snake.objects.create(name="snake", length=10) BigSnake.objects.create(name="big snake", length=101) HugeSnake.objects.create(name="huge snake", length=155) # Assert `select_subclasses` correctly calls `select_related` and `filter`. animals = Animal.objects.select_subclasses() animals_expected_query_select_related = { "mammal": {"monkey": {}}, "snake": {}, } self.assertEqual( animals.query.select_related, animals_expected_query_select_related ) with self.assertNumQueries(1): self.assertQuerySetEqual( animals.all(), [ "", "", "", "", "", "", ], transform=repr, ) with self.assertNumQueries(1): self.assertQuerySetEqual( list(animals.iterator()), [ "", "", "", "", "", "", ], transform=repr, ) # Filter out non-mammal (direct subclass) animal_mammals = Animal.objects.select_subclasses(Mammal) animal_mammals_expected_query_select_related = {"mammal": {"monkey": {}}} self.assertEqual( animal_mammals.query.select_related, animal_mammals_expected_query_select_related, ) with self.assertNumQueries(1): self.assertQuerySetEqual( animal_mammals.all(), ["", ""], transform=repr, ) # Filter out non-snake (subclass through an abstract one) animal_snakes = Animal.objects.select_subclasses(Snake) self.assertEqual(animal_snakes.query.select_related, {"snake": {}}) with self.assertNumQueries(1): self.assertQuerySetEqual( animal_snakes.all(), ["", "", ""], transform=repr, ) # Subclass with only proxies snakes = Snake.objects.select_subclasses() self.assertFalse(snakes.query.select_related) with self.assertNumQueries(1): self.assertQuerySetEqual( snakes.all(), ["", "", ""], transform=repr, ) # Subclass filter proxies snake_bigsnakes = Snake.objects.select_subclasses(BigSnake) self.assertFalse(snakes.query.select_related) with self.assertNumQueries(1): self.assertQuerySetEqual( snake_bigsnakes.all(), ["", ""], transform=repr, ) def test_select_subclasses_get(self): snake = Snake.objects.create(name="snake", length=10) self.assertEqual(Animal.objects.select_subclasses().get(), snake) def test_select_subclasses_values(self): Animal.objects.create(name="animal") self.assertQuerySetEqual( Animal.objects.select_subclasses().values_list("name", flat=True), ["animal"], lambda x: x, ) def test_exclude_subclasses(self): Animal.objects.create(name="animal") Mammal.objects.create(name="first mammal") Mammal.objects.create(name="second mammal") Monkey.objects.create(name="donkey kong") self.assertQuerySetEqual( Animal.objects.exclude_subclasses(), [""], transform=repr, ) self.assertQuerySetEqual( Mammal.objects.exclude_subclasses(), ["", ""], transform=repr, ) self.assertQuerySetEqual( Monkey.objects.exclude_subclasses(), [""], transform=repr, ) def test_select_subclasses_prefetch_related(self): zoo = Zoo.objects.create() animal = Animal.objects.create(name="animal") mammal = Mammal.objects.create(name="mammal") monkey = Monkey.objects.create(name="monkey") zoo.animals.add(animal, mammal, monkey) other_monkey = Monkey.objects.create(name="monkey") monkey.friends.add(other_monkey) queryset = Animal.objects.select_subclasses().prefetch_related("zoos") with self.assertNumQueries(2): self.assertSequenceEqual( queryset, [ animal, mammal, monkey, other_monkey, ], ) self.assertSequenceEqual(queryset[0].zoos.all(), [zoo]) self.assertSequenceEqual(queryset[1].zoos.all(), [zoo]) self.assertSequenceEqual(queryset[2].zoos.all(), [zoo]) # Test prefetch related combination. queryset = Animal.objects.select_subclasses().prefetch_related( "zoos", "mammal__monkey__friends", ) with self.assertNumQueries(3): self.assertSequenceEqual( queryset, [ animal, mammal, monkey, other_monkey, ], ) self.assertSequenceEqual(queryset[0].zoos.all(), [zoo]) self.assertSequenceEqual(queryset[1].zoos.all(), [zoo]) self.assertSequenceEqual(queryset[2].zoos.all(), [zoo]) self.assertSequenceEqual(queryset[2].friends.all(), [other_monkey]) self.assertSequenceEqual(queryset[3].friends.all(), [monkey]) class PolymorphicManagerTest(TestCase): def test_improperly_configured(self): with self.assertRaisesMessage( ImproperlyConfigured, "`PolymorphicManager` can only be used on `BasePolymorphicModel` subclasses.", ): class NonPolymorphicModel(models.Model): objects = PolymorphicManager() class Meta: app_label = "polymodels" def test_proxy_filtering(self): """ Make sure managers attached to proxy models returns a queryset of proxies only. """ Snake.objects.create(name="snake", length=1) BigSnake.objects.create(name="big snake", length=10) HugeSnake.objects.create(name="huge snake", length=100) self.assertQuerySetEqual( Snake.objects.all(), ["", "", ""], transform=repr, ) self.assertQuerySetEqual( BigSnake.objects.all(), ["", ""], transform=repr, ) self.assertQuerySetEqual( HugeSnake.objects.all(), [""], transform=repr, ) django-polymodels-1.8.1/tests/test_models.py000066400000000000000000000216411477237676400212410ustar00rootroot00000000000000from django.apps.registry import Apps from django.contrib.contenttypes.models import ContentType from django.core import checks from django.db import models from django.test.testcases import SimpleTestCase from polymodels.models import ( EMPTY_ACCESSOR, BasePolymorphicModel, SubclassAccessor, SubclassAccessors, ) from .base import TestCase from .models import Animal, BigSnake, HugeSnake, Mammal, Snake class BasePolymorphicModelTest(TestCase): def test_checks(self): test_apps = Apps() options = type(str("Meta"), (), {"apps": test_apps, "app_label": "polymodels"}) class NoCtFieldModel(BasePolymorphicModel): Meta = options self.assertIn( checks.Error( "`BasePolymorphicModel` subclasses must define a `CONTENT_TYPE_FIELD`.", hint=None, obj=NoCtFieldModel, id="polymodels.E001", ), NoCtFieldModel.check(), ) class InexistentCtFieldModel(BasePolymorphicModel): CONTENT_TYPE_FIELD = "inexistent_field" Meta = options self.assertIn( checks.Error( "`CONTENT_TYPE_FIELD` points to an inexistent field 'inexistent_field'.", hint=None, obj=InexistentCtFieldModel, id="polymodels.E002", ), InexistentCtFieldModel.check(), ) class InvalidCtFieldModel(BasePolymorphicModel): CONTENT_TYPE_FIELD = "a_char_field" a_char_field = models.CharField(max_length=255) Meta = options self.assertIn( checks.Error( "`a_char_field` must be a `ForeignKey` to `ContentType`.", hint=None, obj=InvalidCtFieldModel._meta.get_field("a_char_field"), id="polymodels.E003", ), InvalidCtFieldModel.check(), ) class InvalidCtFkFieldToModel(BasePolymorphicModel): CONTENT_TYPE_FIELD = "a_fk" a_fk = models.ForeignKey("self", on_delete=models.CASCADE) Meta = options self.assertIn( checks.Error( "`a_fk` must be a `ForeignKey` to `ContentType`.", hint=None, obj=InvalidCtFkFieldToModel._meta.get_field("a_fk"), id="polymodels.E003", ), InvalidCtFkFieldToModel.check(), ) def test_type_cast(self): animal_dog = Animal.objects.create(name="dog") with self.assertNumQueries(0): self.assertEqual( animal_dog.type_cast(), animal_dog, "Type casting a correctly typed class should work.", ) mammal_cat = Mammal.objects.create(name="cat") with self.assertNumQueries(0): self.assertEqual( mammal_cat.type_cast(), mammal_cat, "Type casting a correctly typed subclass should work.", ) animal_cat = Animal.objects.get(pk=mammal_cat.pk) with self.assertNumQueries(1): self.assertEqual(animal_cat.type_cast(), mammal_cat) # When trying to type cast to an inexistent model an exception # should be raised.' with self.assertRaises(Mammal.DoesNotExist): animal_dog.type_cast(Mammal) # That's a big snake anaconda_snake = Snake.objects.create( name="anaconda", length=152, color="green" ) with self.assertNumQueries(0): self.assertIsInstance( anaconda_snake.type_cast(BigSnake), BigSnake, "Proxy type casting should work", ) with self.assertNumQueries(0): self.assertIsInstance( anaconda_snake.type_cast(HugeSnake), HugeSnake, "Two level proxy type casting should work", ) for subclass in [Snake, BigSnake, HugeSnake]: anaconda_animal = Animal.objects.get(pk=anaconda_snake.pk) with self.assertNumQueries(1): anaconda_animal_type_casted = anaconda_animal.type_cast(subclass) self.assertIsInstance(anaconda_animal_type_casted, subclass) self.assertEqual(anaconda_animal_type_casted.color, "green") def test_content_type_saving(self): # Creating a base class should assign the correct content_type. animal_content_type = ContentType.objects.get_for_model(Animal) with self.assertNumQueries(1): animal = Animal.objects.create(name="dog") self.assertEqual(animal.content_type, animal_content_type) # Creating subclass should assign the correct content_type. mammal_content_type = ContentType.objects.get_for_model(Mammal) with self.assertNumQueries(2): mammal = Mammal.objects.create(name="cat") self.assertEqual(mammal.content_type, mammal_content_type) # Updating a subclass's base class pointer should preserve content_type. mammal.animal_ptr.save() self.assertEqual(mammal.animal_ptr.content_type, mammal_content_type) self.assertEqual(mammal.content_type, mammal_content_type) # Creating a base class should honor explicit content_type. with self.assertNumQueries(1): explicit_mammal = Animal.objects.create( name="beaver", content_type=mammal_content_type ) self.assertEqual(explicit_mammal.content_type, mammal_content_type) with self.assertNumQueries(2): beaver = Mammal.objects.create(animal_ptr=explicit_mammal) self.assertEqual(explicit_mammal.content_type, mammal_content_type) self.assertEqual(beaver.content_type, mammal_content_type) def test_delete_keep_parents(self): snake = HugeSnake.objects.create(name="snek", length=30) animal = snake.animal_ptr snake.delete(keep_parents=True) animal.refresh_from_db() animal_content_type = ContentType.objects.get_for_model(Animal) self.assertEqual(animal.content_type, animal_content_type) class SubclassAccessorsTests(SimpleTestCase): def test_dynamic_model_creation_cache_busting(self): test_apps = Apps(["tests"]) class Base(models.Model): class Meta: apps = test_apps accessors = SubclassAccessors() self.assertEqual(Base.accessors["tests", "base"], {Base: EMPTY_ACCESSOR}) class DynamicChild(Base): class Meta: apps = test_apps self.assertEqual( Base.accessors["tests", "base"], { Base: EMPTY_ACCESSOR, DynamicChild: (("dynamicchild",), None, "dynamicchild"), }, ) self.assertEqual( DynamicChild.accessors, { DynamicChild: EMPTY_ACCESSOR, }, ) def test_key_error(self): test_apps = Apps(["tests"]) class Base(models.Model): class Meta: apps = test_apps accessors = SubclassAccessors() class Other(models.Model): class Meta: apps = test_apps with self.assertRaises(KeyError): Base.accessors["tests", "other"] def test_proxy_accessors(self): test_apps = Apps(["tests"]) class Base(models.Model): class Meta: apps = test_apps abstract = True accessors = SubclassAccessors() class Polymorphic(Base): class Meta: apps = test_apps abstract = True class Root(Polymorphic): class Meta: apps = test_apps class Subclass(Root): class Meta: apps = test_apps class SubclassProxy(Subclass): class Meta: apps = test_apps proxy = True class SubclassProxyProxy(SubclassProxy): class Meta: apps = test_apps proxy = True self.assertEqual( Root.accessors[Subclass], SubclassAccessor(("subclass",), None, "subclass") ) self.assertEqual( Root.accessors[SubclassProxy], SubclassAccessor(("subclass",), SubclassProxy, "subclass"), ) self.assertEqual( Root.accessors[SubclassProxyProxy], SubclassAccessor(("subclass",), SubclassProxyProxy, "subclass"), ) self.assertEqual( Subclass.accessors[SubclassProxy], SubclassAccessor((), SubclassProxy, "") ) self.assertEqual( Subclass.accessors[SubclassProxyProxy], SubclassAccessor((), SubclassProxyProxy, ""), ) self.assertEqual( SubclassProxy.accessors[SubclassProxyProxy], SubclassAccessor((), SubclassProxyProxy, ""), ) django-polymodels-1.8.1/tests/test_related.py000066400000000000000000000011311477237676400213660ustar00rootroot00000000000000from .base import TestCase from .models import Mammal, Monkey, Zoo class RelatedManagerTest(TestCase): def test_select_subclasses(self): """ Make sure instances are correctly filtered and type casted when calling `select_subclasses` on a related manager. """ zoo = Zoo.objects.create() yeti = Mammal.objects.create(name="Yeti") pepe = Monkey.objects.create(name="Pepe") zoo.animals.add(yeti) zoo_animals = zoo.animals.select_subclasses() self.assertIn(yeti, zoo_animals) self.assertNotIn(pepe, zoo_animals) django-polymodels-1.8.1/tox.ini000066400000000000000000000026371477237676400165220ustar00rootroot00000000000000[tox] skipsdist = true args_are_paths = false envlist = black flake8 isort pypi py39-4.2 py310-{4.2,5.0,5.1,5.2,main} py{311,312}-{4.2,5.0,5.1,5.2,main} py313-{5.1,5.2,main} [gh-actions] python = 3.9: py39, black, flake8, isort 3.10: py310 3.11: py311 3.12: py312 3.13: py313 [testenv] basepython = py39: python3.9 py310: python3.10 py311: python3.11 py312: python3.12 py313: python3.13 usedevelop = true setenv = DJANGO_SETTINGS_MODULE=tests.settings passenv = GITHUB_* DB_* commands = {envpython} -R -Wonce {envbindir}/coverage run -a -m django test -v2 {posargs} coverage report deps = coverage 4.2: Django>=4.2,<5 5.0: Django>=5,<5.1 5.1: Django>=5.1,<5.2 5.2: Django>=5.2a1,<6.0 main: https://github.com/django/django/archive/main.tar.gz ignore_outcome = main: true [testenv:black] usedevelop = false basepython = python3.9 commands = black --check polymodels tests deps = black [testenv:flake8] usedevelop = false basepython = python3.9 commands = flake8 deps = flake8 [testenv:isort] usedevelop = false basepython = python3.9 commands = isort --check-only --diff polymodels tests deps = isort Django>=4.2 [testenv:pypi] usedevelop = false basepython = python3.9 commands = python setup.py sdist --format=gztar bdist_wheel twine check dist/* deps = pip setuptools twine wheel