mirror of
https://github.com/django-polymorphic/django-polymorphic.git
synced 2026-02-14 02:30:23 +03:00
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:
parent
b954b46b2a
commit
7b7ea3a51f
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
polymorphic.contrib.extra_views
|
||||
===============================
|
||||
extra_views
|
||||
===========
|
||||
|
||||
.. automodule:: polymorphic.contrib.extra_views
|
||||
:members:
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
polymorphic.contrib.guardian
|
||||
============================
|
||||
guardian
|
||||
========
|
||||
|
||||
.. automodule:: polymorphic.contrib.guardian
|
||||
:members:
|
||||
14
docs/api/polymorphic.contrib/index.rst
Normal file
14
docs/api/polymorphic.contrib/index.rst
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
polymorphic.contrib
|
||||
===================
|
||||
|
||||
.. _contrib:
|
||||
|
||||
.. automodule:: polymorphic.contrib
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:inherited-members:
|
||||
|
||||
.. toctree::
|
||||
|
||||
extra_views
|
||||
guardian
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
polymorphic.templatetags.polymorphic_admin_tags
|
||||
===============================================
|
||||
|
||||
.. automodule:: polymorphic.templatetags
|
||||
11
docs/api/polymorphic.templatetags/index.rst
Normal file
11
docs/api/polymorphic.templatetags/index.rst
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
polymorphic.templatetags
|
||||
========================
|
||||
|
||||
.. _templatetags:
|
||||
|
||||
.. automodule:: polymorphic.templatetags
|
||||
|
||||
.. toctree::
|
||||
|
||||
polymorphic_admin_tags
|
||||
polymorphic_formset_tags
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
polymorphic_admin_tags
|
||||
======================
|
||||
|
||||
.. automodule:: polymorphic.templatetags.polymorphic_admin_tags
|
||||
:members:
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
polymorphic_formset_tags
|
||||
========================
|
||||
|
||||
.. automodule:: polymorphic.templatetags.polymorphic_formset_tags
|
||||
:members:
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
22
justfile
22
justfile
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
src/polymorphic/tests/conftest.py
Normal file
14
src/polymorphic/tests/conftest.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
7
src/polymorphic/tests/examples/integrations/apps.py
Normal file
7
src/polymorphic/tests/examples/integrations/apps.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IntegrationsExampleConfig(AppConfig):
|
||||
name = "polymorphic.tests.examples.integrations"
|
||||
label = "integrations"
|
||||
verbose_name = "Integration Examples"
|
||||
|
|
@ -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',),
|
||||
),
|
||||
]
|
||||
19
src/polymorphic/tests/examples/integrations/models.py
Normal file
19
src/polymorphic/tests/examples/integrations/models.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{% extends 'reversion/object_history.html' %}
|
||||
{% load polymorphic_admin_tags %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{% breadcrumb_scope base_opts %}{{ block.super }}{% endbreadcrumb_scope %}
|
||||
{% endblock %}
|
||||
533
src/polymorphic/tests/examples/integrations/reversion/test.py
Normal file
533
src/polymorphic/tests/examples/integrations/reversion/test.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
19
uv.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user