diff --git a/docs/index.rst b/docs/index.rst index bc304684..a9cbdc83 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -117,6 +117,7 @@ Advanced topics :maxdepth: 2 formsets + views migrating managers deletion diff --git a/docs/views.rst b/docs/views.rst new file mode 100644 index 00000000..f7808255 --- /dev/null +++ b/docs/views.rst @@ -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 + `_. + +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. diff --git a/pyproject.toml b/pyproject.toml index 8aae670f..363a3c5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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__" diff --git a/src/polymorphic/tests/deletion/migrations/0001_initial.py b/src/polymorphic/tests/deletion/migrations/0001_initial.py index c71a0131..8170f9fb 100644 --- a/src/polymorphic/tests/deletion/migrations/0001_initial.py +++ b/src/polymorphic/tests/deletion/migrations/0001_initial.py @@ -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 @@ -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 = [ diff --git a/src/polymorphic/tests/examples/__init__.py b/src/polymorphic/tests/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/views/__init__.py b/src/polymorphic/tests/examples/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/views/migrations/0001_initial.py b/src/polymorphic/tests/examples/views/migrations/0001_initial.py new file mode 100644 index 00000000..bf4302ae --- /dev/null +++ b/src/polymorphic/tests/examples/views/migrations/0001_initial.py @@ -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',), + ), + ] diff --git a/src/polymorphic/tests/examples/views/migrations/__init__.py b/src/polymorphic/tests/examples/views/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/polymorphic/tests/examples/views/models.py b/src/polymorphic/tests/examples/views/models.py new file mode 100644 index 00000000..6d6b1378 --- /dev/null +++ b/src/polymorphic/tests/examples/views/models.py @@ -0,0 +1,14 @@ +from django.db import models +from polymorphic.models import PolymorphicModel + + +class Project(PolymorphicModel): + topic = models.CharField(max_length=30) + + +class ArtProject(Project): + artist = models.CharField(max_length=30) + + +class ResearchProject(Project): + supervisor = models.CharField(max_length=30) diff --git a/src/polymorphic/tests/examples/views/templates/project_form.html b/src/polymorphic/tests/examples/views/templates/project_form.html new file mode 100644 index 00000000..3b6ae3ba --- /dev/null +++ b/src/polymorphic/tests/examples/views/templates/project_form.html @@ -0,0 +1,5 @@ +
+ {% csrf_token %} + {{ form.as_p }} + +
diff --git a/src/polymorphic/tests/examples/views/templates/project_type_select.html b/src/polymorphic/tests/examples/views/templates/project_type_select.html new file mode 100644 index 00000000..50ef2f61 --- /dev/null +++ b/src/polymorphic/tests/examples/views/templates/project_type_select.html @@ -0,0 +1,5 @@ +
+ {% csrf_token %} + {{ form.as_p }} + +
diff --git a/src/polymorphic/tests/examples/views/test.py b/src/polymorphic/tests/examples/views/test.py new file mode 100644 index 00000000..70fb0fa0 --- /dev/null +++ b/src/polymorphic/tests/examples/views/test.py @@ -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)) diff --git a/src/polymorphic/tests/examples/views/urls.py b/src/polymorphic/tests/examples/views/urls.py new file mode 100644 index 00000000..681d5efe --- /dev/null +++ b/src/polymorphic/tests/examples/views/urls.py @@ -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"), +] diff --git a/src/polymorphic/tests/examples/views/views.py b/src/polymorphic/tests/examples/views/views.py new file mode 100644 index 00000000..85b2fc15 --- /dev/null +++ b/src/polymorphic/tests/examples/views/views.py @@ -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 diff --git a/src/polymorphic/tests/migrations/0001_initial.py b/src/polymorphic/tests/migrations/0001_initial.py index 89e9aee8..b782b33f 100644 --- a/src/polymorphic/tests/migrations/0001_initial.py +++ b/src/polymorphic/tests/migrations/0001_initial.py @@ -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 diff --git a/src/polymorphic/tests/settings.py b/src/polymorphic/tests/settings.py index a0deefab..89ba8c79 100644 --- a/src/polymorphic/tests/settings.py +++ b/src/polymorphic/tests/settings.py @@ -102,6 +102,7 @@ "polymorphic.tests", "polymorphic.tests.deletion", "polymorphic.tests.test_migrations", + "polymorphic.tests.examples.views", ) MIDDLEWARE = ( diff --git a/src/polymorphic/tests/test_admin.py b/src/polymorphic/tests/test_admin.py index c91aaceb..66d53e87 100644 --- a/src/polymorphic/tests/test_admin.py +++ b/src/polymorphic/tests/test_admin.py @@ -9,8 +9,6 @@ from django.test import RequestFactory from django.urls import resolve -from django.contrib.staticfiles.testing import StaticLiveServerTestCase - from polymorphic.admin import ( PolymorphicChildModelAdmin, PolymorphicChildModelFilter, @@ -18,7 +16,6 @@ PolymorphicParentModelAdmin, StackedPolymorphicInline, ) -from polymorphic import tests from polymorphic.tests.admintestcase import AdminTestCase from polymorphic.tests.models import ( PlainA, @@ -35,6 +32,8 @@ from playwright.sync_api import sync_playwright, expect from urllib.parse import urljoin +from .utils import _GenericUITest + class PolymorphicAdminTests(AdminTestCase): def test_admin_registration(self): @@ -277,15 +276,9 @@ class InlineParentAdmin(PolymorphicInlineSupportMixin, admin.ModelAdmin): assert child.field2 == "B2" -class _GenericAdminFormTest(StaticLiveServerTestCase): +class _GenericAdminFormTest(_GenericUITest): """Generic admin form test using Playwright.""" - HEADLESS = tests.HEADLESS - - admin_username = "admin" - admin_password = "password" - admin = None - def admin_url(self): return f"{self.live_server_url}{reverse('admin:index')}" @@ -310,49 +303,6 @@ def get_object_ids(self, model): "input[name='_selected_action']", "elements => elements.map(e => e.value)" ) - @classmethod - def setUpClass(cls): - """Set up the test class with a live server and Playwright instance.""" - os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "1" - super().setUpClass() - try: - cls.playwright = sync_playwright().start() - cls.browser = cls.playwright.chromium.launch(headless=cls.HEADLESS) - except Exception as e: - if "asyncio loop" in str(e) or "executable" in str(e).lower(): - raise RuntimeError( - "Playwright failed to start. This often happens if browser drivers are missing. " - "Please run 'just install-playwright' to install them." - ) from e - raise - - @classmethod - def tearDownClass(cls): - """Clean up Playwright instance after tests.""" - cls.browser.close() - cls.playwright.stop() - super().tearDownClass() - del os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] - - def setUp(self): - """Create an admin user before running tests.""" - self.admin = get_user_model().objects.create_superuser( - username=self.admin_username, email="admin@example.com", password=self.admin_password - ) - self.page = self.browser.new_page() - # Log in to the Django admin - self.page.goto(f"{self.live_server_url}/admin/login/") - self.page.fill("input[name='username']", self.admin_username) - self.page.fill("input[name='password']", self.admin_password) - self.page.click("input[type='submit']") - - # Ensure login is successful - expect(self.page).to_have_url(f"{self.live_server_url}/admin/") - - def tearDown(self): - if self.page: - self.page.close() - class StackedInlineTests(_GenericAdminFormTest): def test_admin_inline_add_autocomplete(self): diff --git a/src/polymorphic/tests/urls.py b/src/polymorphic/tests/urls.py index 083932c6..32d12a17 100644 --- a/src/polymorphic/tests/urls.py +++ b/src/polymorphic/tests/urls.py @@ -1,6 +1,7 @@ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), + path("examples/views", include("polymorphic.tests.examples.views.urls")), ] diff --git a/src/polymorphic/tests/utils.py b/src/polymorphic/tests/utils.py index 3c9f6de3..e2af7f1d 100644 --- a/src/polymorphic/tests/utils.py +++ b/src/polymorphic/tests/utils.py @@ -1,13 +1,17 @@ import os import shutil from pathlib import Path -import io from django.core.management import call_command - from django_test_migrations.migrator import Migrator +from django.contrib.auth import get_user_model +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.urls import reverse from django.apps import apps +from playwright.sync_api import sync_playwright, expect +from polymorphic import tests + class GeneratedMigrationsPerClassMixin: """ @@ -85,3 +89,80 @@ def _find_latest_migration_name(cls, app_label: str) -> str: if not candidates: raise RuntimeError(f"No migrations generated for {app_label}") return candidates[-1].stem + + +class _GenericUITest(StaticLiveServerTestCase): + """Generic admin form test using Playwright.""" + + HEADLESS = tests.HEADLESS + + admin_username = "admin" + admin_password = "password" + admin = None + + def admin_url(self): + return f"{self.live_server_url}{reverse('admin:index')}" + + def add_url(self, model): + path = reverse(f"admin:{model._meta.label_lower.replace('.', '_')}_add") + return f"{self.live_server_url}{path}" + + def change_url(self, model, id): + path = reverse( + f"admin:{model._meta.label_lower.replace('.', '_')}_change", + args=[id], + ) + return f"{self.live_server_url}{path}" + + def list_url(self, model): + path = reverse(f"admin:{model._meta.label_lower.replace('.', '_')}_changelist") + return f"{self.live_server_url}{path}" + + def get_object_ids(self, model): + self.page.goto(self.list_url(model)) + return self.page.eval_on_selector_all( + "input[name='_selected_action']", "elements => elements.map(e => e.value)" + ) + + @classmethod + def setUpClass(cls): + """Set up the test class with a live server and Playwright instance.""" + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "1" + super().setUpClass() + try: + cls.playwright = sync_playwright().start() + cls.browser = cls.playwright.chromium.launch(headless=cls.HEADLESS) + except Exception as e: + if "asyncio loop" in str(e) or "executable" in str(e).lower(): + raise RuntimeError( + "Playwright failed to start. This often happens if browser drivers are missing. " + "Please run 'just install-playwright' to install them." + ) from e + raise + + @classmethod + def tearDownClass(cls): + """Clean up Playwright instance after tests.""" + cls.browser.close() + cls.playwright.stop() + super().tearDownClass() + del os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] + + def setUp(self): + """Create an admin user before running tests.""" + self.admin = get_user_model().objects.create_superuser( + username=self.admin_username, email="admin@example.com", password=self.admin_password + ) + self.page = self.browser.new_page() + # Log in to the Django admin + self.page.goto(f"{self.live_server_url}/admin/login/") + self.page.fill("input[name='username']", self.admin_username) + self.page.fill("input[name='password']", self.admin_password) + self.page.click("input[type='submit']") + + # Ensure login is successful + expect(self.page).to_have_url(f"{self.live_server_url}/admin/") + + def tearDown(self): + if self.page: + self.page.close()