diff --git a/docs/admin.rst b/docs/admin.rst index 652f3acd..5bfcc547 100644 --- a/docs/admin.rst +++ b/docs/admin.rst @@ -2,8 +2,7 @@ Admin Integration ================= Of course, it's possible to register individual polymorphic models in the -:doc:`Django admin interface `. However, to use these models in a -single cohesive interface, some extra base classes are available. +:doc:`Django admin interface `. Setup ----- @@ -11,260 +10,59 @@ Setup Both the parent model and child model need to have a :class:`~django.contrib.admin.ModelAdmin` class. -The shared base model should use the :class:`~polymorphic.admin.PolymorphicParentModelAdmin` as base -class. - -* :attr:`~polymorphic.admin.PolymorphicParentModelAdmin.base_model` should be set -* :attr:`~polymorphic.admin.PolymorphicParentModelAdmin.child_models` or - :meth:`~polymorphic.admin.PolymorphicParentModelAdmin.get_child_models` should return an iterable - of Model classes. - -The admin class for every child model should inherit from -:class:`~polymorphic.admin.PolymorphicChildModelAdmin` - -* :attr:`~polymorphic.admin.PolymorphicChildModelAdmin.base_model` should be set. - -Although the child models are registered too, they won't be shown in the admin index page. -This only happens when :attr:`~polymorphic.admin.PolymorphicChildModelAdmin.show_in_index` is set to -``True``. - -Fieldset configuration -~~~~~~~~~~~~~~~~~~~~~~ - -The parent admin is only used for the list display of models, and for the edit/delete view of -non-subclassed models. - -All other model types are redirected to the edit/delete/history view of the child model admin. -Hence, the fieldset configuration should be placed on the child admin. - -.. tip:: - - When the child admin is used as base class for various derived classes, avoid using - the standard ``ModelAdmin`` attributes ``form`` and ``fieldsets``. - Instead, use the ``base_form`` and ``base_fieldsets`` attributes. - This allows the :class:`~polymorphic.admin.PolymorphicChildModelAdmin` class - to detect any additional fields in case the child model is overwritten. - -.. versionchanged:: 1.0 - - It's now needed to register the child model classes too. - - In :pypi:`django-polymorphic` 0.9 and below, the - :meth:`~polymorphic.admin.PolymorphicParentModelAdmin.child_models` was a tuple of a - (:class:`~django.db.models.Model`, :class:`~polymorphic.admin.PolymorphicChildModelAdmin`). The - admin classes were registered in an internal class, and kept away from the main admin site. This - caused various subtle problems with the :class:`~django.db.models.ManyToManyField` and related - field wrappers, which are fixed by registering the child admin classes too. Note that they are - hidden from the main view, unless - :attr:`~polymorphic.admin.PolymorphicChildModelAdmin.show_in_index` is set. - .. _admin-example: Example ------- -The models are taken from :ref:`advanced-features`. - -.. code-block:: python - - from django.contrib import admin - from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin, PolymorphicChildModelFilter - from .models import ModelA, ModelB, ModelC, StandardModel - - - class ModelAChildAdmin(PolymorphicChildModelAdmin): - """ Base admin class for all child models """ - base_model = ModelA # Optional, explicitly set here. - - # By using these `base_...` attributes instead of the regular ModelAdmin `form` and `fieldsets`, - # the additional fields of the child models are automatically added to the admin form. - base_form = ... - base_fieldsets = ( - ... - ) - - - @admin.register(ModelB) - class ModelBAdmin(ModelAChildAdmin): - base_model = ModelB # Explicitly set here! - # define custom features here - - - @admin.register(ModelC) - class ModelCAdmin(ModelBAdmin): - base_model = ModelC # Explicitly set here! - show_in_index = True # makes child model admin visible in main admin site - # define custom features here - - - @admin.register(ModelA) - class ModelAParentAdmin(PolymorphicParentModelAdmin): - """ The parent model admin """ - base_model = ModelA # Optional, explicitly set here. - child_models = (ModelB, ModelC) - list_filter = (PolymorphicChildModelFilter,) # This is optional. - +.. literalinclude:: ../src/polymorphic/tests/examples/admin/models.py + :caption: src/polymorphic/tests/examples/admin/models.py + :language: python + :lines: 1-20 + :linenos: +.. literalinclude:: ../src/polymorphic/tests/examples/admin/admin.py + :caption: src/polymorphic/tests/examples/admin/admin.py (parent/child admin setup) + :language: python + :lines: 1-47 + :linenos: Filtering child types --------------------- Child model types can be filtered by adding a -:class:`~polymorphic.admin.PolymorphicChildModelFilter` to the -:attr:`~django.contrib.admin.ModelAdmin.list_filter` attribute. See the example above. - +:class:`~polymorphic.admin.PolymorphicChildModelFilter` to +:attr:`~django.contrib.admin.ModelAdmin.list_filter`. Inline models ------------- -.. versionadded:: 1.0 - -Inline models are handled via a special :class:`~polymorphic.admin.StackedPolymorphicInline` class. - -For models with a generic foreign key, there is a -:class:`~polymorphic.admin.GenericStackedPolymorphicInline` class available. - -When the inline is included to a normal :class:`~django.contrib.admin.ModelAdmin`, make sure the -:class:`~polymorphic.admin.PolymorphicInlineSupportMixin` is included. This is not needed when the -admin inherits from the :class:`~polymorphic.admin.PolymorphicParentModelAdmin` or -:class:`~polymorphic.admin.PolymorphicChildModelAdmin` classes. - -In the following example, the ``PaymentInline`` supports several types. These are defined as -separate inline classes. The child classes can be nested for clarity, but this is not a requirement. - -.. code-block:: python - - from django.contrib import admin - - from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline - from .models import Order, Payment, CreditCardPayment, BankPayment, SepaPayment - - - class PaymentInline(StackedPolymorphicInline): - """ - An inline for a polymorphic model. - The actual form appearance of each row is determined by - the child inline that corresponds with the actual model type. - """ - class CreditCardPaymentInline(StackedPolymorphicInline.Child): - model = CreditCardPayment - - class BankPaymentInline(StackedPolymorphicInline.Child): - model = BankPayment - - class SepaPaymentInline(StackedPolymorphicInline.Child): - model = SepaPayment - - model = Payment - child_inlines = ( - CreditCardPaymentInline, - BankPaymentInline, - SepaPaymentInline, - ) - - - @admin.register(Order) - class OrderAdmin(PolymorphicInlineSupportMixin, admin.ModelAdmin): - """ - Admin for orders. - The inline is polymorphic. - To make sure the inlines are properly handled, - the ``PolymorphicInlineSupportMixin`` is needed to - """ - inlines = (PaymentInline,) - +.. literalinclude:: ../src/polymorphic/tests/examples/admin/admin.py + :caption: src/polymorphic/tests/examples/admin/admin.py (polymorphic inlines) + :language: python + :pyobject: PaymentInline + :linenos: Using polymorphic models in standard inlines ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To add a polymorphic child model as an Inline for another model, add a field to the inline's -:attr:`~django.contrib.admin.ModelAdmin.readonly_fields` list formed by the lowercased name of the -polymorphic parent model with the string ``_ptr`` appended to it. Otherwise, trying to save that -model in the admin will raise an :exc:`AttributeError` with the message "can't set attribute". - -.. code-block:: python - - from django.contrib import admin - from .models import StandardModel - - - class ModelBInline(admin.StackedInline): - model = ModelB - fk_name = 'modelb' - readonly_fields = ['modela_ptr'] - - - @admin.register(StandardModel) - class StandardModelAdmin(admin.ModelAdmin): - inlines = [ModelBInline] - - - -Internal details ----------------- - -The polymorphic admin interface works in a simple way: - -* The add screen gains an additional step where the desired child model is selected. -* The edit screen displays the admin interface of the child model. -* The list screen still displays all objects of the base class. - -The polymorphic admin is implemented via a parent admin that redirects the ``edit`` and ``delete`` -views to the :class:`~django.contrib.admin.ModelAdmin` of the derived child model. The ``list`` page -is still implemented by the parent model admin. - -The parent model -~~~~~~~~~~~~~~~~ - -The parent model needs to inherit :class:`~polymorphic.admin.PolymorphicParentModelAdmin`, and -implement the following: - -* :attr:`~polymorphic.admin.PolymorphicParentModelAdmin.base_model` should be set -* :attr:`~polymorphic.admin.PolymorphicParentModelAdmin.child_models` or - :meth:`~polymorphic.admin.PolymorphicParentModelAdmin.get_child_models` should return an iterable - of Model classes. - -The exact implementation can depend on the way your module is structured. For simple inheritance -situations, :meth:`~polymorphic.admin.PolymorphicParentModelAdmin.child_models` is the best -solution. For large applications, -:meth:`~polymorphic.admin.PolymorphicParentModelAdmin.get_child_models` can be used to query a -plugin registration system. - -By default, the :meth:`~polymorphic.managers.PolymorphicQuerySet.non_polymorphic` method will be -called on the queryset, so only the Parent model will be provided to the list template. This is to -avoid the performance hit of retrieving child models. - -This can be controlled by setting the -:attr:`~polymorphic.admin.PolymorphicParentModelAdmin.polymorphic_list` property on the parent -admin. Setting it to True will provide child models to the list template. - -If you use other applications such as django-reversion_ or django-mptt_, please check -:ref:`integrations`. - -Note: If you are using non-integer primary keys in your model, you have to edit -:attr:`~polymorphic.admin.PolymorphicParentModelAdmin.pk_regex`, for example -``pk_regex = '([\w-]+)'`` if you use :class:`~uuid.UUID` primary keys. Otherwise you cannot change -model entries. - -The child models -~~~~~~~~~~~~~~~~ - -The admin interface of the derived models should inherit from -:class:`~polymorphic.admin.PolymorphicChildModelAdmin`. Again, -:attr:`~polymorphic.admin.PolymorphicChildModelAdmin.base_model` should be set in this class as -well. This class implements the following features: - -* It corrects the breadcrumbs in the admin pages. -* It extends the template lookup paths, to look for both the parent model and child model in the - ``admin/app/model/change_form.html`` path. -* It allows to set :attr:`~polymorphic.admin.PolymorphicChildModelAdmin.base_form` so the derived - class will automatically include other fields in the form. -* It allows to set :attr:`~polymorphic.admin.PolymorphicChildModelAdmin.base_fieldsets` so the - derived class will automatically display any extra fields. -* Although it must be registered with admin site, by default it's hidden from admin site index page. - This can be overridden by adding - :attr:`~polymorphic.admin.PolymorphicChildModelAdmin.show_in_index` = ``True`` in admin class. - - -.. _django-reversion: https://github.com/etianen/django-reversion -.. _django-mptt: https://github.com/django-mptt/django-mptt +.. literalinclude:: ../src/polymorphic/tests/examples/admin/admin.py + :caption: src/polymorphic/tests/examples/admin/admin.py (standard inline integration) + :language: python + :pyobject: ModelBInline + :linenos: + +.. literalinclude:: ../src/polymorphic/tests/examples/admin/admin.py + :caption: src/polymorphic/tests/examples/admin/admin.py (standard model admin registration) + :language: python + :pyobject: StandardModelAdmin + :linenos: + +Verification +------------ + +.. literalinclude:: ../src/polymorphic/tests/examples/admin/tests.py + :caption: src/polymorphic/tests/examples/admin/tests.py + :language: python + :pyobject: AdminExamplesTests + :linenos: diff --git a/docs/advanced.rst b/docs/advanced.rst index ff29145f..82e403d7 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -5,414 +5,111 @@ Advanced Features In the examples below, these models are being used: -.. code-block:: python +.. literalinclude:: ../src/polymorphic/tests/examples/advanced/models.py + :caption: src/polymorphic/tests/examples/advanced/models.py + :language: python + :lines: 1-17 + :linenos: - from django.db import models - from polymorphic.models import PolymorphicModel - - class ModelA(PolymorphicModel): - field1 = models.CharField(max_length=10) - - class ModelB(ModelA): - field2 = models.CharField(max_length=10) - - class ModelC(ModelB): - field3 = models.CharField(max_length=10) - - -Filtering for classes (equivalent to python's :func:`isinstance`): +Filtering for classes (equivalent to python's :func:`isinstance`) ------------------------------------------------------------------ -.. code-block:: python - - >>> ModelA.objects.instance_of(ModelB) - [ , - ] +.. literalinclude:: ../src/polymorphic/tests/examples/advanced/tests.py + :caption: src/polymorphic/tests/examples/advanced/tests.py (instance_of + Q) + :language: python + :pyobject: AdvancedExamplesTests.test_instance_of_and_q + :linenos: In general, including or excluding parts of the inheritance tree: -.. code-block:: python - - ModelA.objects.instance_of(ModelB [, ModelC ...]) - ModelA.objects.not_instance_of(ModelB [, ModelC ...]) - -You can also use this feature in Q-objects (with the same result as above): - -.. code-block:: python - - >>> ModelA.objects.filter( Q(instance_of=ModelB) ) - +* ``ModelA.objects.instance_of(ModelB [, ModelC ...])`` +* ``ModelA.objects.not_instance_of(ModelB [, ModelC ...])`` Polymorphic filtering (for fields in inherited classes) ------------------------------------------------------- For example, cherry-picking objects from multiple derived classes anywhere in the inheritance tree, -using Q objects (with the syntax: ``exact model name + three _ + field name``): - -.. code-block:: python - - >>> ModelA.objects.filter( Q(ModelB___field2 = 'B2') | Q(ModelC___field3 = 'C3') ) - [ , - ] +using Q objects with syntax ``exact model name + three underscores + field name``: +.. literalinclude:: ../src/polymorphic/tests/examples/advanced/tests.py + :caption: src/polymorphic/tests/examples/advanced/tests.py (field filtering) + :language: python + :pyobject: AdvancedExamplesTests.test_polymorphic_field_filtering + :linenos: Combining Querysets ------------------- -Querysets could now be regarded as object containers that allow the -aggregation of different object types, very similar to python -lists - as long as the objects are accessed through the manager of -a common base class: - -.. code-block:: python - - >>> Base.objects.instance_of(ModelX) | Base.objects.instance_of(ModelY) - - [ , - ] - +Querysets can be treated as object containers and combined using ``|`` as long as objects are +accessed through managers of a common polymorphic base class. This allows aggregation of different +subtypes while keeping the concrete model instances. ManyToManyField, ForeignKey, OneToOneField ------------------------------------------ -Relationship fields referring to polymorphic models work as -expected: like polymorphic querysets they now always return the -referred objects with the same type/class these were created and -saved as. - -E.g., if in your model you define: - -.. code-block:: python - - field1 = OneToOneField(ModelA) - -then field1 may now also refer to objects of type ``ModelB`` or ``ModelC``. - -A :class:`~django.db.models.ManyToManyField` example: - -.. code-block:: python - - # The model holding the relation may be any kind of model, polymorphic or not - class RelatingModel(models.Model): +Relationship fields referring to polymorphic models work as expected and return concrete subclass +instances. - # ManyToMany relation to a polymorphic model - many2many = models.ManyToManyField('ModelA') +.. literalinclude:: ../src/polymorphic/tests/examples/advanced/models.py + :caption: src/polymorphic/tests/examples/advanced/models.py (many-to-many model) + :language: python + :pyobject: RelatingModel + :linenos: - >>> o=RelatingModel.objects.create() - >>> o.many2many.add(ModelA.objects.get(id=1)) - >>> o.many2many.add(ModelB.objects.get(id=2)) - >>> o.many2many.add(ModelC.objects.get(id=3)) - - >>> o.many2many.all() - [ , - , - ] +.. literalinclude:: ../src/polymorphic/tests/examples/advanced/tests.py + :caption: src/polymorphic/tests/examples/advanced/tests.py (many-to-many polymorphic results) + :language: python + :pyobject: AdvancedExamplesTests.test_many_to_many_returns_real_instances + :linenos: Copying Polymorphic objects --------------------------- -**Copying polymorphic models is no different than copying regular multi-table models.** You have -two options: - -1. Use :meth:`~django.db.models.query.QuerySet.create` and provide all field values from the - original instance except the primary key(s). -2. Set the primary key attribute, and parent table pointers at all levels of inheritance to ``None`` - and call :meth:`~django.db.models.Model.save`. - -The Django documentation :ref:`offers some discussion on copying -`, including the complexity around related fields and -multi-table inheritance. :pypi:`django-polymorphic` offers a utility function -:func:`~polymorphic.utils.prepare_for_copy` that resets all necessary fields on a model instance to -prepare it for copying: - -.. code-block:: python - - from polymorphic.utils import prepare_for_copy - - obj = ModelB.objects.first() - prepare_for_copy(obj) - obj.save() - # obj is now a copy of the original ModelB instance +Copying polymorphic models is the same as copying regular multi-table models. The +:func:`~polymorphic.utils.prepare_for_copy` helper resets all required parent pointers and PK +fields before ``save()``. +.. literalinclude:: ../src/polymorphic/tests/examples/advanced/tests.py + :caption: src/polymorphic/tests/examples/advanced/tests.py (copy + non_polymorphic behavior) + :language: python + :pyobject: AdvancedExamplesTests.test_copy_and_non_polymorphic + :linenos: Working with Fixtures --------------------- -Polymorphic models work with Django's :django-admin:`dumpdata` and :django-admin:`loaddata` -commands just as regular models do. There are two important considerations: - -1. Polymorphic models are multi-table models and :django-admin:`dumpdata` serializes each table - separately. :pypi:`django-polymorphic` `does it's best - `_ to ensure non-polymorphic managers - are used when creating fixtures but there may be edge cases where this fails. If you override - :django-admin:`dumpdata` you must make sure any polymorphic managers encountered - :meth:`toggle polymorphism off `. Other - usual multi-table model caveats apply. If you serialize a subset of tables in the model - inheritance you may generate corrupt data or "upcast" your models if child tables were omitted. -2. Polymorphic models rely on the :class:`~django.contrib.contenttypes.models.ContentType` - framework. When serializing and deserializing polymorphic models, the - ``polymorphic_ctype`` field must be handled correctly. If there is any question about if the - content type primary keys are or will be different between the source and target database you - should use the :option:`--natural-foreign ` flag to serialize those - relations by-value. Polymorphism introduces no special consideration here - any model using - contenttypes, polymorphic or not, must handle this correctly. - -.. note:: - - Prior documentation urged users to use both :option:`--natural-primary ` - and :option:`--natural-foreign ` flags when dumping polymorphic - models. This is not necessary and only needs to be done when the primary keys are not guaranteed - to match or be available at the target database. - -Loading Fixtures (loaddata) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Fixtures should be loadable as normal with :django-admin:`loaddata`. However, if there are problems -with the ``polymorphic_ctype`` references, you may fix them using -:func:`~polymorphic.utils.reset_polymorphic_ctype`: - -.. code-block:: python +Polymorphic models work with :django-admin:`dumpdata` and :django-admin:`loaddata`, but the same +multi-table inheritance caveats still apply: - from polymorphic.utils import reset_polymorphic_ctype - from myapp.models import Animal, Dog, Cat +1. Dumping only a subset of tables in an inheritance tree can produce incomplete or upcasted data. +2. ``polymorphic_ctype`` references depend on ``ContentType`` records and must remain valid between + source and target databases. - # Reset polymorphic_ctype for all models in the inheritance tree - reset_polymorphic_ctype(Animal, Dog, Cat) +When content type references drift, use +:func:`~polymorphic.utils.reset_polymorphic_ctype` across the full inheritance tree. Using Third Party Models (without modifying them) ------------------------------------------------- -Third party models can be used as polymorphic models without -restrictions by subclassing them. E.g. using a third party -model as the root of a polymorphic inheritance tree: - -.. code-block:: python - - from thirdparty import ThirdPartyModel - - class MyThirdPartyBaseModel(PolymorphicModel, ThirdPartyModel): - pass # or add fields - -Or instead integrating the third party model anywhere into an -existing polymorphic inheritance tree: - -.. code-block:: python - - class MyBaseModel(SomePolymorphicModel): - my_field = models.CharField(max_length=10) - - class MyModelWithThirdParty(MyBaseModel, ThirdPartyModel): - pass # or add fields - +Third-party models can participate in polymorphic inheritance by subclassing them. You can place a +third-party base model at the root of a polymorphic tree or mix it into an existing polymorphic +branch where multiple inheritance is supported by Django model constraints. Non-Polymorphic Queries ----------------------- If you insert :meth:`~polymorphic.managers.PolymorphicQuerySet.non_polymorphic` anywhere into the -query chain, then :pypi:`django-polymorphic` will simply leave out the final step of retrieving the -real objects, and the manager/queryset will return objects of the type of the base class you used -for the query, like vanilla Django would (``ModelA`` in this example). - -.. code-block:: python - - >>> qs=ModelA.objects.non_polymorphic().all() - >>> qs - [ , - , - ] - -There are no other changes in the behaviour of the queryset. For example, -enhancements for ``filter()`` or ``instance_of()`` etc. still work as expected. -If you do the final step yourself, you get the usual polymorphic result: - -.. code-block:: python - - >>> ModelA.objects.get_real_instances(qs) - [ , - , - ] - - -About Queryset Methods ----------------------- - -* :meth:`~django.db.models.query.QuerySet.annotate` and - :meth:`~django.db.models.query.QuerySet.aggregate` work just as usual, with the addition that - the ``ModelX___field`` syntax can be used for the keyword arguments (but not for the non-keyword - arguments). - -* :meth:`~django.db.models.query.QuerySet.order_by` similarly supports the ``ModelX___field`` - syntax for specifying ordering through a field in a submodel. - -* :meth:`~django.db.models.query.QuerySet.distinct` works as expected. It only regards the fields - of the base class, but this should never make a difference. - -* :meth:`~django.db.models.query.QuerySet.select_related` works just as usual, but it can not - (yet) be used to select relations in inherited models (like - ``ModelA.objects.select_related('ModelC___fieldxy')`` ) - -* :meth:`~django.db.models.query.QuerySet.extra` works as expected (it returns polymorphic - results) but currently has one restriction: The resulting objects are required to have a unique - primary key within the result set - otherwise an error is thrown (this case could be made to - work, however it may be mostly unneeded).. The keyword-argument "polymorphic" is no longer - supported. You can get back the old non-polymorphic behaviour by using - ``ModelA.objects.non_polymorphic().extra(...)``. - -* :meth:`~polymorphic.managers.PolymorphicQuerySet.get_real_instances` allows you to turn a - queryset or list of base model objects efficiently into the real objects. - For example, you could do ``base_objects_queryset=ModelA.extra(...).non_polymorphic()`` - and then call ``real_objects=base_objects_queryset.get_real_instances()``. Or alternatively - ``real_objects=ModelA.objects.get_real_instances(base_objects_queryset_or_object_list)`` - -* :meth:`~django.db.models.query.QuerySet.values` & - :meth:`~django.db.models.query.QuerySet.values_list` currently do not return polymorphic - results. This may change in the future however. If you want to use these methods now, it's best - if you use ``Model.base_objects.values...`` as this is guaranteed to not change. - -* :meth:`~django.db.models.query.QuerySet.defer` and :meth:`~django.db.models.query.QuerySet.only` - work as expected. On Django 1.5+ they support the ``ModelX___field`` syntax, but on Django 1.4 - it is only possible to pass fields on the base model into these methods. - - -Using enhanced Q-objects in any Places --------------------------------------- - -The queryset enhancements (e.g. :meth:`~polymorphic.managers.PolymorphicQuerySet.instance_of`) -only work as arguments to the member functions of a polymorphic queryset. Occasionally it may -be useful to be able to use Q objects with these enhancements in other places. As Django doesn't -understand these enhanced Q objects, you need to transform them manually into normal Q objects -before you can feed them to a Django queryset or function: - -.. code-block:: python - - normal_q_object = ModelA.translate_polymorphic_Q_object( Q(instance_of=Model2B) ) - -This function cannot be used at model creation time however (in models.py), as it may need to access -the ContentTypes database table. - - -Nicely Displaying Polymorphic Querysets ---------------------------------------- - -In order to get the output as seen in all examples here, you need to use the -:class:`~polymorphic.showfields.ShowFieldType` class mixin: - -.. code-block:: python - - from polymorphic.models import PolymorphicModel - from polymorphic.showfields import ShowFieldType - - class ModelA(ShowFieldType, PolymorphicModel): - field1 = models.CharField(max_length=10) - -You may also use :class:`~polymorphic.showfields.ShowFieldContent` or -:class:`~polymorphic.showfields.ShowFieldTypeAndContent` to display additional information when -printing querysets (or converting them to text). - -When showing field contents, they will be truncated to 20 characters. You can modify this behavior -by setting a class variable in your model like this: - -.. code-block:: python - - class ModelA(ShowFieldType, PolymorphicModel): - polymorphic_showfield_max_field_width = 20 - ... - -Similarly, pre-V1.0 output formatting can be re-estated by using -``polymorphic_showfield_old_format = True``. - - -Create Children from Parents (Downcasting) ------------------------------------------- - -You can create an instance of a subclass from an existing instance of a superclass using the -:meth:`~polymorphic.managers.PolymorphicManager.create_from_super` method -of the subclass's manager. For example: - -.. code-block:: python - - super_instance = ModelA.objects.get(id=1) - sub_instance = ModelB.objects.create_from_super(super_instance, field2='value2') - -The restriction is that ``super_instance`` must be an instance of the direct superclass of -``ModelB``, and any required fields of ``ModelB`` must be provided as keyword arguments. If multiple -levels of subclassing are involved, you must call this method multiple times to "promote" each -level. - -Delete Children, Leaving Parents (Upcasting) --------------------------------------------- - -The reverse operation of :meth:`~polymorphic.managers.PolymorphicManager.create_from_super` is to -delete the subclass instance while keeping the superclass instance. This can be done using the -``keep_parents=True`` argument to :meth:`~django.db.models.Model.delete`. :pypi:`django-polymorphic` -ensures that the ``polymorphic_ctype`` fields of the superclass instances are updated accordingly -when doing this. - -.. _restrictions: +query chain, django-polymorphic returns base instances until you call +:meth:`~polymorphic.managers.PolymorphicQuerySet.get_real_instances`. Restrictions & Caveats ---------------------- -* Database Performance regarding concrete Model inheritance in general. Please see - :ref:`performance`. - -* Queryset methods :meth:`~django.db.models.query.QuerySet.values`, - :meth:`~django.db.models.query.QuerySet.values_list`, and - :meth:`~django.db.models.query.QuerySet.select_related` are not yet fully supported (see above). - :meth:`~django.db.models.query.QuerySet.extra` has one restriction: the resulting objects are - required to have a unique primary key within the result set. - -* Diamond shaped inheritance: There seems to be a general problem with diamond shaped multiple - model inheritance with Django models (tested with V1.1 - V1.3). An example - `is here `_. This problem is aggravated when trying - to enhance :class:`~django.db.models.Model` by subclassing it instead of modifying Django core - (as we do here with :class:`~polymorphic.models.PolymorphicModel`). - -* The enhanced filter-definitions/Q-objects only work as arguments for the methods of the - polymorphic querysets. Please see above for ``translate_polymorphic_Q_object``. - -* When using the :django-admin:`dumpdata` management command on polymorphic tables - (or any table that has a reference to :class:`~django.contrib.contenttypes.models.ContentType`), - include the :option:`--natural-primary ` and - :option:`--natural-foreign ` flags in the arguments. - -* If the ``polymorphic_ctype_id`` on the base table points to the wrong - :class:`~django.contrib.contenttypes.models.ContentType` (this can happen if you delete child - rows manually with raw SQL, ``DELETE FROM table``), then polymorphic queries will elide the - corresponding model objects: - - * ``BaseClass.objects.all()`` will **exclude** these rows (it filters for existing child - types). - * ``BaseClass.objects.non_polymorphic().all()`` will behave as normal - but polymorphic - behavior for the affected rows will be undefined - for instance, - :meth:`~polymorphic.managers.PolymorphicQuerySet.get_real_instances` will raise an - exception. - - Always use ``instance.delete()`` or ``QuerySet.delete()`` to ensure cascading deletion of the - base row. If you must delete manually, ensure you also delete the corresponding row from the - base table. - -* There will be problems if the :class:`~django.contrib.contenttypes.models.ContentType` cache - becomes out of sync with the database. This can especially happen in tests. You should ensure - that the cache is cleared ( - :meth:`~django.contrib.contenttypes.models.ContentTypeManager.clear_cache`) whenever this - happens. In tests this happens when if :django-admin:`flush` is called by the teardown sequence - in :class:`~django.test.TransactionTestCase`. - -.. old links: - - http://code.djangoproject.com/wiki/ModelInheritance - - http://lazypython.blogspot.com/2009/02/second-look-at-inheritance-and.html - - http://www.djangosnippets.org/snippets/1031/ - - http://www.djangosnippets.org/snippets/1034/ - - http://groups.google.com/group/django-developers/browse_frm/thread/7d40ad373ebfa912/a20fabc661b7035d?lnk=gst&q=model+inheritance+CORBA#a20fabc661b7035d - - http://groups.google.com/group/django-developers/browse_thread/thread/9bc2aaec0796f4e0/0b92971ffc0aa6f8?lnk=gst&q=inheritance#0b92971ffc0aa6f8 - - http://groups.google.com/group/django-developers/browse_thread/thread/3947c594100c4adb/d8c0af3dacad412d?lnk=gst&q=inheritance#d8c0af3dacad412d - - http://groups.google.com/group/django-users/browse_thread/thread/52f72cffebb705e/b76c9d8c89a5574f - - http://peterbraden.co.uk/article/django-inheritance - - http://www.hopelessgeek.com/2009/11/25/a-hack-for-multi-table-inheritance-in-django - - http://stackoverflow.com/questions/929029/how-do-i-access-the-child-classes-of-an-object-in-django-without-knowing-the-name/929982#929982 - - http://stackoverflow.com/questions/1581024/django-inheritance-how-to-have-one-method-for-all-subclasses - - http://groups.google.com/group/django-users/browse_thread/thread/cbdaf2273781ccab/e676a537d735d9ef?lnk=gst&q=polymorphic#e676a537d735d9ef - - http://groups.google.com/group/django-users/browse_thread/thread/52f72cffebb705e/bc18c18b2e83881e?lnk=gst&q=model+inheritance#bc18c18b2e83881e - - http://code.djangoproject.com/ticket/10808 - - http://code.djangoproject.com/ticket/7270 +* Database performance considerations for concrete model inheritance still apply. +* ``values()``, ``values_list()``, and ``select_related()`` remain partially limited in polymorphic + contexts. +* ``extra()`` still requires unique base primary keys in the result set. +* Enhanced filter definitions / polymorphic Q-object semantics work through polymorphic queryset + APIs. +* When dumping data across environments that may differ in ``ContentType`` primary keys, use + :option:`--natural-foreign `. diff --git a/docs/formsets.rst b/docs/formsets.rst index ad12cae8..3602e65c 100644 --- a/docs/formsets.rst +++ b/docs/formsets.rst @@ -7,27 +7,20 @@ Polymorphic models can be used in formsets. The implementation is almost identical to the regular Django :doc:`django:topics/forms/formsets`. As extra parameter, the factory needs to know how to display the child models. -Provide a list of :class:`~polymorphic.formsets.PolymorphicFormSetChild` objects for this. -.. code-block:: python - - from polymorphic.formsets import polymorphic_modelformset_factory, PolymorphicFormSetChild - - ModelAFormSet = polymorphic_modelformset_factory(ModelA, formset_children=( - PolymorphicFormSetChild(ModelB), - PolymorphicFormSetChild(ModelC), - )) +.. literalinclude:: ../src/polymorphic/tests/examples/formsets/tests.py + :caption: src/polymorphic/tests/examples/formsets/tests.py (factory setup) + :language: python + :lines: 1-16 + :linenos: The formset can be used just like all other formsets: -.. code-block:: python - - if request.method == "POST": - formset = ModelAFormSet(request.POST, request.FILES, queryset=ModelA.objects.all()) - if formset.is_valid(): - formset.save() - else: - formset = ModelAFormSet(queryset=ModelA.objects.all()) +.. literalinclude:: ../src/polymorphic/tests/examples/formsets/tests.py + :caption: src/polymorphic/tests/examples/formsets/tests.py (POST + save flow) + :language: python + :pyobject: FormsetsExamplesTests.test_formset_factory_and_save + :linenos: Like standard Django :doc:`django:topics/forms/formsets`, there are 3 factory methods available: @@ -35,12 +28,3 @@ Like standard Django :doc:`django:topics/forms/formsets`, there are 3 factory me * :func:`~polymorphic.formsets.polymorphic_inlineformset_factory` - create a inline model formset. * :func:`~polymorphic.formsets.generic_polymorphic_inlineformset_factory` - create an inline formset for a generic foreign key. - -Each one uses a different base class: - -* :class:`~polymorphic.formsets.BasePolymorphicModelFormSet` -* :class:`~polymorphic.formsets.BasePolymorphicInlineFormSet` -* :class:`~polymorphic.formsets.BaseGenericPolymorphicInlineFormSet` - -When needed, the base class can be overwritten and provided to the factory via the ``formset`` -parameter. diff --git a/docs/managers.rst b/docs/managers.rst index 380561ac..a8d7507e 100644 --- a/docs/managers.rst +++ b/docs/managers.rst @@ -5,106 +5,49 @@ Using a Custom Manager ---------------------- A nice feature of Django is the possibility to define one's own custom object managers. -This is fully supported with :pypi:`django-polymorphic`. For creating a custom polymorphic -manager class, just derive your manager from :class:`~polymorphic.managers.PolymorphicManager` -instead of :class:`~django.db.models.Manager`. As with vanilla Django, in your model class, you -should explicitly add the default manager first, and then your custom manager: +This is fully supported with :pypi:`django-polymorphic`. -.. code-block:: python +.. literalinclude:: ../src/polymorphic/tests/examples/managers/models.py + :caption: src/polymorphic/tests/examples/managers/models.py (custom manager) + :language: python + :pyobject: TimeOrderedManager + :linenos: - from polymorphic.models import PolymorphicModel - from polymorphic.managers import PolymorphicManager - - class TimeOrderedManager(PolymorphicManager): - def get_queryset(self): - qs = super(TimeOrderedManager,self).get_queryset() - return qs.order_by('-start_date') - - def most_recent(self): - qs = self.get_queryset() # get my ordered queryset - return qs[:10] # limit => get ten most recent entries - - class Project(PolymorphicModel): - objects = PolymorphicManager() # add the default polymorphic manager first - objects_ordered = TimeOrderedManager() # then add your own manager - start_date = DateTimeField() # project start is this date/time - -The first manager defined (:attr:`~django.db.models.Model.objects` in the example) is used by Django -as automatic manager for several purposes, including accessing related objects. It must not filter -objects and it's safest to use the plain :class:`~polymorphic.managers.PolymorphicManager` here. +.. literalinclude:: ../src/polymorphic/tests/examples/managers/models.py + :caption: src/polymorphic/tests/examples/managers/models.py (polymorphic models + manager) + :language: python + :lines: 14-24 + :linenos: Manager Inheritance ------------------- Polymorphic models inherit/propagate all managers from their base models, as long as these are -polymorphic. This means that all managers defined in polymorphic base models continue to work as -expected in models inheriting from this base model: - -.. code-block:: python - - from polymorphic.models import PolymorphicModel - from polymorphic.managers import PolymorphicManager - - class TimeOrderedManager(PolymorphicManager): - def get_queryset(self): - qs = super(TimeOrderedManager,self).get_queryset() - return qs.order_by('-start_date') - - def most_recent(self): - qs = self.get_queryset() # get my ordered queryset - return qs[:10] # limit => get ten most recent entries - - class Project(PolymorphicModel): - objects = PolymorphicManager() # add the default polymorphic manager first - objects_ordered = TimeOrderedManager() # then add your own manager - start_date = DateTimeField() # project start is this date/time - - class ArtProject(Project): # inherit from Project, inheriting its fields and managers - artist = models.CharField(max_length=30) - -ArtProject inherited the managers ``objects`` and ``objects_ordered`` from Project. +polymorphic. -``ArtProject.objects_ordered.all()`` will return all art projects ordered regarding their start time -and ``ArtProject.objects_ordered.most_recent()`` will return the ten most recent art projects. +.. literalinclude:: ../src/polymorphic/tests/examples/managers/tests.py + :caption: src/polymorphic/tests/examples/managers/tests.py (manager inheritance behavior) + :language: python + :pyobject: ManagerExamplesTests.test_custom_manager_and_inheritance + :linenos: Using a Custom Queryset Class ----------------------------- -The :class:`~polymorphic.managers.PolymorphicManager` class accepts one initialization argument, -which is the queryset class the manager should use. Just as with vanilla Django, you may define your -own custom queryset classes. Just use :class:`~polymorphic.managers.PolymorphicQuerySet` instead of -Django's :class:`~django.db.models.query.QuerySet` as the base class: - -.. code-block:: python - - from polymorphic.models import PolymorphicModel - from polymorphic.managers import PolymorphicManager - from polymorphic.query import PolymorphicQuerySet - - class MyQuerySet(PolymorphicQuerySet): - def my_queryset_method(self): - ... - - class MyModel(PolymorphicModel): - my_objects = PolymorphicManager.from_queryset(MyQuerySet)() - ... - -If you do not wish to extend from a custom :class:`~polymorphic.managers.PolymorphicManager` you -may also prefer the :meth:`~polymorphic.managers.PolymorphicQuerySet.as_manager` -shortcut: - -.. code-block:: python - - from polymorphic.models import PolymorphicModel - from polymorphic.query import PolymorphicQuerySet - - class MyQuerySet(PolymorphicQuerySet): - def my_queryset_method(self): - ... - - class MyModel(PolymorphicModel): - my_objects = MyQuerySet.as_manager() - ... - -For further discussion see `this topic on the Q&A page -`_. +.. literalinclude:: ../src/polymorphic/tests/examples/managers/models.py + :caption: src/polymorphic/tests/examples/managers/models.py (from_queryset pattern) + :language: python + :lines: 27-34 + :linenos: + +.. literalinclude:: ../src/polymorphic/tests/examples/managers/models.py + :caption: src/polymorphic/tests/examples/managers/models.py (as_manager pattern) + :language: python + :pyobject: MyOtherModel + :linenos: + +.. literalinclude:: ../src/polymorphic/tests/examples/managers/tests.py + :caption: src/polymorphic/tests/examples/managers/tests.py (queryset manager verification) + :language: python + :pyobject: ManagerExamplesTests.test_custom_queryset_manager_patterns + :linenos: diff --git a/docs/quickstart.rst b/docs/quickstart.rst index b5a800eb..4659656f 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -9,10 +9,15 @@ Update the settings file: .. code-block:: python - INSTALLED_APPS += ( - 'polymorphic', - 'django.contrib.contenttypes', - ) + INSTALLED_APPS = [ + # Required by django-polymorphic for polymorphic_ctype bookkeeping. + "django.contrib.contenttypes", + + # Enable polymorphic model support. + "polymorphic", + ] + +You can append these entries to an existing ``INSTALLED_APPS`` list in your project settings. .. only:: html @@ -37,64 +42,23 @@ Making Your Models Polymorphic Use :class:`~polymorphic.models.PolymorphicModel` instead of Django's :class:`~django.db.models.Model`, like so: -.. code-block:: python - - from polymorphic.models import PolymorphicModel - - class Project(PolymorphicModel): - topic = models.CharField(max_length=30) - - class ArtProject(Project): - artist = models.CharField(max_length=30) - - class ResearchProject(Project): - supervisor = models.CharField(max_length=30) +.. literalinclude:: ../src/polymorphic/tests/examples/quickstart/models.py + :caption: src/polymorphic/tests/examples/quickstart/models.py + :language: python + :linenos: All models inheriting from your polymorphic models will be polymorphic as well. Using Polymorphic Models ------------------------ -Create some objects: - -.. code-block:: python - - >>> Project.objects.create(topic="Department Party") - >>> ArtProject.objects.create(topic="Painting with Tim", artist="T. Turner") - >>> ResearchProject.objects.create(topic="Swallow Aerodynamics", supervisor="Dr. Winter") - -Get polymorphic query results: - -.. code-block:: python - - >>> Project.objects.all() - [ , - , - ] - -Use :meth:`~polymorphic.managers.PolymorphicQuerySet.instance_of` and -:meth:`~polymorphic.managers.PolymorphicQuerySet.not_instance_of` for narrowing the result to -specific subtypes: - -.. code-block:: python - - >>> Project.objects.instance_of(ArtProject) - [ ] - -.. code-block:: python - - >>> Project.objects.instance_of(ArtProject) | Project.objects.instance_of(ResearchProject) - [ , - ] - -Polymorphic filtering: Get all projects where Mr. Turner is involved as an artist -or supervisor (note the three underscores): - -.. code-block:: python +Create objects and execute polymorphic queries exactly as documented: - >>> Project.objects.filter(Q(ArtProject___artist='T. Turner') | Q(ResearchProject___supervisor='T. Turner')) - [ , - ] +.. literalinclude:: ../src/polymorphic/tests/examples/quickstart/tests.py + :caption: src/polymorphic/tests/examples/quickstart/tests.py + :language: python + :pyobject: QuickstartExamplesTests + :linenos: This is basically all you need to know, as *django-polymorphic* mostly works fully automatic and just delivers the expected results. diff --git a/src/polymorphic/tests/examples/admin/__init__.py b/src/polymorphic/tests/examples/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/admin/admin.py b/src/polymorphic/tests/examples/admin/admin.py new file mode 100644 index 00000000..56fb2ebc --- /dev/null +++ b/src/polymorphic/tests/examples/admin/admin.py @@ -0,0 +1,77 @@ +from django.contrib import admin + +from polymorphic.admin import ( + PolymorphicChildModelAdmin, + PolymorphicChildModelFilter, + PolymorphicInlineSupportMixin, + PolymorphicParentModelAdmin, + StackedPolymorphicInline, +) + +from .models import ( + BankPayment, + CreditCardPayment, + ModelA, + ModelB, + ModelC, + Order, + Payment, + SepaPayment, + StandardModel, +) + + +class ModelAChildAdmin(PolymorphicChildModelAdmin): + base_model = ModelA + + +@admin.register(ModelB) +class ModelBAdmin(ModelAChildAdmin): + base_model = ModelB + + +@admin.register(ModelC) +class ModelCAdmin(ModelBAdmin): + base_model = ModelC + show_in_index = True + + +@admin.register(ModelA) +class ModelAParentAdmin(PolymorphicParentModelAdmin): + base_model = ModelA + child_models = (ModelB, ModelC) + list_filter = (PolymorphicChildModelFilter,) + + +class PaymentInline(StackedPolymorphicInline): + class CreditCardPaymentInline(StackedPolymorphicInline.Child): + model = CreditCardPayment + + class BankPaymentInline(StackedPolymorphicInline.Child): + model = BankPayment + + class SepaPaymentInline(StackedPolymorphicInline.Child): + model = SepaPayment + + model = Payment + child_inlines = ( + CreditCardPaymentInline, + BankPaymentInline, + SepaPaymentInline, + ) + + +@admin.register(Order) +class OrderAdmin(PolymorphicInlineSupportMixin, admin.ModelAdmin): + inlines = (PaymentInline,) + + +class ModelBInline(admin.StackedInline): + model = ModelB + fk_name = "standard_model" + readonly_fields = ["modela_ptr"] + + +@admin.register(StandardModel) +class StandardModelAdmin(admin.ModelAdmin): + inlines = [ModelBInline] diff --git a/src/polymorphic/tests/examples/admin/apps.py b/src/polymorphic/tests/examples/admin/apps.py new file mode 100644 index 00000000..3f17bd23 --- /dev/null +++ b/src/polymorphic/tests/examples/admin/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AdminExamplesConfig(AppConfig): + name = "polymorphic.tests.examples.admin" + label = "example_admin" diff --git a/src/polymorphic/tests/examples/admin/migrations/0001_initial.py b/src/polymorphic/tests/examples/admin/migrations/0001_initial.py new file mode 100644 index 00000000..5569ebe7 --- /dev/null +++ b/src/polymorphic/tests/examples/admin/migrations/0001_initial.py @@ -0,0 +1,90 @@ +from django.db import migrations, models +import django.db.models.deletion +import polymorphic.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [("contenttypes", "0002_remove_content_type_name")] + + operations = [ + migrations.CreateModel( + name="ModelA", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("field1", models.CharField(max_length=10)), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_admin.modela_set+", to="contenttypes.contenttype")), + ], + bases=(polymorphic.models.PolymorphicModel,), + ), + migrations.CreateModel( + name="Order", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("number", models.CharField(max_length=20)), + ], + ), + migrations.CreateModel( + name="ModelB", + fields=[ + ("modela_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_admin.modela")), + ("field2", models.CharField(max_length=10)), + ], + bases=("example_admin.modela",), + ), + migrations.CreateModel( + name="Payment", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("amount", models.DecimalField(decimal_places=2, max_digits=8)), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_admin.payment_set+", to="contenttypes.contenttype")), + ("order", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="payments", to="example_admin.order")), + ], + bases=(polymorphic.models.PolymorphicModel,), + ), + migrations.CreateModel( + name="ModelC", + fields=[ + ("modelb_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_admin.modelb")), + ("field3", models.CharField(max_length=10)), + ], + bases=("example_admin.modelb",), + ), + migrations.CreateModel( + name="BankPayment", + fields=[ + ("payment_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_admin.payment")), + ("bank_name", models.CharField(max_length=40)), + ], + bases=("example_admin.payment",), + ), + migrations.CreateModel( + name="CreditCardPayment", + fields=[ + ("payment_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_admin.payment")), + ("card_type", models.CharField(max_length=20)), + ], + bases=("example_admin.payment",), + ), + migrations.CreateModel( + name="SepaPayment", + fields=[ + ("payment_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_admin.payment")), + ("iban", models.CharField(max_length=34)), + ], + bases=("example_admin.payment",), + ), + migrations.CreateModel( + name="StandardModel", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(blank=True, max_length=40)), + ], + ), + migrations.AddField( + model_name="modelb", + name="standard_model", + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="modelb_items", to="example_admin.standardmodel"), + ), + ] diff --git a/src/polymorphic/tests/examples/admin/migrations/0002_alter_modela_polymorphic_ctype_and_more.py b/src/polymorphic/tests/examples/admin/migrations/0002_alter_modela_polymorphic_ctype_and_more.py new file mode 100644 index 00000000..02efbac4 --- /dev/null +++ b/src/polymorphic/tests/examples/admin/migrations/0002_alter_modela_polymorphic_ctype_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.4 on 2026-05-09 05:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('example_admin', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='modela', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='payment', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + ] diff --git a/src/polymorphic/tests/examples/admin/migrations/__init__.py b/src/polymorphic/tests/examples/admin/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/admin/models.py b/src/polymorphic/tests/examples/admin/models.py new file mode 100644 index 00000000..cbea5809 --- /dev/null +++ b/src/polymorphic/tests/examples/admin/models.py @@ -0,0 +1,46 @@ +from django.db import models +from polymorphic.models import PolymorphicModel + + +class ModelA(PolymorphicModel): + field1 = models.CharField(max_length=10) + + +class ModelB(ModelA): + standard_model = models.ForeignKey( + "StandardModel", + on_delete=models.CASCADE, + related_name="modelb_items", + null=True, + blank=True, + ) + field2 = models.CharField(max_length=10) + + +class ModelC(ModelB): + field3 = models.CharField(max_length=10) + + +class StandardModel(models.Model): + title = models.CharField(max_length=40, blank=True) + + +class Order(models.Model): + number = models.CharField(max_length=20) + + +class Payment(PolymorphicModel): + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="payments") + amount = models.DecimalField(max_digits=8, decimal_places=2) + + +class CreditCardPayment(Payment): + card_type = models.CharField(max_length=20) + + +class BankPayment(Payment): + bank_name = models.CharField(max_length=40) + + +class SepaPayment(Payment): + iban = models.CharField(max_length=34) diff --git a/src/polymorphic/tests/examples/admin/tests.py b/src/polymorphic/tests/examples/admin/tests.py new file mode 100644 index 00000000..273550c5 --- /dev/null +++ b/src/polymorphic/tests/examples/admin/tests.py @@ -0,0 +1,64 @@ +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from polymorphic.admin import PolymorphicChildModelFilter + +from .admin import ModelAParentAdmin, ModelCAdmin, OrderAdmin, PaymentInline +from .models import ( + BankPayment, + CreditCardPayment, + ModelA, + ModelB, + ModelC, + Order, + SepaPayment, + StandardModel, +) + + +class AdminExamplesTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_superuser( + username="admin", email="admin@example.com", password="adminpass" + ) + + def test_parent_child_admin_configuration(self): + parent_admin = ModelAParentAdmin(ModelA, admin.site) + assert parent_admin.base_model is ModelA + assert tuple(parent_admin.get_child_models()) == (ModelB, ModelC) + assert parent_admin.list_filter == (PolymorphicChildModelFilter,) + assert ModelCAdmin.show_in_index is True + + def test_polymorphic_inline_models(self): + order = Order.objects.create(number="ORD-1") + CreditCardPayment.objects.create(order=order, amount="10.00", card_type="visa") + BankPayment.objects.create(order=order, amount="20.00", bank_name="ABC") + SepaPayment.objects.create(order=order, amount="30.00", iban="DE02120300000000202051") + + inline = PaymentInline(Order, admin.site) + child_models = [child.model for child in inline.get_child_inline_instances()] + assert set(child_models) == {CreditCardPayment, BankPayment, SepaPayment} + + order_admin = OrderAdmin(Order, admin.site) + assert PaymentInline in order_admin.inlines + + def test_standard_model_inline_admin_renders_and_saves(self): + self.client.force_login(self.user) + add_url = reverse("admin:example_admin_standardmodel_add") + + response = self.client.get(add_url) + assert response.status_code == 200 + + post_data = { + "title": "Inline Parent", + "modelb_items-TOTAL_FORMS": "0", + "modelb_items-INITIAL_FORMS": "0", + "modelb_items-MIN_NUM_FORMS": "0", + "modelb_items-MAX_NUM_FORMS": "1000", + "_save": "Save", + } + response = self.client.post(add_url, data=post_data) + assert response.status_code == 302 + assert StandardModel.objects.filter(title="Inline Parent").exists() diff --git a/src/polymorphic/tests/examples/advanced/__init__.py b/src/polymorphic/tests/examples/advanced/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/advanced/apps.py b/src/polymorphic/tests/examples/advanced/apps.py new file mode 100644 index 00000000..485d5cf4 --- /dev/null +++ b/src/polymorphic/tests/examples/advanced/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AdvancedExamplesConfig(AppConfig): + name = "polymorphic.tests.examples.advanced" + label = "example_advanced" diff --git a/src/polymorphic/tests/examples/advanced/migrations/0001_initial.py b/src/polymorphic/tests/examples/advanced/migrations/0001_initial.py new file mode 100644 index 00000000..3e4bb0d2 --- /dev/null +++ b/src/polymorphic/tests/examples/advanced/migrations/0001_initial.py @@ -0,0 +1,45 @@ +from django.db import migrations, models +import django.db.models.deletion +import polymorphic.models +import polymorphic.showfields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [("contenttypes", "0002_remove_content_type_name")] + + operations = [ + migrations.CreateModel( + name="ModelA", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("field1", models.CharField(max_length=10)), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_advanced.modela_set+", to="contenttypes.contenttype")), + ], + bases=(polymorphic.showfields.ShowFieldType, polymorphic.models.PolymorphicModel), + ), + migrations.CreateModel( + name="ModelB", + fields=[ + ("modela_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_advanced.modela")), + ("field2", models.CharField(max_length=10)), + ], + bases=("example_advanced.modela",), + ), + migrations.CreateModel( + name="ModelC", + fields=[ + ("modelb_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_advanced.modelb")), + ("field3", models.CharField(max_length=10)), + ], + bases=("example_advanced.modelb",), + ), + migrations.CreateModel( + name="RelatingModel", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("many2many", models.ManyToManyField(to="example_advanced.modela")), + ], + ), + ] diff --git a/src/polymorphic/tests/examples/advanced/migrations/0002_alter_modela_polymorphic_ctype.py b/src/polymorphic/tests/examples/advanced/migrations/0002_alter_modela_polymorphic_ctype.py new file mode 100644 index 00000000..05e75d2c --- /dev/null +++ b/src/polymorphic/tests/examples/advanced/migrations/0002_alter_modela_polymorphic_ctype.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.4 on 2026-05-09 05:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('example_advanced', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='modela', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + ] diff --git a/src/polymorphic/tests/examples/advanced/migrations/__init__.py b/src/polymorphic/tests/examples/advanced/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/advanced/models.py b/src/polymorphic/tests/examples/advanced/models.py new file mode 100644 index 00000000..0afb1e8c --- /dev/null +++ b/src/polymorphic/tests/examples/advanced/models.py @@ -0,0 +1,19 @@ +from django.db import models +from polymorphic.models import PolymorphicModel +from polymorphic.showfields import ShowFieldType + + +class ModelA(ShowFieldType, PolymorphicModel): + field1 = models.CharField(max_length=10) + + +class ModelB(ModelA): + field2 = models.CharField(max_length=10) + + +class ModelC(ModelB): + field3 = models.CharField(max_length=10) + + +class RelatingModel(models.Model): + many2many = models.ManyToManyField(ModelA) diff --git a/src/polymorphic/tests/examples/advanced/tests.py b/src/polymorphic/tests/examples/advanced/tests.py new file mode 100644 index 00000000..8529982f --- /dev/null +++ b/src/polymorphic/tests/examples/advanced/tests.py @@ -0,0 +1,39 @@ +from django.db.models import Q +from django.test import TestCase + +from polymorphic.utils import prepare_for_copy + +from .models import ModelA, ModelB, ModelC, RelatingModel + + +class AdvancedExamplesTests(TestCase): + def setUp(self): + self.a = ModelA.objects.create(field1="A1") + self.b = ModelB.objects.create(field1="B1", field2="B2") + self.c = ModelC.objects.create(field1="C1", field2="C2", field3="C3") + + def test_instance_of_and_q(self): + assert list(ModelA.objects.instance_of(ModelB)) == [self.b, self.c] + assert list(ModelA.objects.filter(Q(instance_of=ModelB))) == [self.b, self.c] + + def test_polymorphic_field_filtering(self): + result = ModelA.objects.filter(Q(ModelB___field2="B2") | Q(ModelC___field3="C3")) + assert list(result) == [self.b, self.c] + + def test_many_to_many_returns_real_instances(self): + rel = RelatingModel.objects.create() + rel.many2many.add(self.a, self.b, self.c) + items = list(rel.many2many.all()) + assert isinstance(items[0], ModelA) + assert isinstance(items[1], ModelB) + assert isinstance(items[2], ModelC) + + def test_copy_and_non_polymorphic(self): + original = ModelB.objects.first() + prepare_for_copy(original) + original.save() + + qs = ModelA.objects.non_polymorphic().all() + assert all(type(item) is ModelA for item in qs) + real = ModelA.objects.get_real_instances(qs) + assert any(type(item) is ModelB for item in real) diff --git a/src/polymorphic/tests/examples/formsets/__init__.py b/src/polymorphic/tests/examples/formsets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/formsets/apps.py b/src/polymorphic/tests/examples/formsets/apps.py new file mode 100644 index 00000000..cda45219 --- /dev/null +++ b/src/polymorphic/tests/examples/formsets/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FormsetsExamplesConfig(AppConfig): + name = "polymorphic.tests.examples.formsets" + label = "example_formsets" diff --git a/src/polymorphic/tests/examples/formsets/migrations/0001_initial.py b/src/polymorphic/tests/examples/formsets/migrations/0001_initial.py new file mode 100644 index 00000000..025fc58b --- /dev/null +++ b/src/polymorphic/tests/examples/formsets/migrations/0001_initial.py @@ -0,0 +1,37 @@ +from django.db import migrations, models +import django.db.models.deletion +import polymorphic.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [("contenttypes", "0002_remove_content_type_name")] + + operations = [ + migrations.CreateModel( + name="ModelA", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("field1", models.CharField(max_length=10)), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_formsets.modela_set+", to="contenttypes.contenttype")), + ], + bases=(polymorphic.models.PolymorphicModel,), + ), + migrations.CreateModel( + name="ModelB", + fields=[ + ("modela_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_formsets.modela")), + ("field2", models.CharField(max_length=10)), + ], + bases=("example_formsets.modela",), + ), + migrations.CreateModel( + name="ModelC", + fields=[ + ("modela_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_formsets.modela")), + ("field3", models.CharField(max_length=10)), + ], + bases=("example_formsets.modela",), + ), + ] diff --git a/src/polymorphic/tests/examples/formsets/migrations/0002_alter_modela_polymorphic_ctype.py b/src/polymorphic/tests/examples/formsets/migrations/0002_alter_modela_polymorphic_ctype.py new file mode 100644 index 00000000..66c43d5e --- /dev/null +++ b/src/polymorphic/tests/examples/formsets/migrations/0002_alter_modela_polymorphic_ctype.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.4 on 2026-05-09 05:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('example_formsets', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='modela', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + ] diff --git a/src/polymorphic/tests/examples/formsets/migrations/__init__.py b/src/polymorphic/tests/examples/formsets/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/formsets/models.py b/src/polymorphic/tests/examples/formsets/models.py new file mode 100644 index 00000000..a4590db3 --- /dev/null +++ b/src/polymorphic/tests/examples/formsets/models.py @@ -0,0 +1,14 @@ +from django.db import models +from polymorphic.models import PolymorphicModel + + +class ModelA(PolymorphicModel): + field1 = models.CharField(max_length=10) + + +class ModelB(ModelA): + field2 = models.CharField(max_length=10) + + +class ModelC(ModelA): + field3 = models.CharField(max_length=10) diff --git a/src/polymorphic/tests/examples/formsets/tests.py b/src/polymorphic/tests/examples/formsets/tests.py new file mode 100644 index 00000000..d4b58993 --- /dev/null +++ b/src/polymorphic/tests/examples/formsets/tests.py @@ -0,0 +1,39 @@ +from django.test import TestCase + +from polymorphic.formsets import PolymorphicFormSetChild, polymorphic_modelformset_factory + +from .models import ModelA, ModelB, ModelC + + +ModelAFormSet = polymorphic_modelformset_factory( + ModelA, + fields=("field1",), + extra=0, + formset_children=( + PolymorphicFormSetChild(ModelB, fields=("field1", "field2")), + PolymorphicFormSetChild(ModelC, fields=("field1", "field3")), + ), +) + + +class FormsetsExamplesTests(TestCase): + def test_formset_factory_and_save(self): + ModelB.objects.create(field1="b1", field2="b2") + formset = ModelAFormSet(queryset=ModelA.objects.all()) + assert formset.total_form_count() == 1 + + payload = { + "form-TOTAL_FORMS": "1", + "form-INITIAL_FORMS": "1", + "form-MIN_NUM_FORMS": "0", + "form-MAX_NUM_FORMS": "1000", + "form-0-id": str(ModelA.objects.first().pk), + "form-0-polymorphic_ctype": str(ModelB.objects.first().polymorphic_ctype_id), + "form-0-field1": "b1-updated", + } + post_formset = ModelAFormSet(payload, queryset=ModelA.objects.all()) + assert post_formset.is_valid(), post_formset.errors + post_formset.save() + updated = ModelB.objects.get() + assert updated.field1 == "b1-updated" + assert updated.field2 == "b2" diff --git a/src/polymorphic/tests/examples/integrations/drf/filter_serializers.py b/src/polymorphic/tests/examples/integrations/drf/filter_serializers.py index c684eb44..fee4e6d8 100644 --- a/src/polymorphic/tests/examples/integrations/drf/filter_serializers.py +++ b/src/polymorphic/tests/examples/integrations/drf/filter_serializers.py @@ -30,9 +30,7 @@ class AnnotatorPolymorphicSerializer(PolymorphicSerializer): class AnnotationSerializer(serializers.ModelSerializer): - annotator = serializers.PrimaryKeyRelatedField( - queryset=Annotator.objects.all() - ) + annotator = serializers.PrimaryKeyRelatedField(queryset=Annotator.objects.all()) class Meta: model = Data diff --git a/src/polymorphic/tests/examples/integrations/drf/filter_views.py b/src/polymorphic/tests/examples/integrations/drf/filter_views.py index 644457ab..61b10e8e 100644 --- a/src/polymorphic/tests/examples/integrations/drf/filter_views.py +++ b/src/polymorphic/tests/examples/integrations/drf/filter_views.py @@ -15,9 +15,7 @@ class Meta: fields = ["annotator"] def filter_by_ai_model(self, queryset, name, value): - return queryset.filter( - annotator__in=AiModelAnnotator.objects.filter(ai_model=value) - ) + return queryset.filter(annotator__in=AiModelAnnotator.objects.filter(ai_model=value)) class AnnotationTrainingViewSet( diff --git a/src/polymorphic/tests/examples/integrations/drf/models/filters.py b/src/polymorphic/tests/examples/integrations/drf/models/filters.py index 7000a1f2..8f2899c4 100644 --- a/src/polymorphic/tests/examples/integrations/drf/models/filters.py +++ b/src/polymorphic/tests/examples/integrations/drf/models/filters.py @@ -12,9 +12,7 @@ class Annotator(PolymorphicModel): class UserAnnotator(Annotator): - user = models.ForeignKey( - get_user_model(), on_delete=models.PROTECT, default=None - ) + user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT, default=None) class AiModelAnnotator(Annotator): diff --git a/src/polymorphic/tests/examples/integrations/drf/test.py b/src/polymorphic/tests/examples/integrations/drf/test.py index 1c6cc6a7..8700a4fd 100644 --- a/src/polymorphic/tests/examples/integrations/drf/test.py +++ b/src/polymorphic/tests/examples/integrations/drf/test.py @@ -75,19 +75,11 @@ class TestPolymorphicSerializer(PolymorphicSerializer): serializer = TestPolymorphicSerializer() # The instance should be used directly without re-instantiation - assert ( - serializer.model_serializer_mapping[BlogBase] - is blog_base_serializer_instance - ) + assert serializer.model_serializer_mapping[BlogBase] is blog_base_serializer_instance # The callable should be instantiated - assert isinstance( - serializer.model_serializer_mapping[BlogOne], BlogOneSerializer - ) - assert ( - serializer.model_serializer_mapping[BlogOne] - is not BlogOneSerializer - ) + assert isinstance(serializer.model_serializer_mapping[BlogOne], BlogOneSerializer) + assert serializer.model_serializer_mapping[BlogOne] is not BlogOneSerializer # Both should be in resource_type_model_mapping assert serializer.resource_type_model_mapping["BlogBase"] == BlogBase @@ -95,9 +87,7 @@ class TestPolymorphicSerializer(PolymorphicSerializer): # Now test that serialization actually works with the non-callable serializer base_instance = BlogBase.objects.create(name="base", slug="base-slug") - one_instance = BlogOne.objects.create( - name="one", slug="one-slug", info="info" - ) + one_instance = BlogOne.objects.create(name="one", slug="one-slug", info="info") # Serialize BlogBase (using the pre-instantiated serializer) base_serializer = TestPolymorphicSerializer(base_instance) @@ -199,9 +189,7 @@ def test_partial_update(self): instance = BlogBase.objects.create(name="blog", slug="blog") data = {"name": "new-blog", "resourcetype": "BlogBase"} - serializer = BlogPolymorphicSerializer( - instance, data=data, partial=True - ) + serializer = BlogPolymorphicSerializer(instance, data=data, partial=True) assert serializer.is_valid() serializer.save() @@ -212,9 +200,7 @@ def test_partial_update_without_resourcetype(self): instance = BlogBase.objects.create(name="blog", slug="blog") data = {"name": "new-blog"} - serializer = BlogPolymorphicSerializer( - instance, data=data, partial=True - ) + serializer = BlogPolymorphicSerializer(instance, data=data, partial=True) assert serializer.is_valid() serializer.save() @@ -274,9 +260,7 @@ def test_to_internal_value_with_partial_update(self): instance = BlogBase.objects.create(name="blog", slug="blog") data = {"name": "new-blog"} - serializer = BlogPolymorphicSerializer( - instance, data=data, partial=True - ) + serializer = BlogPolymorphicSerializer(instance, data=data, partial=True) internal_value = serializer.to_internal_value(data) assert internal_value["name"] == "new-blog" @@ -326,9 +310,7 @@ def test_get_serializer_from_resource_type_keyerror_propagation(self): serializer._get_serializer_from_resource_type("InvalidResourceType") assert "resourcetype" in excinfo.value.detail - assert "Invalid resourcetype" in str( - excinfo.value.detail["resourcetype"] - ) + assert "Invalid resourcetype" in str(excinfo.value.detail["resourcetype"]) def test_validate_method_modifications_are_preserved(self): """Test that modifications made in child serializer's validate() method are preserved.""" @@ -413,13 +395,9 @@ def art_project(self): @pytest.fixture def research_project(self): - return ResearchProject.objects.create( - topic="Research", supervisor="Dr. Smith" - ) + return ResearchProject.objects.create(topic="Research", supervisor="Dr. Smith") - def test_list_projects( - self, client, base_project, art_project, research_project - ): + def test_list_projects(self, client, base_project, art_project, research_project): response = client.get("/examples/integrations/drf/projects/") assert response.status_code == 200 assert len(response.data) == 3 @@ -428,17 +406,13 @@ def test_list_projects( assert topics == {"General Project", "Art", "Research"} def test_retrieve_base_project(self, client, base_project): - response = client.get( - f"/examples/integrations/drf/projects/{base_project.pk}/" - ) + response = client.get(f"/examples/integrations/drf/projects/{base_project.pk}/") assert response.status_code == 200 assert response.data["topic"] == "General Project" assert response.data["resourcetype"] == "Project" def test_retrieve_art_project(self, client, art_project): - response = client.get( - f"/examples/integrations/drf/projects/{art_project.pk}/" - ) + response = client.get(f"/examples/integrations/drf/projects/{art_project.pk}/") assert response.status_code == 200 assert response.data["topic"] == "Art" assert response.data["artist"] == "Picasso" @@ -446,9 +420,7 @@ def test_retrieve_art_project(self, client, art_project): assert "url" in response.data def test_retrieve_research_project(self, client, research_project): - response = client.get( - f"/examples/integrations/drf/projects/{research_project.pk}/" - ) + response = client.get(f"/examples/integrations/drf/projects/{research_project.pk}/") assert response.status_code == 200 assert response.data["topic"] == "Research" assert response.data["supervisor"] == "Dr. Smith" @@ -456,9 +428,7 @@ def test_retrieve_research_project(self, client, research_project): def test_create_base_project(self, client): data = {"topic": "New Project", "resourcetype": "Project"} - response = client.post( - "/examples/integrations/drf/projects/", data, format="json" - ) + response = client.post("/examples/integrations/drf/projects/", data, format="json") assert response.status_code == 201 assert response.data["topic"] == "New Project" assert response.data["resourcetype"] == "Project" @@ -474,9 +444,7 @@ def test_create_art_project(self, client): "artist": "Michelangelo", "resourcetype": "ArtProject", } - response = client.post( - "/examples/integrations/drf/projects/", data, format="json" - ) + response = client.post("/examples/integrations/drf/projects/", data, format="json") assert response.status_code == 201 assert response.data["topic"] == "Sculpture" assert response.data["artist"] == "Michelangelo" @@ -494,9 +462,7 @@ def test_create_research_project(self, client): "supervisor": "Dr. Johnson", "resourcetype": "ResearchProject", } - response = client.post( - "/examples/integrations/drf/projects/", data, format="json" - ) + response = client.post("/examples/integrations/drf/projects/", data, format="json") assert response.status_code == 201 assert response.data["topic"] == "AI Research" assert response.data["supervisor"] == "Dr. Johnson" @@ -552,18 +518,14 @@ def test_partial_update_research_project(self, client, research_project): def test_delete_project(self, client, base_project): project_id = base_project.pk - response = client.delete( - f"/examples/integrations/drf/projects/{project_id}/" - ) + response = client.delete(f"/examples/integrations/drf/projects/{project_id}/") assert response.status_code == 204 assert not Project.objects.filter(pk=project_id).exists() def test_create_with_invalid_resourcetype(self, client): data = {"topic": "Test", "resourcetype": "InvalidType"} - response = client.post( - "/examples/integrations/drf/projects/", data, format="json" - ) + response = client.post("/examples/integrations/drf/projects/", data, format="json") assert response.status_code == 400 @@ -576,9 +538,7 @@ def client(self): @pytest.fixture def user(self, django_user_model): - return django_user_model.objects.create_user( - username="testuser", password="testpass" - ) + return django_user_model.objects.create_user(username="testuser", password="testpass") @pytest.fixture def user_annotator(self, user): @@ -596,9 +556,7 @@ def ai_annotator_gpt4(self): def ai_annotator_claude(self): from .models import AiModelAnnotator - return AiModelAnnotator.objects.create( - ai_model="claude-3", version="2.0" - ) + return AiModelAnnotator.objects.create(ai_model="claude-3", version="2.0") @pytest.fixture def data_by_user(self, user_annotator): @@ -618,17 +576,13 @@ def data_by_claude(self, ai_annotator_claude): return Data.objects.create(annotator=ai_annotator_claude) - def test_list_all_annotations( - self, client, data_by_user, data_by_gpt4, data_by_claude - ): + def test_list_all_annotations(self, client, data_by_user, data_by_gpt4, data_by_claude): """Test listing all annotation data without filters.""" response = client.get("/examples/integrations/drf/annotations/") assert response.status_code == 200 assert len(response.data) == 3 - def test_filter_by_annotator( - self, client, data_by_user, data_by_gpt4, ai_annotator_gpt4 - ): + def test_filter_by_annotator(self, client, data_by_user, data_by_gpt4, ai_annotator_gpt4): """Test filtering by annotator ID.""" response = client.get( f"/examples/integrations/drf/annotations/?annotator={ai_annotator_gpt4.pk}" @@ -637,14 +591,10 @@ def test_filter_by_annotator( assert len(response.data) == 1 assert response.data[0]["id"] == data_by_gpt4.pk - def test_filter_by_ai_model( - self, client, data_by_user, data_by_gpt4, data_by_claude - ): + def test_filter_by_ai_model(self, client, data_by_user, data_by_gpt4, data_by_claude): """Test filtering by annotator__ai_model field (issue #520).""" # This is the key test - filtering by a field on the polymorphic child model - response = client.get( - "/examples/integrations/drf/annotations/?annotator__ai_model=gpt-4" - ) + response = client.get("/examples/integrations/drf/annotations/?annotator__ai_model=gpt-4") assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]["id"] == data_by_gpt4.pk @@ -676,9 +626,7 @@ def test_filter_excludes_non_ai_annotators( """Test that filtering by ai_model excludes UserAnnotator instances.""" # When filtering by annotator__ai_model, only AiModelAnnotator results should be returned # UserAnnotator doesn't have ai_model field, so data_by_user should not appear - response = client.get( - "/examples/integrations/drf/annotations/?annotator__ai_model=gpt-4" - ) + response = client.get("/examples/integrations/drf/annotations/?annotator__ai_model=gpt-4") assert response.status_code == 200 assert len(response.data) == 1 # Verify the user-annotated data is not in results @@ -686,20 +634,14 @@ def test_filter_excludes_non_ai_annotators( def test_retrieve_annotation(self, client, data_by_gpt4): """Test retrieving a single annotation.""" - response = client.get( - f"/examples/integrations/drf/annotations/{data_by_gpt4.pk}/" - ) + response = client.get(f"/examples/integrations/drf/annotations/{data_by_gpt4.pk}/") assert response.status_code == 200 assert response.data["id"] == data_by_gpt4.pk - def test_create_annotation_with_user_annotator( - self, client, user_annotator - ): + def test_create_annotation_with_user_annotator(self, client, user_annotator): """Test creating annotation data with a UserAnnotator.""" data = {"annotator": user_annotator.pk} - response = client.post( - "/examples/integrations/drf/annotations/", data, format="json" - ) + response = client.post("/examples/integrations/drf/annotations/", data, format="json") assert response.status_code == 201 assert response.data["annotator"] == user_annotator.pk @@ -709,14 +651,10 @@ def test_create_annotation_with_user_annotator( created = Data.objects.first() assert created.annotator.pk == user_annotator.pk - def test_create_annotation_with_ai_annotator( - self, client, ai_annotator_gpt4 - ): + def test_create_annotation_with_ai_annotator(self, client, ai_annotator_gpt4): """Test creating annotation data with an AiModelAnnotator.""" data = {"annotator": ai_annotator_gpt4.pk} - response = client.post( - "/examples/integrations/drf/annotations/", data, format="json" - ) + response = client.post("/examples/integrations/drf/annotations/", data, format="json") assert response.status_code == 201 assert response.data["annotator"] == ai_annotator_gpt4.pk diff --git a/src/polymorphic/tests/examples/integrations/extra_views/test.py b/src/polymorphic/tests/examples/integrations/extra_views/test.py index 1ec19495..9d62255f 100644 --- a/src/polymorphic/tests/examples/integrations/extra_views/test.py +++ b/src/polymorphic/tests/examples/integrations/extra_views/test.py @@ -114,18 +114,14 @@ def test_formset_saving_new_objects(self): "form-MAX_NUM_FORMS": "1000", # BlogPost "form-0-polymorphic_ctype": str( - ContentType.objects.get_for_model( - BlogPost, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(BlogPost, for_concrete_model=False).pk ), "form-0-title": "New Blog Post", "form-0-content": "Blog post content", "form-0-author": "Blog Author", # NewsArticle "form-1-polymorphic_ctype": str( - ContentType.objects.get_for_model( - NewsArticle, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(NewsArticle, for_concrete_model=False).pk ), "form-1-title": "New News Article", "form-1-content": "News article content", @@ -173,9 +169,7 @@ def test_formset_updating_objects(self): "form-MAX_NUM_FORMS": "1000", "form-0-id": str(blog_post.pk), "form-0-polymorphic_ctype": str( - ContentType.objects.get_for_model( - BlogPost, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(BlogPost, for_concrete_model=False).pk ), "form-0-title": "Updated Blog", "form-0-content": "Updated content", @@ -198,9 +192,7 @@ def test_formset_updating_objects(self): def test_formset_deleting_objects(self): """Test deleting polymorphic objects through formset.""" # Create object to delete - blog_post = BlogPost.objects.create( - title="To Delete", content="Content", author="Author" - ) + blog_post = BlogPost.objects.create(title="To Delete", content="Content", author="Author") from .views import ArticleFormSetView @@ -215,9 +207,7 @@ def test_formset_deleting_objects(self): "form-MAX_NUM_FORMS": "1000", "form-0-id": str(blog_post.pk), "form-0-polymorphic_ctype": str( - ContentType.objects.get_for_model( - BlogPost, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(BlogPost, for_concrete_model=False).pk ), "form-0-title": blog_post.title, "form-0-content": blog_post.content, @@ -265,9 +255,7 @@ def test_formset_mixed_operations(self): # Update existing blog post "form-0-id": str(blog_post.pk), "form-0-polymorphic_ctype": str( - ContentType.objects.get_for_model( - BlogPost, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(BlogPost, for_concrete_model=False).pk ), "form-0-title": "Updated Existing Blog", "form-0-content": "Updated content", @@ -275,9 +263,7 @@ def test_formset_mixed_operations(self): # Delete news article "form-1-id": str(news_to_delete.pk), "form-1-polymorphic_ctype": str( - ContentType.objects.get_for_model( - NewsArticle, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(NewsArticle, for_concrete_model=False).pk ), "form-1-title": news_to_delete.title, "form-1-content": news_to_delete.content, @@ -285,9 +271,7 @@ def test_formset_mixed_operations(self): "form-1-DELETE": "on", # Create new blog post "form-2-polymorphic_ctype": str( - ContentType.objects.get_for_model( - BlogPost, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(BlogPost, for_concrete_model=False).pk ), "form-2-title": "New Blog Post", "form-2-content": "New content", @@ -303,9 +287,7 @@ def test_formset_mixed_operations(self): self.assertEqual(blog_post.title, "Updated Existing Blog") # Verify deletion - self.assertFalse( - NewsArticle.objects.filter(pk=news_to_delete.pk).exists() - ) + self.assertFalse(NewsArticle.objects.filter(pk=news_to_delete.pk).exists()) # Verify creation new_blog = BlogPost.objects.get(title="New Blog Post") @@ -324,12 +306,8 @@ def test_formset_view_get_request(self): from .views import ArticleFormSetView # Create some existing objects - BlogPost.objects.create( - title="Existing Blog", content="Content", author="Author" - ) - NewsArticle.objects.create( - title="Existing News", content="Content", source="Source" - ) + BlogPost.objects.create(title="Existing Blog", content="Content", author="Author") + NewsArticle.objects.create(title="Existing News", content="Content", source="Source") view = ArticleFormSetView.as_view() from django.test import RequestFactory @@ -359,9 +337,7 @@ def test_formset_view_post_request(self): "form-MIN_NUM_FORMS": "0", "form-MAX_NUM_FORMS": "1000", "form-0-polymorphic_ctype": str( - ContentType.objects.get_for_model( - BlogPost, for_concrete_model=False - ).pk + ContentType.objects.get_for_model(BlogPost, for_concrete_model=False).pk ), "form-0-title": "New Blog Post", "form-0-content": "Blog content", @@ -419,12 +395,8 @@ def test_formset_view_renders_existing_objects(self): # Verify existing objects are shown in the formset # The formset should have inputs for the existing objects - expect( - self.page.locator(f"input[value='{blog_post.title}']") - ).to_be_visible() - expect( - self.page.locator(f"input[value='{news_article.title}']") - ).to_be_visible() + expect(self.page.locator(f"input[value='{blog_post.title}']")).to_be_visible() + expect(self.page.locator(f"input[value='{news_article.title}']")).to_be_visible() def test_formset_update_existing_object(self): """Test updating an existing object through the formset.""" @@ -441,16 +413,12 @@ def test_formset_update_existing_object(self): # Find the title input for this blog post # The form should have a hidden id field and visible title field - title_input = self.page.locator( - f"input[value='{blog_post.title}']" - ).first + title_input = self.page.locator(f"input[value='{blog_post.title}']").first title_input.fill("Updated Title") # Find and update the author field # The author field should be in the same form container - author_input = self.page.locator( - f"input[value='{blog_post.author}']" - ).first + author_input = self.page.locator(f"input[value='{blog_post.author}']").first author_input.fill("Updated Author") # Submit the form @@ -478,9 +446,7 @@ def test_formset_delete_existing_object(self): # Find the DELETE checkbox for this object # Django formsets add a DELETE checkbox for each form when can_delete=True - delete_checkbox = self.page.locator( - "input[type='checkbox'][name*='DELETE']" - ).first + delete_checkbox = self.page.locator("input[type='checkbox'][name*='DELETE']").first # Check if the checkbox is visible, if not we need to handle it differently if delete_checkbox.is_visible(): @@ -514,14 +480,10 @@ def test_formset_displays_multiple_polymorphic_types(self): # Verify both types are displayed # Blog post should have author field - expect( - self.page.locator(f"input[value='{blog_post.author}']") - ).to_be_visible() + expect(self.page.locator(f"input[value='{blog_post.author}']")).to_be_visible() # News article should have source field - expect( - self.page.locator(f"input[value='{news_article.source}']") - ).to_be_visible() + expect(self.page.locator(f"input[value='{news_article.source}']")).to_be_visible() # Verify the formset contains both forms # Each form should have an id field @@ -548,15 +510,11 @@ def test_formset_mixed_operations_through_ui(self): self.page.goto(url) # Update the blog post - title_input = self.page.locator( - f"input[value='{blog_to_update.title}']" - ).first + title_input = self.page.locator(f"input[value='{blog_to_update.title}']").first title_input.fill("Updated Blog Title") # Try to delete the news article - delete_checkboxes = self.page.locator( - "input[type='checkbox'][name*='DELETE']" - ).all() + delete_checkboxes = self.page.locator("input[type='checkbox'][name*='DELETE']").all() if delete_checkboxes: # Check the second DELETE checkbox (for the news article) if len(delete_checkboxes) > 1: @@ -572,9 +530,7 @@ def test_formset_mixed_operations_through_ui(self): # Verify the news article was deleted (if DELETE checkbox was available) if delete_checkboxes and len(delete_checkboxes) > 1: - self.assertFalse( - NewsArticle.objects.filter(id=news_to_delete_id).exists() - ) + self.assertFalse(NewsArticle.objects.filter(id=news_to_delete_id).exists()) def test_formset_empty_state_shows_extra_forms(self): """Test that the formset shows extra forms for adding new objects.""" @@ -593,12 +549,8 @@ def test_formset_empty_state_shows_extra_forms(self): expect(form).to_be_visible() # The formset should have management form fields (they're hidden) - self.assertIsNotNone( - self.page.locator("input[name='form-TOTAL_FORMS']").first - ) - self.assertIsNotNone( - self.page.locator("input[name='form-INITIAL_FORMS']").first - ) + self.assertIsNotNone(self.page.locator("input[name='form-TOTAL_FORMS']").first) + self.assertIsNotNone(self.page.locator("input[name='form-INITIAL_FORMS']").first) # Verify we have 2 extra forms (one for each child type) formset_forms = self.page.locator(".formset-form").all() @@ -625,16 +577,12 @@ def test_formset_create_new_objects_via_extra_forms(self): # Fill in the BlogPost form (form-0) self.page.locator("input[name='form-0-title']").fill("New Blog from UI") - self.page.locator("textarea[name='form-0-content']").fill( - "Blog content from UI" - ) + self.page.locator("textarea[name='form-0-content']").fill("Blog content from UI") self.page.locator("input[name='form-0-author']").fill("UI Author") # Fill in the NewsArticle form (form-1) self.page.locator("input[name='form-1-title']").fill("New News from UI") - self.page.locator("textarea[name='form-1-content']").fill( - "News content from UI" - ) + self.page.locator("textarea[name='form-1-content']").fill("News content from UI") self.page.locator("input[name='form-1-source']").fill("UI Source") # Submit the form diff --git a/src/polymorphic/tests/examples/integrations/guardian/test.py b/src/polymorphic/tests/examples/integrations/guardian/test.py index 389f89c3..933586d5 100644 --- a/src/polymorphic/tests/examples/integrations/guardian/test.py +++ b/src/polymorphic/tests/examples/integrations/guardian/test.py @@ -35,9 +35,7 @@ class GuardianIntegrationTests(TestCase): def setUp(self): """Create test objects.""" - self.user = User.objects.create_user( - username="testuser", password="testpass" - ) + self.user = User.objects.create_user(username="testuser", password="testpass") self.blog_post = BlogPost.objects.create( title="Test Blog", content="Blog content", author="Blog Author" ) @@ -60,9 +58,7 @@ def test_get_polymorphic_base_content_type_for_child_model(self): blog_ctype = get_polymorphic_base_content_type(self.blog_post) # Should return Article content type, not BlogPost - article_ctype = ContentType.objects.get_for_model( - Article, for_concrete_model=False - ) + article_ctype = ContentType.objects.get_for_model(Article, for_concrete_model=False) self.assertEqual(blog_ctype, article_ctype) self.assertNotEqual(blog_ctype.model, "blogpost") self.assertEqual(blog_ctype.model, "article") @@ -80,14 +76,10 @@ def test_get_polymorphic_base_content_type_for_different_child_models( def test_get_polymorphic_base_content_type_for_base_model(self): """Test that base polymorphic models return their own content type.""" - article = Article.objects.create( - title="Plain Article", content="Plain content" - ) + article = Article.objects.create(title="Plain Article", content="Plain content") article_ctype = get_polymorphic_base_content_type(article) - expected_ctype = ContentType.objects.get_for_model( - Article, for_concrete_model=False - ) + expected_ctype = ContentType.objects.get_for_model(Article, for_concrete_model=False) self.assertEqual(article_ctype, expected_ctype) self.assertEqual(article_ctype.model, "article") @@ -105,9 +97,7 @@ def test_get_polymorphic_base_content_type_with_model_class(self): # Test with a model class instead of instance blog_class_ctype = get_polymorphic_base_content_type(BlogPost) - article_ctype = ContentType.objects.get_for_model( - Article, for_concrete_model=False - ) + article_ctype = ContentType.objects.get_for_model(Article, for_concrete_model=False) self.assertEqual(blog_class_ctype, article_ctype) def test_content_type_consistency_across_inheritance_chain(self): @@ -117,9 +107,7 @@ def test_content_type_consistency_across_inheritance_chain(self): news_ctype = get_polymorphic_base_content_type(self.news_article) # All should point to the same base Article type - article_ctype = ContentType.objects.get_for_model( - Article, for_concrete_model=False - ) + article_ctype = ContentType.objects.get_for_model(Article, for_concrete_model=False) self.assertEqual(blog_ctype, article_ctype) self.assertEqual(news_ctype, article_ctype) @@ -141,9 +129,7 @@ def test_get_polymorphic_base_content_type_returns_content_type_object( self, ): """Test that the function returns a ContentType instance.""" - self.assertIsInstance( - get_polymorphic_base_content_type(self.blog_post), ContentType - ) + self.assertIsInstance(get_polymorphic_base_content_type(self.blog_post), ContentType) def test_guardian_permissions_use_base_model_namespace(self): """ @@ -174,9 +160,7 @@ def test_guardian_permissions_use_base_model_namespace(self): # The critical assertion: permission should use Article content type, # NOT BlogPost content type - article_ctype = ContentType.objects.get_for_model( - Article, for_concrete_model=False - ) + article_ctype = ContentType.objects.get_for_model(Article, for_concrete_model=False) self.assertEqual( perm.content_type, article_ctype, diff --git a/src/polymorphic/tests/examples/integrations/reversion/test.py b/src/polymorphic/tests/examples/integrations/reversion/test.py index 7c7af23f..df8f0096 100644 --- a/src/polymorphic/tests/examples/integrations/reversion/test.py +++ b/src/polymorphic/tests/examples/integrations/reversion/test.py @@ -141,9 +141,7 @@ def test_revert_to_previous_version(self): # Revert to first version versions = Version.objects.get_for_object(blog_post) - first_version = versions[ - 2 - ] # Versions are in reverse chronological order + first_version = versions[2] # Versions are in reverse chronological order first_version.revision.revert() # Verify reverted state @@ -192,18 +190,14 @@ def test_manual_reversion_workflow(self): ) middle_version = versions[1] - self.assertEqual( - middle_version.field_dict["title"], "Updated Manual Test Post" - ) + self.assertEqual(middle_version.field_dict["title"], "Updated Manual Test Post") self.assertEqual( middle_version.field_dict["content"], "First update to content.", ) original_version = versions[2] - self.assertEqual( - original_version.field_dict["title"], "Manual Test Post" - ) + self.assertEqual(original_version.field_dict["title"], "Manual Test Post") self.assertEqual(original_version.field_dict["author"], "Test Author") # Test reverting to middle version manually @@ -211,9 +205,7 @@ def test_manual_reversion_workflow(self): blog_post.refresh_from_db() self.assertEqual(blog_post.title, "Updated Manual Test Post") self.assertEqual(blog_post.content, "First update to content.") - self.assertEqual( - blog_post.author, "Test Author" - ) # Should be from original + self.assertEqual(blog_post.author, "Test Author") # Should be from original # Test accessing revision metadata revision = middle_version.revision @@ -270,12 +262,8 @@ def test_manual_batch_reversion(self): """Test reverting multiple polymorphic objects in a single revision.""" # Create multiple objects in one revision with revisions.create_revision(): - blog1 = BlogPost.objects.create( - title="Blog 1", content="Content 1", author="Author 1" - ) - blog2 = BlogPost.objects.create( - title="Blog 2", content="Content 2", author="Author 2" - ) + blog1 = BlogPost.objects.create(title="Blog 1", content="Content 1", author="Author 1") + blog2 = BlogPost.objects.create(title="Blog 2", content="Content 2", author="Author 2") news = NewsArticle.objects.create( title="News 1", content="News content", source="Source 1" ) @@ -368,26 +356,18 @@ def test_blogpost_admin_reversion(self): versions = Version.objects.get_for_object(blog_post) self.assertEqual(versions.count(), 2) latest_version = versions[0] - self.assertEqual( - latest_version.field_dict["title"], "Updated Admin Test Post" - ) - self.assertEqual( - latest_version.field_dict["author"], "Updated Admin Author" - ) + self.assertEqual(latest_version.field_dict["title"], "Updated Admin Test Post") + self.assertEqual(latest_version.field_dict["author"], "Updated Admin Author") # Navigate to history page and verify it's accessible history_url = f"{self.live_server_url}{reverse('admin:integrations_blogpost_history', args=[blog_post.pk])}" self.page.goto(history_url) # Verify we can see the history page - expect(self.page.locator("#content h1")).to_contain_text( - "Change history" - ) + expect(self.page.locator("#content h1")).to_contain_text("Change history") # Verify history table shows version information - history_table = self.page.locator( - "table#change-history, div#change-history" - ) + history_table = self.page.locator("table#change-history, div#change-history") expect(history_table).to_be_visible() # Use the UI to revert: Click on the oldest version's date/time link @@ -464,17 +444,13 @@ def test_article_admin_reversion(self): # Verify we now have 2 versions (1 from API, 1 from admin) versions = Version.objects.get_for_object(article) self.assertEqual(versions.count(), 2) - self.assertEqual( - versions[0].field_dict["title"], "Updated Parent Article" - ) + self.assertEqual(versions[0].field_dict["title"], "Updated Parent Article") self.assertEqual(versions[1].field_dict["title"], "Parent Article Test") # Navigate to history page through parent admin history_url = f"{self.live_server_url}{reverse('admin:integrations_article_history', args=[article.pk])}" self.page.goto(history_url) - expect(self.page.locator("#content h1")).to_contain_text( - "Change history" - ) + expect(self.page.locator("#content h1")).to_contain_text("Change history") # Use the UI to revert: Click on the oldest version history_links = self.page.locator("table#change-history a").all() @@ -540,17 +516,13 @@ def test_newsarticle_admin_reversion(self): # Verify we have 2 versions from admin operations versions = Version.objects.get_for_object(news) self.assertEqual(versions.count(), 2) - self.assertEqual( - versions[0].field_dict["title"], "Updated Breaking News" - ) + self.assertEqual(versions[0].field_dict["title"], "Updated Breaking News") self.assertEqual(versions[1].field_dict["title"], "Breaking Admin News") # Verify history page is accessible history_url = f"{self.live_server_url}{reverse('admin:integrations_newsarticle_history', args=[news.pk])}" self.page.goto(history_url) - expect(self.page.locator("#content h1")).to_contain_text( - "Change history" - ) + expect(self.page.locator("#content h1")).to_contain_text("Change history") # Use the UI to revert: Click on the oldest version history_links = self.page.locator("table#change-history a").all() diff --git a/src/polymorphic/tests/examples/managers/__init__.py b/src/polymorphic/tests/examples/managers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/managers/apps.py b/src/polymorphic/tests/examples/managers/apps.py new file mode 100644 index 00000000..27c2e22e --- /dev/null +++ b/src/polymorphic/tests/examples/managers/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManagersExamplesConfig(AppConfig): + name = "polymorphic.tests.examples.managers" + label = "example_managers" diff --git a/src/polymorphic/tests/examples/managers/migrations/0001_initial.py b/src/polymorphic/tests/examples/managers/migrations/0001_initial.py new file mode 100644 index 00000000..517df9b6 --- /dev/null +++ b/src/polymorphic/tests/examples/managers/migrations/0001_initial.py @@ -0,0 +1,47 @@ +from django.db import migrations, models +import django.db.models.deletion +import polymorphic.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [("contenttypes", "0002_remove_content_type_name")] + + operations = [ + migrations.CreateModel( + name="MyModel", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("topic", models.CharField(max_length=40)), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_managers.mymodel_set+", to="contenttypes.contenttype")), + ], + bases=(polymorphic.models.PolymorphicModel,), + ), + migrations.CreateModel( + name="MyOtherModel", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("topic", models.CharField(max_length=40)), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_managers.myothermodel_set+", to="contenttypes.contenttype")), + ], + bases=(polymorphic.models.PolymorphicModel,), + ), + migrations.CreateModel( + name="Project", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("start_date", models.DateTimeField()), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_managers.project_set+", to="contenttypes.contenttype")), + ], + bases=(polymorphic.models.PolymorphicModel,), + ), + migrations.CreateModel( + name="ArtProject", + fields=[ + ("project_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_managers.project")), + ("artist", models.CharField(max_length=30)), + ], + bases=("example_managers.project",), + ), + ] diff --git a/src/polymorphic/tests/examples/managers/migrations/0002_alter_mymodel_managers_alter_myothermodel_managers_and_more.py b/src/polymorphic/tests/examples/managers/migrations/0002_alter_mymodel_managers_alter_myothermodel_managers_and_more.py new file mode 100644 index 00000000..4782f579 --- /dev/null +++ b/src/polymorphic/tests/examples/managers/migrations/0002_alter_mymodel_managers_alter_myothermodel_managers_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.4 on 2026-05-09 05:41 + +import django.db.models.deletion +import django.db.models.manager +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('example_managers', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='mymodel', + managers=[ + ('my_objects', django.db.models.manager.Manager()), + ], + ), + migrations.AlterModelManagers( + name='myothermodel', + managers=[ + ('my_objects', django.db.models.manager.Manager()), + ], + ), + migrations.AlterField( + model_name='mymodel', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='myothermodel', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='project', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + ] diff --git a/src/polymorphic/tests/examples/managers/migrations/__init__.py b/src/polymorphic/tests/examples/managers/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/managers/models.py b/src/polymorphic/tests/examples/managers/models.py new file mode 100644 index 00000000..a49caaa0 --- /dev/null +++ b/src/polymorphic/tests/examples/managers/models.py @@ -0,0 +1,37 @@ +from django.db import models +from polymorphic.managers import PolymorphicManager +from polymorphic.models import PolymorphicModel +from polymorphic.query import PolymorphicQuerySet + + +class TimeOrderedManager(PolymorphicManager): + def get_queryset(self): + return super().get_queryset().order_by("-start_date") + + def most_recent(self): + return self.get_queryset()[:10] + + +class Project(PolymorphicModel): + objects = PolymorphicManager() + objects_ordered = TimeOrderedManager() + start_date = models.DateTimeField() + + +class ArtProject(Project): + artist = models.CharField(max_length=30) + + +class MyQuerySet(PolymorphicQuerySet): + def started_after(self, dt): + return self.filter(start_date__gt=dt) + + +class MyModel(PolymorphicModel): + my_objects = PolymorphicManager.from_queryset(MyQuerySet)() + topic = models.CharField(max_length=40) + + +class MyOtherModel(PolymorphicModel): + my_objects = MyQuerySet.as_manager() + topic = models.CharField(max_length=40) diff --git a/src/polymorphic/tests/examples/managers/tests.py b/src/polymorphic/tests/examples/managers/tests.py new file mode 100644 index 00000000..e5c0981e --- /dev/null +++ b/src/polymorphic/tests/examples/managers/tests.py @@ -0,0 +1,23 @@ +from datetime import datetime, timedelta + +from django.test import TestCase + +from .models import ArtProject, MyModel, MyOtherModel, Project + + +class ManagerExamplesTests(TestCase): + def test_custom_manager_and_inheritance(self): + now = datetime(2026, 1, 1, 8, 0, 0) + Project.objects.create(start_date=now - timedelta(days=1)) + art = ArtProject.objects.create(start_date=now, artist="A") + + ordered = list(ArtProject.objects_ordered.all()) + assert ordered == [art] + assert list(Project.objects_ordered.most_recent())[0] == art + + def test_custom_queryset_manager_patterns(self): + MyModel.my_objects.create(topic="a") + MyOtherModel.my_objects.create(topic="b") + + assert MyModel.my_objects.count() == 1 + assert MyOtherModel.my_objects.count() == 1 diff --git a/src/polymorphic/tests/examples/quickstart/__init__.py b/src/polymorphic/tests/examples/quickstart/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/quickstart/apps.py b/src/polymorphic/tests/examples/quickstart/apps.py new file mode 100644 index 00000000..e0594516 --- /dev/null +++ b/src/polymorphic/tests/examples/quickstart/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class QuickstartExamplesConfig(AppConfig): + name = "polymorphic.tests.examples.quickstart" + label = "example_quickstart" diff --git a/src/polymorphic/tests/examples/quickstart/migrations/0001_initial.py b/src/polymorphic/tests/examples/quickstart/migrations/0001_initial.py new file mode 100644 index 00000000..bd4984fb --- /dev/null +++ b/src/polymorphic/tests/examples/quickstart/migrations/0001_initial.py @@ -0,0 +1,42 @@ +from django.db import migrations, models +import django.db.models.deletion +import polymorphic.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="Project", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("topic", models.CharField(max_length=30)), + ("polymorphic_ctype", models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="polymorphic_example_quickstart.project_set+", to="contenttypes.contenttype")), + ], + options={}, + bases=(polymorphic.models.PolymorphicModel,), + ), + migrations.CreateModel( + name="ArtProject", + fields=[ + ("project_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_quickstart.project")), + ("artist", models.CharField(max_length=30)), + ], + options={}, + bases=("example_quickstart.project",), + ), + migrations.CreateModel( + name="ResearchProject", + fields=[ + ("project_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="example_quickstart.project")), + ("supervisor", models.CharField(max_length=30)), + ], + options={}, + bases=("example_quickstart.project",), + ), + ] diff --git a/src/polymorphic/tests/examples/quickstart/migrations/0002_alter_project_polymorphic_ctype.py b/src/polymorphic/tests/examples/quickstart/migrations/0002_alter_project_polymorphic_ctype.py new file mode 100644 index 00000000..4da41222 --- /dev/null +++ b/src/polymorphic/tests/examples/quickstart/migrations/0002_alter_project_polymorphic_ctype.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.4 on 2026-05-09 05:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('example_quickstart', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + ] diff --git a/src/polymorphic/tests/examples/quickstart/migrations/__init__.py b/src/polymorphic/tests/examples/quickstart/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/quickstart/models.py b/src/polymorphic/tests/examples/quickstart/models.py new file mode 100644 index 00000000..6d6b1378 --- /dev/null +++ b/src/polymorphic/tests/examples/quickstart/models.py @@ -0,0 +1,14 @@ +from django.db import models +from polymorphic.models import PolymorphicModel + + +class Project(PolymorphicModel): + topic = models.CharField(max_length=30) + + +class ArtProject(Project): + artist = models.CharField(max_length=30) + + +class ResearchProject(Project): + supervisor = models.CharField(max_length=30) diff --git a/src/polymorphic/tests/examples/quickstart/tests.py b/src/polymorphic/tests/examples/quickstart/tests.py new file mode 100644 index 00000000..8e6b7e31 --- /dev/null +++ b/src/polymorphic/tests/examples/quickstart/tests.py @@ -0,0 +1,30 @@ +from django.db.models import Q +from django.test import TestCase + +from .models import ArtProject, Project, ResearchProject + + +class QuickstartExamplesTests(TestCase): + def test_create_objects_and_polymorphic_queries(self): + Project.objects.create(topic="Department Party") + ArtProject.objects.create(topic="Painting with Tim", artist="T. Turner") + ResearchProject.objects.create(topic="Swallow Aerodynamics", supervisor="Dr. Winter") + ResearchProject.objects.create(topic="Color Use in Late Cubism", supervisor="T. Turner") + + projects = list(Project.objects.all()) + assert len(projects) == 4 + assert isinstance(projects[0], Project) + assert isinstance(projects[1], ArtProject) + assert isinstance(projects[2], ResearchProject) + + assert list(Project.objects.instance_of(ArtProject)) == [projects[1]] + + combined = Project.objects.instance_of(ArtProject) | Project.objects.instance_of( + ResearchProject + ) + assert combined.count() == 3 + + filtered = Project.objects.filter( + Q(ArtProject___artist="T. Turner") | Q(ResearchProject___supervisor="T. Turner") + ) + assert filtered.count() == 2 diff --git a/src/polymorphic/tests/examples/type_hints/fk/test.py b/src/polymorphic/tests/examples/type_hints/fk/test.py index 1d3757b7..a71388dd 100644 --- a/src/polymorphic/tests/examples/type_hints/fk/test.py +++ b/src/polymorphic/tests/examples/type_hints/fk/test.py @@ -20,22 +20,14 @@ def test_type_hints(self): from django.db.models.fields.related import ForeignKey from django.db.models.fields.reverse_related import ManyToOneRel - assert_type( - RelatedModel.parents_reverse.field, ForeignKey[t.Any, t.Any] - ) + assert_type(RelatedModel.parents_reverse.field, ForeignKey[t.Any, t.Any]) assert_type(RelatedModel.parents_reverse.rel, ManyToOneRel) - _1: t.Optional[ParentModel | Child1 | Child2] = ( - related.parents_reverse.first() - ) + _1: t.Optional[ParentModel | Child1 | Child2] = related.parents_reverse.first() assert _1 == parent - _2: t.Optional[ParentModel | Child1 | Child2] = ( - related.parents_reverse.filter().first() - ) + _2: t.Optional[ParentModel | Child1 | Child2] = related.parents_reverse.filter().first() assert _2 == parent - _3: t.Optional[ParentModel] = ( - related.parents_reverse.non_polymorphic().last() - ) + _3: t.Optional[ParentModel] = related.parents_reverse.non_polymorphic().last() assert _3 == ParentModel.objects.non_polymorphic().get(pk=child2.pk) diff --git a/src/polymorphic/tests/examples/type_hints/m2m/models.py b/src/polymorphic/tests/examples/type_hints/m2m/models.py index f9655f7b..1c6336b2 100644 --- a/src/polymorphic/tests/examples/type_hints/m2m/models.py +++ b/src/polymorphic/tests/examples/type_hints/m2m/models.py @@ -15,13 +15,9 @@ class ParentModel(PolymorphicModel): "RelatedModel", related_name="to_related_reverse", through="PolyThrough" ) - parents: PolymorphicReverseManyToOneDescriptor[ - ParentModel | Child1 | Child2, ParentModel - ] + parents: PolymorphicReverseManyToOneDescriptor[ParentModel | Child1 | Child2, ParentModel] - objects: ClassVar[ - PolymorphicManager[ParentModel | Child1 | Child2, ParentModel] - ] + objects: ClassVar[PolymorphicManager[ParentModel | Child1 | Child2, ParentModel]] class Child1(ParentModel): @@ -33,17 +29,15 @@ class Child2(Child1): class PolyThrough(PolymorphicModel): - parent: PolymorphicForwardManyToOneDescriptor[ - ParentModel | Child1 | Child2, ParentModel - ] = models.ForeignKey( # type: ignore[assignment] - ParentModel, on_delete=models.CASCADE, related_name="parents" + parent: PolymorphicForwardManyToOneDescriptor[ParentModel | Child1 | Child2, ParentModel] = ( + models.ForeignKey( # type: ignore[assignment] + ParentModel, on_delete=models.CASCADE, related_name="parents" + ) ) related = models.ForeignKey("RelatedModel", on_delete=models.CASCADE) - objects: ClassVar[ - PolymorphicManager[PolyThrough | ThroughChild, PolyThrough] - ] + objects: ClassVar[PolymorphicManager[PolyThrough | ThroughChild, PolyThrough]] class ThroughChild(PolyThrough): diff --git a/src/polymorphic/tests/examples/type_hints/m2m/test.py b/src/polymorphic/tests/examples/type_hints/m2m/test.py index 7f5edbc2..e8d37aae 100644 --- a/src/polymorphic/tests/examples/type_hints/m2m/test.py +++ b/src/polymorphic/tests/examples/type_hints/m2m/test.py @@ -42,24 +42,16 @@ def test_type_hints(self): through1: type[PolyThrough] = parent.to_related.through assert through1.objects.count() == 3 - tlist1: t.List[PolyThrough | ThroughChild] = list( - parent.to_related.through.objects.all() - ) + tlist1: t.List[PolyThrough | ThroughChild] = list(parent.to_related.through.objects.all()) assert len(tlist1) == 3 - _1: t.List[PolyThrough] = list( - parent.to_related.through.objects.non_polymorphic() - ) + _1: t.List[PolyThrough] = list(parent.to_related.through.objects.non_polymorphic()) assert len(_1) == 3 - _2: t.List[ParentModel | Child1 | Child2] = list( - related1.to_related_reverse.all() - ) + _2: t.List[ParentModel | Child1 | Child2] = list(related1.to_related_reverse.all()) assert set(_2) == {child1, child2} - _3: t.List[ParentModel | Child1 | Child2] = list( - related1.to_parents.all() - ) + _3: t.List[ParentModel | Child1 | Child2] = list(related1.to_parents.all()) assert set(_3) == {parent} @@ -74,16 +66,10 @@ def test_type_hints(self): PolyThrough.objects.last(), } - _4: t.List[ParentModel] = list( - related1.to_related_reverse.non_polymorphic() - ) + _4: t.List[ParentModel] = list(related1.to_related_reverse.non_polymorphic()) assert set(_4) == set( - ParentModel.objects.non_polymorphic().filter( - pk__in=[child1.pk, child2.pk] - ) + ParentModel.objects.non_polymorphic().filter(pk__in=[child1.pk, child2.pk]) ) - _5: t.List[ParentModel] = list( - related1.to_parents.all().non_polymorphic() - ) + _5: t.List[ParentModel] = list(related1.to_parents.all().non_polymorphic()) assert set(_5) == {parent} diff --git a/src/polymorphic/tests/examples/type_hints/managers/test.py b/src/polymorphic/tests/examples/type_hints/managers/test.py index 499abd29..86d8a994 100644 --- a/src/polymorphic/tests/examples/type_hints/managers/test.py +++ b/src/polymorphic/tests/examples/type_hints/managers/test.py @@ -10,14 +10,10 @@ def test_type_hints(self): child1 = Child1.objects.create() child2 = Child2.objects.create() - _1: t.Optional[ParentModel | Child1 | Child2] = ( - ParentModel.objects.first() - ) + _1: t.Optional[ParentModel | Child1 | Child2] = ParentModel.objects.first() assert _1 == parent or _1 == child1 or _1 == child2 assert _1 == parent - _2: t.Optional[ParentModel | Child1 | Child2] = ( - ParentModel.objects.order_by("pk").first() - ) + _2: t.Optional[ParentModel | Child1 | Child2] = ParentModel.objects.order_by("pk").first() assert _2 == parent _3: t.Optional[Child1 | Child2] = Child1.objects.first() assert _3 == child1 or _3 == child2 @@ -35,9 +31,7 @@ def test_type_hints(self): # mypy has trouble with these - they work on pyright/pylance. I consider this # a failing of mypy type inference not a deficiency in our typing - for now # we can ignore the errors - _7: t.Optional[ParentModel] = ( - ParentModel.objects.non_polymorphic().first() # type: ignore[assignment] - ) + _7: t.Optional[ParentModel] = ParentModel.objects.non_polymorphic().first() # type: ignore[assignment] assert _7 == parent _8: t.Optional[ParentModel] = ( ParentModel.objects.all().non_polymorphic().first() # type: ignore[assignment] @@ -51,33 +45,19 @@ def test_type_hints(self): assert _10 == child1 _11: t.Optional[Child2] = Child2.objects.non_polymorphic().first() assert _11 == child2 - _12: Child2 = ( - Child2.objects.filter().non_polymorphic().get(pk=child2.pk) - ) + _12: Child2 = Child2.objects.filter().non_polymorphic().get(pk=child2.pk) assert _12 == child2 - _13: t.Optional[ParentModel] = ParentModel.objects.instance_of( - ParentModel - ).first() + _13: t.Optional[ParentModel] = ParentModel.objects.instance_of(ParentModel).first() assert _13 == parent - _14: t.Optional[ParentModel] = ( - ParentModel.objects.all().instance_of(ParentModel).first() - ) + _14: t.Optional[ParentModel] = ParentModel.objects.all().instance_of(ParentModel).first() assert _14 == parent - _15: t.Optional[Child1] = ParentModel.objects.instance_of( - Child1 - ).first() + _15: t.Optional[Child1] = ParentModel.objects.instance_of(Child1).first() assert _15 == child1 - _16: t.Optional[Child1] = ( - ParentModel.objects.all().instance_of(Child1).first() - ) + _16: t.Optional[Child1] = ParentModel.objects.all().instance_of(Child1).first() assert _16 == child1 - _17: t.Optional[Child2] = ParentModel.objects.instance_of( - Child2 - ).first() + _17: t.Optional[Child2] = ParentModel.objects.instance_of(Child2).first() assert _17 == child2 - _18: t.Optional[Child2] = ( - ParentModel.objects.all().instance_of(Child2).first() - ) + _18: t.Optional[Child2] = ParentModel.objects.all().instance_of(Child2).first() assert _18 == child2 diff --git a/src/polymorphic/tests/examples/type_hints/one2one/test.py b/src/polymorphic/tests/examples/type_hints/one2one/test.py index 34d4d660..59a94845 100644 --- a/src/polymorphic/tests/examples/type_hints/one2one/test.py +++ b/src/polymorphic/tests/examples/type_hints/one2one/test.py @@ -16,9 +16,7 @@ def test_type_hints(self): parent = ParentModel.objects.create(related_forward=related1) child1 = Child1.objects.create(related_forward=related2) Child2.objects.create(related_forward=related3) - assert_type( - related1.parent_forward, t.Optional[ParentModel | Child1 | Child2] - ) + assert_type(related1.parent_forward, t.Optional[ParentModel | Child1 | Child2]) assert_type(related1.parent_reverse, ParentModel | Child1 | Child2) related1.parent_forward = child1 diff --git a/src/polymorphic/tests/examples/views/test.py b/src/polymorphic/tests/examples/views/test.py index 91f39815..70fb0fa0 100644 --- a/src/polymorphic/tests/examples/views/test.py +++ b/src/polymorphic/tests/examples/views/test.py @@ -20,9 +20,7 @@ def test_view_example(self): research_project_label = ResearchProject._meta.label # Verify radio buttons for both types exist - art_radio = self.page.locator( - f"input[type='radio'][value='{art_project_label}']" - ) + art_radio = self.page.locator(f"input[type='radio'][value='{art_project_label}']") research_radio = self.page.locator( f"input[type='radio'][value='{research_project_label}']" ) @@ -35,7 +33,9 @@ def test_view_example(self): self.page.click("button[type='submit']") # Should redirect to the create view with model parameter - create_url_pattern = f"{self.live_server_url}{reverse('project-create')}?model={art_project_label}" + create_url_pattern = ( + f"{self.live_server_url}{reverse('project-create')}?model={art_project_label}" + ) expect(self.page).to_have_url(create_url_pattern) # Step 3: Fill in the ArtProject form @@ -48,9 +48,7 @@ def test_view_example(self): self.page.click("button[type='submit']") # Verify the object was created - art_project = ArtProject.objects.filter( - topic="Modern Art", artist="Picasso" - ).first() + art_project = ArtProject.objects.filter(topic="Modern Art", artist="Picasso").first() assert art_project is not None, "ArtProject was not created" assert art_project.topic == "Modern Art" assert art_project.artist == "Picasso" @@ -64,7 +62,9 @@ def test_view_example(self): self.page.click("button[type='submit']") # Verify redirect to create view - create_url_pattern = f"{self.live_server_url}{reverse('project-create')}?model={research_project_label}" + create_url_pattern = ( + f"{self.live_server_url}{reverse('project-create')}?model={research_project_label}" + ) expect(self.page).to_have_url(create_url_pattern) # Fill in the ResearchProject form diff --git a/src/polymorphic/tests/settings.py b/src/polymorphic/tests/settings.py index a4a8ef38..048273f8 100644 --- a/src/polymorphic/tests/settings.py +++ b/src/polymorphic/tests/settings.py @@ -105,6 +105,11 @@ } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" INSTALLED_APPS = [ + "polymorphic.tests.examples.quickstart.apps.QuickstartExamplesConfig", + "polymorphic.tests.examples.admin.apps.AdminExamplesConfig", + "polymorphic.tests.examples.managers.apps.ManagersExamplesConfig", + "polymorphic.tests.examples.formsets.apps.FormsetsExamplesConfig", + "polymorphic.tests.examples.advanced.apps.AdvancedExamplesConfig", "polymorphic.tests.examples.type_hints.managers", "polymorphic.tests.examples.type_hints.one2one", "polymorphic.tests.examples.type_hints.m2m",