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.
- django_admin_radio_select-0.1.0/LICENSE +21 -0
- django_admin_radio_select-0.1.0/PKG-INFO +157 -0
- django_admin_radio_select-0.1.0/README.md +127 -0
- django_admin_radio_select-0.1.0/pyproject.toml +80 -0
- django_admin_radio_select-0.1.0/setup.cfg +4 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select/__init__.py +5 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select/apps.py +6 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select/formsets.py +53 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select/mixins.py +121 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select/py.typed +0 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select/static/django_admin_radio_select/radio-select.js +35 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select/widgets.py +45 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/PKG-INFO +157 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/SOURCES.txt +22 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/dependency_links.txt +1 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/requires.txt +1 -0
- django_admin_radio_select-0.1.0/src/django_admin_radio_select.egg-info/top_level.txt +1 -0
- django_admin_radio_select-0.1.0/tests/test_admin_integration.py +81 -0
- django_admin_radio_select-0.1.0/tests/test_changelist.py +78 -0
- django_admin_radio_select-0.1.0/tests/test_e2e.py +151 -0
- django_admin_radio_select-0.1.0/tests/test_exclusive_radio_fields_mixin.py +410 -0
- django_admin_radio_select-0.1.0/tests/test_formsets.py +177 -0
- django_admin_radio_select-0.1.0/tests/test_package.py +19 -0
- 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
|
+
[](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/pytest.yml)
|
|
34
|
+
[](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/js-tests.yml)
|
|
35
|
+
[](https://pypi.org/project/django-admin-radio-select/)
|
|
36
|
+
[](https://pypi.org/project/django-admin-radio-select/)
|
|
37
|
+
[](https://pypi.org/project/django-admin-radio-select/)
|
|
38
|
+
[](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
|
+

|
|
65
|
+

|
|
66
|
+
|
|
67
|
+
Same behavior on a `ModelAdmin` changelist, via `list_editable` (see below):
|
|
68
|
+
|
|
69
|
+

|
|
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
|
+
[](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/pytest.yml)
|
|
4
|
+
[](https://github.com/rodolvbg/django-admin-radio-select/actions/workflows/js-tests.yml)
|
|
5
|
+
[](https://pypi.org/project/django-admin-radio-select/)
|
|
6
|
+
[](https://pypi.org/project/django-admin-radio-select/)
|
|
7
|
+
[](https://pypi.org/project/django-admin-radio-select/)
|
|
8
|
+
[](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
|
+

|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
Same behavior on a `ModelAdmin` changelist, via `list_editable` (see below):
|
|
38
|
+
|
|
39
|
+

|
|
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,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
|
|
File without changes
|
|
@@ -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)
|