django-admin-radio-select 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. django_admin_radio_select-0.1.0/LICENSE +21 -0
  2. django_admin_radio_select-0.1.0/PKG-INFO +157 -0
  3. django_admin_radio_select-0.1.0/README.md +127 -0
  4. django_admin_radio_select-0.1.0/pyproject.toml +80 -0
  5. django_admin_radio_select-0.1.0/setup.cfg +4 -0
  6. django_admin_radio_select-0.1.0/src/django_admin_radio_select/__init__.py +5 -0
  7. django_admin_radio_select-0.1.0/src/django_admin_radio_select/apps.py +6 -0
  8. django_admin_radio_select-0.1.0/src/django_admin_radio_select/formsets.py +53 -0
  9. django_admin_radio_select-0.1.0/src/django_admin_radio_select/mixins.py +121 -0
  10. django_admin_radio_select-0.1.0/src/django_admin_radio_select/py.typed +0 -0
  11. django_admin_radio_select-0.1.0/src/django_admin_radio_select/static/django_admin_radio_select/radio-select.js +35 -0
  12. django_admin_radio_select-0.1.0/src/django_admin_radio_select/widgets.py +45 -0
  13. django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/PKG-INFO +157 -0
  14. django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/SOURCES.txt +22 -0
  15. django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/dependency_links.txt +1 -0
  16. django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/requires.txt +1 -0
  17. django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/top_level.txt +1 -0
  18. django_admin_radio_select-0.1.0/tests/test_admin_integration.py +81 -0
  19. django_admin_radio_select-0.1.0/tests/test_changelist.py +78 -0
  20. django_admin_radio_select-0.1.0/tests/test_e2e.py +151 -0
  21. django_admin_radio_select-0.1.0/tests/test_exclusive_radio_fields_mixin.py +410 -0
  22. django_admin_radio_select-0.1.0/tests/test_formsets.py +177 -0
  23. django_admin_radio_select-0.1.0/tests/test_package.py +19 -0
  24. django_admin_radio_select-0.1.0/tests/test_widgets.py +68 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodolfo Becerra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-admin-radio-select
3
+ Version: 0.1.0
4
+ Summary: Synchronize BooleanFields across Django Admin inline rows as mutually exclusive radio button groups.
5
+ Author-email: Rodolfo Becerra <rodolvbg@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rodolvbg/django-admin-radio-select
8
+ Project-URL: Repository, https://github.com/rodolvbg/django-admin-radio-select
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 4.2
13
+ Classifier: Framework :: Django :: 5.0
14
+ Classifier: Framework :: Django :: 5.1
15
+ Classifier: Framework :: Django :: 5.2
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: django>=4.2
29
+ Dynamic: license-file
30
+
31
+ # django-admin-radio-select
32
+
33
+ [![Python tests](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/pytest.yml/badge.svg)](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/pytest.yml)
34
+ [![JavaScript tests](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/js-tests.yml/badge.svg)](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/js-tests.yml)
35
+ [![Current version on PyPI](https://img.shields.io/pypi/v/django-admin-radio-select.svg)](https://pypi.org/project/django-admin-radio-select/)
36
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-admin-radio-select)](https://pypi.org/project/django-admin-radio-select/)
37
+ [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-admin-radio-select)](https://pypi.org/project/django-admin-radio-select/)
38
+ [![Downloads](https://static.pepy.tech/personalized-badge/django-admin-radio-select?period=month&units=international_system&left_color=black&right_color=blue&left_text=Downloads/month)](https://pepy.tech/project/django-admin-radio-select)
39
+
40
+ Synchronize BooleanFields across Django Admin inline rows as mutually exclusive radio button groups.
41
+
42
+ ## Problem
43
+
44
+ An inline `BooleanField` column renders as independent checkboxes:
45
+
46
+ ```text
47
+ Image A [ ]
48
+ Image B [x]
49
+ Image C [ ]
50
+ ```
51
+
52
+ Nothing stops you from checking more than one — but often only one row should ever be true (the primary image, the default address, the featured item). This package renders that column as radio buttons instead:
53
+
54
+ ```text
55
+ Image A ( )
56
+ Image B (•)
57
+ Image C ( )
58
+ ```
59
+
60
+ Selecting one clears the others, enforced with a small vanilla JS module. Every row still POSTs under its own normal Django-generated field name (e.g. `images-0-is_primary`, `images-1-is_primary`) — nothing here relies on giving different forms the same HTML `name`. A formset-level check on the server also rejects a handcrafted POST that tries to set more than one row true, so the guarantee doesn't depend on JavaScript running at all.
61
+
62
+ Inline formset, before and after clicking a different row's radio — note `is_primary` switches from "Snowy Peak" to "Frozen Lake" while the independent `is_featured` column ("Cabin") is untouched:
63
+
64
+ ![Inline radio-select columns](docs/screenshots/inline-radio-select.png)
65
+ ![Inline radio-select columns after clicking a different row](docs/screenshots/inline-radio-select-after-click.png)
66
+
67
+ Same behavior on a `ModelAdmin` changelist, via `list_editable` (see below):
68
+
69
+ ![Changelist radio-select column](docs/screenshots/changelist-radio-select.png)
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ uv add django-admin-radio-select
75
+ # or
76
+ pip install django-admin-radio-select
77
+ ```
78
+
79
+ Add it to `INSTALLED_APPS` — it's a real Django app (with its own `AppConfig`), which is what makes `django.contrib.staticfiles` pick up its JS:
80
+
81
+ ```python
82
+ INSTALLED_APPS = [
83
+ ...,
84
+ "django_admin_radio_select",
85
+ ]
86
+ ```
87
+
88
+ ## Usage
89
+
90
+ ```python
91
+ from django.contrib import admin
92
+ from django_admin_radio_select import ExclusiveRadioFieldsMixin
93
+
94
+ from .models import Album, Image
95
+
96
+
97
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
98
+ model = Image
99
+ radio_select_exclusive_fields = ("is_primary",)
100
+
101
+
102
+ @admin.register(Album)
103
+ class AlbumAdmin(admin.ModelAdmin):
104
+ inlines = [ImageInline]
105
+ ```
106
+
107
+ Works the same way with `StackedInline`:
108
+
109
+ ```python
110
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.StackedInline):
111
+ model = Image
112
+ radio_select_exclusive_fields = ("is_primary",)
113
+ ```
114
+
115
+ Multiple fields are independent groups:
116
+
117
+ ```python
118
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
119
+ model = Image
120
+ radio_select_exclusive_fields = ("is_primary", "is_featured")
121
+ ```
122
+
123
+ Compute the field list per request (and, for an inline or a change form, per object) instead of (or in addition to) the class attribute:
124
+
125
+ ```python
126
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
127
+ model = Image
128
+
129
+ def get_radio_select_exclusive_fields(self, request, obj=None):
130
+ return ("is_primary",) if request.user.is_superuser else ()
131
+ ```
132
+
133
+ Whatever `get_radio_select_exclusive_fields` returns — the default above, or an override like the one above it — always gets filtered against `get_readonly_fields(request, obj)` before being used: a readonly field isn't rendered as an editable widget at all, so there's nothing to turn into a radio button. This matters for a field that's only conditionally readonly (permissions, object state, ...): without it, a field in `radio_select_exclusive_fields` that becomes readonly for some request would no longer be on the form at all and raise `ImproperlyConfigured`, for a state the admin class itself put it in. This filtering happens in a separate `_get_effective_radio_select_exclusive_fields(request, obj)` method precisely so overriding `get_radio_select_exclusive_fields` doesn't also have to reimplement it.
134
+
135
+ Each configured name must be a `BooleanField` present on the **form** — anything else raises `ImproperlyConfigured` when the admin builds the formset (a `python manage.py check`-time-ish failure, not a silent no-op). "Present on the form" is deliberate: the field only has to exist there, not on the model. A field declared only on a custom `ModelForm`/`InlineModelAdmin.form` (not a real model field at all) works exactly the same as any other configured field, and so does a form field that overrides the model's own type for that name (e.g. the model has a `CharField` but the form redeclares it as a `BooleanField`) — this package never inspects the model, only `form.base_fields`.
136
+
137
+ ### `ModelAdmin` changelist (`list_editable`)
138
+
139
+ The same mixin also works directly on a `ModelAdmin`, for a `BooleanField` column made editable in the changelist via `list_editable`. The field still has to be in `list_display` too — that's a normal Django `list_editable` requirement, unrelated to this package:
140
+
141
+ ```python
142
+ @admin.register(Album)
143
+ class AlbumAdmin(ExclusiveRadioFieldsMixin, admin.ModelAdmin):
144
+ list_display = ("title", "is_featured")
145
+ list_editable = ("is_featured",)
146
+ radio_select_exclusive_fields = ("is_featured",)
147
+ ```
148
+
149
+ Now only one `Album` row in the whole changelist can have `is_featured` checked at a time — same client-side sync, same per-row Django field names (`form-0-is_featured`, `form-1-is_featured`, ...), same server-side exclusivity check on save.
150
+
151
+ ## Supported versions
152
+
153
+ Python 3.10–3.13, Django 4.2–5.2. Works on `TabularInline`, `StackedInline`, and `ModelAdmin` (via `list_editable`).
154
+
155
+ ## Development
156
+
157
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, running the example project, and the test/lint/type-check commands.
@@ -0,0 +1,127 @@
1
+ # django-admin-radio-select
2
+
3
+ [![Python tests](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/pytest.yml/badge.svg)](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/pytest.yml)
4
+ [![JavaScript tests](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/js-tests.yml/badge.svg)](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/js-tests.yml)
5
+ [![Current version on PyPI](https://img.shields.io/pypi/v/django-admin-radio-select.svg)](https://pypi.org/project/django-admin-radio-select/)
6
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-admin-radio-select)](https://pypi.org/project/django-admin-radio-select/)
7
+ [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-admin-radio-select)](https://pypi.org/project/django-admin-radio-select/)
8
+ [![Downloads](https://static.pepy.tech/personalized-badge/django-admin-radio-select?period=month&units=international_system&left_color=black&right_color=blue&left_text=Downloads/month)](https://pepy.tech/project/django-admin-radio-select)
9
+
10
+ Synchronize BooleanFields across Django Admin inline rows as mutually exclusive radio button groups.
11
+
12
+ ## Problem
13
+
14
+ An inline `BooleanField` column renders as independent checkboxes:
15
+
16
+ ```text
17
+ Image A [ ]
18
+ Image B [x]
19
+ Image C [ ]
20
+ ```
21
+
22
+ Nothing stops you from checking more than one — but often only one row should ever be true (the primary image, the default address, the featured item). This package renders that column as radio buttons instead:
23
+
24
+ ```text
25
+ Image A ( )
26
+ Image B (•)
27
+ Image C ( )
28
+ ```
29
+
30
+ Selecting one clears the others, enforced with a small vanilla JS module. Every row still POSTs under its own normal Django-generated field name (e.g. `images-0-is_primary`, `images-1-is_primary`) — nothing here relies on giving different forms the same HTML `name`. A formset-level check on the server also rejects a handcrafted POST that tries to set more than one row true, so the guarantee doesn't depend on JavaScript running at all.
31
+
32
+ Inline formset, before and after clicking a different row's radio — note `is_primary` switches from "Snowy Peak" to "Frozen Lake" while the independent `is_featured` column ("Cabin") is untouched:
33
+
34
+ ![Inline radio-select columns](docs/screenshots/inline-radio-select.png)
35
+ ![Inline radio-select columns after clicking a different row](docs/screenshots/inline-radio-select-after-click.png)
36
+
37
+ Same behavior on a `ModelAdmin` changelist, via `list_editable` (see below):
38
+
39
+ ![Changelist radio-select column](docs/screenshots/changelist-radio-select.png)
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ uv add django-admin-radio-select
45
+ # or
46
+ pip install django-admin-radio-select
47
+ ```
48
+
49
+ Add it to `INSTALLED_APPS` — it's a real Django app (with its own `AppConfig`), which is what makes `django.contrib.staticfiles` pick up its JS:
50
+
51
+ ```python
52
+ INSTALLED_APPS = [
53
+ ...,
54
+ "django_admin_radio_select",
55
+ ]
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```python
61
+ from django.contrib import admin
62
+ from django_admin_radio_select import ExclusiveRadioFieldsMixin
63
+
64
+ from .models import Album, Image
65
+
66
+
67
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
68
+ model = Image
69
+ radio_select_exclusive_fields = ("is_primary",)
70
+
71
+
72
+ @admin.register(Album)
73
+ class AlbumAdmin(admin.ModelAdmin):
74
+ inlines = [ImageInline]
75
+ ```
76
+
77
+ Works the same way with `StackedInline`:
78
+
79
+ ```python
80
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.StackedInline):
81
+ model = Image
82
+ radio_select_exclusive_fields = ("is_primary",)
83
+ ```
84
+
85
+ Multiple fields are independent groups:
86
+
87
+ ```python
88
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
89
+ model = Image
90
+ radio_select_exclusive_fields = ("is_primary", "is_featured")
91
+ ```
92
+
93
+ Compute the field list per request (and, for an inline or a change form, per object) instead of (or in addition to) the class attribute:
94
+
95
+ ```python
96
+ class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
97
+ model = Image
98
+
99
+ def get_radio_select_exclusive_fields(self, request, obj=None):
100
+ return ("is_primary",) if request.user.is_superuser else ()
101
+ ```
102
+
103
+ Whatever `get_radio_select_exclusive_fields` returns — the default above, or an override like the one above it — always gets filtered against `get_readonly_fields(request, obj)` before being used: a readonly field isn't rendered as an editable widget at all, so there's nothing to turn into a radio button. This matters for a field that's only conditionally readonly (permissions, object state, ...): without it, a field in `radio_select_exclusive_fields` that becomes readonly for some request would no longer be on the form at all and raise `ImproperlyConfigured`, for a state the admin class itself put it in. This filtering happens in a separate `_get_effective_radio_select_exclusive_fields(request, obj)` method precisely so overriding `get_radio_select_exclusive_fields` doesn't also have to reimplement it.
104
+
105
+ Each configured name must be a `BooleanField` present on the **form** — anything else raises `ImproperlyConfigured` when the admin builds the formset (a `python manage.py check`-time-ish failure, not a silent no-op). "Present on the form" is deliberate: the field only has to exist there, not on the model. A field declared only on a custom `ModelForm`/`InlineModelAdmin.form` (not a real model field at all) works exactly the same as any other configured field, and so does a form field that overrides the model's own type for that name (e.g. the model has a `CharField` but the form redeclares it as a `BooleanField`) — this package never inspects the model, only `form.base_fields`.
106
+
107
+ ### `ModelAdmin` changelist (`list_editable`)
108
+
109
+ The same mixin also works directly on a `ModelAdmin`, for a `BooleanField` column made editable in the changelist via `list_editable`. The field still has to be in `list_display` too — that's a normal Django `list_editable` requirement, unrelated to this package:
110
+
111
+ ```python
112
+ @admin.register(Album)
113
+ class AlbumAdmin(ExclusiveRadioFieldsMixin, admin.ModelAdmin):
114
+ list_display = ("title", "is_featured")
115
+ list_editable = ("is_featured",)
116
+ radio_select_exclusive_fields = ("is_featured",)
117
+ ```
118
+
119
+ Now only one `Album` row in the whole changelist can have `is_featured` checked at a time — same client-side sync, same per-row Django field names (`form-0-is_featured`, `form-1-is_featured`, ...), same server-side exclusivity check on save.
120
+
121
+ ## Supported versions
122
+
123
+ Python 3.10–3.13, Django 4.2–5.2. Works on `TabularInline`, `StackedInline`, and `ModelAdmin` (via `list_editable`).
124
+
125
+ ## Development
126
+
127
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, running the example project, and the test/lint/type-check commands.
@@ -0,0 +1,80 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = [ "setuptools>=68" ]
4
+
5
+ [project]
6
+ name = "django-admin-radio-select"
7
+ version = "0.1.0"
8
+ description = "Synchronize BooleanFields across Django Admin inline rows as mutually exclusive radio button groups."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = [ "LICENSE" ]
12
+ authors = [ { name = "Rodolfo Becerra", email = "rodolvbg@gmail.com" } ]
13
+ requires-python = ">=3.10"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Web Environment",
17
+ "Framework :: Django",
18
+ "Framework :: Django :: 4.2",
19
+ "Framework :: Django :: 5.0",
20
+ "Framework :: Django :: 5.1",
21
+ "Framework :: Django :: 5.2",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Typing :: Typed",
31
+ ]
32
+ dependencies = [ "django>=4.2" ]
33
+ urls.Homepage = "https://github.com/rodolvbg/django-admin-radio-select"
34
+ urls.Repository = "https://github.com/rodolvbg/django-admin-radio-select"
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "coverage[toml]>=7.4",
39
+ "django-stubs[compatible-mypy]>=5",
40
+ "mypy>=1.10",
41
+ "pre-commit>=4",
42
+ "pytest>=8",
43
+ "pytest-cov>=5",
44
+ "pytest-django>=4.8",
45
+ "pytest-playwright>=0.5",
46
+ "ruff>=0.6",
47
+ ]
48
+
49
+ [tool.setuptools]
50
+ packages.find.where = [ "src" ]
51
+ packages.find.include = [ "django_admin_radio_select*" ]
52
+ package-data.django_admin_radio_select = [ "py.typed", "static/django_admin_radio_select/*.js" ]
53
+
54
+ [tool.ruff]
55
+ target-version = "py310"
56
+ line-length = 100
57
+ extend-exclude = [ "**/migrations/*.py" ]
58
+ lint.select = [ "B", "DJ", "E", "F", "I", "UP" ]
59
+
60
+ [tool.mypy]
61
+ mypy_path = "src:example"
62
+ python_version = "3.10"
63
+ warn_redundant_casts = true
64
+ warn_unused_ignores = true
65
+ plugins = [ "mypy_django_plugin.main" ]
66
+
67
+ [tool.django-stubs]
68
+ django_settings_module = "example.settings"
69
+
70
+ [tool.pytest]
71
+ ini_options.testpaths = [ "tests" ]
72
+ ini_options.pythonpath = [ "src", "example" ]
73
+ ini_options.addopts = "--no-migrations --cov=django_admin_radio_select --cov-report=term-missing"
74
+ ini_options.DJANGO_SETTINGS_MODULE = "example.settings"
75
+
76
+ [tool.coverage]
77
+ run.branch = true
78
+ run.source = [ "django_admin_radio_select" ]
79
+ report.show_missing = true
80
+ report.skip_covered = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .mixins import ExclusiveRadioFieldsMixin
2
+
3
+ __all__ = ["ExclusiveRadioFieldsMixin"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoAdminRadioSelectConfig(AppConfig):
5
+ name = "django_admin_radio_select"
6
+ verbose_name = "Django Admin Radio Select"
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import TYPE_CHECKING, TypeVar
5
+
6
+ from django import forms
7
+
8
+ if TYPE_CHECKING:
9
+ from django.forms.models import BaseModelFormSet
10
+
11
+ _FormSetT = TypeVar("_FormSetT", bound="BaseModelFormSet")
12
+
13
+
14
+ def make_radio_select_exclusive_formset(
15
+ formset_class: type[_FormSetT], field_names: Sequence[str]
16
+ ) -> type[_FormSetT]:
17
+ """Return a subclass of ``formset_class`` whose ``clean()`` rejects
18
+ more than one non-deleted form having any of ``field_names`` set to
19
+ ``True``.
20
+
21
+ Works the same for an inline's formset and for a ``ModelAdmin``
22
+ changelist's ``list_editable`` formset — both are ``BaseModelFormSet``
23
+ subclasses. This is the server-side backstop for the client-side
24
+ radio behavior: JS keeps at most one row checked interactively, but a
25
+ handcrafted POST could still submit several. The check runs *after*
26
+ ``formset_class``'s own ``clean()`` (via ``super()``), so whatever
27
+ validation a user-supplied ``InlineModelAdmin.formset`` (or
28
+ ``ModelAdmin.get_changelist_formset``) already does keeps running
29
+ unchanged; this only adds to it.
30
+ """
31
+
32
+ class RadioSelectExclusiveFormSet(formset_class): # type: ignore[misc,valid-type]
33
+ def clean(self) -> None:
34
+ super().clean()
35
+ if any(self.errors):
36
+ return
37
+
38
+ for field_name in field_names:
39
+ selected = [
40
+ form
41
+ for form in self.forms
42
+ if not self._should_delete_form(form) and form.cleaned_data.get(field_name)
43
+ ]
44
+ if len(selected) > 1:
45
+ raise forms.ValidationError(
46
+ "Only one row may have '%(field)s' selected.",
47
+ code="radio-select-exclusive",
48
+ params={"field": field_name},
49
+ )
50
+
51
+ RadioSelectExclusiveFormSet.__name__ = formset_class.__name__
52
+ RadioSelectExclusiveFormSet.__qualname__ = formset_class.__qualname__
53
+ return RadioSelectExclusiveFormSet
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import TYPE_CHECKING, Any, TypeVar
5
+
6
+ from django import forms
7
+ from django.contrib.admin.options import BaseModelAdmin
8
+ from django.core.exceptions import ImproperlyConfigured
9
+
10
+ from .formsets import make_radio_select_exclusive_formset
11
+ from .widgets import RadioCheckboxInput
12
+
13
+ if TYPE_CHECKING:
14
+ from django.db.models import Model
15
+ from django.forms.models import BaseInlineFormSet, BaseModelFormSet
16
+ from django.http import HttpRequest
17
+
18
+ _FormSetT = TypeVar("_FormSetT", bound="BaseModelFormSet")
19
+
20
+
21
+ class ExclusiveRadioFieldsMixin(BaseModelAdmin):
22
+ """Mixin for ``ModelAdmin``, ``TabularInline``, or ``StackedInline``
23
+ that renders the ``BooleanField``\\ s named in
24
+ ``radio_select_exclusive_fields`` as radio buttons, synchronized
25
+ client-side so selecting one clears the others in the same column —
26
+ while every row keeps its own Django-generated field name, so the
27
+ formset POST is completely unaffected.
28
+
29
+ - On an inline, this applies to every row of that inline's formset.
30
+ - On a ``ModelAdmin``, it applies to the changelist's
31
+ ``list_editable`` formset — the field must be in both
32
+ ``list_display`` and ``list_editable`` for Django to render it as
33
+ an editable column at all; ``radio_select_exclusive_fields`` only
34
+ controls how that column renders and is validated, same as inline.
35
+
36
+ Meant to sit before ``admin.ModelAdmin``/``admin.TabularInline``/
37
+ ``admin.StackedInline`` in a subclass's bases, e.g.
38
+ ``class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline)``.
39
+ Inheriting from ``BaseModelAdmin`` — the common base of both
40
+ ``ModelAdmin`` and ``InlineModelAdmin`` — rather than staying a bare
41
+ mixin gives ``self``/``super()`` their real types for free; the
42
+ ``# type: ignore[misc]`` on each ``super()`` call below is only
43
+ because ``BaseModelAdmin`` itself doesn't declare ``get_formset``/
44
+ ``get_changelist_formset`` (only the concrete ``InlineModelAdmin``/
45
+ ``ModelAdmin`` a using class also inherits from do), so a type
46
+ checker can't confirm they'll exist until the two are combined.
47
+
48
+ See ``RadioCheckboxInput.media`` for how the JS gets loaded — not
49
+ from a ``media`` property here, since ``ModelAdmin.media`` /
50
+ ``InlineModelAdmin.media`` are computed the same way no matter which
51
+ view is asking, so a property here couldn't tell a changelist render
52
+ apart from a change-form render.
53
+ """
54
+
55
+ radio_select_exclusive_fields: Sequence[str] = ()
56
+
57
+ def get_radio_select_exclusive_fields(
58
+ self, request: HttpRequest, obj: Model | None = None
59
+ ) -> Sequence[str]:
60
+ return tuple(self.radio_select_exclusive_fields)
61
+
62
+ def _get_effective_radio_select_exclusive_fields(
63
+ self, request: HttpRequest, obj: Model | None = None
64
+ ) -> Sequence[str]:
65
+ # A readonly field is never part of form.base_fields as an
66
+ # editable widget (Django renders it as plain text instead), so
67
+ # radioizing it would either be a no-op or, if it's also missing
68
+ # from the form for that reason, raise ImproperlyConfigured for
69
+ # something the caller didn't really misconfigure — e.g. a field
70
+ # only made readonly for some requests via get_readonly_fields.
71
+ # Kept separate from get_radio_select_exclusive_fields() so this
72
+ # filtering applies whether or not that method is overridden.
73
+ readonly_fields = set(self.get_readonly_fields(request, obj))
74
+ return tuple(
75
+ field_name
76
+ for field_name in self.get_radio_select_exclusive_fields(request, obj)
77
+ if field_name not in readonly_fields
78
+ )
79
+
80
+ def get_formset(
81
+ self, request: HttpRequest, obj: Model | None = None, **kwargs: Any
82
+ ) -> type[BaseInlineFormSet]:
83
+ formset_class = super().get_formset(request, obj, **kwargs) # type: ignore[misc]
84
+ field_names = self._get_effective_radio_select_exclusive_fields(request, obj)
85
+ return self._wrap_formset(formset_class, field_names)
86
+
87
+ def get_changelist_formset(self, request: HttpRequest, **kwargs: Any) -> type[BaseModelFormSet]:
88
+ formset_class = super().get_changelist_formset(request, **kwargs) # type: ignore[misc]
89
+ field_names = self._get_effective_radio_select_exclusive_fields(request)
90
+ return self._wrap_formset(formset_class, field_names)
91
+
92
+ @staticmethod
93
+ def _wrap_formset(
94
+ formset_class: type[_FormSetT], field_names: Sequence[str]
95
+ ) -> type[_FormSetT]:
96
+ if not field_names:
97
+ return formset_class
98
+
99
+ prefix = formset_class.get_default_prefix()
100
+ ExclusiveRadioFieldsMixin._radioize_form_fields(formset_class.form, field_names, prefix)
101
+ return make_radio_select_exclusive_formset(formset_class, field_names)
102
+
103
+ @staticmethod
104
+ def _radioize_form_fields(
105
+ form_class: type[forms.ModelForm], field_names: Sequence[str], prefix: str
106
+ ) -> None:
107
+ for field_name in field_names:
108
+ field = form_class.base_fields.get(field_name)
109
+ if not isinstance(field, forms.BooleanField):
110
+ raise ImproperlyConfigured(
111
+ f"{form_class.__name__}: radio_select_exclusive_fields references "
112
+ f"'{field_name}', which must be a BooleanField on the form "
113
+ f"(got {field.__class__.__name__ if field else 'nothing'})."
114
+ )
115
+ field.widget = RadioCheckboxInput(
116
+ group=f"{prefix}::{field_name}", field_name=field_name
117
+ )
118
+ # A "must be checked" BooleanField can't represent the "this
119
+ # row isn't the selected one" (False) state, which is a
120
+ # completely valid, expected state for every row but one.
121
+ field.required = False
@@ -0,0 +1,35 @@
1
+ (() => {
2
+ var SELECTOR = 'input[type="radio"].radio-select-exclusive';
3
+
4
+ function escapeAttrValue(value) {
5
+ if (window.CSS && typeof CSS.escape === "function") {
6
+ return CSS.escape(value);
7
+ }
8
+ return String(value).replace(/["\\]/g, "\\$&");
9
+ }
10
+
11
+ function uncheckOthersInGroup(radio) {
12
+ var group = radio.dataset.radioSelectGroup;
13
+ var selector =
14
+ SELECTOR +
15
+ '[data-radio-select-group="' +
16
+ escapeAttrValue(group) +
17
+ '"]';
18
+ document.querySelectorAll(selector).forEach((other) => {
19
+ if (other !== radio) {
20
+ other.checked = false;
21
+ }
22
+ });
23
+ }
24
+
25
+ // A single delegated listener on `document` covers inline rows added
26
+ // later too (Django admin's "Add another"), since `change` bubbles
27
+ // from any input inserted into the page — no MutationObserver or
28
+ // dependency on Django's internal jQuery formset events needed.
29
+ document.addEventListener("change", (event) => {
30
+ var radio = event.target;
31
+ if (radio.matches?.(SELECTOR) && radio.checked) {
32
+ uncheckOthersInGroup(radio);
33
+ }
34
+ });
35
+ })();
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from django.forms.widgets import CheckboxInput
6
+
7
+
8
+ class RadioCheckboxInput(CheckboxInput):
9
+ """A :class:`~django.forms.CheckboxInput` rendered with
10
+ ``type="radio"`` instead of ``type="checkbox"``.
11
+
12
+ ``CheckboxInput`` already POSTs exactly the way a radio-per-row needs
13
+ to: present in the data means checked, absent means unchecked/False.
14
+ Only ``input_type`` changes here, which is all Django's widget
15
+ templates key off of for the rendered ``type`` attribute — so a
16
+ ``BooleanField`` using this widget round-trips through a normal
17
+ Django form/formset completely unchanged; only the rendered control
18
+ (and therefore its default browser styling and grouping semantics)
19
+ is different.
20
+
21
+ ``media`` lives here rather than on the admin mixin on purpose: a
22
+ widget only exists once a field is actually radioized, and only ever
23
+ gets *rendered* as part of the specific form/formset that uses it —
24
+ Django aggregates a rendered page's media from exactly the
25
+ forms/widgets it renders. So this script only ever loads on a
26
+ changelist that actually has a ``list_editable`` radio column, or a
27
+ change view that actually has a radioized inline — never anywhere
28
+ else — with no extra request- or view-awareness needed here. An
29
+ admin-level ``media`` property couldn't do this: Django computes
30
+ ``ModelAdmin.media``/``InlineModelAdmin.media`` the same way
31
+ regardless of which view asked for it, so it can't tell "changelist"
32
+ from "change form" apart.
33
+ """
34
+
35
+ class Media:
36
+ js = ("django_admin_radio_select/radio-select.js",)
37
+
38
+ input_type = "radio"
39
+
40
+ def __init__(self, group: str, field_name: str, attrs: dict[str, Any] | None = None) -> None:
41
+ attrs = dict(attrs or {})
42
+ attrs["class"] = f"{attrs.get('class', '')} radio-select-exclusive".strip()
43
+ attrs["data-radio-select-group"] = group
44
+ attrs["data-radio-select-field"] = field_name
45
+ super().__init__(attrs=attrs)