Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Advanced topics
:maxdepth: 2

formsets
views
migrating
managers
deletion
Expand Down
67 changes: 67 additions & 0 deletions docs/views.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
.. _views:

Class Based Views
=================

While :pypi:`django-polymorphic` provides full admin integration, you might want to build front-end
views that allow users to create polymorphic objects. Since a single URL cannot easily handle
different form fields for different models, the best approach is a two-step process:

1. **Step 1:** Let the user choose the desired type.
2. **Step 2:** Display the form for that specific type.

.. tip::

The code for this example can be found `here
<https://github.com/jazzband/django-polymorphic/tree/HEAD/src/polymorphic/tests/examples/views>`_.

This example uses model labels (e.g., ``app.ModelName``) to identify the selected type. Assume we
have the following models:

.. literalinclude:: ../src/polymorphic/tests/examples/views/models.py
:language: python
:linenos:

Step 1: Selecting the Type
--------------------------

Create a form that allows users select the desired model type. You can use a simple choice field
for this.

.. literalinclude:: ../src/polymorphic/tests/examples/views/views.py
:language: python
:lines: 1-45
:linenos:

Your template ``project_type_select.html``, might look like this:

.. literalinclude:: ../src/polymorphic/tests/examples/views/templates/project_type_select.html
:language: html

Step 2: Displaying the Form
---------------------------

The creation view needs to dynamically select the correct form class based on the chosen model label.

.. literalinclude:: ../src/polymorphic/tests/examples/views/views.py
:language: python
:lines: 47-
:linenos:


In your template ``project_form.html``, make sure to preserve the ``model`` parameter:

.. literalinclude:: ../src/polymorphic/tests/examples/views/templates/project_form.html
:language: html


And our urls might look like this:

.. literalinclude:: ../src/polymorphic/tests/examples/views/urls.py
:linenos:

Using ``extra_views``
---------------------

If you are using :pypi:`django-extra-views`, :pypi:`django-polymorphic` provides mixins to help with formsets.
See :mod:`polymorphic.contrib.extra_views` for more details.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ DJANGO_SETTINGS_MODULE = "polymorphic.tests.settings"
pythonpath = ["src"]
django_find_project = false
testpaths = ["src/polymorphic/tests"]
python_files = "test_*.py"
python_files = "test*.py"
python_classes = "Test*"
python_functions = "test_*"
norecursedirs = "*.egg .eggs dist build docs .tox .git __pycache__"
Expand Down
4 changes: 2 additions & 2 deletions src/polymorphic/tests/deletion/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 4.2 on 2026-01-04 02:18
# Generated by Django 4.2 on 2026-01-06 17:46

from decimal import Decimal
from django.conf import settings
Expand All @@ -12,8 +12,8 @@ class Migration(migrations.Migration):
initial = True

dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
]

operations = [
Expand Down
Empty file.
Empty file.
52 changes: 52 additions & 0 deletions src/polymorphic/tests/examples/views/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Generated by Django 4.2 on 2026-01-06 17:46

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='Project',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('topic', models.CharField(max_length=30)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='ArtProject',
fields=[
('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='views.project')),
('artist', models.CharField(max_length=30)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('views.project',),
),
migrations.CreateModel(
name='ResearchProject',
fields=[
('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='views.project')),
('supervisor', models.CharField(max_length=30)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('views.project',),
),
]
Empty file.
14 changes: 14 additions & 0 deletions src/polymorphic/tests/examples/views/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.db import models
from polymorphic.models import PolymorphicModel


class Project(PolymorphicModel):
topic = models.CharField(max_length=30)


class ArtProject(Project):
artist = models.CharField(max_length=30)


class ResearchProject(Project):
supervisor = models.CharField(max_length=30)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<form method="post" action=".?model={{ model_label }}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Next</button>
</form>
91 changes: 91 additions & 0 deletions src/polymorphic/tests/examples/views/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from django.urls import reverse
from playwright.sync_api import expect

from polymorphic.tests.utils import _GenericUITest
from .models import Project, ArtProject, ResearchProject


class ViewExampleTests(_GenericUITest):
def test_view_example(self):
"""Test that the example view code works correctly."""
# Step 1: Navigate to the type selection page
select_url = f"{self.live_server_url}{reverse('project-select')}"
self.page.goto(select_url)

# Verify the page loaded
expect(self.page).to_have_url(select_url)

# Get model labels for the child models
art_project_label = ArtProject._meta.label
research_project_label = ResearchProject._meta.label

# Verify radio buttons for both types exist
art_radio = self.page.locator(f"input[type='radio'][value='{art_project_label}']")
research_radio = self.page.locator(
f"input[type='radio'][value='{research_project_label}']"
)

expect(art_radio).to_be_visible()
expect(research_radio).to_be_visible()

# Step 2: Select ArtProject and submit
art_radio.click()
self.page.click("button[type='submit']")

# Should redirect to the create view with model parameter
create_url_pattern = (
f"{self.live_server_url}{reverse('project-create')}?model={art_project_label}"
)
expect(self.page).to_have_url(create_url_pattern)

# Step 3: Fill in the ArtProject form
# The form should have fields: topic (from Project) and artist (from ArtProject)
self.page.fill("input[name='topic']", "Modern Art")
self.page.fill("input[name='artist']", "Picasso")

# Submit the form
with self.page.expect_navigation(timeout=10000):
self.page.click("button[type='submit']")

# Verify the object was created
art_project = ArtProject.objects.filter(topic="Modern Art", artist="Picasso").first()
assert art_project is not None, "ArtProject was not created"
assert art_project.topic == "Modern Art"
assert art_project.artist == "Picasso"

# Step 4: Test creating a ResearchProject
self.page.goto(select_url)
research_radio = self.page.locator(
f"input[type='radio'][value='{research_project_label}']"
)
research_radio.click()
self.page.click("button[type='submit']")

# Verify redirect to create view
create_url_pattern = (
f"{self.live_server_url}{reverse('project-create')}?model={research_project_label}"
)
expect(self.page).to_have_url(create_url_pattern)

# Fill in the ResearchProject form
# Should have fields: topic and supervisor
self.page.fill("input[name='topic']", "Quantum Computing")
self.page.fill("input[name='supervisor']", "Dr. Smith")

# Submit the form
with self.page.expect_navigation(timeout=10000):
self.page.click("button[type='submit']")

# Verify the object was created
research_project = ResearchProject.objects.filter(
topic="Quantum Computing", supervisor="Dr. Smith"
).first()
assert research_project is not None, "ResearchProject was not created"
assert research_project.topic == "Quantum Computing"
assert research_project.supervisor == "Dr. Smith"

# Verify polymorphic querying works
all_projects = Project.objects.all()
assert all_projects.count() == 2
assert isinstance(all_projects[0], (ArtProject, ResearchProject))
assert isinstance(all_projects[1], (ArtProject, ResearchProject))
7 changes: 7 additions & 0 deletions src/polymorphic/tests/examples/views/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import path
from .views import ProjectTypeSelectView, ProjectCreateView

urlpatterns = [
path("select/", ProjectTypeSelectView.as_view(), name="project-select"),
path("create/", ProjectCreateView.as_view(), name="project-create"),
]
78 changes: 78 additions & 0 deletions src/polymorphic/tests/examples/views/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from django.apps import apps
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import FormView, CreateView
from .models import Project, ArtProject, ResearchProject

from django import forms
from django.utils.translation import gettext_lazy as _


class ProjectTypeChoiceForm(forms.Form):
model_type = forms.ChoiceField(
label=_("Project Type"),
widget=forms.RadioSelect(attrs={"class": "radiolist"}),
)


class ProjectTypeSelectView(FormView):
form_class = ProjectTypeChoiceForm
template_name = "project_type_select.html"

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
# Build choices using model labels: [(model_label, verbose_name), ...]
choices = [
(model._meta.label, model._meta.verbose_name)
for model in [ArtProject, ResearchProject]
]
kwargs["initial"] = {"model_type": choices[0][0] if choices else None}
return kwargs

def get_form(self, form_class=None):
form = super().get_form(form_class)
# Populate the choices for the form using model labels
choices = [
(model._meta.label, model._meta.verbose_name)
for model in [ArtProject, ResearchProject]
]
form.fields["model_type"].choices = choices
return form

def form_valid(self, form):
model_label = form.cleaned_data["model_type"]
return redirect(f"{reverse('project-create')}?model={model_label}")


class ProjectCreateView(CreateView):
model = Project
template_name = "project_form.html"

def get_success_url(self):
return reverse("project-select")

def get_form_class(self):
# Get the requested model label from query parameter
model_label = self.request.GET.get("model")
if not model_label:
# Fallback or redirect to selection view
return super().get_form_class()

# Get the model class using the app registry
model_class = apps.get_model(model_label)

# Create a form for this model
# You can also use a factory or a dict mapping if you have custom forms
class SpecificForm(forms.ModelForm):
class Meta:
model = model_class
fields = "__all__" # Or specify fields

return SpecificForm

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Pass the model label to the template so it can be preserved
# in the form action
context["model_label"] = self.request.GET.get("model")
return context
2 changes: 1 addition & 1 deletion src/polymorphic/tests/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 4.2 on 2026-01-04 02:18
# Generated by Django 4.2 on 2026-01-06 17:46

from django.conf import settings
from django.db import migrations, models
Expand Down
1 change: 1 addition & 0 deletions src/polymorphic/tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"polymorphic.tests",
"polymorphic.tests.deletion",
"polymorphic.tests.test_migrations",
"polymorphic.tests.examples.views",
)

MIDDLEWARE = (
Expand Down
Loading
Loading