├── docs ├── _static │ ├── .gitkeep │ └── style.css ├── _ext │ └── djangodummy │ │ ├── __init__.py │ │ ├── requirements.txt │ │ └── settings.py ├── api │ ├── polymorphic.utils.rst │ ├── polymorphic.query.rst │ ├── polymorphic.templatetags.rst │ ├── polymorphic.showfields.rst │ ├── polymorphic.models.rst │ ├── polymorphic.contrib.guardian.rst │ ├── polymorphic.contrib.extra_views.rst │ ├── polymorphic.deletion.rst │ ├── polymorphic.managers.rst │ ├── index.rst │ ├── polymorphic.formsets.rst │ └── polymorphic.admin.rst ├── formsets.rst ├── deletion.rst ├── migrating.rst ├── performance.rst ├── quickstart.rst ├── index.rst ├── managers.rst └── integrations.rst ├── example ├── example │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ └── settings.py ├── orders │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── admin.py │ └── models.py ├── pexp │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── polymorphic_create_test_data.py │ │ │ ├── pcmd.py │ │ │ ├── polybench.py │ │ │ └── p2cmd.py │ ├── migrations │ │ └── __init__.py │ ├── dumpdata_test_correct_output.txt │ ├── admin.py │ └── models.py └── manage.py ├── src └── polymorphic │ ├── contrib │ ├── __init__.py │ ├── guardian.py │ └── extra_views.py │ ├── tests │ ├── deletion │ │ ├── __init__.py │ │ └── migrations │ │ │ └── __init__.py │ ├── errata │ │ ├── __init__.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── models.py │ │ └── test_errata.py │ ├── migrations │ │ └── __init__.py │ ├── __init__.py │ ├── test_migrations │ │ ├── __init__.py │ │ ├── migrations │ │ │ └── __init__.py │ │ └── models.py │ ├── debug.py │ ├── urls.py │ ├── test_inheritance.py │ ├── test_contrib.py │ ├── admin.py │ ├── test_utils.py │ ├── utils.py │ ├── test_query_translate.py │ ├── settings.py │ └── test_regression.py │ ├── templates │ └── admin │ │ └── polymorphic │ │ ├── change_form.html │ │ ├── object_history.html │ │ ├── delete_confirmation.html │ │ ├── add_type_form.html │ │ └── edit_inline │ │ └── stacked.html │ ├── formsets │ ├── utils.py │ ├── __init__.py │ └── generic.py │ ├── admin │ ├── forms.py │ ├── filters.py │ ├── __init__.py │ ├── generic.py │ └── helpers.py │ ├── locale │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ └── django.po │ ├── static │ └── polymorphic │ │ └── css │ │ └── polymorphic_inlines.css │ ├── apps.py │ ├── __init__.py │ ├── templatetags │ ├── polymorphic_admin_tags.py │ ├── polymorphic_formset_tags.py │ └── __init__.py │ ├── deletion.py │ ├── utils.py │ ├── managers.py │ ├── showfields.py │ └── base.py ├── manage.py ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── tox.ini ├── AUTHORS.md ├── LICENSE ├── conftest.py ├── .github └── workflows │ ├── lint.yml │ └── release.yml ├── pyproject.toml ├── README.md ├── .gitignore ├── CONTRIBUTING.md └── justfile /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/orders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/pexp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_ext/djangodummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/orders/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/pexp/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/pexp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/deletion/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/errata/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/pexp/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/__init__.py: -------------------------------------------------------------------------------- 1 | HEADLESS = True 2 | -------------------------------------------------------------------------------- /src/polymorphic/tests/deletion/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/errata/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_migrations/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/polymorphic/tests/debug.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | DEBUG = True 4 | -------------------------------------------------------------------------------- /docs/api/polymorphic.utils.rst: -------------------------------------------------------------------------------- 1 | polymorphic.utils 2 | ================= 3 | 4 | .. automodule:: polymorphic.utils 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/polymorphic.query.rst: -------------------------------------------------------------------------------- 1 | polymorphic.query 2 | ================= 3 | 4 | .. automodule:: polymorphic.query 5 | :members: 6 | :show-inheritance: 7 | 8 | -------------------------------------------------------------------------------- /src/polymorphic/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /docs/api/polymorphic.templatetags.rst: -------------------------------------------------------------------------------- 1 | polymorphic.templatetags.polymorphic_admin_tags 2 | =============================================== 3 | 4 | .. automodule:: polymorphic.templatetags 5 | -------------------------------------------------------------------------------- /docs/api/polymorphic.showfields.rst: -------------------------------------------------------------------------------- 1 | polymorphic.showfields 2 | ====================== 3 | 4 | .. automodule:: polymorphic.showfields 5 | :members: 6 | :show-inheritance: 7 | :inherited-members: -------------------------------------------------------------------------------- /src/polymorphic/tests/errata/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class BadModel(models.Model): 5 | instance_of = models.CharField(max_length=100) 6 | not_instance_of = models.IntegerField() 7 | -------------------------------------------------------------------------------- /docs/_ext/djangodummy/requirements.txt: -------------------------------------------------------------------------------- 1 | # for readthedocs 2 | # Remaining requirements are picked up from setup.py 3 | Django>=4.2.20 4 | django-extra-views>=0.14.0 5 | sphinxcontrib-django>=2.5 6 | sphinx_rtd_theme>=2.0.0 7 | -------------------------------------------------------------------------------- /docs/api/polymorphic.models.rst: -------------------------------------------------------------------------------- 1 | polymorphic.models 2 | ================== 3 | 4 | .. automodule:: polymorphic.models 5 | 6 | .. autoclass:: polymorphic.models.PolymorphicModel 7 | :members: 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/api/polymorphic.contrib.guardian.rst: -------------------------------------------------------------------------------- 1 | polymorphic.contrib.guardian 2 | ============================ 3 | 4 | .. automodule:: polymorphic.contrib.guardian 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/polymorphic.contrib.extra_views.rst: -------------------------------------------------------------------------------- 1 | polymorphic.contrib.extra_views 2 | =============================== 3 | 4 | .. automodule:: polymorphic.contrib.extra_views 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /src/polymorphic/templates/admin/polymorphic/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load polymorphic_admin_tags %} 3 | 4 | {% block breadcrumbs %} 5 | {% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /src/polymorphic/templates/admin/polymorphic/object_history.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/object_history.html" %} 2 | {% load polymorphic_admin_tags %} 3 | 4 | {% block breadcrumbs %} 5 | {% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /src/polymorphic/templates/admin/polymorphic/delete_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/delete_confirmation.html" %} 2 | {% load polymorphic_admin_tags %} 3 | 4 | {% block breadcrumbs %} 5 | {% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /src/polymorphic/formsets/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internal utils 3 | """ 4 | 5 | 6 | def add_media(dest, media): 7 | """ 8 | Optimized version of django.forms.Media.__add__() that doesn't create new objects. 9 | """ 10 | dest._css_lists.extend(media._css_lists) 11 | dest._js_lists.extend(media._js_lists) 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # This helps pytest-django locate the project. 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polymorphic.tests.settings") 7 | from django.core.management import execute_from_command_line 8 | 9 | execute_from_command_line(sys.argv) 10 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, reverse_lazy 3 | from django.views.generic import RedirectView 4 | 5 | admin.autodiscover() 6 | 7 | urlpatterns = [ 8 | path("admin/", admin.site.urls), 9 | path("", RedirectView.as_view(url=reverse_lazy("admin:index"), permanent=False)), 10 | ] 11 | -------------------------------------------------------------------------------- /docs/api/polymorphic.deletion.rst: -------------------------------------------------------------------------------- 1 | polymorphic.models 2 | ================== 3 | 4 | .. automodule:: polymorphic.deletion 5 | 6 | .. autoclass:: polymorphic.deletion.PolymorphicGuard 7 | :members: __call__ 8 | :show-inheritance: 9 | 10 | .. autoclass:: polymorphic.deletion.PolymorphicGuardSerializer 11 | :members: 12 | :show-inheritance: 13 | -------------------------------------------------------------------------------- /src/polymorphic/templates/admin/polymorphic/add_type_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | 3 | {% if save_on_top %} 4 | {% block submit_buttons_top %} 5 | {% include 'admin/submit_line.html' with show_save=1 %} 6 | {% endblock %} 7 | {% endif %} 8 | 9 | {% block submit_buttons_bottom %} 10 | {% include 'admin/submit_line.html' with show_save=1 %} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | # Don't run these in pre-commit.ci at all 3 | skip: [lint, format] 4 | 5 | repos: 6 | - repo: local 7 | hooks: 8 | - id: lint 9 | name: Lint 10 | entry: just lint 11 | language: system 12 | pass_filenames: false 13 | 14 | - id: format 15 | name: Format 16 | entry: just format 17 | language: system 18 | pass_filenames: false 19 | -------------------------------------------------------------------------------- /docs/_ext/djangodummy/settings.py: -------------------------------------------------------------------------------- 1 | # Settings file to allow parsing API documentation of Django modules, 2 | # and provide defaults to use in the documentation. 3 | # 4 | # This file is placed in a subdirectory, 5 | # so the docs root won't be detected by find_packages() 6 | 7 | # Display sane URLs in the docs: 8 | STATIC_URL = "/static/" 9 | 10 | # Avoid error for missing the secret key 11 | SECRET_KEY = "docs" 12 | 13 | INSTALLED_APPS = ["django.contrib.contenttypes"] 14 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | # Import polymorphic from this folder. 9 | SRC_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 10 | sys.path.insert(0, SRC_ROOT) 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_inheritance.py: -------------------------------------------------------------------------------- 1 | from polymorphic.tests.models import Foo, Bar, Baz 2 | from polymorphic.managers import PolymorphicManager 3 | from django.test import TestCase 4 | 5 | 6 | class InheritanceTests(TestCase): 7 | def test_mixin_inherited_managers(self): 8 | self.assertIsInstance(Foo._base_manager, PolymorphicManager) 9 | self.assertIsInstance(Bar._base_manager, PolymorphicManager) 10 | self.assertIsInstance(Baz._base_manager, PolymorphicManager) 11 | -------------------------------------------------------------------------------- /docs/api/polymorphic.managers.rst: -------------------------------------------------------------------------------- 1 | polymorphic.managers 2 | ==================== 3 | 4 | .. automodule:: polymorphic.managers 5 | 6 | 7 | The ``PolymorphicManager`` class 8 | -------------------------------- 9 | 10 | 11 | .. autoclass:: polymorphic.managers.PolymorphicManager 12 | :members: 13 | :show-inheritance: 14 | 15 | 16 | The ``PolymorphicQuerySet`` class 17 | --------------------------------- 18 | 19 | .. autoclass:: polymorphic.managers.PolymorphicQuerySet 20 | :members: 21 | :show-inheritance: 22 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. _base: 5 | 6 | .. automodule:: polymorphic 7 | :members: 8 | :show-inheritance: 9 | :inherited-members: 10 | 11 | .. toctree:: 12 | 13 | polymorphic.admin 14 | polymorphic.contrib.extra_views 15 | polymorphic.contrib.guardian 16 | polymorphic.formsets 17 | polymorphic.managers 18 | polymorphic.models 19 | polymorphic.deletion 20 | polymorphic.query 21 | polymorphic.showfields 22 | polymorphic.templatetags 23 | polymorphic.utils 24 | -------------------------------------------------------------------------------- /example/pexp/management/commands/polymorphic_create_test_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a scratchpad for general development, testing & debugging 3 | """ 4 | 5 | from django.core.management import BaseCommand 6 | from pexp.models import * 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "" 11 | 12 | def handle_noargs(self, **options): 13 | Project.objects.all().delete() 14 | o = Project.objects.create(topic="John's gathering") 15 | o = ArtProject.objects.create(topic="Sculpting with Tim", artist="T. Turner") 16 | o = ResearchProject.objects.create(topic="Swallow Aerodynamics", supervisor="Dr. Winter") 17 | print(Project.objects.all()) 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.12" 13 | apt_packages: 14 | - gettext 15 | jobs: 16 | post_install: 17 | - pip install uv 18 | - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy 19 | 20 | # Build documentation in the docs/ directory with Sphinx 21 | sphinx: 22 | configuration: docs/conf.py 23 | 24 | # Optionally build your docs in additional formats such as PDF and ePub 25 | formats: 26 | - pdf 27 | -------------------------------------------------------------------------------- /src/polymorphic/admin/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.admin.widgets import AdminRadioSelect 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class PolymorphicModelChoiceForm(forms.Form): 7 | """ 8 | The default form for the ``add_type_form``. Can be overwritten and replaced. 9 | """ 10 | 11 | #: Define the label for the radiofield 12 | type_label = _("Type") 13 | 14 | ct_id = forms.ChoiceField( 15 | label=type_label, widget=AdminRadioSelect(attrs={"class": "radiolist"}) 16 | ) 17 | 18 | def __init__(self, *args, **kwargs): 19 | # Allow to easily redefine the label (a commonly expected usecase) 20 | super().__init__(*args, **kwargs) 21 | self.fields["ct_id"].label = self.type_label 22 | -------------------------------------------------------------------------------- /src/polymorphic/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-11-29 18:12+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: admin.py:41 21 | msgid "Type" 22 | msgstr "" 23 | 24 | #: admin.py:56 25 | msgid "Content type" 26 | msgstr "" 27 | 28 | 29 | #: admin.py:403 30 | msgid "Contents" 31 | msgstr "" 32 | -------------------------------------------------------------------------------- /src/polymorphic/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Gonzalo Bustos, 2015. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2013-11-29 18:12+0100\n" 11 | "PO-Revision-Date: 2015-10-12 11:42-0300\n" 12 | "Last-Translator: Gonzalo Bustos\n" 13 | "Language-Team: Spanish \n" 14 | "Language: es\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "X-Generator: Poedit 1.6.10\n" 19 | 20 | #: admin.py:41 21 | msgid "Type" 22 | msgstr "Tipo" 23 | 24 | #: admin.py:56 25 | msgid "Content type" 26 | msgstr "Tipo de contenido" 27 | 28 | #: admin.py:333 admin.py:403 29 | #, python-format 30 | msgid "Contents" 31 | msgstr "Contenidos" 32 | -------------------------------------------------------------------------------- /example/pexp/dumpdata_test_correct_output.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "pexp.project", 5 | "fields": { 6 | "topic": "John's gathering", 7 | "polymorphic_ctype": 2 8 | } 9 | }, 10 | { 11 | "pk": 2, 12 | "model": "pexp.project", 13 | "fields": { 14 | "topic": "Sculpting with Tim", 15 | "polymorphic_ctype": 3 16 | } 17 | }, 18 | { 19 | "pk": 3, 20 | "model": "pexp.project", 21 | "fields": { 22 | "topic": "Swallow Aerodynamics", 23 | "polymorphic_ctype": 4 24 | } 25 | }, 26 | { 27 | "pk": 2, 28 | "model": "pexp.artproject", 29 | "fields": { 30 | "artist": "T. Turner" 31 | } 32 | }, 33 | { 34 | "pk": 3, 35 | "model": "pexp.researchproject", 36 | "fields": { 37 | "supervisor": "Dr. Winter" 38 | } 39 | } 40 | ] 41 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from polymorphic.contrib.guardian import get_polymorphic_base_content_type 4 | from polymorphic.tests.models import Model2D, PlainC 5 | 6 | 7 | class ContribTests(TestCase): 8 | """ 9 | The test suite 10 | """ 11 | 12 | def test_contrib_guardian(self): 13 | # Regular Django inheritance should return the child model content type. 14 | obj = PlainC() 15 | ctype = get_polymorphic_base_content_type(obj) 16 | assert ctype.name == "plain c" 17 | 18 | ctype = get_polymorphic_base_content_type(PlainC) 19 | assert ctype.name == "plain c" 20 | 21 | # Polymorphic inheritance should return the parent model content type. 22 | obj = Model2D() 23 | ctype = get_polymorphic_base_content_type(obj) 24 | assert ctype.name == "model2a" 25 | 26 | ctype = get_polymorphic_base_content_type(Model2D) 27 | assert ctype.name == "model2a" 28 | -------------------------------------------------------------------------------- /src/polymorphic/static/polymorphic/css/polymorphic_inlines.css: -------------------------------------------------------------------------------- 1 | .polymorphic-add-choice { 2 | position: relative; 3 | clear: left; 4 | } 5 | 6 | .polymorphic-add-choice a:focus { 7 | text-decoration: none; 8 | } 9 | 10 | .polymorphic-type-menu { 11 | position: absolute; 12 | top: 2.2em; 13 | left: 0.5em; 14 | border: 1px solid var(--border-color, #ccc); 15 | border-radius: 4px; 16 | padding: 2px; 17 | background-color: var(--body-bg, #fff); 18 | z-index: 1000; 19 | } 20 | 21 | .polymorphic-type-menu ul { 22 | padding: 2px; 23 | margin: 0; 24 | } 25 | 26 | .polymorphic-type-menu li { 27 | list-style: none inside none; 28 | padding: 4px 8px; 29 | } 30 | 31 | .inline-related.empty-form { 32 | /* needed for grapelli, which uses grp-empty-form */ 33 | display: none; 34 | } 35 | 36 | @media (prefers-color-scheme: dark) { 37 | .polymorphic-type-menu { 38 | border: 1px solid var(--border-color, #121212); 39 | background-color: var(--body-bg, #212121); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/polymorphic/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-11-29 18:12+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: admin.py:41 22 | msgid "Type" 23 | msgstr "Type" 24 | 25 | #: admin.py:56 26 | msgid "Content type" 27 | msgstr "Type de contenu" 28 | 29 | # This is already translated in Django 30 | # #: admin.py:333 31 | # #, python-format 32 | # msgid "Add %s" 33 | # msgstr "" 34 | 35 | #: admin.py:403 36 | msgid "Contents" 37 | msgstr "Contenus" 38 | -------------------------------------------------------------------------------- /example/pexp/management/commands/pcmd.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a scratchpad for general development, testing & debugging. 3 | """ 4 | 5 | from django.core.management.base import NoArgsCommand 6 | from django.db import connection 7 | from pexp.models import * 8 | 9 | 10 | def reset_queries(): 11 | connection.queries = [] 12 | 13 | 14 | class Command(NoArgsCommand): 15 | help = "" 16 | 17 | def handle_noargs(self, **options): 18 | Project.objects.all().delete() 19 | a = Project.objects.create(topic="John's gathering") 20 | b = ArtProject.objects.create(topic="Sculpting with Tim", artist="T. Turner") 21 | c = ResearchProject.objects.create(topic="Swallow Aerodynamics", supervisor="Dr. Winter") 22 | print(Project.objects.all()) 23 | print("") 24 | 25 | TestModelA.objects.all().delete() 26 | a = TestModelA.objects.create(field1="A1") 27 | b = TestModelB.objects.create(field1="B1", field2="B2") 28 | c = TestModelC.objects.create(field1="C1", field2="C2", field3="C3") 29 | print(TestModelA.objects.all()) 30 | print("") 31 | -------------------------------------------------------------------------------- /docs/api/polymorphic.formsets.rst: -------------------------------------------------------------------------------- 1 | polymorphic.formsets 2 | ==================== 3 | 4 | .. automodule:: polymorphic.formsets 5 | 6 | 7 | Model formsets 8 | -------------- 9 | 10 | .. autofunction:: polymorphic.formsets.polymorphic_modelformset_factory 11 | 12 | .. autoclass:: polymorphic.formsets.PolymorphicFormSetChild 13 | 14 | 15 | Inline formsets 16 | --------------- 17 | 18 | .. autofunction:: polymorphic.formsets.polymorphic_inlineformset_factory 19 | 20 | 21 | Generic formsets 22 | ---------------- 23 | 24 | .. autofunction:: polymorphic.formsets.generic_polymorphic_inlineformset_factory 25 | 26 | 27 | Low-level features 28 | ------------------ 29 | 30 | The internal machinery can be used to extend the formset classes. This includes: 31 | 32 | .. autofunction:: polymorphic.formsets.polymorphic_child_forms_factory 33 | 34 | .. autoclass:: polymorphic.formsets.BasePolymorphicModelFormSet 35 | :show-inheritance: 36 | 37 | .. autoclass:: polymorphic.formsets.BasePolymorphicInlineFormSet 38 | :show-inheritance: 39 | 40 | .. autoclass:: polymorphic.formsets.BaseGenericPolymorphicInlineFormSet 41 | :show-inheritance: 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{310,311,312}-django{42} 4 | py{310,311,312,313}-django{51} 5 | py{310,311,312,313}-django{52} 6 | py{312,313,314}-django{60} 7 | # TODO: reinstate running on postgres: py310-django{42}-postgres 8 | docs 9 | 10 | [testenv] 11 | setenv = 12 | PYTHONWARNINGS = all 13 | postgres: DEFAULT_DATABASE = postgres:///default 14 | postgres: SECONDARY_DATABASE = postgres:///secondary 15 | deps = 16 | pytest 17 | pytest-cov 18 | pytest-django 19 | dj-database-url 20 | django42: Django ~= 4.2 21 | django51: Django ~= 5.1 22 | django52: Django ~= 5.2 23 | django60: Django == 6.0rc1 24 | djangomain: https://github.com/django/django/archive/main.tar.gz 25 | postgres: psycopg2 26 | commands = 27 | pytest --cov --cov-report=term-missing --cov-report=xml . 28 | 29 | [testenv:docs] 30 | deps = 31 | Sphinx 32 | sphinx_rtd_theme 33 | -r{toxinidir}/docs/_ext/djangodummy/requirements.txt 34 | changedir = docs 35 | commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 36 | 37 | [gh-actions] 38 | python = 39 | 3.10: py310 40 | 3.11: py311 41 | 3.12: py312 42 | 3.13: py313 43 | 3.14: py314 44 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | 17 | import os 18 | 19 | # This application object is used by any WSGI server configured to use this 20 | # file. This includes Django's development server, if the WSGI_APPLICATION 21 | # setting points here. 22 | from django.core.wsgi import get_wsgi_application 23 | 24 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 25 | 26 | 27 | application = get_wsgi_application() 28 | 29 | # Apply WSGI middleware here. 30 | # from helloworld.wsgi import HelloWorldApplication 31 | # application = HelloWorldApplication(application) 32 | -------------------------------------------------------------------------------- /example/orders/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline 4 | 5 | from .models import BankPayment, CreditCardPayment, Order, Payment, SepaPayment 6 | 7 | 8 | class CreditCardPaymentInline(StackedPolymorphicInline.Child): 9 | model = CreditCardPayment 10 | 11 | 12 | class BankPaymentInline(StackedPolymorphicInline.Child): 13 | model = BankPayment 14 | 15 | 16 | class SepaPaymentInline(StackedPolymorphicInline.Child): 17 | model = SepaPayment 18 | 19 | 20 | class PaymentInline(StackedPolymorphicInline): 21 | """ 22 | An inline for a polymorphic model. 23 | The actual form appearance of each row is determined by 24 | the child inline that corresponds with the actual model type. 25 | """ 26 | 27 | model = Payment 28 | child_inlines = (CreditCardPaymentInline, BankPaymentInline, SepaPaymentInline) 29 | 30 | 31 | @admin.register(Order) 32 | class OrderAdmin(PolymorphicInlineSupportMixin, admin.ModelAdmin): 33 | """ 34 | Admin for orders. 35 | The inline is polymorphic. 36 | To make sure the inlines are properly handled, 37 | the ``PolymorphicInlineSupportMixin`` is needed to 38 | """ 39 | 40 | inlines = (PaymentInline,) 41 | -------------------------------------------------------------------------------- /docs/_static/style.css: -------------------------------------------------------------------------------- 1 | section#api-documentation div.highlight pre { 2 | color: #b30000; 3 | display: block; /* ensures it's treated as a block */ 4 | margin-left: auto; /* auto margins center block elements */ 5 | margin-right: auto; 6 | width: fit-content; 7 | } 8 | body[data-theme="light"] section#api-documentation div.highlight, 9 | body[data-theme="light"] section#api-documentation div.highlight pre { 10 | background-color: #f8f8f8; 11 | } 12 | 13 | body[data-theme="dark"] section#api-documentation div.highlight, 14 | body[data-theme="dark"] section#api-documentation div.highlight pre { 15 | background-color: #202020; 16 | } 17 | 18 | /* AUTO → system prefers DARK (acts like dark unless user forced light) */ 19 | @media (prefers-color-scheme: dark) { 20 | body:not([data-theme="light"]) #api-documentation .highlight, 21 | body:not([data-theme="light"]) #api-documentation .highlight pre { 22 | background-color: #202020; 23 | } 24 | } 25 | 26 | /* AUTO → system prefers LIGHT (acts like light unless user forced dark) */ 27 | @media (prefers-color-scheme: light) { 28 | body:not([data-theme="dark"]) #api-documentation .highlight, 29 | body:not([data-theme="dark"]) #api-documentation .highlight pre { 30 | background-color: #f8f8f8; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/polymorphic/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.core.checks import Error, Tags, register 3 | 4 | 5 | @register(Tags.models) 6 | def check_reserved_field_names(app_configs, **kwargs): 7 | """ 8 | System check that ensures models don't use reserved field names. 9 | """ 10 | errors = [] 11 | 12 | # If app_configs is None, check all installed apps 13 | if app_configs is None: 14 | app_configs = apps.get_app_configs() 15 | 16 | for app_config in app_configs: 17 | for model in app_config.get_models(): 18 | errors.extend(_check_model_reserved_field_names(model)) 19 | 20 | return errors 21 | 22 | 23 | def _check_model_reserved_field_names(model): 24 | from polymorphic.base import POLYMORPHIC_SPECIAL_Q_KWORDS 25 | 26 | errors = [] 27 | 28 | for field in model._meta.get_fields(): 29 | if field.name in POLYMORPHIC_SPECIAL_Q_KWORDS: 30 | errors.append( 31 | Error( 32 | f"Field '{field.name}' on model '{model.__name__}' is a reserved name.", 33 | obj=field, 34 | id="polymorphic.E001", 35 | ) 36 | ) 37 | 38 | return errors 39 | 40 | 41 | class PolymorphicConfig(AppConfig): 42 | name = "polymorphic" 43 | verbose_name = "Django Polymorphic" 44 | -------------------------------------------------------------------------------- /src/polymorphic/admin/filters.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.core.exceptions import PermissionDenied 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class PolymorphicChildModelFilter(admin.SimpleListFilter): 7 | """ 8 | An admin list filter for the PolymorphicParentModelAdmin which enables 9 | filtering by its child models. 10 | 11 | This can be used in the parent admin: 12 | 13 | .. code-block:: python 14 | 15 | list_filter = (PolymorphicChildModelFilter,) 16 | """ 17 | 18 | title = _("Type") 19 | parameter_name = "polymorphic_ctype" 20 | 21 | def lookups(self, request, model_admin): 22 | return model_admin.get_child_type_choices(request, "change") 23 | 24 | def queryset(self, request, queryset): 25 | try: 26 | value = int(self.value()) 27 | except TypeError: 28 | value = None 29 | if value: 30 | # ensure the content type is allowed 31 | for choice_value, _ in self.lookup_choices: # noqa: F402 32 | if choice_value == value: 33 | return queryset.filter(polymorphic_ctype_id=choice_value) 34 | raise PermissionDenied( 35 | f'Invalid ContentType "{value}". It must be registered as child model.' 36 | ) 37 | return queryset 38 | -------------------------------------------------------------------------------- /src/polymorphic/__init__.py: -------------------------------------------------------------------------------- 1 | r""" 2 | :: 3 | 4 | ██████╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ██████╗ 5 | ██╔══██╗ ██║██╔══██╗████╗ ██║██╔════╝ ██╔═══██╗ 6 | ██║ ██║ ██║███████║██╔██╗ ██║██║ ███╗██║ ██║ 7 | ██║ ██║██ ██║██╔══██║██║╚██╗██║██║ ██║██║ ██║ 8 | ██████╔╝╚█████╔╝██║ ██║██║ ╚████║╚██████╔╝╚██████╔╝ 9 | ╚═════╝ ╚════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ 10 | 11 | ██████╗ ██████╗ ██╗ ██╗ ██╗███╗ ███╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗██╗ ██████╗ 12 | ██╔══██╗██╔═══██╗██║ ╚██╗ ██╔╝████╗ ████║██╔═══██╗██╔══██╗██╔══██╗██║ ██║██║██╔════╝ 13 | ██████╔╝██║ ██║██║ ╚████╔╝ ██╔████╔██║██║ ██║██████╔╝██████╔╝███████║██║██║ 14 | ██╔═══╝ ██║ ██║██║ ╚██╔╝ ██║╚██╔╝██║██║ ██║██╔══██╗██╔═══╝ ██╔══██║██║██║ 15 | ██║ ╚██████╔╝███████╗██║ ██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ██║ ██║██║╚██████╗ 16 | ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═════╝ 17 | 18 | 19 | Seamless Polymorphic Inheritance for Django Models 20 | """ 21 | 22 | VERSION = "4.4.1" 23 | 24 | __title__ = "Django Polymorphic" 25 | __version__ = VERSION # version synonym for backwards compatibility 26 | __author__ = "Brian Kohan" 27 | __license__ = "BSD-3-Clause" 28 | __copyright__ = ( 29 | "Copyright 2010-2025, Bert Constantin, Chris Glass, Diederik van der Boor, Brian Kohan" 30 | ) 31 | -------------------------------------------------------------------------------- /src/polymorphic/contrib/guardian.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | 3 | 4 | def get_polymorphic_base_content_type(obj): 5 | """ 6 | Helper function to return the base polymorphic content type id. This should used 7 | with django-guardian and the ``GUARDIAN_GET_CONTENT_TYPE`` option. 8 | 9 | See the django-guardian documentation for more information: 10 | 11 | https://django-guardian.readthedocs.io/en/latest/configuration 12 | """ 13 | if hasattr(obj, "polymorphic_model_marker"): 14 | try: 15 | superclasses = list(obj.__class__.mro()) 16 | except TypeError: 17 | # obj is an object so mro() need to be called with the obj. 18 | superclasses = list(obj.__class__.mro(obj)) 19 | 20 | polymorphic_superclasses = list() 21 | for sclass in superclasses: 22 | if hasattr(sclass, "polymorphic_model_marker"): 23 | polymorphic_superclasses.append(sclass) 24 | 25 | # PolymorphicMPTT adds an additional class between polymorphic and base class. 26 | if hasattr(obj, "can_have_children"): 27 | root_polymorphic_class = polymorphic_superclasses[-3] 28 | else: 29 | root_polymorphic_class = polymorphic_superclasses[-2] 30 | ctype = ContentType.objects.get_for_model(root_polymorphic_class) 31 | 32 | else: 33 | ctype = ContentType.objects.get_for_model(obj) 34 | 35 | return ctype 36 | -------------------------------------------------------------------------------- /src/polymorphic/formsets/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This allows creating formsets where each row can be a different form type. 3 | The logic of the formsets work similar to the standard Django formsets; 4 | there are factory methods to construct the classes with the proper form settings. 5 | 6 | The "parent" formset hosts the entire model and their child model. 7 | For every child type, there is an :class:`PolymorphicFormSetChild` instance 8 | that describes how to display and construct the child. 9 | It's parameters are very similar to the parent's factory method. 10 | """ 11 | 12 | from .generic import ( # Can import generic here, as polymorphic already depends on the 'contenttypes' app. 13 | BaseGenericPolymorphicInlineFormSet, 14 | GenericPolymorphicFormSetChild, 15 | generic_polymorphic_inlineformset_factory, 16 | ) 17 | from .models import ( 18 | BasePolymorphicInlineFormSet, 19 | BasePolymorphicModelFormSet, 20 | PolymorphicFormSetChild, 21 | UnsupportedChildType, 22 | polymorphic_child_forms_factory, 23 | polymorphic_inlineformset_factory, 24 | polymorphic_modelformset_factory, 25 | ) 26 | 27 | __all__ = ( 28 | "BasePolymorphicModelFormSet", 29 | "BasePolymorphicInlineFormSet", 30 | "PolymorphicFormSetChild", 31 | "UnsupportedChildType", 32 | "polymorphic_modelformset_factory", 33 | "polymorphic_inlineformset_factory", 34 | "polymorphic_child_forms_factory", 35 | "BaseGenericPolymorphicInlineFormSet", 36 | "GenericPolymorphicFormSetChild", 37 | "generic_polymorphic_inlineformset_factory", 38 | ) 39 | -------------------------------------------------------------------------------- /src/polymorphic/admin/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ModelAdmin code to display polymorphic models. 3 | 4 | The admin consists of a parent admin (which shows in the admin with a list), 5 | and a child admin (which is used internally to show the edit/delete dialog). 6 | """ 7 | 8 | # Admins for the regular models 9 | from .parentadmin import PolymorphicParentModelAdmin # noqa 10 | from .childadmin import PolymorphicChildModelAdmin 11 | from .filters import PolymorphicChildModelFilter 12 | 13 | # Utils 14 | from .forms import PolymorphicModelChoiceForm 15 | 16 | # Expose generic admin features too. There is no need to split those 17 | # as the admin already relies on contenttypes. 18 | from .generic import GenericPolymorphicInlineModelAdmin # base class 19 | from .generic import GenericStackedPolymorphicInline # stacked inline 20 | 21 | # Helpers for the inlines 22 | from .helpers import PolymorphicInlineSupportMixin # mixin for the regular model admin! 23 | from .helpers import PolymorphicInlineAdminForm, PolymorphicInlineAdminFormSet 24 | 25 | # Inlines 26 | from .inlines import PolymorphicInlineModelAdmin # base class 27 | from .inlines import StackedPolymorphicInline # stacked inline 28 | 29 | __all__ = ( 30 | "PolymorphicParentModelAdmin", 31 | "PolymorphicChildModelAdmin", 32 | "PolymorphicModelChoiceForm", 33 | "PolymorphicChildModelFilter", 34 | "PolymorphicInlineAdminForm", 35 | "PolymorphicInlineAdminFormSet", 36 | "PolymorphicInlineSupportMixin", 37 | "PolymorphicInlineModelAdmin", 38 | "StackedPolymorphicInline", 39 | "GenericPolymorphicInlineModelAdmin", 40 | "GenericStackedPolymorphicInline", 41 | ) 42 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Current Maintainer(s) 2 | 3 | * Brian Kohan 4 | 5 | ## Contributors 6 | 7 | * Abel Daniel 8 | * Adam Chainz 9 | * Adam Wentz 10 | * Adam Donaghy 11 | * Andrew Ingram (contributed setup.py) 12 | * Al Johri 13 | * Alex Alvarez 14 | * Andrew Dodd 15 | * Angel Velasquez 16 | * Austin Matsick 17 | * Bastien Vallet 18 | * Ben Konrath 19 | * Bert Constantin 20 | * Bertrand Bordage 21 | * Chad Shryock 22 | * Charles Leifer (python 2.4 compatibility) 23 | * Chris Barna 24 | * Chris Brantley 25 | * Christopher Glass 26 | * David Sanders 27 | * Emad Rad 28 | * Éric Araujo 29 | * Evan Borgstrom 30 | * Frankie Dintino 31 | * Gavin Wahl 32 | * Germán M. Bravo 33 | * Gonzalo Bustos 34 | * Gregory Avery-Weir 35 | * Hugo Osvaldo Barrera 36 | * Jacob Rief 37 | * James Murty 38 | * Jedediah Smith (proxy models support) 39 | * Jesús Leganés-Combarro (Auto-discover child models and inlines, #582) 40 | * John Furr 41 | * Jonas Haag 42 | * Jonas Obrist 43 | * Julian Wachholz 44 | * Kamil Bar 45 | * Kelsey Gilmore-Innis 46 | * Kevin Armenat 47 | * Krzysztof Gromadzki 48 | * Krzysztof Nazarewski 49 | * Luis Zárate 50 | * Marius Lueck 51 | * Martin Brochhaus 52 | * Martin Maillard 53 | * Michael Fladischer 54 | * Nick Ward 55 | * Oleg Myltsyn 56 | * Omer Strumpf 57 | * Paweł Adamczak 58 | * Petr Dlouhý 59 | * Sander van Leeuwen 60 | * Sobolev Nikita 61 | * Tadas Dailyda 62 | * Tai Lee 63 | * Tomas Peterka 64 | * Tony Narlock 65 | * Vail Gold 66 | 67 | 68 | ## Former authors / maintainers 69 | 70 | * Bert Constantin 2009/2010 (Original author, disappeared from the internet :( ) 71 | * Chris Glass 72 | * Diederik van der Boor 73 | * Charlie Denton 74 | * Jerome Leclanche 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 or later by the individual contributors. 2 | Please see the AUTHORS file. 3 | 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above 14 | copyright notice, this list of conditions and the following 15 | disclaimer in the documentation and/or other materials provided 16 | with the distribution. 17 | 18 | * The names of the contributors may not be used to endorse or 19 | promote products derived from this software without specific 20 | prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 23 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 24 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 25 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 26 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 27 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 28 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 29 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 30 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 31 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 32 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 33 | -------------------------------------------------------------------------------- /example/pexp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from pexp.models import * 3 | 4 | from polymorphic.admin import ( 5 | PolymorphicChildModelAdmin, 6 | PolymorphicChildModelFilter, 7 | PolymorphicParentModelAdmin, 8 | ) 9 | 10 | 11 | class ProjectAdmin(PolymorphicParentModelAdmin): 12 | base_model = Project # Can be set explicitly. 13 | list_filter = (PolymorphicChildModelFilter,) 14 | child_models = (Project, ArtProject, ResearchProject) 15 | 16 | 17 | class ProjectChildAdmin(PolymorphicChildModelAdmin): 18 | base_model = Project # Can be set explicitly. 19 | 20 | # On purpose, only have the shared fields here. 21 | # The fields of the derived model should still be displayed. 22 | base_fieldsets = (("Base fields", {"fields": ("topic",)}),) 23 | 24 | 25 | admin.site.register(Project, ProjectAdmin) 26 | admin.site.register(ArtProject, ProjectChildAdmin) 27 | admin.site.register(ResearchProject, ProjectChildAdmin) 28 | 29 | 30 | class UUIDModelAAdmin(PolymorphicParentModelAdmin): 31 | list_filter = (PolymorphicChildModelFilter,) 32 | child_models = (UUIDModelA, UUIDModelB) 33 | 34 | 35 | class UUIDModelAChildAdmin(PolymorphicChildModelAdmin): 36 | pass 37 | 38 | 39 | admin.site.register(UUIDModelA, UUIDModelAAdmin) 40 | admin.site.register(UUIDModelB, UUIDModelAChildAdmin) 41 | admin.site.register(UUIDModelC, UUIDModelAChildAdmin) 42 | 43 | 44 | class ProxyAdmin(PolymorphicParentModelAdmin): 45 | list_filter = (PolymorphicChildModelFilter,) 46 | child_models = (ProxyA, ProxyB) 47 | 48 | 49 | class ProxyChildAdmin(PolymorphicChildModelAdmin): 50 | pass 51 | 52 | 53 | admin.site.register(ProxyBase, ProxyAdmin) 54 | admin.site.register(ProxyA, ProxyChildAdmin) 55 | admin.site.register(ProxyB, ProxyChildAdmin) 56 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | 4 | def pytest_configure(config): 5 | # stash it somewhere global-ish 6 | from polymorphic import tests 7 | 8 | tests.HEADLESS = not config.getoption("--headed") 9 | 10 | 11 | def first_breakable_line(obj) -> tuple[str, int]: 12 | """ 13 | Return the absolute line number of the first executable statement 14 | in a function or bound method. 15 | """ 16 | import ast 17 | import textwrap 18 | 19 | func = obj.__func__ if inspect.ismethod(obj) else obj 20 | 21 | source = inspect.getsource(func) 22 | source = textwrap.dedent(source) 23 | filename = inspect.getsourcefile(func) 24 | assert filename 25 | _, start_lineno = inspect.getsourcelines(func) 26 | 27 | tree = ast.parse(source) 28 | 29 | for node in tree.body[0].body: 30 | if ( 31 | isinstance(node, ast.Expr) 32 | and isinstance(node.value, ast.Constant) 33 | and isinstance(node.value.value, str) 34 | ): 35 | continue 36 | 37 | return filename, start_lineno + node.lineno - 1 38 | 39 | # fallback: just return the line after the def 40 | return filename, start_lineno + 1 41 | 42 | 43 | def pytest_runtest_call(item): 44 | # --trace cli option does not work for unittest style tests so we implement it here 45 | test = getattr(item, "obj", None) 46 | if item.config.option.trace and inspect.ismethod(test): 47 | from IPython.terminal.debugger import TerminalPdb 48 | 49 | try: 50 | file = inspect.getsourcefile(test) 51 | assert file 52 | dbg = TerminalPdb() 53 | dbg.set_break(*first_breakable_line(test)) 54 | dbg.cmdqueue.append("continue") 55 | dbg.set_trace() 56 | except (OSError, AssertionError): 57 | pass 58 | -------------------------------------------------------------------------------- /docs/formsets.rst: -------------------------------------------------------------------------------- 1 | Formsets 2 | ======== 3 | 4 | .. versionadded:: 1.0 5 | 6 | Polymorphic models can be used in formsets. 7 | 8 | The implementation is almost identical to the regular Django :doc:`django:topics/forms/formsets`. 9 | As extra parameter, the factory needs to know how to display the child models. 10 | Provide a list of :class:`~polymorphic.formsets.PolymorphicFormSetChild` objects for this. 11 | 12 | .. code-block:: python 13 | 14 | from polymorphic.formsets import polymorphic_modelformset_factory, PolymorphicFormSetChild 15 | 16 | ModelAFormSet = polymorphic_modelformset_factory(ModelA, formset_children=( 17 | PolymorphicFormSetChild(ModelB), 18 | PolymorphicFormSetChild(ModelC), 19 | )) 20 | 21 | The formset can be used just like all other formsets: 22 | 23 | .. code-block:: python 24 | 25 | if request.method == "POST": 26 | formset = ModelAFormSet(request.POST, request.FILES, queryset=ModelA.objects.all()) 27 | if formset.is_valid(): 28 | formset.save() 29 | else: 30 | formset = ModelAFormSet(queryset=ModelA.objects.all()) 31 | 32 | Like standard Django :doc:`django:topics/forms/formsets`, there are 3 factory methods available: 33 | 34 | * :func:`~polymorphic.formsets.polymorphic_modelformset_factory` - create a regular model formset. 35 | * :func:`~polymorphic.formsets.polymorphic_inlineformset_factory` - create a inline model formset. 36 | * :func:`~polymorphic.formsets.generic_polymorphic_inlineformset_factory` - create an inline formset 37 | for a generic foreign key. 38 | 39 | Each one uses a different base class: 40 | 41 | * :class:`~polymorphic.formsets.BasePolymorphicModelFormSet` 42 | * :class:`~polymorphic.formsets.BasePolymorphicInlineFormSet` 43 | * :class:`~polymorphic.formsets.BaseGenericPolymorphicInlineFormSet` 44 | 45 | When needed, the base class can be overwritten and provided to the factory via the ``formset`` 46 | parameter. 47 | -------------------------------------------------------------------------------- /src/polymorphic/templatetags/polymorphic_admin_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library, Node, TemplateSyntaxError 2 | 3 | register = Library() 4 | 5 | 6 | class BreadcrumbScope(Node): 7 | def __init__(self, base_opts, nodelist): 8 | self.base_opts = base_opts 9 | self.nodelist = nodelist # Note, takes advantage of Node.child_nodelists 10 | 11 | @classmethod 12 | def parse(cls, parser, token): 13 | bits = token.split_contents() 14 | if len(bits) == 2: 15 | (tagname, base_opts) = bits 16 | base_opts = parser.compile_filter(base_opts) 17 | nodelist = parser.parse(("endbreadcrumb_scope",)) 18 | parser.delete_first_token() 19 | 20 | return cls(base_opts=base_opts, nodelist=nodelist) 21 | else: 22 | raise TemplateSyntaxError(f"{token.contents[0]} tag expects 1 argument") 23 | 24 | def render(self, context): 25 | # app_label is really hard to overwrite in the standard Django ModelAdmin. 26 | # To insert it in the template, the entire render_change_form() and delete_view() have to copied and adjusted. 27 | # Instead, have an assignment tag that inserts that in the template. 28 | base_opts = self.base_opts.resolve(context) 29 | new_vars = {} 30 | if base_opts and not isinstance(base_opts, str): 31 | new_vars = { 32 | "app_label": base_opts.app_label, # What this is all about 33 | "opts": base_opts, 34 | } 35 | 36 | new_scope = context.push() 37 | new_scope.update(new_vars) 38 | html = self.nodelist.render(context) 39 | context.pop() 40 | return html 41 | 42 | 43 | @register.tag 44 | def breadcrumb_scope(parser, token): 45 | """ 46 | Easily allow the breadcrumb to be generated in the admin change templates. 47 | """ 48 | return BreadcrumbScope.parse(parser, token) 49 | -------------------------------------------------------------------------------- /src/polymorphic/tests/admin.py: -------------------------------------------------------------------------------- 1 | from inspect import isclass 2 | from django.contrib.admin import register, ModelAdmin, site as admin_site 3 | from django.db.models.query import QuerySet 4 | from django.http import HttpRequest 5 | from polymorphic.admin import ( 6 | StackedPolymorphicInline, 7 | PolymorphicInlineSupportMixin, 8 | PolymorphicChildModelAdmin, 9 | PolymorphicChildModelFilter, 10 | PolymorphicParentModelAdmin, 11 | ) 12 | 13 | from polymorphic.tests.models import ( 14 | PlainA, 15 | Model2A, 16 | Model2B, 17 | Model2C, 18 | Model2D, 19 | InlineModelA, 20 | InlineModelB, 21 | InlineParent, 22 | NoChildren, 23 | ) 24 | 25 | 26 | @register(Model2A) 27 | class Model2Admin(PolymorphicParentModelAdmin): 28 | list_filter = (PolymorphicChildModelFilter,) 29 | child_models = (Model2A, Model2B, Model2C, Model2D) 30 | 31 | 32 | admin_site.register(Model2B, PolymorphicChildModelAdmin) 33 | admin_site.register(Model2C, PolymorphicChildModelAdmin) 34 | 35 | 36 | @register(Model2D) 37 | class Model2DAdmin(PolymorphicChildModelAdmin): 38 | exclude = ("field3",) 39 | 40 | 41 | @register(PlainA) 42 | class PlainAAdmin(ModelAdmin): 43 | search_fields = ["field1"] 44 | 45 | def get_queryset(self, request: HttpRequest) -> QuerySet: 46 | return super().get_queryset(request).order_by("pk") 47 | 48 | 49 | class Inline(StackedPolymorphicInline): 50 | model = InlineModelA 51 | 52 | def get_child_inlines(self): 53 | return [ 54 | child 55 | for child in self.__class__.__dict__.values() 56 | if isclass(child) and issubclass(child, StackedPolymorphicInline.Child) 57 | ] 58 | 59 | class InlineModelAChild(StackedPolymorphicInline.Child): 60 | model = InlineModelA 61 | 62 | class InlineModelBChild(StackedPolymorphicInline.Child): 63 | model = InlineModelB 64 | autocomplete_fields = ["plain_a"] 65 | 66 | 67 | @register(InlineParent) 68 | class InlineParentAdmin(PolymorphicInlineSupportMixin, ModelAdmin): 69 | inlines = (Inline,) 70 | extra = 1 71 | 72 | 73 | @register(NoChildren) 74 | class NoChildrenAdmin(PolymorphicParentModelAdmin): 75 | child_models = (NoChildren,) 76 | -------------------------------------------------------------------------------- /example/orders/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.dates import MONTHS_3 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from polymorphic.models import PolymorphicModel 6 | 7 | 8 | class Order(models.Model): 9 | """ 10 | An example order that has polymorphic relations 11 | """ 12 | 13 | title = models.CharField(_("Title"), max_length=200) 14 | 15 | class Meta: 16 | verbose_name = _("Organisation") 17 | verbose_name_plural = _("Organisations") 18 | ordering = ("title",) 19 | 20 | def __str__(self): 21 | return self.title 22 | 23 | 24 | class Payment(PolymorphicModel): 25 | """ 26 | A generic payment model. 27 | """ 28 | 29 | order = models.ForeignKey(Order, on_delete=models.CASCADE) 30 | currency = models.CharField(default="USD", max_length=3) 31 | amount = models.DecimalField(max_digits=10, decimal_places=2) 32 | 33 | class Meta: 34 | verbose_name = _("Payment") 35 | verbose_name_plural = _("Payments") 36 | 37 | def __str__(self): 38 | return f"{self.currency} {self.amount}" 39 | 40 | 41 | class CreditCardPayment(Payment): 42 | """ 43 | Credit card 44 | """ 45 | 46 | MONTH_CHOICES = [(i, n) for i, n in sorted(MONTHS_3.items())] 47 | 48 | card_type = models.CharField(max_length=10) 49 | expiry_month = models.PositiveSmallIntegerField(choices=MONTH_CHOICES) 50 | expiry_year = models.PositiveIntegerField() 51 | 52 | class Meta: 53 | verbose_name = _("Credit Card Payment") 54 | verbose_name_plural = _("Credit Card Payments") 55 | 56 | 57 | class BankPayment(Payment): 58 | """ 59 | Payment by bank 60 | """ 61 | 62 | bank_name = models.CharField(max_length=100) 63 | swift = models.CharField(max_length=20) 64 | 65 | class Meta: 66 | verbose_name = _("Bank Payment") 67 | verbose_name_plural = _("Bank Payments") 68 | 69 | 70 | class SepaPayment(Payment): 71 | """ 72 | Payment by SEPA (EU) 73 | """ 74 | 75 | iban = models.CharField(max_length=34) 76 | bic = models.CharField(max_length=11) 77 | 78 | class Meta: 79 | verbose_name = _("SEPA Payment") 80 | verbose_name_plural = _("SEPA Payments") 81 | -------------------------------------------------------------------------------- /src/polymorphic/tests/errata/test_errata.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Error, run_checks 2 | from django.test.utils import override_settings 3 | from django.test import SimpleTestCase 4 | 5 | 6 | @override_settings( 7 | INSTALLED_APPS=[ 8 | "polymorphic.tests.errata", 9 | "django.contrib.contenttypes", 10 | "django.contrib.auth", 11 | ] 12 | ) 13 | class TestErrata(SimpleTestCase): 14 | def test_reserved_field_name_triggers_system_check(self): 15 | """Test that using reserved field names triggers polymorphic.E001 system check.""" 16 | 17 | # Run the check function directly on the model 18 | errors = run_checks() 19 | 20 | assert len(errors) == 2, f"Expected 2 system check errors but got {len(errors)}: {errors}" 21 | 22 | # Verify all errors are the correct type 23 | assert all(isinstance(err, Error) and err.id == "polymorphic.E001" for err in errors), ( 24 | f"Expected all errors to have ID 'polymorphic.E001' but got: {errors}" 25 | ) 26 | 27 | # Verify the error messages mention the correct field names 28 | error_messages = [err.msg for err in errors] 29 | assert any("instance_of" in msg for msg in error_messages), ( 30 | f"Expected error for 'instance_of' field but got: {error_messages}" 31 | ) 32 | assert any("not_instance_of" in msg for msg in error_messages), ( 33 | f"Expected error for 'not_instance_of' field but got: {error_messages}" 34 | ) 35 | 36 | def test_polymorphic_guard_requires_callable(self): 37 | """Test that PolymorphicGuard raises TypeError if initialized with non-callable.""" 38 | 39 | from polymorphic.deletion import PolymorphicGuard 40 | 41 | non_callable_values = [42, "not a function", None, 3.14, [], {}] 42 | 43 | for value in non_callable_values: 44 | try: 45 | PolymorphicGuard(value) 46 | except TypeError as e: 47 | assert str(e) == "action must be callable", ( 48 | f"Expected TypeError with message 'action must be callable' but got: {e}" 49 | ) 50 | else: 51 | assert False, f"Expected TypeError when initializing PolymorphicGuard with {value}" 52 | -------------------------------------------------------------------------------- /docs/api/polymorphic.admin.rst: -------------------------------------------------------------------------------- 1 | polymorphic.admin 2 | ================= 3 | 4 | ModelAdmin classes 5 | ------------------ 6 | 7 | The ``PolymorphicParentModelAdmin`` class 8 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 9 | 10 | .. autoclass:: polymorphic.admin.PolymorphicParentModelAdmin 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | The ``PolymorphicChildModelAdmin`` class 17 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 18 | 19 | .. autoclass:: polymorphic.admin.PolymorphicChildModelAdmin 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | 24 | 25 | List filtering 26 | -------------- 27 | 28 | The ``PolymorphicChildModelFilter`` class 29 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 30 | 31 | .. autoclass:: polymorphic.admin.PolymorphicChildModelFilter 32 | :show-inheritance: 33 | 34 | 35 | Inlines support 36 | --------------- 37 | 38 | The ``StackedPolymorphicInline`` class 39 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | .. autoclass:: polymorphic.admin.StackedPolymorphicInline 42 | :show-inheritance: 43 | 44 | 45 | The ``GenericStackedPolymorphicInline`` class 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | .. autoclass:: polymorphic.admin.GenericStackedPolymorphicInline 49 | :members: 50 | :undoc-members: 51 | :show-inheritance: 52 | 53 | 54 | The ``PolymorphicInlineSupportMixin`` class 55 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 56 | 57 | .. autoclass:: polymorphic.admin.PolymorphicInlineSupportMixin 58 | :members: 59 | :undoc-members: 60 | :show-inheritance: 61 | 62 | 63 | Low-level classes 64 | ----------------- 65 | 66 | These classes are useful when existing parts of the admin classes. 67 | 68 | 69 | .. autoclass:: polymorphic.admin.PolymorphicModelChoiceForm 70 | :members: 71 | :undoc-members: 72 | :show-inheritance: 73 | 74 | 75 | .. autoclass:: polymorphic.admin.PolymorphicInlineModelAdmin 76 | :members: 77 | :undoc-members: 78 | :show-inheritance: 79 | 80 | 81 | .. autoclass:: polymorphic.admin.GenericPolymorphicInlineModelAdmin 82 | :members: 83 | :undoc-members: 84 | :show-inheritance: 85 | 86 | 87 | .. autoclass:: polymorphic.admin.PolymorphicInlineAdminForm 88 | :show-inheritance: 89 | 90 | 91 | .. autoclass:: polymorphic.admin.PolymorphicInlineAdminFormSet 92 | :show-inheritance: 93 | -------------------------------------------------------------------------------- /src/polymorphic/templatetags/polymorphic_formset_tags.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.template import Library 4 | from django.utils.encoding import force_str 5 | from django.utils.text import capfirst 6 | from django.utils.translation import gettext 7 | 8 | from polymorphic.formsets import BasePolymorphicModelFormSet 9 | 10 | register = Library() 11 | 12 | 13 | @register.filter() 14 | def include_empty_form(formset): 15 | """ 16 | Make sure the "empty form" is included when displaying a formset (typically table with input rows) 17 | """ 18 | yield from formset 19 | 20 | if hasattr(formset, "empty_forms"): 21 | # BasePolymorphicModelFormSet 22 | yield from formset.empty_forms 23 | else: 24 | # Standard Django formset 25 | yield formset.empty_form 26 | 27 | 28 | @register.filter 29 | def as_script_options(formset): 30 | """ 31 | A JavaScript data structure for the JavaScript code 32 | 33 | This generates the ``data-options`` attribute for ``jquery.django-inlines.js`` 34 | The formset may define the following extra attributes: 35 | 36 | - ``verbose_name`` 37 | - ``add_text`` 38 | - ``show_add_button`` 39 | """ 40 | verbose_name = getattr(formset, "verbose_name", formset.model._meta.verbose_name) 41 | options = { 42 | "prefix": formset.prefix, 43 | "pkFieldName": formset.model._meta.pk.name, 44 | "addText": getattr(formset, "add_text", None) 45 | or gettext("Add another %(verbose_name)s") % {"verbose_name": capfirst(verbose_name)}, 46 | "showAddButton": getattr(formset, "show_add_button", True), 47 | "deleteText": gettext("Delete"), 48 | } 49 | 50 | if isinstance(formset, BasePolymorphicModelFormSet): 51 | # Allow to add different types 52 | options["childTypes"] = [ 53 | { 54 | "name": force_str(model._meta.verbose_name), 55 | "type": model._meta.model_name, 56 | } 57 | for model in formset.child_forms.keys() 58 | ] 59 | 60 | return json.dumps(options) 61 | 62 | 63 | @register.filter 64 | def as_form_type(form): 65 | """ 66 | Usage: ``{{ form|as_form_type }}`` 67 | """ 68 | return form._meta.model._meta.model_name 69 | 70 | 71 | @register.filter 72 | def as_model_name(model): 73 | """ 74 | Usage: ``{{ model|as_model_name }}`` 75 | """ 76 | return model._meta.model_name 77 | -------------------------------------------------------------------------------- /example/pexp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from polymorphic.models import PolymorphicModel 4 | from polymorphic.showfields import ShowFieldContent, ShowFieldTypeAndContent 5 | 6 | 7 | class Project(ShowFieldContent, PolymorphicModel): 8 | """Polymorphic model""" 9 | 10 | topic = models.CharField(max_length=30) 11 | 12 | 13 | class ArtProject(Project): 14 | artist = models.CharField(max_length=30) 15 | 16 | 17 | class ResearchProject(Project): 18 | supervisor = models.CharField(max_length=30) 19 | 20 | 21 | class UUIDModelA(ShowFieldTypeAndContent, PolymorphicModel): 22 | """UUID as primary key example""" 23 | 24 | uuid_primary_key = models.UUIDField(primary_key=True) 25 | field1 = models.CharField(max_length=10) 26 | 27 | 28 | class UUIDModelB(UUIDModelA): 29 | field2 = models.CharField(max_length=10) 30 | 31 | 32 | class UUIDModelC(UUIDModelB): 33 | field3 = models.CharField(max_length=10) 34 | 35 | 36 | class ProxyBase(PolymorphicModel): 37 | """Proxy model example - a single table with multiple types.""" 38 | 39 | title = models.CharField(max_length=200) 40 | 41 | def __unicode__(self): 42 | return f"" 43 | 44 | class Meta: 45 | ordering = ("title",) 46 | 47 | 48 | class ProxyA(ProxyBase): 49 | class Meta: 50 | proxy = True 51 | 52 | def __unicode__(self): 53 | return f"" 54 | 55 | 56 | class ProxyB(ProxyBase): 57 | class Meta: 58 | proxy = True 59 | 60 | def __unicode__(self): 61 | return f"" 62 | 63 | 64 | # Internals for management command tests 65 | 66 | 67 | class TestModelA(ShowFieldTypeAndContent, PolymorphicModel): 68 | field1 = models.CharField(max_length=10) 69 | 70 | 71 | class TestModelB(TestModelA): 72 | field2 = models.CharField(max_length=10) 73 | 74 | 75 | class TestModelC(TestModelB): 76 | field3 = models.CharField(max_length=10) 77 | field4 = models.ManyToManyField(TestModelB, related_name="related_c") 78 | 79 | 80 | class NormalModelA(models.Model): 81 | """Normal Django inheritance, no polymorphic behavior""" 82 | 83 | field1 = models.CharField(max_length=10) 84 | 85 | 86 | class NormalModelB(NormalModelA): 87 | field2 = models.CharField(max_length=10) 88 | 89 | 90 | class NormalModelC(NormalModelB): 91 | field3 = models.CharField(max_length=10) 92 | -------------------------------------------------------------------------------- /src/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls static %} 2 | 3 |
7 | 8 |
9 |

{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}

10 | {{ inline_admin_formset.formset.management_form }} 11 | {{ inline_admin_formset.formset.non_form_errors }} 12 | 13 | {% for inline_admin_form in inline_admin_formset %} 14 | 34 | {% endfor %} 35 |
36 |
37 | -------------------------------------------------------------------------------- /src/polymorphic/deletion.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classes and utilities for handling deletions in polymorphic models. 3 | """ 4 | 5 | from django.db.migrations.serializer import BaseSerializer, serializer_factory 6 | from django.db.migrations.writer import MigrationWriter 7 | 8 | from .query import PolymorphicQuerySet 9 | 10 | 11 | class PolymorphicGuard: 12 | """ 13 | Wrap an :attr:`django.db.models.ForeignKey.on_delete` callable 14 | (CASCADE/PROTECT/SET_NULL/SET(...)/custom), but serialize as the underlying 15 | callable. 16 | 17 | :param action: The :attr:`django.db.models.ForeignKey.on_delete` callable to wrap. 18 | """ 19 | 20 | def __init__(self, action): 21 | if not callable(action): 22 | raise TypeError("action must be callable") 23 | self.action = action 24 | 25 | def __call__(self, collector, field, sub_objs, using): 26 | """ 27 | This guard wraps an on_delete action to ensure that any polymorphic queryset 28 | passed to it is converted to a non-polymorphic queryset before proceeding. 29 | This prevents issues with cascading deletes on polymorphic models. 30 | 31 | This guard should be automatically applied to reverse relations such that 32 | 33 | .. code-block:: python 34 | 35 | class MyModel(PolymorphicModel): 36 | related = models.ForeignKey( 37 | OtherModel, 38 | on_delete=models.CASCADE # <- equal to PolymorphicGuard(models.CASCADE) 39 | ) 40 | 41 | """ 42 | if isinstance(sub_objs, PolymorphicQuerySet) and not sub_objs.polymorphic_disabled: 43 | sub_objs = sub_objs.non_polymorphic() 44 | return self.action(collector, field, sub_objs, using) 45 | 46 | 47 | class PolymorphicGuardSerializer(BaseSerializer): 48 | """ 49 | A serializer for PolymorphicGuard that serializes the underlying action. 50 | 51 | There is no need to serialize the PolymorphicGuard itself, as it is just a wrapper 52 | that ensures that polymorphic querysets are converted to non-polymorphic but no 53 | polymorphic managers are present in migrations. This also ensures that new 54 | migrations will not be generated. 55 | """ 56 | 57 | def serialize(self): 58 | """ 59 | Serialize the underlying action of the PolymorphicGuard. 60 | """ 61 | return serializer_factory(self.value.action).serialize() 62 | 63 | 64 | MigrationWriter.register_serializer(PolymorphicGuard, PolymorphicGuardSerializer) 65 | -------------------------------------------------------------------------------- /src/polymorphic/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db import DEFAULT_DB_ALIAS 3 | 4 | from polymorphic.base import PolymorphicModelBase 5 | from polymorphic.models import PolymorphicModel 6 | 7 | 8 | def reset_polymorphic_ctype(*models, **filters): 9 | """ 10 | Set the polymorphic content-type ID field to the proper model 11 | Sort the ``*models`` from base class to descending class, 12 | to make sure the content types are properly assigned. 13 | 14 | Add ``ignore_existing=True`` to skip models which already 15 | have a polymorphic content type. 16 | """ 17 | using = filters.pop("using", DEFAULT_DB_ALIAS) 18 | ignore_existing = filters.pop("ignore_existing", False) 19 | 20 | models = sort_by_subclass(*models) 21 | if ignore_existing: 22 | # When excluding models, make sure we don't ignore the models we 23 | # just assigned the an content type to. hence, start with child first. 24 | models = reversed(models) 25 | 26 | for new_model in models: 27 | new_ct = ContentType.objects.db_manager(using).get_for_model( 28 | new_model, for_concrete_model=False 29 | ) 30 | 31 | qs = new_model.objects.db_manager(using) 32 | if ignore_existing: 33 | qs = qs.filter(polymorphic_ctype__isnull=True) 34 | if filters: 35 | qs = qs.filter(**filters) 36 | qs.update(polymorphic_ctype=new_ct) 37 | 38 | 39 | def _compare_mro(cls1, cls2): 40 | if cls1 is cls2: 41 | return 0 42 | 43 | try: 44 | index1 = cls1.mro().index(cls2) 45 | except ValueError: 46 | return -1 # cls2 not inherited by 1 47 | 48 | try: 49 | index2 = cls2.mro().index(cls1) 50 | except ValueError: 51 | return 1 # cls1 not inherited by 2 52 | 53 | return (index1 > index2) - (index1 < index2) # python 3 compatible cmp. 54 | 55 | 56 | def sort_by_subclass(*classes): 57 | """ 58 | Sort a series of models by their inheritance order. 59 | """ 60 | from functools import cmp_to_key 61 | 62 | return sorted(classes, key=cmp_to_key(_compare_mro)) 63 | 64 | 65 | def get_base_polymorphic_model(ChildModel, allow_abstract=False): 66 | """ 67 | First the first concrete model in the inheritance chain that inherited from the PolymorphicModel. 68 | """ 69 | for Model in reversed(ChildModel.mro()): 70 | if ( 71 | isinstance(Model, PolymorphicModelBase) 72 | and Model is not PolymorphicModel 73 | and (allow_abstract or not Model._meta.abstract) 74 | ): 75 | return Model 76 | return None 77 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | tags-ignore: 9 | - '*' 10 | branches: 11 | - '*' 12 | pull_request: 13 | workflow_call: 14 | workflow_dispatch: 15 | inputs: 16 | debug: 17 | description: 'Open ssh debug session.' 18 | required: true 19 | default: false 20 | type: boolean 21 | 22 | jobs: 23 | 24 | linting: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | # run static analysis on bleeding and trailing edges 29 | python-version: [ '3.10', '3.12', '3.14' ] 30 | django-version: 31 | - '4.2' # LTS April 2026 32 | - '5.2' # LTS April December 2027 33 | - '6.0' # 34 | exclude: 35 | - python-version: '3.12' 36 | django-version: '4.2' 37 | - python-version: '3.14' 38 | django-version: '4.2' 39 | 40 | - python-version: '3.10' 41 | django-version: '5.2' 42 | - python-version: '3.14' 43 | django-version: '5.2' 44 | 45 | - python-version: '3.10' 46 | django-version: '6.0' 47 | - python-version: '3.12' 48 | django-version: '6.0' 49 | 50 | env: 51 | TEST_PYTHON_VERSION: ${{ matrix.python-version }} 52 | TEST_DJANGO_VERSION: ${{ matrix.django-version }} 53 | steps: 54 | - uses: actions/checkout@v5 55 | with: 56 | persist-credentials: false 57 | - name: Set up Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v5 59 | id: sp 60 | with: 61 | python-version: ${{ matrix.python-version }} 62 | 63 | - name: Install uv 64 | uses: astral-sh/setup-uv@v6 65 | with: 66 | enable-cache: true 67 | - name: Install Just 68 | uses: extractions/setup-just@v3 69 | - name: Install Dependencies 70 | run: | 71 | just setup ${{ steps.sp.outputs.python-path }} 72 | if [[ "${{ matrix.django-version }}" =~ (a|b|rc) ]]; then 73 | just test-lock Django==${{ matrix.django-version }} 74 | else 75 | just test-lock Django~=${{ matrix.django-version }}.0 76 | fi 77 | just install-docs 78 | - name: Install Emacs 79 | if: ${{ github.event.inputs.debug == 'true' }} 80 | run: | 81 | sudo apt install emacs 82 | - name: Setup tmate session 83 | if: ${{ github.event.inputs.debug == 'true' }} 84 | uses: mxschmitt/action-tmate@v3.22 85 | with: 86 | detached: true 87 | timeout-minutes: 60 88 | - name: Run Static Analysis 89 | run: | 90 | just check-lint 91 | just check-format 92 | just check-types 93 | just check-package 94 | just check-readme 95 | -------------------------------------------------------------------------------- /src/polymorphic/admin/generic.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.admin import GenericInlineModelAdmin 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.utils.functional import cached_property 4 | 5 | from polymorphic.formsets import ( 6 | BaseGenericPolymorphicInlineFormSet, 7 | GenericPolymorphicFormSetChild, 8 | polymorphic_child_forms_factory, 9 | ) 10 | 11 | from .inlines import PolymorphicInlineModelAdmin 12 | 13 | 14 | class GenericPolymorphicInlineModelAdmin(PolymorphicInlineModelAdmin, GenericInlineModelAdmin): 15 | """ 16 | Base class for variation of inlines based on generic foreign keys. 17 | """ 18 | 19 | #: The formset class 20 | formset = BaseGenericPolymorphicInlineFormSet 21 | 22 | def get_formset(self, request, obj=None, **kwargs): 23 | """ 24 | Construct the generic inline formset class. 25 | """ 26 | # Construct the FormSet class. This is almost the same as parent version, 27 | # except that a different super is called so generic_inlineformset_factory() is used. 28 | # NOTE that generic_inlineformset_factory() also makes sure the GFK fields are excluded in the form. 29 | FormSet = GenericInlineModelAdmin.get_formset(self, request, obj=obj, **kwargs) 30 | 31 | FormSet.child_forms = polymorphic_child_forms_factory( 32 | formset_children=self.get_formset_children(request, obj=obj) 33 | ) 34 | return FormSet 35 | 36 | class Child(PolymorphicInlineModelAdmin.Child): 37 | """ 38 | Variation for generic inlines. 39 | """ 40 | 41 | # Make sure that the GFK fields are excluded from the child forms 42 | formset_child = GenericPolymorphicFormSetChild 43 | ct_field = "content_type" 44 | ct_fk_field = "object_id" 45 | 46 | @cached_property 47 | def content_type(self): 48 | """ 49 | Expose the ContentType that the child relates to. 50 | This can be used for the ``polymorphic_ctype`` field. 51 | """ 52 | return ContentType.objects.get_for_model(self.model, for_concrete_model=False) 53 | 54 | def get_formset_child(self, request, obj=None, **kwargs): 55 | # Similar to GenericInlineModelAdmin.get_formset(), 56 | # make sure the GFK is automatically excluded from the form 57 | defaults = {"ct_field": self.ct_field, "fk_field": self.ct_fk_field} 58 | defaults.update(kwargs) 59 | return super(GenericPolymorphicInlineModelAdmin.Child, self).get_formset_child( 60 | request, obj=obj, **defaults 61 | ) 62 | 63 | 64 | class GenericStackedPolymorphicInline(GenericPolymorphicInlineModelAdmin): 65 | """ 66 | The stacked layout for generic inlines. 67 | """ 68 | 69 | #: The default template to use. 70 | template = "admin/polymorphic/edit_inline/stacked.html" 71 | -------------------------------------------------------------------------------- /docs/deletion.rst: -------------------------------------------------------------------------------- 1 | Deletion 2 | ======== 3 | 4 | .. versionadded:: 4.5.0 5 | 6 | There is nothing special about deleting polymorphic models. The same rules apply as to 7 | :ref:`the deletion of normal Django models ` that have parent/child 8 | relationships up and down a model inheritance hierarchy. Django must walk the model inheritance and 9 | relationship graph and collect all of the affected objects so that it can correctly order deletion 10 | SQL statements to respect database constraints and issue signals. 11 | 12 | The polymorphic deletion logic is the same as the normal Django deletion logic because Django 13 | already walks the model inheritance hierarchy. :class:`~polymorphic.query.PolymorphicQuerySet` and 14 | :class:`~polymorphic.managers.PolymorphicManager` disrupt this process by confusing Django's graph 15 | walker by returning concrete subclass instances instead of base class instances when it attempts to 16 | walk reverse relationships to polymorphic models. To prevent this confusion, 17 | :pypi:`django-polymorphic` wraps the :attr:`~django.db.models.ForeignKey.on_delete` handlers of 18 | reverse relations to polymorphic models with :class:`~polymorphic.deletion.PolymorphicGuard` 19 | which disables polymorphic behavior on the related querysets during collection. 20 | 21 | **You may define your polymorphic models as you normally would using the standard Django** 22 | :attr:`~django.db.models.ForeignKey.on_delete` **actions**. 23 | :class:`~polymorphic.models.PolymorphicModel` will automatically wrap the actions for you. actions 24 | wrapped with :class:`~polymorphic.deletion.PolymorphicGuard` serialize in migrations as the 25 | underlying wrapped action. This ensures migrations generated by versions of 26 | :pypi:`django-polymorphic` after 4.5.0 should be the same as with prior versions. The guard is also 27 | unnecessary during migrations because Django generates basic managers instead of using the default 28 | polymorphic managers. 29 | 30 | It is a design goal of :pypi:`django-polymorphic` that deletion should just work without any special 31 | treatment. However if you encounter attribute errors or database integrity errors during deletion 32 | you may manually wrap the :attr:`~django.db.models.ForeignKey.on_delete` action of reverse relations 33 | to polymorphic models with :class:`~polymorphic.deletion.PolymorphicGuard` to disable polymorphic 34 | behavior during deletion collection. If you encounter an issue like this 35 | `please report it to us `_. For example: 36 | 37 | .. code-block:: python 38 | 39 | from polymorphic.models import PolymorphicModel 40 | from polymorphic.deletion import PolymorphicGuard 41 | from django.db import models 42 | 43 | class MyModel(models.Model): 44 | # ... 45 | 46 | class RelatedModel(PolymorphicModel): 47 | my_model = models.ForeignKey( 48 | MyModel, 49 | on_delete=PolymorphicGuard(models.CASCADE), 50 | ) 51 | 52 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import TransactionTestCase 3 | 4 | from polymorphic.models import PolymorphicModel, PolymorphicTypeUndefined 5 | from polymorphic.tests.models import ( 6 | Enhance_Base, 7 | Enhance_Inherit, 8 | Model2A, 9 | Model2B, 10 | Model2C, 11 | Model2D, 12 | ) 13 | from polymorphic.utils import get_base_polymorphic_model, reset_polymorphic_ctype, sort_by_subclass 14 | 15 | 16 | class UtilsTests(TransactionTestCase): 17 | def test_sort_by_subclass(self): 18 | assert sort_by_subclass(Model2D, Model2B, Model2D, Model2A, Model2C) == [ 19 | Model2A, 20 | Model2B, 21 | Model2C, 22 | Model2D, 23 | Model2D, 24 | ] 25 | 26 | def test_reset_polymorphic_ctype(self): 27 | """ 28 | Test the the polymorphic_ctype_id can be restored. 29 | """ 30 | Model2A.objects.create(field1="A1") 31 | Model2D.objects.create(field1="A1", field2="B2", field3="C3", field4="D4") 32 | Model2B.objects.create(field1="A1", field2="B2") 33 | Model2B.objects.create(field1="A1", field2="B2") 34 | Model2A.objects.all().update(polymorphic_ctype_id=None) 35 | 36 | with pytest.raises(PolymorphicTypeUndefined): 37 | list(Model2A.objects.all()) 38 | 39 | reset_polymorphic_ctype(Model2D, Model2B, Model2D, Model2A, Model2C) 40 | 41 | self.assertQuerySetEqual( 42 | Model2A.objects.order_by("pk"), 43 | [Model2A, Model2D, Model2B, Model2B], 44 | transform=lambda o: o.__class__, 45 | ) 46 | 47 | def test_get_base_polymorphic_model(self): 48 | """ 49 | Test that finding the base polymorphic model works. 50 | """ 51 | # Finds the base from every level (including lowest) 52 | assert get_base_polymorphic_model(Model2D) is Model2A 53 | assert get_base_polymorphic_model(Model2C) is Model2A 54 | assert get_base_polymorphic_model(Model2B) is Model2A 55 | assert get_base_polymorphic_model(Model2A) is Model2A 56 | 57 | # Properly handles multiple inheritance 58 | assert get_base_polymorphic_model(Enhance_Inherit) is Enhance_Base 59 | 60 | # Ignores PolymorphicModel itself. 61 | assert get_base_polymorphic_model(PolymorphicModel) is None 62 | 63 | def test_get_base_polymorphic_model_skip_abstract(self): 64 | """ 65 | Skipping abstract models that can't be used for querying. 66 | """ 67 | 68 | class A(PolymorphicModel): 69 | class Meta: 70 | abstract = True 71 | 72 | class B(A): 73 | pass 74 | 75 | class C(B): 76 | pass 77 | 78 | assert get_base_polymorphic_model(A) is None 79 | assert get_base_polymorphic_model(B) is B 80 | assert get_base_polymorphic_model(C) is B 81 | 82 | assert get_base_polymorphic_model(C, allow_abstract=True) is A 83 | -------------------------------------------------------------------------------- /docs/migrating.rst: -------------------------------------------------------------------------------- 1 | Migrating Existing Models 2 | ========================= 3 | 4 | Existing models can be migrated to become polymorphic models. During migration, the 5 | :attr:`~polymorphic.models.PolymorphicModel.polymorphic_ctype` field needs to be populated. 6 | 7 | This can be done in the following steps: 8 | 9 | #. Inherit your model from :class:`~polymorphic.models.PolymorphicModel`. 10 | #. Create a Django migration file to create the ``polymorphic_ctype_id`` database column. 11 | #. Make sure the proper :class:`~django.contrib.contenttypes.models.ContentType` value is filled in. 12 | 13 | Filling the content type value 14 | ------------------------------ 15 | 16 | The following code can be used to fill the value of a model: 17 | 18 | .. code-block:: python 19 | 20 | from django.contrib.contenttypes.models import ContentType 21 | from myapp.models import MyModel 22 | 23 | new_ct = ContentType.objects.get_for_model(MyModel) 24 | MyModel.objects.filter(polymorphic_ctype__isnull=True).update(polymorphic_ctype=new_ct) 25 | 26 | The creation and update of the ``polymorphic_ctype_id`` column can be included in a single Django 27 | migration. For example: 28 | 29 | .. code-block:: python 30 | 31 | # -*- coding: utf-8 -*- 32 | from django.db import migrations, models 33 | 34 | 35 | def forwards_func(apps, schema_editor): 36 | MyModel = apps.get_model('myapp', 'MyModel') 37 | ContentType = apps.get_model('contenttypes', 'ContentType') 38 | 39 | new_ct = ContentType.objects.get_for_model(MyModel) 40 | MyModel.objects.filter(polymorphic_ctype__isnull=True).update( 41 | polymorphic_ctype=new_ct 42 | ) 43 | 44 | 45 | class Migration(migrations.Migration): 46 | 47 | dependencies = [ 48 | ('contenttypes', '0001_initial'), 49 | ('myapp', '0001_initial'), 50 | ] 51 | 52 | operations = [ 53 | migrations.AddField( 54 | model_name='mymodel', 55 | name='polymorphic_ctype', 56 | field=models.ForeignKey( 57 | related_name='polymorphic_myapp.mymodel_set+', 58 | editable=False, 59 | to='contenttypes.ContentType', 60 | null=True 61 | ), 62 | ), 63 | migrations.RunPython(forwards_func, migrations.RunPython.noop), 64 | ] 65 | 66 | It's recommended to let :django-admin:`makemigrations` create the migration file, and include the 67 | :class:`~django.db.migrations.operations.RunPython` manually before running the migration. 68 | 69 | .. versionadded:: 1.1 70 | 71 | When the model is created elsewhere, you can also use the 72 | :func:`~polymorphic.utils.reset_polymorphic_ctype` function: 73 | 74 | .. code-block:: python 75 | 76 | from polymorphic.utils import reset_polymorphic_ctype 77 | from myapp.models import Base, Sub1, Sub2 78 | 79 | reset_polymorphic_ctype(Base, Sub1, Sub2) 80 | 81 | reset_polymorphic_ctype(Base, Sub1, Sub2, ignore_existing=True) 82 | -------------------------------------------------------------------------------- /src/polymorphic/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Template tags for polymorphic 3 | 4 | 5 | The ``polymorphic_formset_tags`` Library 6 | ---------------------------------------- 7 | 8 | .. versionadded:: 1.1 9 | 10 | To render formsets in the frontend, the ``polymorphic_tags`` provides extra 11 | filters to implement HTML rendering of polymorphic formsets. 12 | 13 | The following filters are provided; 14 | 15 | * ``{{ formset|as_script_options }}`` render the ``data-options`` for a JavaScript formset library. 16 | * ``{{ formset|include_empty_form }}`` provide the placeholder form for an add button. 17 | * ``{{ form|as_form_type }}`` return the model name that the form instance uses. 18 | * ``{{ model|as_model_name }}`` performs the same, for a model class or instance. 19 | 20 | .. code-block:: html+django 21 | 22 | {% load i18n polymorphic_formset_tags %} 23 | 24 |
25 | {% block add_button %} 26 | {% if formset.show_add_button|default_if_none:'1' %} 27 | {% if formset.empty_forms %} 28 | {# django-polymorphic formset (e.g. PolymorphicInlineFormSetView) #} 29 |
30 | {% for model in formset.child_forms %} 31 | {% glyphicon 'plus' %} {{ model|as_verbose_name }} 32 | {% endfor %} 33 |
34 | {% else %} 35 | {% trans "Add" %} 36 | {% endif %} 37 | {% endif %} 38 | {% endblock %} 39 | 40 | {{ formset.management_form }} 41 | 42 | {% for form in formset|include_empty_form %} 43 | {% block formset_form_wrapper %} 44 |
45 | {{ form.non_field_errors }} 46 | 47 | {# Add the 'pk' field that is not mentioned in crispy #} 48 | {% for field in form.hidden_fields %} 49 | {{ field }} 50 | {% endfor %} 51 | 52 | {% block formset_form %} 53 | {% crispy form %} 54 | {% endblock %} 55 |
56 | {% endblock %} 57 | {% endfor %} 58 |
59 | 60 | 61 | The ``polymorphic_admin_tags`` Library 62 | -------------------------------------- 63 | 64 | The ``{% breadcrumb_scope ... %}`` tag makes sure the ``{{ opts }}`` and ``{{ app_label }}`` 65 | values are temporary based on the provided ``{{ base_opts }}``. 66 | This allows fixing the breadcrumb in admin templates: 67 | 68 | .. code-block:: html+django 69 | 70 | {% extends "admin/change_form.html" %} 71 | {% load polymorphic_admin_tags %} 72 | 73 | {% block breadcrumbs %} 74 | {% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %} 75 | {% endblock %} 76 | 77 | """ 78 | -------------------------------------------------------------------------------- /src/polymorphic/tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | import io 5 | 6 | from django.core.management import call_command 7 | 8 | from django_test_migrations.migrator import Migrator 9 | from django.apps import apps 10 | 11 | 12 | class GeneratedMigrationsPerClassMixin: 13 | """ 14 | Generates migrations at class setup, applies them, and rolls them back at teardown. 15 | 16 | Configure: 17 | - apps_to_migrate = ["my_app", ...] 18 | - database = "default" (optional) 19 | """ 20 | 21 | apps_to_migrate: list[str] = [] 22 | database: str = "default" 23 | settings: str = os.environ.get("DJANGO_SETTINGS_MODULE", "polymorphic.tests.settings") 24 | 25 | @classmethod 26 | def setUpClass(cls): 27 | super().setUpClass() 28 | 29 | if not cls.apps_to_migrate: 30 | raise RuntimeError("Set apps_to_migrate = ['your_app', ...]") 31 | 32 | for app_label in cls.apps_to_migrate: 33 | call_command( 34 | "makemigrations", 35 | app_label, 36 | interactive=False, 37 | verbosity=0, 38 | ) 39 | 40 | # 2) Apply all migrations (up to latest) using django-test-migrations 41 | cls.migrator = Migrator(database=cls.database) 42 | 43 | cls._applied_states = {} 44 | for app_label in cls.apps_to_migrate: 45 | latest = cls._find_latest_migration_name(app_label) 46 | # apply_initial_migration applies all migrations up to and including `latest` 47 | cls._applied_states[app_label] = cls.migrator.apply_initial_migration( 48 | (app_label, latest) 49 | ) 50 | 51 | @classmethod 52 | def tearDownClass(cls): 53 | try: 54 | # Roll everything back / cleanup: 55 | if hasattr(cls, "migrator"): 56 | cls.migrator.reset() 57 | finally: 58 | # remove files 59 | for app_label in cls.apps_to_migrate: 60 | app_config = apps.get_app_config(app_label) # app *label* 61 | mig_dir = Path(app_config.path) / "migrations" 62 | 63 | for mig_file in mig_dir.glob("*.py"): 64 | if mig_file.name != "__init__.py" and mig_file.name[0:4].isdigit(): 65 | os.remove(mig_file) 66 | 67 | # also remove __pycache__ if exists 68 | pycache_dir = mig_dir / "__pycache__" 69 | if pycache_dir.exists() and pycache_dir.is_dir(): 70 | shutil.rmtree(pycache_dir) 71 | 72 | super().tearDownClass() 73 | 74 | @classmethod 75 | def _find_latest_migration_name(cls, app_label: str) -> str: 76 | """ 77 | Returns "000X_..." latest migration filename (without .py). 78 | """ 79 | app_config = apps.get_app_config(app_label) # app *label* 80 | mig_dir = Path(app_config.path) / "migrations" 81 | 82 | candidates = sorted( 83 | p for p in mig_dir.glob("*.py") if p.name != "__init__.py" and p.name[0:4].isdigit() 84 | ) 85 | if not candidates: 86 | raise RuntimeError(f"No migrations generated for {app_label}") 87 | return candidates[-1].stem 88 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_migrations/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from polymorphic.models import PolymorphicModel 3 | 4 | 5 | def get_default_related(): 6 | """Default function for SET() callable""" 7 | return None 8 | 9 | 10 | class RelatedModel(models.Model): 11 | """A regular non-polymorphic model that will be referenced""" 12 | 13 | name = models.CharField(max_length=100) 14 | 15 | 16 | class BasePolyModel(PolymorphicModel): 17 | """ 18 | Base polymorphic model to test that PolymorphicGuard wraps 19 | on_delete handlers properly and serializes them correctly. 20 | """ 21 | 22 | name = models.CharField(max_length=100) 23 | 24 | 25 | class ChildPolyModel(BasePolyModel): 26 | """Child polymorphic model""" 27 | 28 | description = models.CharField(max_length=200, blank=True) 29 | 30 | 31 | class GrandChildPolyModel(ChildPolyModel): 32 | """Grandchild polymorphic model""" 33 | 34 | extra_info = models.CharField(max_length=200, blank=True) 35 | 36 | 37 | # Models with ForeignKey using different on_delete behaviors 38 | # These should all be wrapped with PolymorphicGuard automatically 39 | 40 | 41 | class ModelWithCascade(PolymorphicModel): 42 | """Test CASCADE on_delete""" 43 | 44 | related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE) 45 | 46 | 47 | class ModelWithProtect(PolymorphicModel): 48 | """Test PROTECT on_delete""" 49 | 50 | related = models.ForeignKey(RelatedModel, on_delete=models.PROTECT) 51 | 52 | 53 | class ModelWithSetNull(PolymorphicModel): 54 | """Test SET_NULL on_delete""" 55 | 56 | related = models.ForeignKey(RelatedModel, on_delete=models.SET_NULL, null=True) 57 | 58 | 59 | class ModelWithSetDefault(PolymorphicModel): 60 | """Test SET_DEFAULT on_delete""" 61 | 62 | related = models.ForeignKey( 63 | RelatedModel, on_delete=models.SET_DEFAULT, null=True, default=None 64 | ) 65 | 66 | 67 | class ModelWithSet(PolymorphicModel): 68 | """Test SET(...) on_delete""" 69 | 70 | related = models.ForeignKey(RelatedModel, on_delete=models.SET(get_default_related), null=True) 71 | 72 | 73 | class ModelWithDoNothing(PolymorphicModel): 74 | """Test DO_NOTHING on_delete""" 75 | 76 | related = models.ForeignKey(RelatedModel, on_delete=models.DO_NOTHING) 77 | 78 | 79 | class ModelWithRestrict(PolymorphicModel): 80 | """Test RESTRICT on_delete""" 81 | 82 | related = models.ForeignKey(RelatedModel, on_delete=models.RESTRICT) 83 | 84 | 85 | # OneToOneField tests 86 | 87 | 88 | class ModelWithOneToOneCascade(PolymorphicModel): 89 | """Test CASCADE on_delete with OneToOneField""" 90 | 91 | related = models.OneToOneField(RelatedModel, on_delete=models.CASCADE) 92 | 93 | 94 | class ModelWithOneToOneProtect(PolymorphicModel): 95 | """Test PROTECT on_delete with OneToOneField""" 96 | 97 | related = models.OneToOneField( 98 | RelatedModel, on_delete=models.PROTECT, related_name="one_to_one_protect" 99 | ) 100 | 101 | 102 | class ModelWithOneToOneSetNull(PolymorphicModel): 103 | """Test SET_NULL on_delete with OneToOneField""" 104 | 105 | related = models.OneToOneField( 106 | RelatedModel, on_delete=models.SET_NULL, null=True, related_name="one_to_one_set_null" 107 | ) 108 | -------------------------------------------------------------------------------- /example/pexp/management/commands/polybench.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a scratchpad for general development, testing & debugging 3 | """ 4 | 5 | import sys 6 | import time 7 | from pprint import pprint 8 | 9 | from django.core.management import BaseCommand 10 | from django.db import connection 11 | from pexp.models import * 12 | 13 | num_objects = 1000 14 | 15 | 16 | def show_queries(): 17 | print() 18 | print("QUERIES:", len(connection.queries)) 19 | pprint(connection.queries) 20 | print() 21 | connection.queries_log.clear() 22 | 23 | 24 | ################################################################################### 25 | # benchmark wrappers 26 | 27 | 28 | def print_timing(func, message="", iterations=1): 29 | def wrapper(*arg): 30 | results = [] 31 | connection.queries_log.clear() 32 | for i in range(iterations): 33 | t1 = time.time() 34 | x = func(*arg) 35 | t2 = time.time() 36 | results.append((t2 - t1) * 1000.0) 37 | res_sum = 0 38 | for r in results: 39 | res_sum += r 40 | median = res_sum / len(results) 41 | print( 42 | f"{message}{func.func_name:<19}: {median:.0f} ms, " 43 | f"{len(connection.queries) / len(results):d} queries" 44 | ) 45 | sys.stdout.flush() 46 | 47 | return wrapper 48 | 49 | 50 | def run_vanilla_any_poly(func, iterations=1): 51 | f = print_timing(func, " ", iterations) 52 | f(NormalModelC) 53 | f = print_timing(func, "poly ", iterations) 54 | f(TestModelC) 55 | 56 | 57 | ################################################################################### 58 | # benchmarks 59 | 60 | 61 | def bench_create(model): 62 | for i in range(num_objects): 63 | model.objects.create( 64 | field1=f"abc{i}", 65 | field2=f"abcd{i}", 66 | field3=f"abcde{i}", 67 | ) 68 | # print 'count:',model.objects.count() 69 | 70 | 71 | def bench_load1(model): 72 | for o in model.objects.all(): 73 | pass 74 | 75 | 76 | def bench_load1_short(model): 77 | for i in range(num_objects / 100): 78 | for o in model.objects.all()[:100]: 79 | pass 80 | 81 | 82 | def bench_load2(model): 83 | for o in model.objects.all(): 84 | f1 = o.field1 85 | f2 = o.field2 86 | f3 = o.field3 87 | 88 | 89 | def bench_load2_short(model): 90 | for i in range(num_objects / 100): 91 | for o in model.objects.all()[:100]: 92 | f1 = o.field1 93 | f2 = o.field2 94 | f3 = o.field3 95 | 96 | 97 | def bench_delete(model): 98 | model.objects.all().delete() 99 | 100 | 101 | ################################################################################### 102 | # Command 103 | 104 | 105 | class Command(BaseCommand): 106 | help = "" 107 | 108 | def handle_noargs(self, **options): 109 | func_list = [ 110 | (bench_delete, 1), 111 | (bench_create, 1), 112 | (bench_load1, 5), 113 | (bench_load1_short, 5), 114 | (bench_load2, 5), 115 | (bench_load2_short, 5), 116 | ] 117 | for f, iterations in func_list: 118 | run_vanilla_any_poly(f, iterations=iterations) 119 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = True 4 | 5 | ADMINS = ( 6 | # ('Your Name', 'your_email@example.com'), 7 | ) 8 | 9 | MANAGERS = ADMINS 10 | PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__)) 11 | 12 | DATABASES = { 13 | "default": { 14 | "ENGINE": "django.db.backends.sqlite3", 15 | "NAME": os.path.join(PROJECT_ROOT, "example.db"), 16 | } 17 | } 18 | 19 | SITE_ID = 1 20 | 21 | # Make this unique, and don't share it with anybody. 22 | SECRET_KEY = "5$f%)&a4tc*bg(79+ku!7o$kri-duw99@hq_)va^_kaw9*l)!7" 23 | 24 | 25 | # Language 26 | # TIME_ZONE = 'America/Chicago' 27 | LANGUAGE_CODE = "en-us" 28 | USE_I18N = True 29 | USE_L10N = True 30 | USE_TZ = True 31 | 32 | # Paths 33 | MEDIA_ROOT = "" 34 | MEDIA_URL = "/media/" 35 | STATIC_ROOT = "" 36 | STATIC_URL = "/static/" 37 | 38 | # Apps 39 | STATICFILES_FINDERS = ( 40 | "django.contrib.staticfiles.finders.FileSystemFinder", 41 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 42 | ) 43 | 44 | MIDDLEWARE = ( 45 | "django.middleware.common.CommonMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | ) 51 | 52 | TEMPLATES = [ 53 | { 54 | "BACKEND": "django.template.backends.django.DjangoTemplates", 55 | "DIRS": (), 56 | "OPTIONS": { 57 | "loaders": ( 58 | "django.template.loaders.filesystem.Loader", 59 | "django.template.loaders.app_directories.Loader", 60 | ), 61 | "context_processors": ( 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.i18n", 64 | "django.template.context_processors.media", 65 | "django.template.context_processors.request", 66 | "django.template.context_processors.static", 67 | "django.contrib.messages.context_processors.messages", 68 | "django.contrib.auth.context_processors.auth", 69 | ), 70 | }, 71 | } 72 | ] 73 | 74 | ROOT_URLCONF = "example.urls" 75 | 76 | WSGI_APPLICATION = "example.wsgi.application" 77 | 78 | INSTALLED_APPS = ( 79 | "django.contrib.auth", 80 | "django.contrib.admin", 81 | "django.contrib.contenttypes", 82 | "django.contrib.sessions", 83 | "django.contrib.messages", 84 | "django.contrib.staticfiles", 85 | "polymorphic", # needed if you want to use the polymorphic admin 86 | "pexp", # this Django app is for testing and experimentation; not needed otherwise 87 | "orders", 88 | ) 89 | 90 | TEST_RUNNER = "django.test.runner.DiscoverRunner" # silence system checks 91 | 92 | # Logging configuration 93 | LOGGING = { 94 | "version": 1, 95 | "disable_existing_loggers": False, 96 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 97 | "handlers": { 98 | "mail_admins": { 99 | "level": "ERROR", 100 | "filters": ["require_debug_false"], 101 | "class": "django.utils.log.AdminEmailHandler", 102 | } 103 | }, 104 | "loggers": { 105 | "django.request": { 106 | "handlers": ["mail_admins"], 107 | "level": "ERROR", 108 | "propagate": True, 109 | } 110 | }, 111 | } 112 | -------------------------------------------------------------------------------- /src/polymorphic/managers.py: -------------------------------------------------------------------------------- 1 | """ 2 | The manager class for use in the models. 3 | """ 4 | 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import DEFAULT_DB_ALIAS, models 7 | 8 | from polymorphic.query import PolymorphicQuerySet 9 | 10 | __all__ = ("PolymorphicManager", "PolymorphicQuerySet") 11 | 12 | 13 | class PolymorphicManager(models.Manager): 14 | """ 15 | Manager for PolymorphicModel 16 | 17 | Usually not explicitly needed, except if a custom manager or 18 | a custom queryset class is to be used. 19 | """ 20 | 21 | queryset_class = PolymorphicQuerySet 22 | 23 | @classmethod 24 | def from_queryset(cls, queryset_class, class_name=None): 25 | manager = super().from_queryset(queryset_class, class_name=class_name) 26 | # also set our version, Django uses _queryset_class 27 | manager.queryset_class = queryset_class 28 | return manager 29 | 30 | def get_queryset(self): 31 | qs = self.queryset_class(self.model, using=self._db, hints=self._hints) 32 | if self.model._meta.proxy: 33 | qs = qs.instance_of(self.model) 34 | return qs 35 | 36 | def __str__(self): 37 | return ( 38 | f"{self.__class__.__name__} (PolymorphicManager) using {self.queryset_class.__name__}" 39 | ) 40 | 41 | # Proxied methods 42 | def non_polymorphic(self): 43 | return self.all().non_polymorphic() 44 | 45 | def instance_of(self, *args): 46 | return self.all().instance_of(*args) 47 | 48 | def not_instance_of(self, *args): 49 | return self.all().not_instance_of(*args) 50 | 51 | def get_real_instances(self, base_result_objects=None): 52 | return self.all().get_real_instances(base_result_objects=base_result_objects) 53 | 54 | def create_from_super(self, obj, **kwargs): 55 | """ 56 | Create an instance of this manager's model class from the given instance of a 57 | parent class. 58 | 59 | This is useful when "promoting" an instance down the inheritance chain. 60 | 61 | :param obj: An instance of a parent class of the manager's model class. 62 | :param kwargs: Additional fields to set on the new instance. 63 | :return: The newly created instance. 64 | """ 65 | from .models import PolymorphicModel 66 | 67 | # ensure we have the most derived real instance 68 | if isinstance(obj, PolymorphicModel): 69 | obj = obj.get_real_instance() 70 | 71 | parent_ptr = self.model._meta.parents.get(type(obj), None) 72 | 73 | if not parent_ptr: 74 | raise TypeError( 75 | f"{obj.__class__.__name__} is not a direct parent of {self.model.__name__}" 76 | ) 77 | kwargs[parent_ptr.get_attname()] = obj.pk 78 | 79 | # create the new base class with only fields that apply to it. 80 | ctype = ContentType.objects.db_manager( 81 | using=(obj._state.db or DEFAULT_DB_ALIAS) 82 | ).get_for_model(self.model) 83 | nobj = self.model(**kwargs, polymorphic_ctype=ctype) 84 | nobj.save_base(raw=True, using=obj._state.db or DEFAULT_DB_ALIAS, force_insert=True) 85 | # force update the content type, but first we need to 86 | # retrieve a clean copy from the db to fill in the null 87 | # fields otherwise they would be overwritten. 88 | if isinstance(obj, PolymorphicModel): 89 | parent = obj.__class__.objects.using(obj._state.db or DEFAULT_DB_ALIAS).get(pk=obj.pk) 90 | parent.polymorphic_ctype = ctype 91 | parent.save() 92 | 93 | nobj.refresh_from_db() # cast to cls 94 | return nobj 95 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_query_translate.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import tempfile 3 | import pickle 4 | import threading 5 | 6 | from django.db.models import Q 7 | from django.test import TestCase 8 | 9 | from polymorphic.tests.models import Bottom, Middle, Top 10 | from polymorphic.query_translate import translate_polymorphic_filter_definitions_in_args 11 | 12 | 13 | class QueryTranslateTests(TestCase): 14 | def test_translate_with_not_pickleable_query(self): 15 | """ 16 | In some cases, Django may attacha _thread object to the query and we 17 | will get the following when we try to deepcopy inside of 18 | translate_polymorphic_filter_definitions_in_args: 19 | 20 | TypeError: cannot pickle '_thread.lock' object 21 | 22 | 23 | For this to trigger, we need to somehoe go down this path: 24 | 25 | File "/perfdash/.venv/lib64/python3.12/site-packages/polymorphic/query_translate.py", line 95, in translate_polymorphic_filter_definitions_in_args 26 | translate_polymorphic_Q_object(queryset_model, copy.deepcopy(q), using=using) for q in args 27 | ^^^^^^^^^^^^^^^^ 28 | File "/usr/lib64/python3.12/copy.py", line 143, in deepcopy 29 | y = copier(memo) 30 | ^^^^^^^^^^^^ 31 | File "/perfdash/.venv/lib64/python3.12/site-packages/django/utils/tree.py", line 53, in __deepcopy__ 32 | obj.children = copy.deepcopy(self.children, memodict) 33 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 34 | File "/usr/lib64/python3.12/copy.py", line 136, in deepcopy 35 | y = copier(x, memo) 36 | ^^^^^^^^^^^^^^^ 37 | 38 | Internals in Django, somehow we must trigger this tree.py code in django via 39 | the deepcopy in order to trigger this. 40 | 41 | """ 42 | 43 | with tempfile.TemporaryFile() as fd: 44 | # verify this is definitely not pickleable 45 | with self.assertRaises(TypeError): 46 | pickle.dumps(threading.Lock()) 47 | 48 | # I know this doesn't make sense to pass as a Q(), but 49 | # I haven't found another way to trigger the copy.deepcopy failing. 50 | q = Q(blog__info="blog info") | Q(blog__info=threading.Lock()) 51 | 52 | translate_polymorphic_filter_definitions_in_args(Bottom, args=[q]) 53 | 54 | def test_deep_copy_of_q_objects(self): 55 | import os 56 | from polymorphic.tests.models import DeepCopyTester, DeepCopyTester2 57 | # binary fields can have an unpickleable memoryview object in them 58 | # see https://github.com/jazzband/django-polymorphic/issues/524 59 | 60 | d1_bf = os.urandom(32) 61 | d2_bf1 = os.urandom(32) 62 | d2_bf2 = os.urandom(32) 63 | 64 | dct1 = DeepCopyTester.objects.create(binary_field=d1_bf) 65 | dct2 = DeepCopyTester2.objects.create(binary_field=d2_bf1, binary_field2=d2_bf2) 66 | 67 | self.assertEqual(list(DeepCopyTester.objects.filter(binary_field=d1_bf).all()), [dct1]) 68 | 69 | q1 = Q(DeepCopyTester2___binary_field2=d2_bf1) 70 | self.assertEqual(list(DeepCopyTester.objects.filter(q1).all()), []) 71 | assert q1.children[0][0] == "DeepCopyTester2___binary_field2" 72 | q2 = Q(DeepCopyTester2___binary_field2=d2_bf2) 73 | self.assertEqual(list(DeepCopyTester.objects.filter(q2).all()), [dct2]) 74 | assert q2.children[0][0] == "DeepCopyTester2___binary_field2" 75 | 76 | assert len(DeepCopyTester.objects.filter(Q(binary_field=memoryview(d1_bf)))) == 1 77 | 78 | self.assertEqual(DeepCopyTester.objects.all().delete()[0], 3) 79 | self.assertEqual(DeepCopyTester.objects.count(), 0) 80 | -------------------------------------------------------------------------------- /example/pexp/management/commands/p2cmd.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a scratchpad for general development, testing & debugging 3 | Well, even more so than pcmd.py. You best ignore p2cmd.py. 4 | """ 5 | 6 | import sys 7 | import time 8 | from pprint import pprint 9 | from random import Random 10 | 11 | from django.core.management import BaseCommand 12 | from django.db import connection 13 | from pexp.models import * 14 | 15 | rnd = Random() 16 | 17 | 18 | def show_queries(): 19 | print() 20 | print("QUERIES:", len(connection.queries)) 21 | pprint(connection.queries) 22 | print() 23 | connection.queries = [] 24 | 25 | 26 | def print_timing(func, message="", iterations=1): 27 | def wrapper(*arg): 28 | results = [] 29 | connection.queries_log.clear() 30 | for i in range(iterations): 31 | t1 = time.time() 32 | x = func(*arg) 33 | t2 = time.time() 34 | results.append((t2 - t1) * 1000.0) 35 | res_sum = 0 36 | for r in results: 37 | res_sum += r 38 | print( 39 | f"{message}{func.func_name:<19}: {res_sum:.4f} ms, " 40 | f"{len(connection.queries)} queries ({iterations} times)" 41 | ) 42 | sys.stdout.flush() 43 | 44 | return wrapper 45 | 46 | 47 | class Command(BaseCommand): 48 | help = "" 49 | 50 | def handle_noargs(self, **options): 51 | if False: 52 | TestModelA.objects.all().delete() 53 | a = TestModelA.objects.create(field1="A1") 54 | b = TestModelB.objects.create(field1="B1", field2="B2") 55 | c = TestModelC.objects.create(field1="C1", field2="C2", field3="C3") 56 | connection.queries_log.clear() 57 | print(TestModelC.base_objects.all()) 58 | show_queries() 59 | 60 | if False: 61 | TestModelA.objects.all().delete() 62 | for i in range(1000): 63 | a = TestModelA.objects.create(field1=str(i % 100)) 64 | b = TestModelB.objects.create(field1=str(i % 100), field2=str(i % 200)) 65 | c = TestModelC.objects.create( 66 | field1=str(i % 100), field2=str(i % 200), field3=str(i % 300) 67 | ) 68 | if i % 100 == 0: 69 | print(i) 70 | 71 | f = print_timing(poly_sql_query, iterations=1000) 72 | f() 73 | 74 | f = print_timing(poly_sql_query2, iterations=1000) 75 | f() 76 | 77 | return 78 | 79 | NormalModelA.objects.all().delete() 80 | a = NormalModelA.objects.create(field1="A1") 81 | b = NormalModelB.objects.create(field1="B1", field2="B2") 82 | c = NormalModelC.objects.create(field1="C1", field2="C2", field3="C3") 83 | qs = TestModelA.objects.raw("SELECT * from pexp_testmodela") 84 | for o in list(qs): 85 | print(o) 86 | 87 | 88 | def poly_sql_query(): 89 | cursor = connection.cursor() 90 | cursor.execute( 91 | """ 92 | SELECT id, pexp_testmodela.field1, pexp_testmodelb.field2, pexp_testmodelc.field3 93 | FROM pexp_testmodela 94 | LEFT OUTER JOIN pexp_testmodelb 95 | ON pexp_testmodela.id = pexp_testmodelb.testmodela_ptr_id 96 | LEFT OUTER JOIN pexp_testmodelc 97 | ON pexp_testmodelb.testmodela_ptr_id = pexp_testmodelc.testmodelb_ptr_id 98 | WHERE pexp_testmodela.field1=%i 99 | ORDER BY pexp_testmodela.id 100 | """ 101 | % rnd.randint(0, 100) 102 | ) 103 | # row=cursor.fetchone() 104 | return 105 | 106 | 107 | def poly_sql_query2(): 108 | cursor = connection.cursor() 109 | cursor.execute( 110 | f""" 111 | SELECT id, pexp_testmodela.field1 112 | FROM pexp_testmodela 113 | WHERE pexp_testmodela.field1={rnd.randint(0, 100)} 114 | ORDER BY pexp_testmodela.id 115 | """ 116 | ) 117 | # row=cursor.fetchone() 118 | return 119 | -------------------------------------------------------------------------------- /docs/performance.rst: -------------------------------------------------------------------------------- 1 | .. _performance: 2 | 3 | Performance Considerations 4 | ========================== 5 | 6 | Usually, when Django users create their own polymorphic ad-hoc solution without a tool like 7 | :pypi:`django-polymorphic`, this usually results in a variation of: 8 | 9 | .. code-block:: python 10 | 11 | result_objects = [ o.get_real_instance() for o in BaseModel.objects.filter(...) ] 12 | 13 | which has very bad performance, as it introduces one additional SQL query for every object in the 14 | result which is not of class ``BaseModel``. Compared to these solutions, :pypi:`django-polymorphic` 15 | has the advantage that it only needs 1 SQL query *per object type*, and not *per object*. 16 | 17 | The current implementation does not use any custom SQL or Django DB layer internals - it is purely 18 | based on the standard Django ORM. Specifically, the query: 19 | 20 | .. code-block:: python 21 | 22 | result_objects = list( ModelA.objects.filter(...) ) 23 | 24 | performs one SQL query to retrieve ``ModelA`` objects and one additional query for each unique 25 | derived class occurring in result_objects. The best case for retrieving 100 objects is 1 SQL query 26 | if all are class ``ModelA``. If 50 objects are ``ModelA`` and 50 are ``ModelB``, then two queries 27 | are executed. The pathological worst case is 101 db queries if result_objects contains 100 different 28 | object types (with all of them subclasses of ``ModelA``). 29 | 30 | Iteration: Memory vs DB Round Trips 31 | ----------------------------------- 32 | 33 | When iterating over large QuerySets, there is a trade-off between memory consumption and number 34 | of round trips to the database. One additional query is needed per model subclass present in the 35 | QuerySet and these queries take the form of ``SELECT ... WHERE pk IN (....)`` with a potentially 36 | large number of IDs in the IN clause. All models in the IN clause will be loaded into memory during 37 | iteration. 38 | 39 | To balance this trade-off, by default a maximum of 2000 objects are requested at once. This means 40 | that if your QuerySet contains 10,000 objects of 3 different subclasses, then 16 queries will be 41 | executed: 1 to fetch the base objects, and 5 (10/2 == 5) * 3 more to fetch the subclasses. 42 | 43 | The `chunk_size` parameter on :meth:`~django.db.models.query.QuerySet.iterator` can be used to 44 | change the number of objects loaded into memory at once during iteration. For example, to load 5000 objects at once: 45 | 46 | .. code-block:: python 47 | 48 | for obj in ModelA.objects.all().iterator(chunk_size=5000): 49 | process(obj) 50 | 51 | .. note:: 52 | 53 | ``chunk_size`` on non-polymorphic QuerySets controls the number of rows fetched from the 54 | database at once, but for polymorphic QuerySets the behavior is more analogous to its behavior 55 | when :meth:`~django.db.models.query.QuerySet.prefetch_related` is used. 56 | 57 | Some database backends limit the number of parameters in a query. For those backends the 58 | ``chunk_size`` will be restricted to be no greater than that limit. This limit can be checked in: 59 | 60 | .. code-block:: python 61 | 62 | from django.db import connection 63 | 64 | print(connection.features.max_query_params) 65 | 66 | 67 | You may change the global default fallback ``chunk_size`` by modifying the 68 | :attr:`polymorphic.query.Polymorphic_QuerySet_objects_per_request` attribute. Place code like 69 | this somewhere that will be executed during startup: 70 | 71 | .. code-block:: python 72 | 73 | from polymorphic import query 74 | 75 | query.Polymorphic_QuerySet_objects_per_request = 5000 76 | 77 | 78 | :class:`~django.contrib.contenttypes.models.ContentType` retrieval 79 | ------------------------------------------------------------------ 80 | 81 | When fetching the :class:`~django.contrib.contenttypes.models.ContentType` class, it's tempting to 82 | read the :attr:`~polymorphic.models.PolymorphicModel.polymorphic_ctype` field directly. However, 83 | this performs an additional query via the :class:`~django.db.models.ForeignKey` object to fetch the 84 | :class:`~django.contrib.contenttypes.models.ContentType`. Instead, use: 85 | 86 | .. code-block:: python 87 | 88 | from django.contrib.contenttypes.models import ContentType 89 | 90 | ctype = ContentType.objects.get_for_id(object.polymorphic_ctype_id) 91 | 92 | This uses the :meth:`~django.contrib.contenttypes.models.ContentTypeManager.get_for_id` function 93 | which caches the results internally. 94 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | =========== 3 | 4 | Install the project using:: 5 | 6 | pip install django-polymorphic 7 | 8 | Update the settings file: 9 | 10 | .. code-block:: python 11 | 12 | INSTALLED_APPS += ( 13 | 'polymorphic', 14 | 'django.contrib.contenttypes', 15 | ) 16 | 17 | The current release of :pypi:`django-polymorphic` supports: 18 | 19 | .. image:: https://badge.fury.io/py/django-polymorphic.svg 20 | :target: https://pypi.python.org/pypi/django-polymorphic/ 21 | :alt: PyPI version 22 | 23 | .. image:: https://img.shields.io/pypi/pyversions/django-polymorphic.svg 24 | :target: https://pypi.python.org/pypi/django-polymorphic/ 25 | :alt: Supported Pythons 26 | 27 | .. image:: https://img.shields.io/pypi/djversions/django-polymorphic.svg 28 | :target: https://pypi.org/project/django-polymorphic/ 29 | :alt: Supported Django 30 | 31 | 32 | Making Your Models Polymorphic 33 | ------------------------------ 34 | 35 | Use :class:`~polymorphic.models.PolymorphicModel` instead of Django's 36 | :class:`~django.db.models.Model`, like so: 37 | 38 | .. code-block:: python 39 | 40 | from polymorphic.models import PolymorphicModel 41 | 42 | class Project(PolymorphicModel): 43 | topic = models.CharField(max_length=30) 44 | 45 | class ArtProject(Project): 46 | artist = models.CharField(max_length=30) 47 | 48 | class ResearchProject(Project): 49 | supervisor = models.CharField(max_length=30) 50 | 51 | All models inheriting from your polymorphic models will be polymorphic as well. 52 | 53 | Using Polymorphic Models 54 | ------------------------ 55 | 56 | Create some objects: 57 | 58 | .. code-block:: python 59 | 60 | >>> Project.objects.create(topic="Department Party") 61 | >>> ArtProject.objects.create(topic="Painting with Tim", artist="T. Turner") 62 | >>> ResearchProject.objects.create(topic="Swallow Aerodynamics", supervisor="Dr. Winter") 63 | 64 | Get polymorphic query results: 65 | 66 | .. code-block:: python 67 | 68 | >>> Project.objects.all() 69 | [ , 70 | , 71 | ] 72 | 73 | Use :meth:`~polymorphic.managers.PolymorphicQuerySet.instance_of` and 74 | :meth:`~polymorphic.managers.PolymorphicQuerySet.not_instance_of` for narrowing the result to 75 | specific subtypes: 76 | 77 | .. code-block:: python 78 | 79 | >>> Project.objects.instance_of(ArtProject) 80 | [ ] 81 | 82 | .. code-block:: python 83 | 84 | >>> Project.objects.instance_of(ArtProject) | Project.objects.instance_of(ResearchProject) 85 | [ , 86 | ] 87 | 88 | Polymorphic filtering: Get all projects where Mr. Turner is involved as an artist 89 | or supervisor (note the three underscores): 90 | 91 | .. code-block:: python 92 | 93 | >>> Project.objects.filter(Q(ArtProject___artist='T. Turner') | Q(ResearchProject___supervisor='T. Turner')) 94 | [ , 95 | ] 96 | 97 | This is basically all you need to know, as *django-polymorphic* mostly 98 | works fully automatic and just delivers the expected results. 99 | 100 | .. note:: 101 | 102 | When using the :django-admin:`dumpdata` management command on polymorphic tables 103 | (or any table that has a reference to :class:`~django.contrib.contenttypes.models.ContentType`), 104 | include the :option:`--natural-primary ` and 105 | :option:`--natural-foreign ` flag in the arguments. This makes sure 106 | the :class:`~django.contrib.contenttypes.models.ContentType` models will be referenced by name 107 | instead of their primary key as that changes between Django instances. 108 | 109 | 110 | .. note:: 111 | While :pypi:`django-polymorphic` makes subclassed models easy to use in Django, 112 | we still encourage to use them with caution. Each subclassed model will require 113 | Django to perform an ``INNER JOIN`` to fetch the model fields from the database. 114 | While taking this in mind, there are valid reasons for using subclassed models. 115 | That's what this library is designed for! 116 | -------------------------------------------------------------------------------- /src/polymorphic/contrib/extra_views.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ``extra_views.formsets`` provides a simple way to handle formsets. 3 | The ``extra_views.advanced`` provides a method to combine that with a create/update form. 4 | 5 | This package provides classes that support both options for polymorphic formsets. 6 | """ 7 | 8 | import extra_views 9 | from django.core.exceptions import ImproperlyConfigured 10 | 11 | from polymorphic.formsets import ( 12 | BasePolymorphicInlineFormSet, 13 | BasePolymorphicModelFormSet, 14 | polymorphic_child_forms_factory, 15 | ) 16 | 17 | __all__ = ( 18 | "PolymorphicFormSetView", 19 | "PolymorphicInlineFormSetView", 20 | "PolymorphicInlineFormSet", 21 | ) 22 | 23 | 24 | class PolymorphicFormSetMixin: 25 | """ 26 | Internal Mixin, that provides polymorphic integration with the ``extra_views`` package. 27 | """ 28 | 29 | formset_class = BasePolymorphicModelFormSet 30 | 31 | #: Default 0 extra forms 32 | factory_kwargs = {"extra": 0} 33 | 34 | #: Define the children 35 | # :type: list[PolymorphicFormSetChild] 36 | formset_children = None 37 | 38 | def get_formset_children(self): 39 | """ 40 | :rtype: list[PolymorphicFormSetChild] 41 | """ 42 | if not self.formset_children: 43 | raise ImproperlyConfigured( 44 | "Define 'formset_children' as list of `PolymorphicFormSetChild`" 45 | ) 46 | return self.formset_children 47 | 48 | def get_formset_child_kwargs(self): 49 | return {} 50 | 51 | def get_formset(self): 52 | """ 53 | Returns the formset class from the inline formset factory 54 | """ 55 | # Implementation detail: 56 | # Since `polymorphic_modelformset_factory` and `polymorphic_inlineformset_factory` mainly 57 | # reuse the standard factories, and then add `child_forms`, the same can be done here. 58 | # This makes sure the base class construction is completely honored. 59 | FormSet = super().get_formset() 60 | FormSet.child_forms = polymorphic_child_forms_factory( 61 | self.get_formset_children(), **self.get_formset_child_kwargs() 62 | ) 63 | return FormSet 64 | 65 | 66 | class PolymorphicFormSetView(PolymorphicFormSetMixin, extra_views.ModelFormSetView): 67 | """ 68 | A view that displays a single polymorphic formset. 69 | 70 | .. code-block:: python 71 | 72 | from polymorphic.formsets import PolymorphicFormSetChild 73 | 74 | 75 | class ItemsView(PolymorphicFormSetView): 76 | model = Item 77 | formset_children = [ 78 | PolymorphicFormSetChild(ItemSubclass1), 79 | PolymorphicFormSetChild(ItemSubclass2), 80 | ] 81 | 82 | """ 83 | 84 | formset_class = BasePolymorphicModelFormSet 85 | 86 | 87 | class PolymorphicInlineFormSetView(PolymorphicFormSetMixin, extra_views.InlineFormSetView): 88 | """ 89 | A view that displays a single polymorphic formset - with one parent object. 90 | This is a variation of the :mod:`extra_views` package classes for django-polymorphic. 91 | 92 | .. code-block:: python 93 | 94 | from polymorphic.formsets import PolymorphicFormSetChild 95 | 96 | 97 | class OrderItemsView(PolymorphicInlineFormSetView): 98 | model = Order 99 | inline_model = Item 100 | formset_children = [ 101 | PolymorphicFormSetChild(ItemSubclass1), 102 | PolymorphicFormSetChild(ItemSubclass2), 103 | ] 104 | """ 105 | 106 | formset_class = BasePolymorphicInlineFormSet 107 | 108 | 109 | class PolymorphicInlineFormSet(PolymorphicFormSetMixin, extra_views.InlineFormSetFactory): 110 | """ 111 | An inline to add to the ``inlines`` of 112 | the :class:`~extra_views.advanced.CreateWithInlinesView` 113 | and :class:`~extra_views.advanced.UpdateWithInlinesView` class. 114 | 115 | .. code-block:: python 116 | 117 | from polymorphic.formsets import PolymorphicFormSetChild 118 | 119 | 120 | class ItemsInline(PolymorphicInlineFormSet): 121 | model = Item 122 | formset_children = [ 123 | PolymorphicFormSetChild(ItemSubclass1), 124 | PolymorphicFormSetChild(ItemSubclass2), 125 | ] 126 | 127 | 128 | class OrderCreateView(CreateWithInlinesView): 129 | model = Order 130 | inlines = [ItemsInline] 131 | 132 | def get_success_url(self): 133 | return self.object.get_absolute_url() 134 | 135 | """ 136 | 137 | formset_class = BasePolymorphicInlineFormSet 138 | -------------------------------------------------------------------------------- /src/polymorphic/formsets/generic.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.forms import ( 2 | BaseGenericInlineFormSet, 3 | generic_inlineformset_factory, 4 | ) 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import models 7 | from django.forms.models import ModelForm 8 | 9 | from .models import ( 10 | BasePolymorphicModelFormSet, 11 | PolymorphicFormSetChild, 12 | polymorphic_child_forms_factory, 13 | ) 14 | 15 | 16 | class GenericPolymorphicFormSetChild(PolymorphicFormSetChild): 17 | """ 18 | Formset child for generic inlines 19 | """ 20 | 21 | def __init__(self, *args, **kwargs): 22 | self.ct_field = kwargs.pop("ct_field", "content_type") 23 | self.fk_field = kwargs.pop("fk_field", "object_id") 24 | super().__init__(*args, **kwargs) 25 | 26 | def get_form(self, ct_field="content_type", fk_field="object_id", **kwargs): 27 | """ 28 | Construct the form class for the formset child. 29 | """ 30 | exclude = list(self.exclude) 31 | extra_exclude = kwargs.pop("extra_exclude", None) 32 | if extra_exclude: 33 | exclude += list(extra_exclude) 34 | 35 | # Make sure the GFK fields are excluded by default 36 | # This is similar to what generic_inlineformset_factory() does 37 | # if there is no field called `ct_field` let the exception propagate 38 | opts = self.model._meta 39 | ct_field = opts.get_field(self.ct_field) 40 | 41 | if ( 42 | not isinstance(ct_field, models.ForeignKey) 43 | or ct_field.remote_field.model != ContentType 44 | ): 45 | raise Exception(f"fk_name '{ct_field}' is not a ForeignKey to ContentType") 46 | 47 | fk_field = opts.get_field(self.fk_field) # let the exception propagate 48 | exclude.extend([ct_field.name, fk_field.name]) 49 | kwargs["exclude"] = exclude 50 | 51 | return super().get_form(**kwargs) 52 | 53 | 54 | class BaseGenericPolymorphicInlineFormSet(BaseGenericInlineFormSet, BasePolymorphicModelFormSet): 55 | """ 56 | Polymorphic formset variation for inline generic formsets 57 | """ 58 | 59 | 60 | def generic_polymorphic_inlineformset_factory( 61 | model, 62 | formset_children, 63 | form=ModelForm, 64 | formset=BaseGenericPolymorphicInlineFormSet, 65 | ct_field="content_type", 66 | fk_field="object_id", 67 | # Base form 68 | # TODO: should these fields be removed in favor of creating 69 | # the base form as a formset child too? 70 | fields=None, 71 | exclude=None, 72 | extra=1, 73 | can_order=False, 74 | can_delete=True, 75 | max_num=None, 76 | formfield_callback=None, 77 | validate_max=False, 78 | for_concrete_model=True, 79 | min_num=None, 80 | validate_min=False, 81 | child_form_kwargs=None, 82 | ): 83 | """ 84 | Construct the class for a generic inline polymorphic formset. 85 | 86 | All arguments are identical to :func:`~django.contrib.contenttypes.forms.generic_inlineformset_factory`, 87 | with the exception of the ``formset_children`` argument. 88 | 89 | :param formset_children: A list of all child :class:`PolymorphicFormSetChild` objects 90 | that tell the inline how to render the child model types. 91 | :type formset_children: Iterable[PolymorphicFormSetChild] 92 | :rtype: type 93 | """ 94 | kwargs = { 95 | "model": model, 96 | "form": form, 97 | "formfield_callback": formfield_callback, 98 | "formset": formset, 99 | "ct_field": ct_field, 100 | "fk_field": fk_field, 101 | "extra": extra, 102 | "can_delete": can_delete, 103 | "can_order": can_order, 104 | "fields": fields, 105 | "exclude": exclude, 106 | "min_num": min_num, 107 | "max_num": max_num, 108 | "validate_min": validate_min, 109 | "validate_max": validate_max, 110 | "for_concrete_model": for_concrete_model, 111 | # 'localized_fields': localized_fields, 112 | # 'labels': labels, 113 | # 'help_texts': help_texts, 114 | # 'error_messages': error_messages, 115 | # 'field_classes': field_classes, 116 | } 117 | if child_form_kwargs is None: 118 | child_form_kwargs = {} 119 | 120 | child_kwargs = { 121 | # 'exclude': exclude, 122 | "ct_field": ct_field, 123 | "fk_field": fk_field, 124 | } 125 | if child_form_kwargs: 126 | child_kwargs.update(child_form_kwargs) 127 | 128 | FormSet = generic_inlineformset_factory(**kwargs) 129 | FormSet.child_forms = polymorphic_child_forms_factory(formset_children, **child_kwargs) 130 | return FormSet 131 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-polymorphic 2 | ================== 3 | 4 | .. image:: https://img.shields.io/badge/License-BSD-blue.svg 5 | :target: https://opensource.org/license/bsd-3-clause 6 | :alt: License: BSD 7 | 8 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 9 | :target: https://github.com/astral-sh/ruff 10 | :alt: Ruff 11 | 12 | .. image:: https://badge.fury.io/py/django-polymorphic.svg 13 | :target: https://pypi.python.org/pypi/django-polymorphic/ 14 | :alt: PyPI version 15 | 16 | .. image:: https://img.shields.io/pypi/pyversions/django-polymorphic.svg 17 | :target: https://pypi.python.org/pypi/django-polymorphic/ 18 | :alt: PyPI pyversions 19 | 20 | .. image:: https://img.shields.io/pypi/djversions/django-polymorphic.svg 21 | :target: https://pypi.org/project/django-polymorphic/ 22 | :alt: PyPI Django versions 23 | 24 | .. image:: https://img.shields.io/pypi/status/django-polymorphic.svg 25 | :target: https://pypi.python.org/pypi/django-polymorphic 26 | :alt: PyPI status 27 | 28 | .. image:: https://readthedocs.org/projects/django-polymorphic/badge/?version=latest 29 | :target: http://django-polymorphic.readthedocs.io/?badge=latest/ 30 | :alt: Documentation Status 31 | 32 | .. image:: https://img.shields.io/codecov/c/github/jazzband/django-polymorphic/master.svg 33 | :target: https://codecov.io/github/jazzband/django-polymorphic?branch=master 34 | :alt: Code Coverage 35 | 36 | .. image:: https://github.com/jazzband/django-polymorphic/actions/workflows/test.yml/badge.svg?branch=master 37 | :target: https://github.com/jazzband/django-polymorphic/actions/workflows/test.yml?query=branch:master 38 | :alt: Test Status 39 | 40 | .. image:: https://github.com/jazzband/django-polymorphic/actions/workflows/lint.yml/badge.svg?branch=master 41 | :target: https://github.com/jazzband/django-polymorphic/actions/workflows/lint.yml?query=branch:master 42 | :alt: Lint Status 43 | 44 | .. image:: https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26 45 | :target: https://djangopackages.org/packages/p/django-polymorphic/ 46 | :alt: Published on Django Packages 47 | 48 | .. image:: https://jazzband.co/static/img/badge.svg 49 | :target: https://jazzband.co/ 50 | :alt: Jazzband 51 | 52 | 53 | :pypi:`django-polymorphic` builds on top of the standard Django model inheritance. 54 | It makes using inherited models easier. When a query is made at the base model, 55 | the inherited model classes are returned. 56 | 57 | When we store models that inherit from a ``Project`` model... 58 | 59 | .. code-block:: python 60 | 61 | >>> Project.objects.create(topic="Department Party") 62 | >>> ArtProject.objects.create(topic="Painting with Tim", artist="T. Turner") 63 | >>> ResearchProject.objects.create(topic="Swallow Aerodynamics", supervisor="Dr. Winter") 64 | 65 | ...and want to retrieve all our projects, the subclassed models are returned! 66 | 67 | .. code-block:: python 68 | 69 | >>> Project.objects.all() 70 | [ , 71 | , 72 | ] 73 | 74 | Using vanilla Django, we get the base class objects, which is rarely what we wanted: 75 | 76 | .. code-block:: python 77 | 78 | >>> Project.objects.all() 79 | [ , 80 | , 81 | ] 82 | 83 | Features 84 | -------- 85 | 86 | * Full admin integration. 87 | * ORM integration: 88 | 89 | - Support for ForeignKey, ManyToManyField, OneToOneField descriptors. 90 | - Support for proxy models. 91 | - Filtering/ordering of inherited models (``ArtProject___artist``). 92 | - Filtering model types: :meth:`~polymorphic.managers.PolymorphicQuerySet.instance_of` and 93 | :meth:`~polymorphic.managers.PolymorphicQuerySet.not_instance_of` 94 | - Combining querysets of different models (``qs3 = qs1 | qs2``) 95 | - Support for custom user-defined managers. 96 | 97 | * Formset support. 98 | * Uses the minimum amount of queries needed to fetch the inherited models. 99 | * Disabling polymorphic behavior when needed. 100 | 101 | 102 | Getting started 103 | --------------- 104 | 105 | .. toctree:: 106 | :maxdepth: 2 107 | 108 | quickstart 109 | admin 110 | performance 111 | integrations 112 | 113 | Advanced topics 114 | --------------- 115 | 116 | .. toctree:: 117 | :maxdepth: 2 118 | 119 | formsets 120 | migrating 121 | managers 122 | deletion 123 | advanced 124 | changelog 125 | api/index 126 | 127 | 128 | Indices and tables 129 | ================== 130 | 131 | * :ref:`genindex` 132 | * :ref:`modindex` 133 | * :ref:`search` 134 | -------------------------------------------------------------------------------- /docs/managers.rst: -------------------------------------------------------------------------------- 1 | Managers & Querysets 2 | ==================== 3 | 4 | Using a Custom Manager 5 | ---------------------- 6 | 7 | A nice feature of Django is the possibility to define one's own custom object managers. 8 | This is fully supported with :pypi:`django-polymorphic`. For creating a custom polymorphic 9 | manager class, just derive your manager from :class:`~polymorphic.managers.PolymorphicManager` 10 | instead of :class:`~django.db.models.Manager`. As with vanilla Django, in your model class, you 11 | should explicitly add the default manager first, and then your custom manager: 12 | 13 | .. code-block:: python 14 | 15 | from polymorphic.models import PolymorphicModel 16 | from polymorphic.managers import PolymorphicManager 17 | 18 | class TimeOrderedManager(PolymorphicManager): 19 | def get_queryset(self): 20 | qs = super(TimeOrderedManager,self).get_queryset() 21 | return qs.order_by('-start_date') 22 | 23 | def most_recent(self): 24 | qs = self.get_queryset() # get my ordered queryset 25 | return qs[:10] # limit => get ten most recent entries 26 | 27 | class Project(PolymorphicModel): 28 | objects = PolymorphicManager() # add the default polymorphic manager first 29 | objects_ordered = TimeOrderedManager() # then add your own manager 30 | start_date = DateTimeField() # project start is this date/time 31 | 32 | The first manager defined (:attr:`~django.db.models.Model.objects` in the example) is used by Django 33 | as automatic manager for several purposes, including accessing related objects. It must not filter 34 | objects and it's safest to use the plain :class:`~polymorphic.managers.PolymorphicManager` here. 35 | 36 | Manager Inheritance 37 | ------------------- 38 | 39 | Polymorphic models inherit/propagate all managers from their base models, as long as these are 40 | polymorphic. This means that all managers defined in polymorphic base models continue to work as 41 | expected in models inheriting from this base model: 42 | 43 | .. code-block:: python 44 | 45 | from polymorphic.models import PolymorphicModel 46 | from polymorphic.managers import PolymorphicManager 47 | 48 | class TimeOrderedManager(PolymorphicManager): 49 | def get_queryset(self): 50 | qs = super(TimeOrderedManager,self).get_queryset() 51 | return qs.order_by('-start_date') 52 | 53 | def most_recent(self): 54 | qs = self.get_queryset() # get my ordered queryset 55 | return qs[:10] # limit => get ten most recent entries 56 | 57 | class Project(PolymorphicModel): 58 | objects = PolymorphicManager() # add the default polymorphic manager first 59 | objects_ordered = TimeOrderedManager() # then add your own manager 60 | start_date = DateTimeField() # project start is this date/time 61 | 62 | class ArtProject(Project): # inherit from Project, inheriting its fields and managers 63 | artist = models.CharField(max_length=30) 64 | 65 | ArtProject inherited the managers ``objects`` and ``objects_ordered`` from Project. 66 | 67 | ``ArtProject.objects_ordered.all()`` will return all art projects ordered regarding their start time 68 | and ``ArtProject.objects_ordered.most_recent()`` will return the ten most recent art projects. 69 | 70 | Using a Custom Queryset Class 71 | ----------------------------- 72 | 73 | The :class:`~polymorphic.managers.PolymorphicManager` class accepts one initialization argument, 74 | which is the queryset class the manager should use. Just as with vanilla Django, you may define your 75 | own custom queryset classes. Just use :class:`~polymorphic.managers.PolymorphicQuerySet` instead of 76 | Django's :class:`~django.db.models.query.QuerySet` as the base class: 77 | 78 | .. code-block:: python 79 | 80 | from polymorphic.models import PolymorphicModel 81 | from polymorphic.managers import PolymorphicManager 82 | from polymorphic.query import PolymorphicQuerySet 83 | 84 | class MyQuerySet(PolymorphicQuerySet): 85 | def my_queryset_method(self): 86 | ... 87 | 88 | class MyModel(PolymorphicModel): 89 | my_objects = PolymorphicManager.from_queryset(MyQuerySet)() 90 | ... 91 | 92 | If you do not wish to extend from a custom :class:`~polymorphic.managers.PolymorphicManager` you 93 | may also prefer the :meth:`~polymorphic.managers.PolymorphicQuerySet.as_manager` 94 | shortcut: 95 | 96 | .. code-block:: python 97 | 98 | from polymorphic.models import PolymorphicModel 99 | from polymorphic.query import PolymorphicQuerySet 100 | 101 | class MyQuerySet(PolymorphicQuerySet): 102 | def my_queryset_method(self): 103 | ... 104 | 105 | class MyModel(PolymorphicModel): 106 | my_objects = MyQuerySet.as_manager() 107 | ... 108 | 109 | For further discussion see `this topic on the Q&A page 110 | `_. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | permissions: read-all 4 | 5 | concurrency: 6 | # stop previous release runs if tag is recreated 7 | group: release-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | on: 11 | push: 12 | tags: 13 | - 'v*' # only publish on version tags (e.g. v1.0.0) 14 | 15 | jobs: 16 | 17 | lint: 18 | if: github.repository == 'jazzband/django-polymorphic' 19 | name: Lint 20 | permissions: 21 | contents: read 22 | actions: write 23 | uses: ./.github/workflows/lint.yml 24 | secrets: inherit 25 | 26 | test: 27 | if: github.repository == 'jazzband/django-polymorphic' 28 | name: Test 29 | permissions: 30 | contents: read 31 | actions: write 32 | uses: ./.github/workflows/test.yml 33 | secrets: inherit 34 | 35 | build: 36 | if: github.repository == 'jazzband/django-polymorphic' 37 | name: Build Package 38 | runs-on: ubuntu-latest 39 | permissions: 40 | contents: read 41 | actions: write 42 | outputs: 43 | PACKAGE_NAME: ${{ steps.set-package.outputs.package_name }} 44 | RELEASE_VERSION: ${{ steps.set-package.outputs.release_version }} 45 | steps: 46 | - uses: actions/checkout@v6 47 | - name: Set up Python 48 | uses: actions/setup-python@v6 49 | id: sp 50 | with: 51 | python-version: "3.13" # for tomlib 52 | - name: Install uv 53 | uses: astral-sh/setup-uv@v7 54 | with: 55 | enable-cache: true 56 | - name: Setup Just 57 | uses: extractions/setup-just@v3 58 | - name: Install Dependencies 59 | run: | 60 | just setup ${{ steps.sp.outputs.python-path }} 61 | sudo apt-get install -y gettext 62 | - name: Verify Tag 63 | run: | 64 | TAG_NAME=${GITHUB_REF#refs/tags/} 65 | echo "Verifying tag $TAG_NAME..." 66 | # if a tag was deleted and recreated we may have the old one cached 67 | # be sure that we're publishing the current tag! 68 | git fetch --force origin refs/tags/$TAG_NAME:refs/tags/$TAG_NAME 69 | 70 | # verify signature 71 | curl -sL https://github.com/${{ github.actor }}.gpg | gpg --import 72 | git tag -v "$TAG_NAME" 73 | 74 | # verify version 75 | RELEASE_VERSION=$(just validate_version $TAG_NAME) 76 | 77 | # export the release version 78 | echo "RELEASE_VERSION=${RELEASE_VERSION}" >> $GITHUB_ENV 79 | - name: Build the binary wheel and a source tarball 80 | run: just build 81 | - name: Store the distribution packages 82 | uses: actions/upload-artifact@v5 83 | with: 84 | name: python-package-distributions 85 | path: dist/ 86 | - name: Set Package Name 87 | id: set-package 88 | run: 89 | PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])") 90 | echo "PACKAGE_NAME=${PACKAGE_NAME}" >> $GITHUB_ENV 91 | 92 | publish-to-jazzband: 93 | name: Publish to Jazzband 94 | needs: 95 | - lint 96 | - test 97 | - build 98 | runs-on: ubuntu-latest 99 | steps: 100 | - name: Download all the dists 101 | uses: actions/download-artifact@v6 102 | with: 103 | name: python-package-distributions 104 | path: dist/ 105 | - name: Upload Package to Jazzband 106 | uses: pypa/gh-action-pypi-publish@release/v1.13 107 | with: 108 | user: jazzband 109 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 110 | attestations: false 111 | repository-url: https://jazzband.co/projects/django-polymorphic/upload 112 | verbose: true 113 | 114 | github-release: 115 | name: Publish GitHub Release 116 | runs-on: ubuntu-latest 117 | needs: 118 | - lint 119 | - test 120 | - build 121 | permissions: 122 | contents: write # IMPORTANT: mandatory for making GitHub Releases 123 | id-token: write # IMPORTANT: mandatory for sigstore 124 | 125 | steps: 126 | - name: Download all the dists 127 | uses: actions/download-artifact@v6 128 | with: 129 | name: python-package-distributions 130 | path: dist/ 131 | - name: Sign the dists with Sigstore 132 | uses: sigstore/gh-action-sigstore-python@v3.1.0 133 | with: 134 | inputs: >- 135 | ./dist/*.tar.gz 136 | ./dist/*.whl 137 | - name: Create GitHub Release 138 | env: 139 | GITHUB_TOKEN: ${{ github.token }} 140 | run: >- 141 | gh release create 142 | '${{ github.ref_name }}' 143 | --repo '${{ github.repository }}' 144 | --generate-notes 145 | --prerelease 146 | - name: Upload artifact signatures to GitHub Release 147 | env: 148 | GITHUB_TOKEN: ${{ github.token }} 149 | # Upload to GitHub Release using the `gh` CLI. 150 | # `dist/` contains the built packages, and the 151 | # sigstore-produced signatures and certificates. 152 | run: >- 153 | gh release upload 154 | '${{ github.ref_name }}' dist/** 155 | --repo '${{ github.repository }}' 156 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-polymorphic" 7 | version = "4.4.1" 8 | description = "Seamless polymorphic inheritance for Django models." 9 | readme = "README.md" 10 | license = "BSD-3-Clause" 11 | license-files = [ "LICENSE" ] 12 | requires-python = ">=3.10,<4.0" 13 | repository = "https://github.com/jazzband/django-polymorphic" 14 | homepage = "https://django-polymorphic.rtfd.io" 15 | authors = [ 16 | {name = "Bert Constantin", email = "bert.constantin@gmx.de"}, 17 | {name = "Diederik van der Boor", email = "vdboor@edoburu.nl"}, 18 | {name = "Christopher Glass", email = "tribaal@ubuntu.com"}, 19 | ] 20 | maintainers = [ 21 | {name = "Brian Kohan", email = "bckohan@gmail.com"} 22 | ] 23 | keywords = [ 24 | "django", "polymorphic", "polymorphism", "django-admin", "django-orm", "django-formsets", "model" 25 | ] 26 | classifiers = [ 27 | "Development Status :: 5 - Production/Stable", 28 | "Environment :: Web Environment", 29 | "Framework :: Django", 30 | "Framework :: Django :: 4.2", 31 | "Framework :: Django :: 5.1", 32 | "Framework :: Django :: 5.2", 33 | "Framework :: Django :: 6.0", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: BSD License", 36 | "Natural Language :: English", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3 :: Only", 40 | "Programming Language :: Python :: 3.10", 41 | "Programming Language :: Python :: 3.11", 42 | "Programming Language :: Python :: 3.12", 43 | "Programming Language :: Python :: 3.13", 44 | "Programming Language :: Python :: 3.14", 45 | "Topic :: Database", 46 | "Topic :: Internet :: WWW/HTTP", 47 | "Topic :: Internet :: WWW/HTTP :: Site Management", 48 | "Topic :: Software Development :: Libraries", 49 | "Topic :: Software Development :: Libraries :: Python Modules" 50 | ] 51 | dependencies = [ 52 | "Django >= 4.2", 53 | ] 54 | 55 | [project.urls] 56 | "Download" = "https://github.com/jazzband/django-polymorphic/tarball/master" 57 | "Documentation" = "https://django-polymorphic.readthedocs.io" 58 | "Homepage" = "https://github.com/jazzband/django-polymorphic" 59 | "Repository" = "https://github.com/jazzband/django-polymorphic" 60 | "Issues" = "https://github.com/jazzband/django-polymorphic/issues" 61 | "Changelog" = "https://django-polymorphic.readthedocs.io/en/stable/changelog.html" 62 | "Code_of_Conduct" = "https://jazzband.co/about/conduct" 63 | 64 | [tool.uv] 65 | package = true 66 | 67 | [tool.hatch.version] 68 | path = "src/polymorphic/__init__.py" 69 | 70 | [tool.hatch.build.targets.sdist] 71 | include = ["src/polymorphic"] 72 | exclude = ["src/polymorphic/tests"] 73 | 74 | [tool.hatch.build.targets.wheel] 75 | packages = ["src/polymorphic"] 76 | artifacts = ["*.mo"] 77 | 78 | [tool.doc8] 79 | max-line-length = 100 80 | sphinx = true 81 | 82 | [tool.ruff] 83 | line-length = 99 84 | 85 | exclude = [ 86 | "**/migrations/*.py", 87 | "**/migrations/**", 88 | ] 89 | 90 | [tool.ruff.lint] 91 | extend-ignore = [ 92 | "E501", 93 | ] 94 | select = [ 95 | "E", 96 | "F", 97 | "I", 98 | "W", 99 | ] 100 | 101 | [tool.ruff.lint.per-file-ignores] 102 | "example/**" = [ 103 | "F401", 104 | "F403", 105 | "F405", 106 | "F841", 107 | "I", 108 | ] 109 | "src/polymorphic/tests/**" = [ 110 | "F401", 111 | "F403", 112 | "F405", 113 | "F841", 114 | "I", 115 | ] 116 | 117 | [tool.pytest.ini_options] 118 | DJANGO_SETTINGS_MODULE = "polymorphic.tests.settings" 119 | pythonpath = ["src"] 120 | django_find_project = false 121 | testpaths = ["src/polymorphic/tests"] 122 | python_files = "test_*.py" 123 | python_classes = "Test*" 124 | python_functions = "test_*" 125 | norecursedirs = "*.egg .eggs dist build docs .tox .git __pycache__" 126 | addopts = [ 127 | "--strict-markers", 128 | "--cov" 129 | ] 130 | 131 | [tool.coverage.run] 132 | source = [ 133 | "src" 134 | ] 135 | omit = ["*/migrations/*", "*/tests/*", "src/polymorphic/tests/*"] 136 | branch = true 137 | relative_files = true 138 | 139 | [tool.coverage.report] 140 | show_missing = true 141 | 142 | [dependency-groups] 143 | dev = [ 144 | "coverage>=7.6.1", 145 | "dj-database-url>=2.2.0", 146 | "django-test-migrations>=1.5.0", 147 | "ipdb>=0.13.13", 148 | "ipython>=8.18.1", 149 | "mypy>=1.14.1", 150 | "pre-commit>=3.5.0", 151 | "pytest>=8.3.4", 152 | "pytest-cov>=5.0.0", 153 | "pytest-django>=4.10.0", 154 | "pytest-playwright>=0.7.2", 155 | "ruff>=0.9.8", 156 | "tomlkit>=0.13.3", 157 | "tox>=4.24.1", 158 | "tox-uv>=1.13.1", 159 | ] 160 | docs = [ 161 | "django-extra-views>=0.15.0", 162 | "doc8>=1.1.2", 163 | "furo>=2025.7.19", 164 | "readme-renderer[md]>=43.0", 165 | "sphinx>=7.1.2", 166 | "sphinx-autobuild>=2024.10.3", 167 | "sphinxcontrib-django>=2.5", 168 | ] 169 | psycopg2 = [ 170 | "psycopg2>=2.9.10", 171 | ] 172 | psycopg3 = [ 173 | "psycopg", 174 | ] 175 | mysql = [ 176 | "mysqlclient>=1.4.0", 177 | ] 178 | 179 | cx_oracle = [ 180 | "cx-oracle>=8.3.0", 181 | ] 182 | oracledb = [ 183 | "oracledb>=2.3.0", 184 | ] 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-polymorphic 2 | 3 | [![License: BSD](https://img.shields.io/badge/License-BSD-blue.svg)](https://opensource.org/license/bsd-3-clause) 4 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 5 | [![PyPI version](https://badge.fury.io/py/django-polymorphic.svg)](https://pypi.python.org/pypi/django-polymorphic/) 6 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/django-polymorphic.svg)](https://pypi.python.org/pypi/django-polymorphic/) 7 | [![PyPI djversions](https://img.shields.io/pypi/djversions/django-polymorphic.svg)](https://pypi.org/project/django-polymorphic/) 8 | [![PyPI status](https://img.shields.io/pypi/status/django-polymorphic.svg)](https://pypi.python.org/pypi/django-polymorphic) 9 | [![Documentation Status](https://readthedocs.org/projects/django-polymorphic/badge/?version=latest)](http://django-polymorphic.readthedocs.io/?badge=latest/) 10 | [![Code Cov](https://img.shields.io/codecov/c/github/jazzband/django-polymorphic/master.svg)](https://codecov.io/github/jazzband/django-polymorphic?branch=master) 11 | [![Test Status](https://github.com/jazzband/django-polymorphic/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/jazzband/django-polymorphic/actions/workflows/test.yml?query=branch:master) 12 | [![Lint Status](https://github.com/jazzband/django-polymorphic/actions/workflows/lint.yml/badge.svg?branch=master)](https://github.com/jazzband/django-polymorphic/actions/workflows/lint.yml?query=branch:master) 13 | [![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-polymorphic/) 14 | [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) 15 | 16 | --------------------------------------------------------------------------------------------------- 17 | 18 | [![Postgres](https://img.shields.io/badge/Postgres-12%2B-blue)](https://www.postgresql.org/) 19 | [![MySQL](https://img.shields.io/badge/MySQL-8.0%2B-blue)](https://www.mysql.com/) 20 | [![MariaDB](https://img.shields.io/badge/MariaDB-10.4%2B-blue)](https://mariadb.org/) 21 | [![SQLite](https://img.shields.io/badge/SQLite-3.8%2B-blue)](https://www.sqlite.org/) 22 | [![Oracle](https://img.shields.io/badge/Oracle-21c%2B-blue)](https://www.oracle.com/database/) 23 | 24 | --------------------------------------------------------------------------------------------------- 25 | 26 | ## Polymorphic Models for Django 27 | 28 | [django-polymorphic](https://pypi.python.org/pypi/django-polymorphic) simplifies using inherited models in [Django](https://djangoproject.com) projects. When a query is made at the base model, the inherited model classes are returned. 29 | 30 | When we store models that inherit from a ``Project`` model... 31 | 32 | ```python 33 | 34 | >>> Project.objects.create(topic="Department Party") 35 | >>> ArtProject.objects.create(topic="Painting with Tim", artist="T. Turner") 36 | >>> ResearchProject.objects.create(topic="Swallow Aerodynamics", supervisor="Dr. Winter") 37 | ``` 38 | 39 | ...and want to retrieve all our projects, the subclassed models are returned! 40 | 41 | ```python 42 | 43 | >>> Project.objects.all() 44 | [ , 45 | , 46 | ] 47 | ``` 48 | 49 | Using vanilla Django, we get the base class objects, which is rarely what we wanted: 50 | 51 | ```python 52 | 53 | >>> Project.objects.all() 54 | [ , 55 | , 56 | ] 57 | ``` 58 | 59 | This also works when the polymorphic model is accessed via 60 | ForeignKeys, ManyToManyFields or OneToOneFields. 61 | 62 | ### Features 63 | 64 | * Full admin integration. 65 | * ORM integration: 66 | 67 | * support for ForeignKey, ManyToManyField, OneToOneField descriptors. 68 | * Filtering/ordering of inherited models (``ArtProject___artist``). 69 | * Filtering model types: ``instance_of(...)`` and ``not_instance_of(...)`` 70 | * Combining querysets of different models (``qs3 = qs1 | qs2``) 71 | * Support for custom user-defined managers. 72 | * Uses the minimum amount of queries needed to fetch the inherited models. 73 | * Disabling polymorphic behavior when needed. 74 | 75 | 76 | **Note:** While [django-polymorphic](https://pypi.python.org/pypi/django-polymorphic) makes subclassed models easy to use in Django, we still encourage to use them with caution. Each subclassed model will require Django to perform an ``INNER JOIN`` to fetch the model fields from the database. While taking this in mind, there are valid reasons for using subclassed models. That's what this library is designed for! 77 | 78 | The current release of [django-polymorphic](https://pypi.python.org/pypi/django-polymorphic) supports Django 2.2 - 5.2 on Python 3.9+. 79 | 80 | For more information, see the [documentation at Read the Docs](https://django-polymorphic.readthedocs.io). 81 | 82 | ### Installation 83 | 84 | ```bash 85 | $ pip install django-polymorphic 86 | ``` 87 | 88 | ## License 89 | 90 | [django-polymorphic](https://pypi.python.org/pypi/django-polymorphic) uses the same license as Django (BSD-like). 91 | -------------------------------------------------------------------------------- /src/polymorphic/tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = False 4 | 5 | rdbms = os.environ.get("RDBMS", "sqlite") 6 | 7 | if rdbms == "sqlite": # pragma: no cover 8 | sqlite_dbs = os.environ.get("SQLITE_DATABASES", ":memory:,:memory:").split(",") 9 | DATABASES = { 10 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": sqlite_dbs[0]}, 11 | "secondary": {"ENGINE": "django.db.backends.sqlite3", "NAME": sqlite_dbs[1]}, 12 | } 13 | elif rdbms == "postgres": # pragma: no cover 14 | creds = { 15 | "USER": os.environ.get("POSTGRES_USER", "postgres"), 16 | "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), 17 | "HOST": os.environ.get("POSTGRES_HOST", ""), 18 | "PORT": os.environ.get("POSTGRES_PORT", ""), 19 | } 20 | DATABASES = { 21 | "default": { 22 | "ENGINE": "django.db.backends.postgresql", 23 | "NAME": "test1", 24 | **creds, 25 | }, 26 | "secondary": { 27 | "ENGINE": "django.db.backends.postgresql", 28 | "NAME": "test2", 29 | **creds, 30 | }, 31 | } 32 | elif rdbms == "mysql": # pragma: no cover 33 | dbs = os.environ.get("MYSQL_MULTIPLE_DATABASES", "test1,test2").split(",") 34 | creds = { 35 | "USER": os.environ.get("MYSQL_USER", "root"), 36 | "PASSWORD": os.environ.get("MYSQL_PASSWORD", "root"), 37 | "HOST": os.environ.get("MYSQL_HOST", "127.0.0.1"), 38 | "PORT": os.environ.get("MYSQL_PORT", "3306"), 39 | } 40 | DATABASES = { 41 | "default": { 42 | "ENGINE": "django.db.backends.mysql", 43 | "NAME": dbs[0], 44 | **creds, 45 | }, 46 | "secondary": { 47 | "ENGINE": "django.db.backends.mysql", 48 | "NAME": dbs[1], 49 | **creds, 50 | }, 51 | } 52 | elif rdbms == "mariadb": # pragma: no cover 53 | dbs = os.environ.get("MYSQL_MULTIPLE_DATABASES", "test1,test2").split(",") 54 | creds = { 55 | "USER": os.environ.get("MYSQL_USER", "root"), 56 | "PASSWORD": os.environ.get("MYSQL_ROOT_PASSWORD", "root"), 57 | "HOST": os.environ.get("MYSQL_HOST", "127.0.0.1"), 58 | "PORT": os.environ.get("MYSQL_PORT", "3306"), 59 | } 60 | DATABASES = { 61 | "default": { 62 | "ENGINE": "django.db.backends.mysql", 63 | "NAME": dbs[0], 64 | **creds, 65 | }, 66 | "secondary": { 67 | "ENGINE": "django.db.backends.mysql", 68 | "NAME": dbs[1], 69 | **creds, 70 | }, 71 | } 72 | elif rdbms == "oracle": # pragma: no cover 73 | dbs = os.environ.get("ORACLE_DATABASES", "test1,test2").split(",") 74 | ports = os.environ.get("ORACLE_PORTS", "1521,1522").split(",") 75 | creds = { 76 | "USER": os.environ.get("ORACLE_USER", "system"), 77 | "PASSWORD": os.environ.get("ORACLE_PASSWORD", "password"), 78 | } 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": "django.db.backends.oracle", 82 | "NAME": f"{os.environ.get('ORACLE_HOST', 'localhost')}:{ports[0]}/{dbs[0]}", 83 | **creds, 84 | }, 85 | "secondary": { 86 | "ENGINE": "django.db.backends.oracle", 87 | "NAME": f"{os.environ.get('ORACLE_HOST', 'localhost')}:{ports[1]}/{dbs[1]}", 88 | **creds, 89 | }, 90 | } 91 | 92 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 93 | INSTALLED_APPS = ( 94 | "django.contrib.staticfiles", 95 | "django.contrib.auth", 96 | "django.contrib.contenttypes", 97 | "django.contrib.messages", 98 | "django.contrib.sessions", 99 | "django.contrib.sites", 100 | "django.contrib.admin", 101 | "polymorphic", 102 | "polymorphic.tests", 103 | "polymorphic.tests.deletion", 104 | "polymorphic.tests.test_migrations", 105 | ) 106 | 107 | MIDDLEWARE = ( 108 | "django.middleware.common.CommonMiddleware", 109 | "django.contrib.sessions.middleware.SessionMiddleware", 110 | "django.middleware.csrf.CsrfViewMiddleware", 111 | "django.contrib.auth.middleware.AuthenticationMiddleware", 112 | "django.contrib.messages.middleware.MessageMiddleware", 113 | ) 114 | SITE_ID = 3 115 | TEMPLATES = [ 116 | { 117 | "BACKEND": "django.template.backends.django.DjangoTemplates", 118 | "DIRS": (), 119 | "OPTIONS": { 120 | "loaders": ( 121 | "django.template.loaders.filesystem.Loader", 122 | "django.template.loaders.app_directories.Loader", 123 | ), 124 | "context_processors": ( 125 | "django.template.context_processors.debug", 126 | "django.template.context_processors.i18n", 127 | "django.template.context_processors.media", 128 | "django.template.context_processors.request", 129 | "django.template.context_processors.static", 130 | "django.contrib.messages.context_processors.messages", 131 | "django.contrib.auth.context_processors.auth", 132 | ), 133 | }, 134 | } 135 | ] 136 | POLYMORPHIC_TEST_SWAPPABLE = "polymorphic.swappedmodel" 137 | SECRET_KEY = "supersecret" 138 | STATIC_URL = "/static/" 139 | 140 | ALLOWED_HOSTS = ["*"] 141 | 142 | ROOT_URLCONF = "polymorphic.tests.urls" 143 | 144 | USE_TZ = False 145 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Test migrations (generated dynamically by tests) 65 | src/polymorphic/tests/test_migrations/migrations/0*.py 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # UV 101 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | #uv.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | #poetry.lock 112 | #poetry.toml 113 | 114 | # pdm 115 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 116 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 117 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 118 | #pdm.lock 119 | #pdm.toml 120 | .pdm-python 121 | .pdm-build/ 122 | 123 | # pixi 124 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 125 | #pixi.lock 126 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 127 | # in the .venv directory. It is recommended not to include this directory in version control. 128 | .pixi 129 | 130 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 131 | __pypackages__/ 132 | 133 | # Celery stuff 134 | celerybeat-schedule 135 | celerybeat.pid 136 | 137 | # Redis 138 | *.rdb 139 | *.aof 140 | *.pid 141 | 142 | # RabbitMQ 143 | mnesia/ 144 | rabbitmq/ 145 | rabbitmq-data/ 146 | 147 | # ActiveMQ 148 | activemq-data/ 149 | 150 | # SageMath parsed files 151 | *.sage.py 152 | 153 | # Environments 154 | .env 155 | .envrc 156 | .venv 157 | env/ 158 | venv/ 159 | ENV/ 160 | env.bak/ 161 | venv.bak/ 162 | 163 | # Spyder project settings 164 | .spyderproject 165 | .spyproject 166 | 167 | # Rope project settings 168 | .ropeproject 169 | 170 | # mkdocs documentation 171 | /site 172 | 173 | # mypy 174 | .mypy_cache/ 175 | .dmypy.json 176 | dmypy.json 177 | 178 | # Pyre type checker 179 | .pyre/ 180 | 181 | # pytype static type analyzer 182 | .pytype/ 183 | 184 | # Cython debug symbols 185 | cython_debug/ 186 | 187 | # PyCharm 188 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 189 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 190 | # and can be added to the global gitignore or merged into this file. For a more nuclear 191 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 192 | #.idea/ 193 | 194 | # Abstra 195 | # Abstra is an AI-powered process automation framework. 196 | # Ignore directories containing user credentials, local state, and settings. 197 | # Learn more at https://abstra.io/docs 198 | .abstra/ 199 | 200 | # Visual Studio Code 201 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 202 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 203 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 204 | # you could uncomment the following to ignore the entire vscode folder 205 | # .vscode/ 206 | 207 | # Ruff stuff: 208 | .ruff_cache/ 209 | 210 | # PyPI configuration file 211 | .pypirc 212 | 213 | # Marimo 214 | marimo/_static/ 215 | marimo/_lsp/ 216 | __marimo__/ 217 | 218 | # Streamlit 219 | .streamlit/secrets.toml 220 | 221 | .DS_Store 222 | 223 | .python-version 224 | 225 | test1.db 226 | test2.db 227 | example/example.db 228 | -------------------------------------------------------------------------------- /example/orders/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [("contenttypes", "0002_remove_content_type_name")] 7 | 8 | operations = [ 9 | migrations.CreateModel( 10 | name="Order", 11 | fields=[ 12 | ( 13 | "id", 14 | models.AutoField( 15 | verbose_name="ID", 16 | serialize=False, 17 | auto_created=True, 18 | primary_key=True, 19 | ), 20 | ), 21 | ("title", models.CharField(max_length=200, verbose_name="Title")), 22 | ], 23 | options={ 24 | "ordering": ("title",), 25 | "verbose_name": "Organisation", 26 | "verbose_name_plural": "Organisations", 27 | }, 28 | ), 29 | migrations.CreateModel( 30 | name="Payment", 31 | fields=[ 32 | ( 33 | "id", 34 | models.AutoField( 35 | verbose_name="ID", 36 | serialize=False, 37 | auto_created=True, 38 | primary_key=True, 39 | ), 40 | ), 41 | ("currency", models.CharField(default=b"USD", max_length=3)), 42 | ("amount", models.DecimalField(max_digits=10, decimal_places=2)), 43 | ], 44 | options={"verbose_name": "Payment", "verbose_name_plural": "Payments"}, 45 | ), 46 | migrations.CreateModel( 47 | name="BankPayment", 48 | fields=[ 49 | ( 50 | "payment_ptr", 51 | models.OneToOneField( 52 | parent_link=True, 53 | auto_created=True, 54 | primary_key=True, 55 | serialize=False, 56 | on_delete=models.CASCADE, 57 | to="orders.Payment", 58 | ), 59 | ), 60 | ("bank_name", models.CharField(max_length=100)), 61 | ("swift", models.CharField(max_length=20)), 62 | ], 63 | options={ 64 | "verbose_name": "Bank Payment", 65 | "verbose_name_plural": "Bank Payments", 66 | }, 67 | bases=("orders.payment",), 68 | ), 69 | migrations.CreateModel( 70 | name="CreditCardPayment", 71 | fields=[ 72 | ( 73 | "payment_ptr", 74 | models.OneToOneField( 75 | parent_link=True, 76 | auto_created=True, 77 | primary_key=True, 78 | serialize=False, 79 | on_delete=models.CASCADE, 80 | to="orders.Payment", 81 | ), 82 | ), 83 | ("card_type", models.CharField(max_length=10)), 84 | ( 85 | "expiry_month", 86 | models.PositiveSmallIntegerField( 87 | choices=[ 88 | (1, "jan"), 89 | (2, "feb"), 90 | (3, "mar"), 91 | (4, "apr"), 92 | (5, "may"), 93 | (6, "jun"), 94 | (7, "jul"), 95 | (8, "aug"), 96 | (9, "sep"), 97 | (10, "oct"), 98 | (11, "nov"), 99 | (12, "dec"), 100 | ] 101 | ), 102 | ), 103 | ("expiry_year", models.PositiveIntegerField()), 104 | ], 105 | options={ 106 | "verbose_name": "Credit Card Payment", 107 | "verbose_name_plural": "Credit Card Payments", 108 | }, 109 | bases=("orders.payment",), 110 | ), 111 | migrations.CreateModel( 112 | name="SepaPayment", 113 | fields=[ 114 | ( 115 | "payment_ptr", 116 | models.OneToOneField( 117 | parent_link=True, 118 | auto_created=True, 119 | primary_key=True, 120 | serialize=False, 121 | on_delete=models.CASCADE, 122 | to="orders.Payment", 123 | ), 124 | ), 125 | ("iban", models.CharField(max_length=34)), 126 | ("bic", models.CharField(max_length=11)), 127 | ], 128 | options={ 129 | "verbose_name": "Bank Payment", 130 | "verbose_name_plural": "Bank Payments", 131 | }, 132 | bases=("orders.payment",), 133 | ), 134 | migrations.AddField( 135 | model_name="payment", 136 | name="order", 137 | field=models.ForeignKey(to="orders.Order", on_delete=models.CASCADE), 138 | ), 139 | migrations.AddField( 140 | model_name="payment", 141 | name="polymorphic_ctype", 142 | field=models.ForeignKey( 143 | related_name="polymorphic_orders.payment_set+", 144 | editable=False, 145 | on_delete=models.CASCADE, 146 | to="contenttypes.ContentType", 147 | null=True, 148 | ), 149 | ), 150 | ] 151 | -------------------------------------------------------------------------------- /src/polymorphic/tests/test_regression.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django.db import models 4 | from django.db.models import functions 5 | from polymorphic.tests.models import Bottom, Middle, Top, Team, UserProfile, Model2A, Model2B 6 | 7 | 8 | class RegressionTests(TestCase): 9 | def test_for_query_result_incomplete_with_inheritance(self): 10 | """https://github.com/bconstantin/django_polymorphic/issues/15""" 11 | 12 | top = Top() 13 | top.save() 14 | middle = Middle() 15 | middle.save() 16 | bottom = Bottom() 17 | bottom.save() 18 | 19 | expected_queryset = [top, middle, bottom] 20 | self.assertQuerySetEqual( 21 | Top.objects.order_by("pk"), 22 | [repr(r) for r in expected_queryset], 23 | transform=repr, 24 | ) 25 | 26 | expected_queryset = [middle, bottom] 27 | self.assertQuerySetEqual( 28 | Middle.objects.order_by("pk"), 29 | [repr(r) for r in expected_queryset], 30 | transform=repr, 31 | ) 32 | 33 | expected_queryset = [bottom] 34 | self.assertQuerySetEqual( 35 | Bottom.objects.order_by("pk"), 36 | [repr(r) for r in expected_queryset], 37 | transform=repr, 38 | ) 39 | 40 | def test_pr_254(self): 41 | user_a = UserProfile.objects.create(name="a") 42 | user_b = UserProfile.objects.create(name="b") 43 | user_c = UserProfile.objects.create(name="c") 44 | 45 | team1 = Team.objects.create(team_name="team1") 46 | team1.user_profiles.add(user_a, user_b, user_c) 47 | team1.save() 48 | 49 | team2 = Team.objects.create(team_name="team2") 50 | team2.user_profiles.add(user_c) 51 | team2.save() 52 | 53 | # without prefetch_related, the test passes 54 | my_teams = ( 55 | Team.objects.filter(user_profiles=user_c) 56 | .order_by("team_name") 57 | .prefetch_related("user_profiles") 58 | .distinct() 59 | ) 60 | 61 | self.assertEqual(len(my_teams[0].user_profiles.all()), 3) 62 | 63 | self.assertEqual(len(my_teams[1].user_profiles.all()), 1) 64 | 65 | self.assertEqual(len(my_teams[0].user_profiles.all()), 3) 66 | self.assertEqual(len(my_teams[1].user_profiles.all()), 1) 67 | 68 | # without this "for" loop, the test passes 69 | for _ in my_teams: 70 | pass 71 | 72 | # This time, test fails. PR 254 claim 73 | # with sqlite: 4 != 3 74 | # with postgresql: 2 != 3 75 | self.assertEqual(len(my_teams[0].user_profiles.all()), 3) 76 | self.assertEqual(len(my_teams[1].user_profiles.all()), 1) 77 | 78 | def test_alias_queryset(self): 79 | """ 80 | Test that .alias() works works correctly with polymorphic querysets. 81 | It should not raise AttributeError, and the aliased field should NOT be present on the instance. 82 | """ 83 | Model2B.objects.create(field1="val1", field2="val2") 84 | 85 | # Scenario 1: .alias() only 86 | # Should not crash, and 'lower_field1' should NOT be an attribute 87 | qs = Model2A.objects.alias(lower_field1=functions.Lower("field1")) 88 | results = list(qs) 89 | self.assertEqual(len(results), 1) 90 | self.assertIsInstance(results[0], Model2B) 91 | self.assertFalse(hasattr(results[0], "lower_field1")) 92 | 93 | # Scenario 2: .annotate() 94 | # Should work, and 'upper_field1' SHOULD be an attribute 95 | qs = Model2A.objects.annotate(upper_field1=functions.Upper("field1")) 96 | results = list(qs) 97 | self.assertEqual(len(results), 1) 98 | self.assertTrue(hasattr(results[0], "upper_field1")) 99 | self.assertEqual(results[0].upper_field1, "VAL1") 100 | 101 | # Scenario 3: Mixed alias() and annotate() 102 | qs = Model2A.objects.alias(alias_val=functions.Lower("field1")).annotate( 103 | anno_val=functions.Upper("field1") 104 | ) 105 | results = list(qs) 106 | self.assertEqual(len(results), 1) 107 | self.assertFalse(hasattr(results[0], "alias_val")) 108 | self.assertTrue(hasattr(results[0], "anno_val")) 109 | self.assertEqual(results[0].anno_val, "VAL1") 110 | 111 | def test_alias_advanced(self): 112 | """ 113 | Test .alias() interactions with filter, order_by, only, and defer. 114 | """ 115 | obj1 = Model2B.objects.create(field1="Alpha", field2="One") 116 | obj2 = Model2B.objects.create(field1="Beta", field2="Two") 117 | obj3 = Model2B.objects.create(field1="Gamma", field2="Three") 118 | 119 | # 1. Filter by alias 120 | qs = Model2A.objects.alias(lower_f1=functions.Lower("field1")).filter(lower_f1="beta") 121 | self.assertEqual(qs.count(), 1) 122 | self.assertEqual(qs[0], obj2) 123 | self.assertFalse(hasattr(qs[0], "lower_f1")) 124 | 125 | # 2. Order by alias 126 | qs = Model2A.objects.alias(len_f2=functions.Length("model2b__field2")).order_by("len_f2") 127 | # Lengths: One=3, Two=3, Three=5. (Ordering of equal values is DB dep, but logic holds) 128 | results = list(qs) 129 | self.assertEqual(len(results), 3) 130 | self.assertFalse(hasattr(results[0], "len_f2")) 131 | 132 | # 3. Alias + Only 133 | qs = Model2A.objects.alias(lower_f1=functions.Lower("field1")).only("field1") 134 | # Should not crash 135 | results = list(qs) 136 | self.assertEqual(len(results), 3) 137 | # Verify deferral logic didn't break 138 | # accessing field1 should not trigger refresh (hard to test without internals, but basic access works) 139 | self.assertEqual(results[0].field1, "Alpha") 140 | 141 | # 4. Alias + Defer 142 | qs = Model2A.objects.alias(lower_f1=functions.Lower("field1")).defer("field1") 143 | results = list(qs) 144 | self.assertEqual(len(results), 3) 145 | # accessing field1 should trigger refresh 146 | self.assertEqual(results[0].field1, "Alpha") 147 | -------------------------------------------------------------------------------- /src/polymorphic/admin/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rendering utils for admin forms; 3 | 4 | This makes sure that admin fieldsets/layout settings are exported to the template. 5 | """ 6 | 7 | import json 8 | 9 | from django.contrib.admin.helpers import AdminField, InlineAdminForm, InlineAdminFormSet 10 | from django.utils.encoding import force_str 11 | from django.utils.text import capfirst 12 | from django.utils.translation import gettext 13 | 14 | from polymorphic.formsets import BasePolymorphicModelFormSet 15 | 16 | 17 | class PolymorphicInlineAdminForm(InlineAdminForm): 18 | """ 19 | Expose the admin configuration for a form 20 | """ 21 | 22 | def polymorphic_ctype_field(self): 23 | return AdminField(self.form, "polymorphic_ctype", False) 24 | 25 | @property 26 | def is_empty(self): 27 | return "__prefix__" in self.form.prefix 28 | 29 | 30 | class PolymorphicInlineAdminFormSet(InlineAdminFormSet): 31 | """ 32 | Internally used class to expose the formset in the template. 33 | """ 34 | 35 | def __init__(self, *args, **kwargs): 36 | # Assigned later via PolymorphicInlineSupportMixin later. 37 | self.request = kwargs.pop("request", None) 38 | self.obj = kwargs.pop("obj", None) 39 | super().__init__(*args, **kwargs) 40 | 41 | def __iter__(self): 42 | """ 43 | Output all forms using the proper subtype settings. 44 | """ 45 | for form, original in zip(self.formset.initial_forms, self.formset.get_queryset()): 46 | # Output the form 47 | model = original.get_real_instance_class() 48 | child_inline = self.opts.get_child_inline_instance(model) 49 | view_on_site_url = self.opts.get_view_on_site_url(original) 50 | 51 | yield PolymorphicInlineAdminForm( 52 | formset=self.formset, 53 | form=form, 54 | fieldsets=self.get_child_fieldsets(child_inline), 55 | prepopulated_fields=self.get_child_prepopulated_fields(child_inline), 56 | original=original, 57 | readonly_fields=self.get_child_readonly_fields(child_inline), 58 | model_admin=child_inline, 59 | view_on_site_url=view_on_site_url, 60 | ) 61 | 62 | # Extra rows, and empty prefixed forms. 63 | for form in self.formset.extra_forms + self.formset.empty_forms: 64 | model = form._meta.model 65 | child_inline = self.opts.get_child_inline_instance(model) 66 | yield PolymorphicInlineAdminForm( 67 | formset=self.formset, 68 | form=form, 69 | fieldsets=self.get_child_fieldsets(child_inline), 70 | prepopulated_fields=self.get_child_prepopulated_fields(child_inline), 71 | original=None, 72 | readonly_fields=self.get_child_readonly_fields(child_inline), 73 | model_admin=child_inline, 74 | ) 75 | 76 | def get_child_fieldsets(self, child_inline): 77 | return list(child_inline.get_fieldsets(self.request, self.obj) or ()) 78 | 79 | def get_child_readonly_fields(self, child_inline): 80 | return list(child_inline.get_readonly_fields(self.request, self.obj)) 81 | 82 | def get_child_prepopulated_fields(self, child_inline): 83 | fields = self.prepopulated_fields.copy() 84 | fields.update(child_inline.get_prepopulated_fields(self.request, self.obj)) 85 | return fields 86 | 87 | def inline_formset_data(self): 88 | """ 89 | A JavaScript data structure for the JavaScript code 90 | This overrides the default Django version to add the ``childTypes`` data. 91 | """ 92 | verbose_name = self.opts.verbose_name 93 | return json.dumps( 94 | { 95 | "name": f"#{self.formset.prefix}", 96 | "options": { 97 | "prefix": self.formset.prefix, 98 | "addText": gettext("Add another %(verbose_name)s") 99 | % {"verbose_name": capfirst(verbose_name)}, 100 | "childTypes": [ 101 | { 102 | "type": model._meta.model_name, 103 | "name": force_str(model._meta.verbose_name), 104 | } 105 | for model in self.formset.child_forms.keys() 106 | ], 107 | "deleteText": gettext("Remove"), 108 | }, 109 | } 110 | ) 111 | 112 | 113 | class PolymorphicInlineSupportMixin: 114 | """ 115 | A Mixin to add to the regular admin, so it can work with our polymorphic inlines. 116 | 117 | This mixin needs to be included in the admin that hosts the ``inlines``. 118 | It makes sure the generated admin forms have different fieldsets/fields 119 | depending on the polymorphic type of the form instance. 120 | 121 | This is achieved by overwriting :func:`get_inline_formsets` to return 122 | an :class:`PolymorphicInlineAdminFormSet` instead of a standard Django 123 | :class:`~django.contrib.admin.helpers.InlineAdminFormSet` for the polymorphic formsets. 124 | """ 125 | 126 | def get_inline_formsets(self, request, formsets, inline_instances, obj=None, *args, **kwargs): 127 | """ 128 | Overwritten version to produce the proper admin wrapping for the 129 | polymorphic inline formset. This fixes the media and form appearance 130 | of the inline polymorphic models. 131 | """ 132 | inline_admin_formsets = super().get_inline_formsets( 133 | request, formsets, inline_instances, obj=obj 134 | ) 135 | 136 | for admin_formset in inline_admin_formsets: 137 | if isinstance(admin_formset.formset, BasePolymorphicModelFormSet): 138 | # This is a polymorphic formset, which belongs to our inline. 139 | # Downcast the admin wrapper that generates the form fields. 140 | admin_formset.__class__ = PolymorphicInlineAdminFormSet 141 | admin_formset.request = request 142 | admin_formset.obj = obj 143 | return inline_admin_formsets 144 | -------------------------------------------------------------------------------- /src/polymorphic/showfields.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.db import models 4 | 5 | RE_DEFERRED = re.compile("_Deferred_.*") 6 | 7 | 8 | class ShowFieldBase: 9 | """base class for the ShowField... model mixins, does the work""" 10 | 11 | # cause nicer multiline PolymorphicQuery output 12 | polymorphic_query_multiline_output = True 13 | 14 | polymorphic_showfield_type = False 15 | polymorphic_showfield_content = False 16 | polymorphic_showfield_deferred = False 17 | 18 | # these may be overridden by the user 19 | polymorphic_showfield_max_line_width = None 20 | polymorphic_showfield_max_field_width = 20 21 | polymorphic_showfield_old_format = False 22 | 23 | def __repr__(self): 24 | return self.__str__() 25 | 26 | def _showfields_get_content(self, field_name, field_type=type(None)): 27 | "helper for __unicode__" 28 | content = getattr(self, field_name) 29 | if self.polymorphic_showfield_old_format: 30 | out = ": " 31 | else: 32 | out = " " 33 | if issubclass(field_type, models.ForeignKey): 34 | if content is None: 35 | out += "None" 36 | else: 37 | out += content.__class__.__name__ 38 | elif issubclass(field_type, models.ManyToManyField): 39 | out += f"{content.count()}" 40 | elif isinstance(content, int): 41 | out += str(content) 42 | elif content is None: 43 | out += "None" 44 | else: 45 | txt = str(content) 46 | max_len = self.polymorphic_showfield_max_field_width 47 | if len(txt) > max_len: 48 | txt = f"{txt[: max_len - 2]}.." 49 | out += f'"{txt}"' 50 | return out 51 | 52 | def _showfields_add_regular_fields(self, parts): 53 | "helper for __unicode__" 54 | done_fields = set() 55 | for field in self._meta.fields + self._meta.many_to_many: 56 | if field.name in self.polymorphic_internal_model_fields or "_ptr" in field.name: 57 | continue 58 | if field.name in done_fields: 59 | continue # work around django diamond inheritance problem 60 | done_fields.add(field.name) 61 | 62 | out = field.name 63 | 64 | # if this is the standard primary key named "id", print it as we did with older versions of django_polymorphic 65 | if field.primary_key and field.name == "id" and type(field) is models.AutoField: 66 | out += f" {getattr(self, field.name)}" 67 | 68 | # otherwise, display it just like all other fields (with correct type, shortened content etc.) 69 | else: 70 | if self.polymorphic_showfield_type: 71 | out += f" ({type(field).__name__}" 72 | if field.primary_key: 73 | out += "/pk" 74 | out += ")" 75 | 76 | if self.polymorphic_showfield_content: 77 | out += self._showfields_get_content(field.name, type(field)) 78 | 79 | parts.append((False, out, ",")) 80 | 81 | def _showfields_add_dynamic_fields(self, field_list, title, parts): 82 | "helper for __unicode__" 83 | parts.append((True, f"- {title}", ":")) 84 | for field_name in field_list: 85 | out = field_name 86 | content = getattr(self, field_name) 87 | if self.polymorphic_showfield_type: 88 | out += f" ({type(content).__name__})" 89 | if self.polymorphic_showfield_content: 90 | out += self._showfields_get_content(field_name) 91 | 92 | parts.append((False, out, ",")) 93 | 94 | def __str__(self): 95 | # create list ("parts") containing one tuple for each title/field: 96 | # ( bool: new section , item-text , separator to use after item ) 97 | 98 | # start with model name 99 | parts = [(True, RE_DEFERRED.sub("", self.__class__.__name__), ":")] 100 | 101 | # add all regular fields 102 | self._showfields_add_regular_fields(parts) 103 | 104 | # add annotate fields 105 | if hasattr(self, "polymorphic_annotate_names"): 106 | self._showfields_add_dynamic_fields(self.polymorphic_annotate_names, "Ann", parts) 107 | 108 | # add extra() select fields 109 | if hasattr(self, "polymorphic_extra_select_names"): 110 | self._showfields_add_dynamic_fields( 111 | self.polymorphic_extra_select_names, "Extra", parts 112 | ) 113 | 114 | if self.polymorphic_showfield_deferred: 115 | fields = self.get_deferred_fields() 116 | if fields: 117 | fields_str = ",".join(sorted(fields)) 118 | parts.append((False, f"deferred[{fields_str}]", "")) 119 | 120 | # format result 121 | 122 | indent = len(self.__class__.__name__) + 5 123 | indentstr = "".rjust(indent) 124 | out = "" 125 | xpos = 0 126 | possible_line_break_pos = None 127 | 128 | for i in range(len(parts)): 129 | new_section, p, separator = parts[i] 130 | final = i == len(parts) - 1 131 | if not final: 132 | next_new_section, _, _ = parts[i + 1] 133 | 134 | if ( 135 | self.polymorphic_showfield_max_line_width 136 | and xpos + len(p) > self.polymorphic_showfield_max_line_width 137 | and possible_line_break_pos is not None 138 | ): 139 | rest = out[possible_line_break_pos:] 140 | out = out[:possible_line_break_pos] 141 | out += f"\n{indentstr}{rest}" 142 | xpos = indent + len(rest) 143 | 144 | out += p 145 | xpos += len(p) 146 | 147 | if not final: 148 | if not next_new_section: 149 | out += separator 150 | xpos += len(separator) 151 | out += " " 152 | xpos += 1 153 | 154 | if not new_section: 155 | possible_line_break_pos = len(out) 156 | 157 | return f"<{out}>" 158 | 159 | 160 | class ShowFieldType(ShowFieldBase): 161 | """model mixin that shows the object's class and it's field types""" 162 | 163 | polymorphic_showfield_type = True 164 | 165 | 166 | class ShowFieldContent(ShowFieldBase): 167 | """model mixin that shows the object's class, it's fields and field contents""" 168 | 169 | polymorphic_showfield_content = True 170 | 171 | 172 | class ShowFieldTypeAndContent(ShowFieldBase): 173 | """model mixin, like ShowFieldContent, but also show field types""" 174 | 175 | polymorphic_showfield_type = True 176 | polymorphic_showfield_content = True 177 | -------------------------------------------------------------------------------- /docs/integrations.rst: -------------------------------------------------------------------------------- 1 | .. _integrations: 2 | 3 | Integrations 4 | ============ 5 | 6 | .. _django-django-guardian-support: 7 | 8 | django-guardian 9 | --------------- 10 | 11 | .. versionadded:: 1.0.2 12 | 13 | You can configure :pypi:`django-guardian` to use the base model for object level permissions. 14 | Add this option to your settings: 15 | 16 | .. code-block:: python 17 | 18 | GUARDIAN_GET_CONTENT_TYPE = \ 19 | 'polymorphic.contrib.guardian.get_polymorphic_base_content_type' 20 | 21 | This option requires :pypi:`django-guardian` >= 1.4.6. Details about how this option works are 22 | available in the `django-guardian documentation 23 | `_. 24 | 25 | 26 | .. _django-rest-framework-support: 27 | 28 | djangorestframework 29 | ------------------- 30 | 31 | The :pypi:`django-rest-polymorphic` package provides polymorphic serializers that help you integrate 32 | your polymorphic models with :pypi:`djangorestframework`. 33 | 34 | 35 | Example 36 | ~~~~~~~ 37 | 38 | Define serializers: 39 | 40 | .. code-block:: python 41 | 42 | from rest_framework import serializers 43 | from rest_polymorphic.serializers import PolymorphicSerializer 44 | from .models import Project, ArtProject, ResearchProject 45 | 46 | 47 | class ProjectSerializer(serializers.ModelSerializer): 48 | class Meta: 49 | model = Project 50 | fields = ('topic', ) 51 | 52 | 53 | class ArtProjectSerializer(serializers.ModelSerializer): 54 | class Meta: 55 | model = ArtProject 56 | fields = ('topic', 'artist') 57 | 58 | 59 | class ResearchProjectSerializer(serializers.ModelSerializer): 60 | class Meta: 61 | model = ResearchProject 62 | fields = ('topic', 'supervisor') 63 | 64 | 65 | class ProjectPolymorphicSerializer(PolymorphicSerializer): 66 | model_serializer_mapping = { 67 | Project: ProjectSerializer, 68 | ArtProject: ArtProjectSerializer, 69 | ResearchProject: ResearchProjectSerializer 70 | } 71 | 72 | Create viewset with serializer_class equals to your polymorphic serializer: 73 | 74 | .. code-block:: python 75 | 76 | from rest_framework import viewsets 77 | from .models import Project 78 | from .serializers import ProjectPolymorphicSerializer 79 | 80 | 81 | class ProjectViewSet(viewsets.ModelViewSet): 82 | queryset = Project.objects.all() 83 | serializer_class = ProjectPolymorphicSerializer 84 | 85 | 86 | .. _django-extra-views-support: 87 | 88 | django-extra-views 89 | ------------------ 90 | 91 | .. versionadded:: 1.1 92 | 93 | The :mod:`polymorphic.contrib.extra_views` package provides classes to display polymorphic formsets 94 | using the classes from :pypi:`django-extra-views`. See the documentation of: 95 | 96 | * :class:`~polymorphic.contrib.extra_views.PolymorphicFormSetView` 97 | * :class:`~polymorphic.contrib.extra_views.PolymorphicInlineFormSetView` 98 | * :class:`~polymorphic.contrib.extra_views.PolymorphicInlineFormSet` 99 | 100 | 101 | .. _django-mptt-support: 102 | 103 | django-mptt 104 | ----------- 105 | 106 | Combining polymorphic with :pypi:`django-mptt` is certainly possible, but not straightforward. 107 | It involves combining both managers, querysets, models, meta-classes and admin classes 108 | using multiple inheritance. 109 | 110 | The :pypi:`django-polymorphic-tree` package provides this out of the box. 111 | 112 | 113 | .. _django-reversion-support: 114 | 115 | django-reversion 116 | ---------------- 117 | 118 | Support for :pypi:`django-reversion` works as expected with polymorphic models. 119 | However, they require more setup than standard models. That's become: 120 | 121 | * Manually register the child models with :pypi:`django-reversion`, so their ``follow`` parameter 122 | can be set. 123 | * Polymorphic models use :ref:`django:multi-table-inheritance`. 124 | See the :doc:`django-reversion:api` for how to deal with this by adding a ``follow`` field for the 125 | primary key. 126 | * Both admin classes redefine ``object_history_template``. 127 | 128 | 129 | Example 130 | ~~~~~~~ 131 | 132 | The admin :ref:`admin example ` becomes: 133 | 134 | .. code-block:: python 135 | 136 | from django.contrib import admin 137 | from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin 138 | from reversion.admin import VersionAdmin 139 | from reversion import revisions 140 | from .models import ModelA, ModelB, ModelC 141 | 142 | 143 | class ModelAChildAdmin(PolymorphicChildModelAdmin, VersionAdmin): 144 | base_model = ModelA # optional, explicitly set here. 145 | base_form = ... 146 | base_fieldsets = ( 147 | ... 148 | ) 149 | 150 | class ModelBAdmin(ModelAChildAdmin, VersionAdmin): 151 | # define custom features here 152 | 153 | class ModelCAdmin(ModelBAdmin): 154 | # define custom features here 155 | 156 | 157 | class ModelAParentAdmin(VersionAdmin, PolymorphicParentModelAdmin): 158 | base_model = ModelA # optional, explicitly set here. 159 | child_models = ( 160 | (ModelB, ModelBAdmin), 161 | (ModelC, ModelCAdmin), 162 | ) 163 | 164 | revisions.register(ModelB, follow=['modela_ptr']) 165 | revisions.register(ModelC, follow=['modelb_ptr']) 166 | admin.site.register(ModelA, ModelAParentAdmin) 167 | 168 | Redefine a :file:`admin/polymorphic/object_history.html` template, so it combines both worlds: 169 | 170 | .. code-block:: html+django 171 | 172 | {% extends 'reversion/object_history.html' %} 173 | {% load polymorphic_admin_tags %} 174 | 175 | {% block breadcrumbs %} 176 | {% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %} 177 | {% endblock %} 178 | 179 | This makes sure both the reversion template is used, and the breadcrumb is corrected for the 180 | polymorphic model. 181 | 182 | .. _django-reversion-compare-support: 183 | 184 | django-reversion-compare 185 | ------------------------ 186 | 187 | The :pypi:`django-reversion-compare` views work as expected, the admin requires a little tweak. 188 | In your parent admin, include the following method: 189 | 190 | .. code-block:: python 191 | 192 | def compare_view(self, request, object_id, extra_context=None): 193 | """Redirect the reversion-compare view to the child admin.""" 194 | real_admin = self._get_real_admin(object_id) 195 | return real_admin.compare_view(request, object_id, extra_context=extra_context) 196 | 197 | As the compare view resolves the the parent admin, it uses it's base model to find revisions. 198 | This doesn't work, since it needs to look for revisions of the child model. Using this tweak, 199 | the view of the actual child model is used, similar to the way the regular change and delete views 200 | are redirected. 201 | -------------------------------------------------------------------------------- /src/polymorphic/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | PolymorphicModel Meta Class 3 | """ 4 | 5 | import inspect 6 | import os 7 | import sys 8 | import warnings 9 | 10 | from django.db import models 11 | from django.db.models.base import ModelBase 12 | 13 | from .deletion import PolymorphicGuard 14 | from .managers import PolymorphicManager 15 | from .query import PolymorphicQuerySet 16 | 17 | # PolymorphicQuerySet Q objects (and filter()) support these additional key words. 18 | # These are forbidden as field names (a descriptive exception is raised) 19 | POLYMORPHIC_SPECIAL_Q_KWORDS = {"instance_of", "not_instance_of"} 20 | 21 | DUMPDATA_COMMAND = os.path.join("django", "core", "management", "commands", "dumpdata.py") 22 | 23 | 24 | class ManagerInheritanceWarning(RuntimeWarning): 25 | pass 26 | 27 | 28 | ################################################################################### 29 | # PolymorphicModel meta class 30 | 31 | 32 | class PolymorphicModelBase(ModelBase): 33 | """ 34 | Manager inheritance is a pretty complex topic which may need 35 | more thought regarding how this should be handled for polymorphic 36 | models. 37 | 38 | In any case, we probably should propagate 'objects' and 'base_objects' 39 | from PolymorphicModel to every subclass. We also want to somehow 40 | inherit/propagate _default_manager as well, as it needs to be polymorphic. 41 | 42 | The current implementation below is an experiment to solve this 43 | problem with a very simplistic approach: We unconditionally 44 | inherit/propagate any and all managers (using _copy_to_model), 45 | as long as they are defined on polymorphic models 46 | (the others are left alone). 47 | 48 | Like Django ModelBase, we special-case _default_manager: 49 | if there are any user-defined managers, it is set to the first of these. 50 | 51 | We also require that _default_manager as well as any user defined 52 | polymorphic managers produce querysets that are derived from 53 | PolymorphicQuerySet. 54 | """ 55 | 56 | def __new__(cls, model_name, bases, attrs, **kwargs): 57 | # create new model 58 | new_class = super().__new__(cls, model_name, bases, attrs, **kwargs) 59 | 60 | if new_class._meta.base_manager_name is None: 61 | # by default, use polymorphic manager as the base manager - i.e. for 62 | # related fields etc. This could happen in multi-inheritance scenarios 63 | # where one parent is polymorphic and the other not and the non poly parent 64 | # is higher in the MRO 65 | new_class._meta.base_manager_name = "objects" 66 | 67 | # validate resulting default manager 68 | if not new_class._meta.abstract and not new_class._meta.swapped: 69 | cls.validate_model_manager(new_class.objects, model_name, "objects") 70 | 71 | # for __init__ function of this class (monkeypatching inheritance accessors) 72 | new_class.polymorphic_super_sub_accessors_replaced = False 73 | 74 | # determine the name of the primary key field and store it into the class variable 75 | # polymorphic_primary_key_name (it is needed by query.py) 76 | if new_class._meta.pk: 77 | new_class.polymorphic_primary_key_name = new_class._meta.pk.name 78 | 79 | # wrap on_delete handlers of reverse relations back to this model with the 80 | # polymorphic deletion guard 81 | for fk in new_class._meta.fields: 82 | if isinstance(fk, (models.ForeignKey, models.OneToOneField)) and not isinstance( 83 | fk.remote_field.on_delete, PolymorphicGuard 84 | ): 85 | fk.remote_field.on_delete = PolymorphicGuard(fk.remote_field.on_delete) 86 | 87 | return new_class 88 | 89 | @classmethod 90 | def validate_model_manager(cls, manager, model_name, manager_name): 91 | """check if the manager is derived from PolymorphicManager 92 | and its querysets from PolymorphicQuerySet - throw AssertionError if not""" 93 | 94 | if not issubclass(type(manager), PolymorphicManager): 95 | extra = "" 96 | e = ( 97 | f'PolymorphicModel: "{model_name}.{manager_name}" manager is of type "{type(manager).__name__}", ' 98 | f"but must be a subclass of PolymorphicManager.{extra} to support retrieving subclasses" 99 | ) 100 | warnings.warn(e, ManagerInheritanceWarning, stacklevel=3) 101 | return manager 102 | 103 | if not getattr(manager, "queryset_class", None) or not issubclass( 104 | manager.queryset_class, PolymorphicQuerySet 105 | ): 106 | e = ( 107 | f'PolymorphicModel: "{model_name}.{manager_name}" has been instantiated with a queryset class ' 108 | f"which is not a subclass of PolymorphicQuerySet (which is required)" 109 | ) 110 | warnings.warn(e, ManagerInheritanceWarning, stacklevel=3) 111 | return manager 112 | 113 | @property 114 | def base_objects(self): 115 | warnings.warn( 116 | "Using PolymorphicModel.base_objects is deprecated.\n" 117 | f"Use {self.__class__.__name__}.objects.non_polymorphic() instead.", 118 | DeprecationWarning, 119 | stacklevel=2, 120 | ) 121 | return self._base_objects 122 | 123 | @property 124 | def _base_objects(self): 125 | # Create a manager so the API works as expected. Just don't register it 126 | # anymore in the Model Meta, so it doesn't substitute our polymorphic 127 | # manager as default manager for the third level of inheritance when 128 | # that third level doesn't define a manager at all. 129 | manager = models.Manager() 130 | manager.name = "base_objects" 131 | manager.model = self 132 | return manager 133 | 134 | @property 135 | def _default_manager(self): 136 | if len(sys.argv) > 1 and sys.argv[1] == "dumpdata": 137 | # TODO: investigate Django how this can be avoided 138 | # hack: a small patch to Django would be a better solution. 139 | # Django's management command 'dumpdata' relies on non-polymorphic 140 | # behaviour of the _default_manager. Therefore, we catch any access to _default_manager 141 | # here and return the non-polymorphic default manager instead if we are called from 'dumpdata.py' 142 | # Otherwise, the base objects will be upcasted to polymorphic models, and be outputted as such. 143 | # (non-polymorphic default manager is 'base_objects' for polymorphic models). 144 | # This way we don't need to patch django.core.management.commands.dumpdata 145 | # for all supported Django versions. 146 | frm = inspect.stack()[1] # frm[1] is caller file name, frm[3] is caller function name 147 | if DUMPDATA_COMMAND in frm[1]: 148 | return self._base_objects 149 | 150 | manager = super()._default_manager 151 | if not isinstance(manager, PolymorphicManager): 152 | warnings.warn( 153 | f"{self.__class__.__name__}._default_manager is not a PolymorphicManager", 154 | ManagerInheritanceWarning, 155 | ) 156 | 157 | return manager 158 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) 4 | 5 | This is a [Jazzband](https://jazzband.co) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 6 | 7 | Contributions are encouraged! Please use the issue page to submit feature requests or bug reports. Issues with attached PRs will be given priority and have a much higher likelihood of acceptance. 8 | 9 | We are actively seeking additional maintainers. If you're interested, [contact me](https://github.com/bckohan). 10 | 11 | ## Installation 12 | 13 | ### Install Just 14 | 15 | We provide a platform independent justfile with recipes for all the development tasks. You should [install just](https://just.systems/man/en/) if it is not on your system already. 16 | 17 | [django-polymorphic](https://pypi.python.org/pypi/django-polymorphic) uses [uv](https://docs.astral.sh/uv) for environment, package, and dependency management. ``just setup`` will install the necessary build tooling if you do not already have it: 18 | 19 | ```bash 20 | just setup 21 | ``` 22 | 23 | Setup also may take a python version: 24 | 25 | ```bash 26 | just setup 3.12 27 | ``` 28 | 29 | If you already have uv and python installed running install will just install the development dependencies: 30 | 31 | ```bash 32 | just install 33 | ``` 34 | 35 | **To run pre-commit checks you will have to install just.** 36 | 37 | ## Documentation 38 | 39 | `django-polymorphic` documentation is generated using [Sphinx](https://www.sphinx-doc.org) with the [furo](https://github.com/pradyunsg/furo) theme. Any new feature PRs must provide updated documentation for the features added. To build the docs run doc8 to check for formatting issues then run Sphinx: 40 | 41 | ```bash 42 | just install-docs # install the doc dependencies 43 | just docs # builds docs 44 | just check-docs # lint the docs 45 | just check-docs-links # check for broken links in the docs 46 | ``` 47 | 48 | Run the docs with auto rebuild using: 49 | 50 | ```bash 51 | just docs-live 52 | ``` 53 | 54 | ## Static Analysis 55 | 56 | `django-polymorphic` uses [ruff](https://docs.astral.sh/ruff/) for Python linting, header import standardization and code formatting. Before any PR is accepted the following must be run, and static analysis tools should not produce any errors or warnings. Disabling certain errors or warnings where justified is acceptable: 57 | 58 | To fix formatting and linting problems that are fixable run: 59 | 60 | ```bash 61 | just fix 62 | ``` 63 | 64 | To run all static analysis without automated fixing you can run: 65 | 66 | ```bash 67 | just check 68 | ``` 69 | 70 | To format source files you can run: 71 | 72 | ```bash 73 | just format 74 | ``` 75 | 76 | ## Running Tests 77 | 78 | `django-polymorphic` is set up to use [pytest](https://docs.pytest.org) to run unit tests. All the tests are housed in `src/polymorphic/tests`. Before a PR is accepted, all tests must be passing and the code coverage must be at 100%. A small number of exempted error handling branches are acceptable. 79 | 80 | To run the full suite: 81 | 82 | ```bash 83 | just test 84 | ``` 85 | 86 | To run a single test, or group of tests in a class: 87 | 88 | ```bash 89 | just test ::ClassName::FunctionName 90 | ``` 91 | 92 | For instance, to run all admin tests, and then just the test_admin_registration test you would do: 93 | 94 | ```bash 95 | just test src/polymorphic/tests/test_admin.py 96 | just test src/polymorphic/tests/test_admin.py::PolymorphicAdminTests::test_admin_registration 97 | ``` 98 | 99 | ### Running UI Tests 100 | 101 | Make sure you have playwright installed: 102 | 103 | ```bash 104 | just install-playwright 105 | ``` 106 | 107 | If you want to see the test step through the UI actions you can run the test like so: 108 | 109 | ```bash 110 | just debug-test -k 111 | ``` 112 | 113 | This will open a browser and the debugger at the start of the test, you can then ``next`` through and see the UI actions happen. 114 | 115 | ## Versioning 116 | 117 | [django-polymorphic](https://pypi.python.org/pypi/django-polymorphic) strictly adheres to [semantic versioning](https://semver.org). 118 | 119 | ## Issuing Releases 120 | 121 | The release workflow is triggered by tag creation. You must have [git tag signing enabled](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). Our justfile has a release shortcut: 122 | 123 | ```bash 124 | just release x.x.x 125 | ``` 126 | 127 | ## Just Recipes 128 | 129 | ```bash 130 | build # build docs and package 131 | build-docs # build the docs 132 | build-docs-html # build html documentation 133 | build-docs-pdf # build pdf documentation 134 | check # run all static checks 135 | check-docs # lint the documentation 136 | check-docs-links # check the documentation links for broken links 137 | check-format # check if the code needs formatting 138 | check-lint # lint the code 139 | check-package # run package checks 140 | check-readme # check that the readme renders 141 | check-types # run static type checking 142 | clean # remove all non repository artifacts 143 | clean-docs # remove doc build artifacts- 144 | clean-env # remove the virtual environment 145 | clean-git-ignored # remove all git ignored files 146 | coverage # generate the test coverage report 147 | debug-test *TESTS # debug a test - (break at test start/run in headed mode) 148 | docs # build and open the documentation 149 | docs-live # serve the documentation, with auto-reload 150 | fetch-refs LIB 151 | fix # fix formatting, linting issues and import sorting 152 | format # format the code and sort imports 153 | install *OPTS # update and install development dependencies 154 | install-docs # install documentation dependencies 155 | install-precommit # install git pre-commit hooks 156 | install_uv # install the uv package manager 157 | lint # sort the imports and fix linting issues 158 | make-test-migrations # regenerate test migrations using the lowest version of Django 159 | manage *COMMAND # run the django admin 160 | open-docs # open the html documentation 161 | precommit # run the pre-commit checks 162 | release VERSION # issue a release for the given semver string (e.g. 2.1.0) 163 | run +ARGS # run the command in the virtual environment 164 | setup python="python" # setup the venv, pre-commit hooks and playwright dependencies 165 | sort-imports # sort the python imports 166 | test *TESTS # run tests 167 | test-db DB_CLIENT="dev" *TESTS 168 | test-lock +PACKAGES # lock to specific python and versions of given dependencies 169 | validate_version VERSION # validate the given version string against the lib version 170 | ``` 171 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 2 | set unstable := true 3 | set script-interpreter := ['uv', 'run', '--script'] 4 | 5 | export PYTHONPATH := source_directory() 6 | 7 | [private] 8 | default: 9 | @just --list --list-submodules 10 | 11 | # run the django admin 12 | [script] 13 | manage *COMMAND: 14 | import os 15 | import sys 16 | from django.core import management 17 | sys.path.append(os.getcwd()) 18 | os.environ["DJANGO_SETTINGS_MODULE"] = "polymorphic.tests.debug" 19 | os.environ["SQLITE_DATABASES"] = "test1.db,test2.db" 20 | management.execute_from_command_line(sys.argv + "{{ COMMAND }}".split(" ")) 21 | 22 | # install the uv package manager 23 | [linux] 24 | [macos] 25 | install_uv: 26 | curl -LsSf https://astral.sh/uv/install.sh | sh 27 | 28 | # install the uv package manager 29 | [windows] 30 | install_uv: 31 | powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 32 | 33 | # setup the venv, pre-commit hooks and playwright dependencies 34 | setup python="python": 35 | uv venv -p {{ python }} 36 | @just run pre-commit install 37 | @just run playwright install 38 | 39 | # install git pre-commit hooks 40 | install-precommit: 41 | @just run pre-commit install 42 | 43 | # update and install development dependencies 44 | install *OPTS: 45 | uv sync {{ OPTS }} 46 | @just run pre-commit install 47 | 48 | # install documentation dependencies 49 | install-docs: 50 | uv sync --group docs --all-extras 51 | 52 | # run static type checking 53 | check-types: 54 | #TODO @just run mypy src/polymorphic 55 | 56 | # run package checks 57 | check-package: 58 | @just run pip check 59 | 60 | # remove doc build artifacts- 61 | [script] 62 | clean-docs: 63 | import shutil 64 | shutil.rmtree('./docs/_build', ignore_errors=True) 65 | 66 | # remove the virtual environment 67 | [script] 68 | clean-env: 69 | import shutil 70 | import sys 71 | shutil.rmtree(".venv", ignore_errors=True) 72 | 73 | # remove all git ignored files 74 | clean-git-ignored: 75 | git clean -fdX 76 | 77 | # remove all non repository artifacts 78 | clean: clean-docs clean-env clean-git-ignored 79 | 80 | # build html documentation 81 | build-docs-html: install-docs 82 | @just run sphinx-build --fresh-env --builder html --doctree-dir ./docs/_build/doctrees ./docs/ ./docs/_build/html 83 | 84 | # build pdf documentation 85 | build-docs-pdf: install-docs 86 | @just run sphinx-build --fresh-env --builder latex --doctree-dir ./docs/_build/doctrees ./docs/ ./docs/_build/pdf 87 | cd docs/_build/pdf && make 88 | 89 | # build the docs 90 | build-docs: build-docs-html 91 | 92 | # build docs and package 93 | build: build-docs-html 94 | @just manage compilemessages --ignore ".venv/*" 95 | uv build 96 | 97 | # regenerate test migrations using the lowest version of Django 98 | make-test-migrations: 99 | - rm src/polymorphic/tests/migrations/00*.py 100 | - rm src/polymorphic/tests/deletion/migrations/00*.py 101 | uv run --isolated --resolution lowest-direct --script ./manage.py makemigrations 102 | 103 | # open the html documentation 104 | [script] 105 | open-docs: 106 | import os 107 | import webbrowser 108 | webbrowser.open(f'file://{os.getcwd()}/docs/_build/html/index.html') 109 | 110 | # build and open the documentation 111 | docs: build-docs-html open-docs 112 | 113 | # serve the documentation, with auto-reload 114 | docs-live: install-docs 115 | @just run sphinx-autobuild docs docs/_build --open-browser --watch src --port 8000 --delay 1 116 | 117 | _link_check: 118 | -uv run sphinx-build -b linkcheck -Q -D linkcheck_timeout=10 ./docs/ ./docs/_build 119 | 120 | # check the documentation links for broken links 121 | [script] 122 | check-docs-links: _link_check 123 | import os 124 | import sys 125 | import json 126 | from pathlib import Path 127 | # The json output isn't valid, so we have to fix it before we can process. 128 | data = json.loads(f"[{','.join((Path(os.getcwd()) / 'docs/_build/output.json').read_text().splitlines())}]") 129 | broken_links = [link for link in data if link["status"] not in {"working", "redirected", "unchecked", "ignored"}] 130 | if broken_links: 131 | for link in broken_links: 132 | print(f"[{link['status']}] {link['filename']}:{link['lineno']} -> {link['uri']}", file=sys.stderr) 133 | sys.exit(1) 134 | 135 | # lint the documentation 136 | check-docs: 137 | @just run doc8 --ignore-path ./docs/_build --max-line-length 100 -q ./docs 138 | 139 | # lint the code 140 | check-lint: 141 | @just run ruff check --select I 142 | @just run ruff check 143 | 144 | # check if the code needs formatting 145 | check-format: 146 | @just run ruff format --check 147 | 148 | # check that the readme renders 149 | check-readme: 150 | @just run -m readme_renderer ./README.md -o /tmp/README.html 151 | 152 | _check-readme-quiet: 153 | @just --quiet check-readme 154 | 155 | # sort the python imports 156 | sort-imports: 157 | @just run ruff check --fix --select I 158 | 159 | # format the code and sort imports 160 | format: sort-imports 161 | just --fmt --unstable 162 | @just run ruff format 163 | 164 | # sort the imports and fix linting issues 165 | lint: sort-imports 166 | @just run ruff check --fix 167 | 168 | # fix formatting, linting issues and import sorting 169 | fix: lint format 170 | 171 | # run all static checks 172 | check: check-lint check-format check-types check-package check-docs check-docs-links _check-readme-quiet 173 | 174 | [script] 175 | _lock-python: 176 | import tomlkit 177 | import sys 178 | f='pyproject.toml' 179 | d=tomlkit.parse(open(f).read()) 180 | d['project']['requires-python']='=={}'.format(sys.version.split()[0]) 181 | open(f,'w').write(tomlkit.dumps(d)) 182 | 183 | # lock to specific python and versions of given dependencies 184 | test-lock +PACKAGES: _lock-python 185 | uv add {{ PACKAGES }} 186 | 187 | # run tests 188 | test *TESTS: 189 | @just run pytest --cov-append {{ TESTS }} 190 | 191 | test-db DB_CLIENT="dev" *TESTS: 192 | # No Optional Dependency Unit Tests 193 | # todo clean this up, rerunning a lot of tests 194 | uv sync --group {{ DB_CLIENT }} 195 | @just run pytest --cov-append {{ TESTS }} 196 | 197 | # debug a test - (break at test start/run in headed mode) 198 | debug-test *TESTS: 199 | @just run pytest -s --trace --pdbcls=IPython.terminal.debugger:Pdb --headed {{ TESTS }} 200 | 201 | # run the pre-commit checks 202 | precommit: 203 | @just run pre-commit 204 | 205 | # generate the test coverage report 206 | coverage: 207 | @just run coverage combine --keep *.coverage 208 | @just run coverage report 209 | @just run coverage xml 210 | 211 | [script] 212 | fetch-refs LIB: install-docs 213 | import os 214 | from pathlib import Path 215 | import logging as _logging 216 | import sys 217 | import runpy 218 | from sphinx.ext.intersphinx import inspect_main 219 | _logging.basicConfig() 220 | 221 | libs = runpy.run_path(Path(os.getcwd()) / "docs/conf.py").get("intersphinx_mapping") 222 | url = libs.get("{{ LIB }}", None) 223 | if not url: 224 | sys.exit(f"Unrecognized {{ LIB }}, must be one of: {', '.join(libs.keys())}") 225 | if url[1] is None: 226 | url = f"{url[0].rstrip('/')}/objects.inv" 227 | else: 228 | url = url[1] 229 | 230 | raise SystemExit(inspect_main([url])) 231 | 232 | # run the command in the virtual environment 233 | run +ARGS: 234 | uv run {{ ARGS }} 235 | 236 | # validate the given version string against the lib version 237 | [script] 238 | validate_version VERSION: 239 | import re 240 | import tomllib 241 | import polymorphic 242 | version = re.match(r"v?(\d+[.]\d+[.]\w+)", "{{ VERSION }}").groups()[0] 243 | assert version == tomllib.load(open('pyproject.toml', 'rb'))['project']['version'] 244 | assert version == polymorphic.__version__ 245 | print(version) 246 | 247 | # issue a release for the given semver string (e.g. 2.1.0) 248 | release VERSION: 249 | @just validate_version v{{ VERSION }} 250 | git tag -s v{{ VERSION }} -m "{{ VERSION }} Release" 251 | git push upstream v{{ VERSION }} 252 | --------------------------------------------------------------------------------