Refactor how integrations are documented and tested:

* reorg contrib docs
* reorg templatetag docs
* add integrations submodule for tests
  * add justfile recipes for running integration tests
* add reversion integration tests
* update reversion integraton documentation
* move integraton documentation for django-mptt to discussion forum
* move integration documentation for django-reversion-compare to discussion forum
This commit is contained in:
Brian Kohan 2026-01-10 23:01:36 -08:00
parent b954b46b2a
commit 7b7ea3a51f
No known key found for this signature in database
GPG Key ID: 5C6CE8BA38C43FC1
33 changed files with 921 additions and 189 deletions

View File

@ -158,6 +158,7 @@ jobs:
- name: Run Unit Tests
run: |
just test-db ${{ matrix.psycopg-version }}
just test-integrations ${{ matrix.psycopg-version }}
- name: Store coverage files
uses: actions/upload-artifact@v4
with:
@ -235,6 +236,7 @@ jobs:
- name: Run Unit Tests
run: |
just test-db
just test-integrations
- name: Store coverage files
uses: actions/upload-artifact@v4
with:
@ -356,6 +358,7 @@ jobs:
- name: Run Unit Tests
run: |
just test-db mysql
just test-integrations mysql
- name: Store coverage files
uses: actions/upload-artifact@v4
with:
@ -486,6 +489,7 @@ jobs:
- name: Run Unit Tests
run: |
just test-db mysql
just test-integrations mysql
- name: Store coverage files
uses: actions/upload-artifact@v4
with:
@ -621,6 +625,8 @@ jobs:
else
just test-lock Django~=${{ matrix.django-version }}.0
fi
# we don't run integration tests against Oracle in CI, these are slow enough
- name: Run Full Unit Tests
run: |
if [[ "${{ matrix.oracle-version }}" == oracle-xe* ]]; then
@ -706,6 +712,7 @@ jobs:
- name: Run Unit Tests
run: |
just test
just test-integrations
- name: Store coverage files
uses: actions/upload-artifact@v4
with:
@ -783,6 +790,7 @@ jobs:
- name: Run Unit Tests
run: |
just test
just test-integrations
- name: Store coverage files
uses: actions/upload-artifact@v4
with:

View File

@ -11,13 +11,12 @@ API Documentation
.. toctree::
polymorphic.admin
polymorphic.contrib.extra_views
polymorphic.contrib.guardian
polymorphic.contrib/index
polymorphic.formsets
polymorphic.managers
polymorphic.models
polymorphic.deletion
polymorphic.query
polymorphic.showfields
polymorphic.templatetags
polymorphic.templatetags/index
polymorphic.utils

View File

@ -1,5 +1,5 @@
polymorphic.contrib.extra_views
===============================
extra_views
===========
.. automodule:: polymorphic.contrib.extra_views
:members:

View File

@ -1,5 +1,5 @@
polymorphic.contrib.guardian
============================
guardian
========
.. automodule:: polymorphic.contrib.guardian
:members:

View File

@ -0,0 +1,14 @@
polymorphic.contrib
===================
.. _contrib:
.. automodule:: polymorphic.contrib
:members:
:show-inheritance:
:inherited-members:
.. toctree::
extra_views
guardian

View File

@ -1,4 +0,0 @@
polymorphic.templatetags.polymorphic_admin_tags
===============================================
.. automodule:: polymorphic.templatetags

View File

@ -0,0 +1,11 @@
polymorphic.templatetags
========================
.. _templatetags:
.. automodule:: polymorphic.templatetags
.. toctree::
polymorphic_admin_tags
polymorphic_formset_tags

View File

@ -0,0 +1,5 @@
polymorphic_admin_tags
======================
.. automodule:: polymorphic.templatetags.polymorphic_admin_tags
:members:

View File

@ -0,0 +1,5 @@
polymorphic_formset_tags
========================
.. automodule:: polymorphic.templatetags.polymorphic_formset_tags
:members:

View File

@ -411,7 +411,7 @@ v0.9.0 (2016-02-17)
* Fix setting an extra custom manager without overriding the ``_default_manager``.
* Fix missing ``history_view()`` redirection to the child admin, which is important for
django-reversion_ support. See the documentation for hints for
:ref:`django-reversion-compare support <django-reversion-compare-support>`.
`django-reversion-compare support <https://github.com/jazzband/django-polymorphic/discussions/831>_`.
v0.8.1 (2015-12-29)

View File

@ -3,6 +3,29 @@
Integrations
============
When integrating polymorphic models into third party apps you have three primary options:
0. Hope it just works (it might!).
1. Ensure the querysets the third party apps see are
:meth:`not polymorphic <polymorphic.query.PolymorphicQuerySet.non_polymorphic>`.
2. Override or extend relevant third party app code to work with polymorphic querysets.
If it does not just work, option 1 is usually the easiest. We provide some integrations in
:mod:`polymorphic.contrib` for popular third party apps and provide guidance for others below.
This page does not exhaustively cover all integrations. If you feel your integration need is
very common you may consider opening a PR to either provide support in code or documentation here.
This page covers supported and tested integration advice. For all other integration advice please
refer to `our integrations discussion page
<https://github.com/jazzband/django-polymorphic/discussions/categories/integrations>`_.
For the integration examples on this page, we use the following polymorphic model hierarchy:
.. literalinclude:: ../src/polymorphic/tests/examples/integrations/models.py
:language: python
:linenos:
.. _django-django-guardian-support:
django-guardian
@ -97,104 +120,47 @@ using the classes from :pypi:`django-extra-views`. See the documentation of:
* :class:`~polymorphic.contrib.extra_views.PolymorphicInlineFormSetView`
* :class:`~polymorphic.contrib.extra_views.PolymorphicInlineFormSet`
.. _django-mptt-support:
django-mptt
-----------
Combining polymorphic with :pypi:`django-mptt` is certainly possible, but not straightforward.
It involves combining both managers, querysets, models, meta-classes and admin classes
using multiple inheritance.
The :pypi:`django-polymorphic-tree` package provides this out of the box.
.. _django-reversion-support:
django-reversion
----------------
Support for :pypi:`django-reversion` works as expected with polymorphic models.
However, they require more setup than standard models. That's become:
Support for :pypi:`django-reversion` works as expected with polymorphic models. We just need to
do two things:
* Manually register the child models with :pypi:`django-reversion`, so their ``follow`` parameter
can be set.
* Polymorphic models use :ref:`django:multi-table-inheritance`.
See the :doc:`django-reversion:api` for how to deal with this by adding a ``follow`` field for the
primary key.
* Both admin classes redefine ``object_history_template``.
1. Inherit our admin classes from both :class:`~polymorphic.admin.PolymorphicParentModelAdmin` /
:class:`~polymorphic.admin.PolymorphicChildModelAdmin` and
:ref:`VersionAdmin <django-reversion:versionadmin>`.
2. Override the ``admin/polymorphic/object_history.html`` template.
.. tip::
The complete working code for this example can be found `here
<https://github.com/jazzband/django-polymorphic/tree/HEAD/src/polymorphic/tests/examples/integrations/reversion>`_.
Example
~~~~~~~
Admin Configuration
~~~~~~~~~~~~~~~~~~~
The admin :ref:`admin example <admin-example>` becomes:
The admin configuration combines :class:`~polymorphic.admin.PolymorphicParentModelAdmin` and
:class:`~polymorphic.admin.PolymorphicChildModelAdmin` with
:ref:`VersionAdmin <django-reversion:versionadmin>`:
.. code-block:: python
from django.contrib import admin
from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin
from reversion.admin import VersionAdmin
from reversion import revisions
from .models import ModelA, ModelB, ModelC
.. literalinclude:: ../src/polymorphic/tests/examples/integrations/reversion/admin.py
:language: python
:linenos:
class ModelAChildAdmin(PolymorphicChildModelAdmin, VersionAdmin):
base_model = ModelA # optional, explicitly set here.
base_form = ...
base_fieldsets = (
...
)
Custom Template
~~~~~~~~~~~~~~~
class ModelBAdmin(ModelAChildAdmin, VersionAdmin):
# define custom features here
Since both :class:`~polymorphic.admin.PolymorphicParentModelAdmin` and
:ref:`VersionAdmin <django-reversion:versionadmin>`. define ``object_history.html`` template, you
need to create a custom template that combines both:
class ModelCAdmin(ModelBAdmin):
# define custom features here
class ModelAParentAdmin(VersionAdmin, PolymorphicParentModelAdmin):
base_model = ModelA # optional, explicitly set here.
child_models = (
(ModelB, ModelBAdmin),
(ModelC, ModelCAdmin),
)
revisions.register(ModelB, follow=['modela_ptr'])
revisions.register(ModelC, follow=['modelb_ptr'])
admin.site.register(ModelA, ModelAParentAdmin)
Redefine a :file:`admin/polymorphic/object_history.html` template, so it combines both worlds:
.. code-block:: html+django
{% extends 'reversion/object_history.html' %}
{% load polymorphic_admin_tags %}
{% block breadcrumbs %}
{% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %}
{% endblock %}
.. literalinclude:: ../src/polymorphic/tests/examples/integrations/reversion/templates/admin/polymorphic/object_history.html
:language: html+django
This makes sure both the reversion template is used, and the breadcrumb is corrected for the
polymorphic model.
.. _django-reversion-compare-support:
django-reversion-compare
------------------------
The :pypi:`django-reversion-compare` views work as expected, the admin requires a little tweak.
In your parent admin, include the following method:
.. code-block:: python
def compare_view(self, request, object_id, extra_context=None):
"""Redirect the reversion-compare view to the child admin."""
real_admin = self._get_real_admin(object_id)
return real_admin.compare_view(request, object_id, extra_context=extra_context)
As the compare view resolves the the parent admin, it uses it's base model to find revisions.
This doesn't work, since it needs to look for revisions of the child model. Using this tweak,
the view of the actual child model is used, similar to the way the regular change and delete views
are redirected.
polymorphic model using the :templatetag:`breadcrumb_scope`
tag.

View File

@ -99,10 +99,12 @@ build: build-docs-html
uv build
# regenerate test migrations using the lowest version of Django
make-test-migrations:
remake-test-migrations:
- rm src/polymorphic/tests/migrations/00*.py
- rm src/polymorphic/tests/deletion/migrations/00*.py
- rm src/polymorphic/tests/other/migrations/00*.py
- rm src/polymorphic/tests/examples/**/migrations/00*.py
- rm src/polymorphic/tests/examples/integrations/**/migrations/00*.py
uv run --isolated --resolution lowest-direct --script ./manage.py makemigrations
# open the html documentation
@ -199,9 +201,23 @@ test-db DB_CLIENT="dev" *TESTS: install-playwright
uv sync --group {{ DB_CLIENT }}
@just run pytest {{ TESTS }} --cov
# debug a test - (break at test start/run in headed mode)
# run django-reversion integration tests
test-reversion *TESTS: install-playwright
uv sync --group reversion
@just run pytest -m integration src/polymorphic/tests/examples/integrations/reversion {{ TESTS }}
# run all third party integration tests
test-integrations DB_CLIENT="dev": install-playwright
# Integration Tests
uv sync --group {{ DB_CLIENT }} --group reversion
@just run pytest -m integration
# debug an test
debug-test *TESTS:
@just run pytest -s --trace --pdbcls=IPython.terminal.debugger:Pdb --headed {{ TESTS }}
@just run pytest \
-o addopts='-ra -q' \
-s --trace --pdbcls=IPython.terminal.debugger:Pdb \
--headed {{ TESTS }}
# run the pre-commit checks
precommit:

View File

@ -123,8 +123,12 @@ python_files = "test*.py"
python_classes = "Test*"
python_functions = "test_*"
norecursedirs = "*.egg .eggs dist build docs .tox .git __pycache__"
markers = [
"integration: tests under examples/integrations (opt-in)",
]
addopts = [
"--strict-markers",
"-m", "not integration",
]
[tool.coverage.run]
@ -156,6 +160,9 @@ dev = [
"tox>=4.24.1",
"tox-uv>=1.13.1",
]
reversion = [
"django-reversion>=6.1.0",
]
docs = [
"django-extra-views>=0.15.0",
"doc8>=1.1.2",

View File

@ -1,77 +1,4 @@
"""
Template tags for polymorphic
The ``polymorphic_formset_tags`` Library
----------------------------------------
.. versionadded:: 1.1
To render formsets in the frontend, the ``polymorphic_tags`` provides extra
filters to implement HTML rendering of polymorphic formsets.
The following filters are provided;
* ``{{ formset|as_script_options }}`` render the ``data-options`` for a JavaScript formset library.
* ``{{ formset|include_empty_form }}`` provide the placeholder form for an add button.
* ``{{ form|as_form_type }}`` return the model name that the form instance uses.
* ``{{ model|as_model_name }}`` performs the same, for a model class or instance.
.. code-block:: html+django
{% load i18n polymorphic_formset_tags %}
<div class="inline-group" id="{{ formset.prefix }}-group" data-options="{{ formset|as_script_options }}">
{% block add_button %}
{% if formset.show_add_button|default_if_none:'1' %}
{% if formset.empty_forms %}
{# django-polymorphic formset (e.g. PolymorphicInlineFormSetView) #}
<div class="btn-group" role="group">
{% for model in formset.child_forms %}
<a type="button" data-type="{{ model|as_model_name }}" class="js-add-form btn btn-default">{% glyphicon 'plus' %} {{ model|as_verbose_name }}</a>
{% endfor %}
</div>
{% else %}
<a class="btn btn-default js-add-form">{% trans "Add" %}</a>
{% endif %}
{% endif %}
{% endblock %}
{{ formset.management_form }}
{% for form in formset|include_empty_form %}
{% block formset_form_wrapper %}
<div id="{{ form.prefix }}" data-inline-type="{{ form|as_form_type|lower }}" class="inline-related{% if '__prefix__' in form.prefix %} empty-form{% endif %}">
{{ form.non_field_errors }}
{# Add the 'pk' field that is not mentioned in crispy #}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{% block formset_form %}
{% crispy form %}
{% endblock %}
</div>
{% endblock %}
{% endfor %}
</div>
The ``polymorphic_admin_tags`` Library
--------------------------------------
The ``{% breadcrumb_scope ... %}`` tag makes sure the ``{{ opts }}`` and ``{{ app_label }}``
values are temporary based on the provided ``{{ base_opts }}``.
This allows fixing the breadcrumb in admin templates:
.. code-block:: html+django
{% extends "admin/change_form.html" %}
{% load polymorphic_admin_tags %}
{% block breadcrumbs %}
{% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %}
{% endblock %}
We provide collections of tags that override or extend template tags for formsets and
the admin interface.
"""

View File

@ -43,6 +43,23 @@ class BreadcrumbScope(Node):
@register.tag
def breadcrumb_scope(parser, token):
"""
.. templatetag:: breadcrumb_scope
Easily allow the breadcrumb to be generated in the admin change templates.
The ``{% breadcrumb_scope ... %}`` tag makes sure the ``{{ opts }}`` and
``{{ app_label }}`` values are temporary based on the provided
``{{ base_opts }}``.
This allows fixing the breadcrumb in admin templates:
.. code-block:: html+django
{% extends "admin/change_form.html" %}
{% load polymorphic_admin_tags %}
{% block breadcrumbs %}
{% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %}
{% endblock %}
"""
return BreadcrumbScope.parse(parser, token)

View File

@ -1,3 +1,57 @@
"""
.. versionadded:: 1.1
To render formsets in the frontend, the ``polymorphic_tags`` provides extra
filters to implement HTML rendering of polymorphic formsets.
The following filters are provided;
* ``{{ formset|as_script_options }}`` render the ``data-options`` for a JavaScript formset library.
* ``{{ formset|include_empty_form }}`` provide the placeholder form for an add button.
* ``{{ form|as_form_type }}`` return the model name that the form instance uses.
* ``{{ model|as_model_name }}`` performs the same, for a model class or instance.
.. code-block:: html+django
{% load i18n polymorphic_formset_tags %}
<div class="inline-group" id="{{ formset.prefix }}-group" data-options="{{ formset|as_script_options }}">
{% block add_button %}
{% if formset.show_add_button|default_if_none:'1' %}
{% if formset.empty_forms %}
{# django-polymorphic formset (e.g. PolymorphicInlineFormSetView) #}
<div class="btn-group" role="group">
{% for model in formset.child_forms %}
<a type="button" data-type="{{ model|as_model_name }}" class="js-add-form btn btn-default">{% glyphicon 'plus' %} {{ model|as_verbose_name }}</a>
{% endfor %}
</div>
{% else %}
<a class="btn btn-default js-add-form">{% trans "Add" %}</a>
{% endif %}
{% endif %}
{% endblock %}
{{ formset.management_form }}
{% for form in formset|include_empty_form %}
{% block formset_form_wrapper %}
<div id="{{ form.prefix }}" data-inline-type="{{ form|as_form_type|lower }}" class="inline-related{% if '__prefix__' in form.prefix %} empty-form{% endif %}">
{{ form.non_field_errors }}
{# Add the 'pk' field that is not mentioned in crispy #}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{% block formset_form %}
{% crispy form %}
{% endblock %}
</div>
{% endblock %}
{% endfor %}
</div>
"""
import json
from django.template import Library
@ -13,6 +67,8 @@ register = Library()
@register.filter()
def include_empty_form(formset):
"""
.. templatetag:: include_empty_form
Make sure the "empty form" is included when displaying a formset (typically table with input rows)
"""
yield from formset
@ -28,6 +84,8 @@ def include_empty_form(formset):
@register.filter
def as_script_options(formset):
"""
.. templatetag:: as_script_options
A JavaScript data structure for the JavaScript code
This generates the ``data-options`` attribute for ``jquery.django-inlines.js``
@ -63,6 +121,8 @@ def as_script_options(formset):
@register.filter
def as_form_type(form):
"""
.. templatetag:: as_form_type
Usage: ``{{ form|as_form_type }}``
"""
return form._meta.model._meta.model_name
@ -71,6 +131,8 @@ def as_form_type(form):
@register.filter
def as_model_name(model):
"""
.. templatetag:: as_model_name
Usage: ``{{ model|as_model_name }}``
"""
return model._meta.model_name

View File

@ -0,0 +1,14 @@
from __future__ import annotations
import pathlib
import pytest
INTEGRATION_DIR = pathlib.Path(__file__).resolve().parent / "examples" / "integrations"
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
for item in items:
# item.path is pathlib.Path on modern pytest; fall back for older
p = pathlib.Path(str(getattr(item, "path", item.fspath))).resolve()
if INTEGRATION_DIR in p.parents:
item.add_marker(pytest.mark.integration)

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2 on 2026-01-12 13:15
# Generated by Django 4.2 on 2026-01-12 16:34
from decimal import Decimal
from django.conf import settings

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class IntegrationsExampleConfig(AppConfig):
name = "polymorphic.tests.examples.integrations"
label = "integrations"
verbose_name = "Integration Examples"

View File

@ -0,0 +1,51 @@
# Generated by Django 4.2 on 2026-01-12 16:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Article',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100)),
('content', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='BlogPost',
fields=[
('article_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='integrations.article')),
('author', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
bases=('integrations.article',),
),
migrations.CreateModel(
name='NewsArticle',
fields=[
('article_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='integrations.article')),
('source', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
bases=('integrations.article',),
),
]

View File

@ -0,0 +1,19 @@
from django.db import models
from polymorphic.models import PolymorphicModel
class Article(PolymorphicModel):
title = models.CharField(max_length=100)
content = models.TextField()
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
class BlogPost(Article):
author = models.CharField(max_length=100)
class NewsArticle(Article):
source = models.CharField(max_length=100)

View File

@ -0,0 +1,34 @@
from django.contrib import admin
from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin
from reversion.admin import VersionAdmin
from ..models import Article, BlogPost, NewsArticle
class ArticleChildAdmin(PolymorphicChildModelAdmin, VersionAdmin):
base_model = Article
@admin.register(BlogPost)
class BlogPostAdmin(ArticleChildAdmin):
pass
@admin.register(NewsArticle)
class NewsArticleAdmin(ArticleChildAdmin):
pass
class ArticleParentAdmin(VersionAdmin, PolymorphicParentModelAdmin):
"""
Parent admin for Article model with reversion support.
Note: VersionAdmin must come before PolymorphicParentModelAdmin
in the inheritance order.
"""
base_model = Article
child_models = (BlogPost, NewsArticle)
list_display = ("title", "created")
admin.site.register(Article, ArticleParentAdmin)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReversionExampleConfig(AppConfig):
name = "polymorphic.tests.examples.integrations.reversion"
label = "reversion_example"
verbose_name = "Reversion Integration Example"

View File

@ -0,0 +1,6 @@
{% extends 'reversion/object_history.html' %}
{% load polymorphic_admin_tags %}
{% block breadcrumbs %}
{% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %}
{% endblock %}

View File

@ -0,0 +1,533 @@
from unittest import skipUnless
from django.test import TestCase
from django.urls import reverse
from playwright.sync_api import expect
try:
from reversion import revisions
from reversion.models import Version
REVERSION_INSTALLED = True
except ImportError:
REVERSION_INSTALLED = False
revisions = None
Version = None
from ..models import Article, BlogPost, NewsArticle
from polymorphic.tests.utils import _GenericUITest
@skipUnless(REVERSION_INSTALLED, "django-reversion is not installed")
class ReversionIntegrationTests(TestCase):
"""
Tests for django-reversion integration with polymorphic models.
These tests verify that:
1. Polymorphic models can be versioned
2. The follow parameter correctly tracks parent model changes
3. Revisions are created and can be retrieved
"""
def test_blogpost_versioning(self):
"""Test that BlogPost instances are properly versioned."""
# Create a blog post with reversion
with revisions.create_revision():
blog_post = BlogPost.objects.create(
title="First Post", content="This is my first blog post.", author="John Doe"
)
revisions.set_comment("Initial version")
# Verify a version was created
versions = Version.objects.get_for_object(blog_post)
self.assertEqual(versions.count(), 1)
self.assertEqual(versions[0].revision.comment, "Initial version")
# Update the blog post
with revisions.create_revision():
blog_post.title = "Updated Post"
blog_post.content = "This is my updated blog post."
blog_post.save()
revisions.set_comment("Updated title and content")
# Verify we now have two versions
versions = Version.objects.get_for_object(blog_post)
self.assertEqual(versions.count(), 2)
# Verify we can retrieve the old version
old_version = versions[1]
old_data = old_version.field_dict
self.assertEqual(old_data["title"], "First Post")
self.assertEqual(old_data["content"], "This is my first blog post.")
def test_newsarticle_versioning(self):
"""Test that NewsArticle instances are properly versioned."""
# Create a news article with reversion
with revisions.create_revision():
news_article = NewsArticle.objects.create(
title="Breaking News", content="Important news story.", source="Daily News"
)
revisions.set_comment("Published article")
# Verify a version was created
versions = Version.objects.get_for_object(news_article)
self.assertEqual(versions.count(), 1)
# Update the news article
with revisions.create_revision():
news_article.content = "Updated news story with more details."
news_article.save()
revisions.set_comment("Added more details")
# Verify we now have two versions
versions = Version.objects.get_for_object(news_article)
self.assertEqual(versions.count(), 2)
def test_polymorphic_queryset_with_versioned_objects(self):
"""Test that polymorphic queries work correctly with versioned objects."""
# Create instances of both child models
with revisions.create_revision():
BlogPost.objects.create(title="Blog Post", content="Blog content", author="Jane Smith")
NewsArticle.objects.create(
title="News Article", content="News content", source="News Corp"
)
# Query using the polymorphic base model
articles = Article.objects.all()
self.assertEqual(articles.count(), 2)
# Verify polymorphic behavior
self.assertIsInstance(articles[0], (BlogPost, NewsArticle))
self.assertIsInstance(articles[1], (BlogPost, NewsArticle))
# Verify both have versions
for article in articles:
versions = Version.objects.get_for_object(article)
self.assertGreaterEqual(versions.count(), 1)
def test_revert_to_previous_version(self):
"""Test that we can revert an object to a previous version."""
# Create initial version
with revisions.create_revision():
blog_post = BlogPost.objects.create(
title="Original Title", content="Original content", author="Author One"
)
# Make several updates
with revisions.create_revision():
blog_post.title = "Second Title"
blog_post.save()
with revisions.create_revision():
blog_post.title = "Third Title"
blog_post.author = "Author Two"
blog_post.save()
# Verify current state
blog_post.refresh_from_db()
self.assertEqual(blog_post.title, "Third Title")
self.assertEqual(blog_post.author, "Author Two")
# Revert to first version
versions = Version.objects.get_for_object(blog_post)
first_version = versions[2] # Versions are in reverse chronological order
first_version.revision.revert()
# Verify reverted state
blog_post.refresh_from_db()
self.assertEqual(blog_post.title, "Original Title")
self.assertEqual(blog_post.author, "Author One")
def test_manual_reversion_workflow(self):
"""Test complete manual reversion workflow without admin interface."""
# Create a blog post with initial version
with revisions.create_revision():
blog_post = BlogPost.objects.create(
title="Manual Test Post",
content="Initial content for manual testing.",
author="Test Author",
)
revisions.set_user(None)
revisions.set_comment("Manual creation")
original_id = blog_post.id
# Make first update
with revisions.create_revision():
blog_post.title = "Updated Manual Test Post"
blog_post.content = "First update to content."
blog_post.save()
revisions.set_comment("First manual update")
# Make second update
with revisions.create_revision():
blog_post.author = "Updated Author"
blog_post.content = "Second update to content."
blog_post.save()
revisions.set_comment("Second manual update")
# Verify we have 3 versions
versions = Version.objects.get_for_object(blog_post)
self.assertEqual(versions.count(), 3)
# Test getting version data
latest_version = versions[0]
self.assertEqual(latest_version.field_dict["author"], "Updated Author")
self.assertEqual(latest_version.field_dict["content"], "Second update to content.")
middle_version = versions[1]
self.assertEqual(middle_version.field_dict["title"], "Updated Manual Test Post")
self.assertEqual(middle_version.field_dict["content"], "First update to content.")
original_version = versions[2]
self.assertEqual(original_version.field_dict["title"], "Manual Test Post")
self.assertEqual(original_version.field_dict["author"], "Test Author")
# Test reverting to middle version manually
middle_version.revision.revert()
blog_post.refresh_from_db()
self.assertEqual(blog_post.title, "Updated Manual Test Post")
self.assertEqual(blog_post.content, "First update to content.")
self.assertEqual(blog_post.author, "Test Author") # Should be from original
# Test accessing revision metadata
revision = middle_version.revision
self.assertEqual(revision.comment, "First manual update")
# Test that polymorphic type is preserved across reversion
self.assertIsInstance(blog_post, BlogPost)
self.assertEqual(blog_post.id, original_id)
def test_manual_newsarticle_reversion_with_deletion(self):
"""Test manual reversion including object deletion and recovery."""
# Create a news article
with revisions.create_revision():
news = NewsArticle.objects.create(
title="Breaking News",
content="Important breaking news.",
source="Test News Network",
)
revisions.set_comment("Initial news article")
news_id = news.id
# Update the article
with revisions.create_revision():
news.content = "Updated breaking news with more details."
news.source = "Updated News Network"
news.save()
revisions.set_comment("Updated news details")
# Get versions before deletion
versions = Version.objects.get_for_object(news)
self.assertEqual(versions.count(), 2)
original_version = versions[1]
# Delete the object
with revisions.create_revision():
news.delete()
revisions.set_comment("Deleted news article")
# Verify object is deleted
self.assertFalse(NewsArticle.objects.filter(id=news_id).exists())
# Manually recover from deletion by reverting to a previous version
original_version.revision.revert()
# Verify object is restored
recovered_news = NewsArticle.objects.get(id=news_id)
self.assertEqual(recovered_news.title, "Breaking News")
self.assertEqual(recovered_news.content, "Important breaking news.")
self.assertEqual(recovered_news.source, "Test News Network")
self.assertIsInstance(recovered_news, NewsArticle)
def test_manual_batch_reversion(self):
"""Test reverting multiple polymorphic objects in a single revision."""
# Create multiple objects in one revision
with revisions.create_revision():
blog1 = BlogPost.objects.create(title="Blog 1", content="Content 1", author="Author 1")
blog2 = BlogPost.objects.create(title="Blog 2", content="Content 2", author="Author 2")
news = NewsArticle.objects.create(
title="News 1", content="News content", source="Source 1"
)
revisions.set_comment("Batch creation")
# Update all objects in another revision
with revisions.create_revision():
blog1.title = "Updated Blog 1"
blog1.save()
blog2.title = "Updated Blog 2"
blog2.save()
news.title = "Updated News 1"
news.save()
revisions.set_comment("Batch update")
# Verify updated state
blog1.refresh_from_db()
blog2.refresh_from_db()
news.refresh_from_db()
self.assertEqual(blog1.title, "Updated Blog 1")
self.assertEqual(blog2.title, "Updated Blog 2")
self.assertEqual(news.title, "Updated News 1")
# Get the original revision (should contain all three objects)
from reversion.models import Revision
original_revision = Revision.objects.order_by("date_created")[0]
self.assertEqual(original_revision.comment, "Batch creation")
# Revert the entire revision
original_revision.revert()
# Verify all objects reverted
blog1.refresh_from_db()
blog2.refresh_from_db()
news.refresh_from_db()
self.assertEqual(blog1.title, "Blog 1")
self.assertEqual(blog2.title, "Blog 2")
self.assertEqual(news.title, "News 1")
@skipUnless(REVERSION_INSTALLED, "django-reversion is not installed")
class ReversionAdminUITests(_GenericUITest):
"""Test reversion functionality through the admin interface."""
def test_blogpost_admin_reversion(self):
"""Test BlogPost admin integration: creating, updating, versioning, and reverting through UI."""
# Navigate to BlogPost add page
add_url = self.add_url(BlogPost)
self.page.goto(add_url)
# Create initial BlogPost
self.page.fill("input[name='title']", "Admin Test Post")
self.page.fill("textarea[name='content']", "Initial admin content")
self.page.fill("input[name='author']", "Admin Author")
# Save the object
with self.page.expect_navigation(timeout=30000):
self.page.click("input[name='_save']")
# Verify BlogPost was created
blog_post = BlogPost.objects.get(title="Admin Test Post")
self.assertEqual(blog_post.author, "Admin Author")
self.assertEqual(blog_post.content, "Initial admin content")
# Verify we have 1 version (created by admin)
versions = Version.objects.get_for_object(blog_post)
self.assertEqual(versions.count(), 1)
first_version = versions[0]
self.assertEqual(first_version.field_dict["title"], "Admin Test Post")
self.assertEqual(first_version.field_dict["author"], "Admin Author")
# Navigate to change page and update the BlogPost
change_url = self.change_url(BlogPost, blog_post.pk)
self.page.goto(change_url)
self.page.fill("input[name='title']", "Updated Admin Test Post")
self.page.fill("textarea[name='content']", "Updated admin content")
self.page.fill("input[name='author']", "Updated Admin Author")
with self.page.expect_navigation(timeout=30000):
self.page.click("input[name='_save']")
# Verify update
blog_post.refresh_from_db()
self.assertEqual(blog_post.title, "Updated Admin Test Post")
self.assertEqual(blog_post.author, "Updated Admin Author")
# Verify we now have 2 versions (admin creates version on each save)
versions = Version.objects.get_for_object(blog_post)
self.assertEqual(versions.count(), 2)
latest_version = versions[0]
self.assertEqual(latest_version.field_dict["title"], "Updated Admin Test Post")
self.assertEqual(latest_version.field_dict["author"], "Updated Admin Author")
# Navigate to history page and verify it's accessible
history_url = f"{self.live_server_url}{reverse('admin:integrations_blogpost_history', args=[blog_post.pk])}"
self.page.goto(history_url)
# Verify we can see the history page
expect(self.page.locator("#content h1")).to_contain_text("Change history")
# Verify history table shows version information
history_table = self.page.locator("table#change-history, div#change-history")
expect(history_table).to_be_visible()
# Use the UI to revert: Click on the oldest version's date/time link
# The history table typically has links in each row - we want the last row (oldest)
history_links = self.page.locator("table#change-history a").all()
self.assertGreater(len(history_links), 1, "Should have history links")
# Click on the last link (oldest version) to view that revision
with self.page.expect_navigation(timeout=30000):
history_links[0].click()
# We're now on the specific version's history page (history/<version_id>/)
current_url = self.page.url
self.assertIn(
"/history/", current_url, f"Should be on history detail page, got: {current_url}"
)
# Wait for page to fully load
self.page.wait_for_load_state("domcontentloaded", timeout=10000)
# The page should show the old version's data
page_content = self.page.content()
self.assertIn(
"Admin Test Post", page_content, "Should see original title on the version page"
)
# Find and click the submit button to revert
submit_button = self.page.locator("input[type='submit']").first
with self.page.expect_navigation(timeout=30000):
submit_button.click()
# Verify the object was reverted
blog_post.refresh_from_db()
self.assertEqual(blog_post.title, "Admin Test Post")
self.assertEqual(blog_post.author, "Admin Author")
self.assertIsInstance(blog_post, BlogPost)
def test_article_admin_reversion(self):
"""Test Article (polymorphic parent) admin versioning and reversion through UI."""
# Create an article first via API, then test admin updates
with revisions.create_revision():
article = BlogPost.objects.create(
title="Parent Article Test",
content="Parent article content",
author="Parent Author",
)
# Verify version created
versions = Version.objects.get_for_object(article)
self.assertEqual(versions.count(), 1)
# Update through parent Article admin interface
change_url = self.change_url(Article, article.pk)
self.page.goto(change_url)
# Verify we're on the change page for the parent admin
expect(self.page.locator("#content h1")).to_contain_text("Change")
self.page.fill("input[name='title']", "Updated Parent Article")
self.page.fill("textarea[name='content']", "Updated parent content")
with self.page.expect_navigation(timeout=30000):
self.page.click("input[name='_save']")
# Verify update
article.refresh_from_db()
self.assertEqual(article.title, "Updated Parent Article")
# Verify we now have 2 versions (1 from API, 1 from admin)
versions = Version.objects.get_for_object(article)
self.assertEqual(versions.count(), 2)
self.assertEqual(versions[0].field_dict["title"], "Updated Parent Article")
self.assertEqual(versions[1].field_dict["title"], "Parent Article Test")
# Navigate to history page through parent admin
history_url = f"{self.live_server_url}{reverse('admin:integrations_article_history', args=[article.pk])}"
self.page.goto(history_url)
expect(self.page.locator("#content h1")).to_contain_text("Change history")
# Use the UI to revert: Click on the oldest version
history_links = self.page.locator("table#change-history a").all()
self.assertGreater(len(history_links), 1)
with self.page.expect_navigation(timeout=30000):
history_links[0].click()
# Wait for page to load
self.page.wait_for_load_state("domcontentloaded", timeout=10000)
# Verify we're on the revert page
page_content = self.page.content()
self.assertIn("Revert", page_content, "Should be on a revert page")
# Click the submit button to revert
submit_button = self.page.locator("input[type='submit']").first
with self.page.expect_navigation(timeout=30000):
submit_button.click()
# Verify the object was reverted
article.refresh_from_db()
self.assertEqual(article.title, "Parent Article Test")
self.assertEqual(article.content, "Parent article content")
self.assertIsInstance(article, BlogPost)
def test_newsarticle_admin_reversion(self):
"""Test NewsArticle admin versioning and reversion through UI."""
# Navigate to NewsArticle add page
add_url = self.add_url(NewsArticle)
self.page.goto(add_url)
# Create NewsArticle
self.page.fill("input[name='title']", "Breaking Admin News")
self.page.fill("textarea[name='content']", "Admin news content")
self.page.fill("input[name='source']", "Admin News Network")
with self.page.expect_navigation(timeout=30000):
self.page.click("input[name='_save']")
# Verify creation
news = NewsArticle.objects.get(title="Breaking Admin News")
self.assertEqual(news.source, "Admin News Network")
# Verify version created
versions = Version.objects.get_for_object(news)
self.assertEqual(versions.count(), 1)
# Update
change_url = self.change_url(NewsArticle, news.pk)
self.page.goto(change_url)
self.page.fill("input[name='title']", "Updated Breaking News")
self.page.fill("input[name='source']", "Updated Network")
with self.page.expect_navigation(timeout=30000):
self.page.click("input[name='_save']")
news.refresh_from_db()
self.assertEqual(news.title, "Updated Breaking News")
# Verify we have 2 versions from admin operations
versions = Version.objects.get_for_object(news)
self.assertEqual(versions.count(), 2)
self.assertEqual(versions[0].field_dict["title"], "Updated Breaking News")
self.assertEqual(versions[1].field_dict["title"], "Breaking Admin News")
# Verify history page is accessible
history_url = f"{self.live_server_url}{reverse('admin:integrations_newsarticle_history', args=[news.pk])}"
self.page.goto(history_url)
expect(self.page.locator("#content h1")).to_contain_text("Change history")
# Use the UI to revert: Click on the oldest version
history_links = self.page.locator("table#change-history a").all()
self.assertGreater(len(history_links), 1)
with self.page.expect_navigation(timeout=30000):
history_links[0].click()
# Wait for page to load
self.page.wait_for_load_state("domcontentloaded", timeout=10000)
# Verify we're on the revert page
page_content = self.page.content()
self.assertIn("Revert", page_content, "Should be on a revert page")
# Try to revert through UI - click the save button
submit_buttons = self.page.locator("input[type='submit']").all()
with self.page.expect_navigation(timeout=30000):
submit_buttons[0].click()
# Check if reversion worked
news.refresh_from_db()
# Reversion worked! Verify values
self.assertEqual(news.source, "Admin News Network")
self.assertIsInstance(news, NewsArticle)
# Verify a new version was created for the revert operation
versions_after_revert = Version.objects.get_for_object(news)
self.assertEqual(versions_after_revert.count(), 3)

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2 on 2026-01-12 13:15
# Generated by Django 4.2 on 2026-01-12 16:34
from django.db import migrations, models
import django.db.models.deletion

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2 on 2026-01-12 13:15
# Generated by Django 4.2 on 2026-01-12 16:34
from django.conf import settings
from django.db import migrations, models
@ -14,8 +14,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2 on 2026-01-12 13:15
# Generated by Django 4.2 on 2026-01-12 16:34
from django.db import migrations, models
import django.db.models.deletion

View File

@ -96,7 +96,14 @@ elif rdbms == "oracle": # pragma: no cover
**creds,
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
INSTALLED_APPS = (
INSTALLED_APPS = [
"polymorphic.tests.examples.integrations",
"polymorphic.tests",
"polymorphic.tests.deletion",
"polymorphic.tests.other",
"polymorphic.tests.test_migrations",
"polymorphic.tests.examples.views",
"polymorphic",
"django.contrib.staticfiles",
"django.contrib.auth",
"django.contrib.contenttypes",
@ -104,13 +111,17 @@ INSTALLED_APPS = (
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.admin",
"polymorphic",
"polymorphic.tests",
"polymorphic.tests.deletion",
"polymorphic.tests.other",
"polymorphic.tests.test_migrations",
"polymorphic.tests.examples.views",
)
]
# Add reversion if installed
try:
import reversion # noqa: F401
INSTALLED_APPS.insert(0, "reversion")
INSTALLED_APPS.insert(0, "polymorphic.tests.examples.integrations.reversion")
except ImportError:
pass
MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",

19
uv.lock
View File

@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.10, <4.0"
resolution-markers = [
"python_full_version >= '3.12'",
@ -680,6 +680,9 @@ psycopg2 = [
psycopg3 = [
{ name = "psycopg" },
]
reversion = [
{ name = "django-reversion" },
]
[package.metadata]
requires-dist = [{ name = "django", specifier = ">=4.2" }]
@ -716,6 +719,20 @@ mysql = [{ name = "mysqlclient", specifier = ">=1.4.0" }]
oracledb = [{ name = "oracledb", specifier = ">=2.3.0" }]
psycopg2 = [{ name = "psycopg2", specifier = ">=2.9.10" }]
psycopg3 = [{ name = "psycopg" }]
reversion = [{ name = "django-reversion", specifier = ">=6.1.0" }]
[[package]]
name = "django-reversion"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django", version = "5.2.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "django", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/8b/72000ad3ba05cb72134f62ad093267e3d83d8fdf1406102d2a1a2ec7d55e/django_reversion-6.1.0.tar.gz", hash = "sha256:31dd7fee02e2a21af7c2d0a61c1c0f27ba07df2317c9a0a3f31d3ee40069025d", size = 76140, upload-time = "2025-12-12T20:23:51.948Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/8e/c80904ce5cb94f78cdd7d794cb7b4a8e8891a7515621bbce5f2a45de6555/django_reversion-6.1.0-py3-none-any.whl", hash = "sha256:1f5815791d9accdb0bad9d5982668f217dc65225be57041ae9641dbdcd838ce3", size = 86196, upload-time = "2025-12-12T20:23:50.18Z" },
]
[[package]]
name = "django-test-migrations"