Skip to main content

django-admin-radio-select

Python tests JavaScript tests Current version on PyPI PyPI - Python Version PyPI - Django Version Downloads

Synchronize BooleanFields across Django Admin inline rows as mutually exclusive radio button groups.

Problem

An inline BooleanField column renders as independent checkboxes:

Image A    [ ]
Image B    [x]
Image C    [ ]

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:

Image A    ( )
Image B    (•)
Image C    ( )

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.

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:

Inline radio-select columns Inline radio-select columns after clicking a different row

Same behavior on a ModelAdmin changelist, via list_editable (see below):

Changelist radio-select column

Install

uv add django-admin-radio-select
# or
pip install django-admin-radio-select

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:

INSTALLED_APPS = [
    ...,
    "django_admin_radio_select",
]

Usage

from django.contrib import admin
from django_admin_radio_select import ExclusiveRadioFieldsMixin

from .models import Album, Image


class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
    model = Image
    radio_select_exclusive_fields = ("is_primary",)


@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
    inlines = [ImageInline]

Works the same way with StackedInline:

class ImageInline(ExclusiveRadioFieldsMixin, admin.StackedInline):
    model = Image
    radio_select_exclusive_fields = ("is_primary",)

Multiple fields are independent groups:

class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
    model = Image
    radio_select_exclusive_fields = ("is_primary", "is_featured")

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:

class ImageInline(ExclusiveRadioFieldsMixin, admin.TabularInline):
    model = Image

    def get_radio_select_exclusive_fields(self, request, obj=None):
        return ("is_primary",) if request.user.is_superuser else ()

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.

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.

ModelAdmin changelist (list_editable)

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:

@admin.register(Album)
class AlbumAdmin(ExclusiveRadioFieldsMixin, admin.ModelAdmin):
    list_display = ("title", "is_featured")
    list_editable = ("is_featured",)
    radio_select_exclusive_fields = ("is_featured",)

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.

Supported versions

Python 3.10–3.13, Django 4.2–5.2. Works on TabularInline, StackedInline, and ModelAdmin (via list_editable).

Development

See CONTRIBUTING.md for setup, running the example project, and the test/lint/type-check commands.

Metadata

Release files for django-admin-radio-select 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-admin-radio-select 0.1.0
File Size Uploaded
django_admin_radio_select-0.1.0.tar.gz 19.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-admin-radio-select 0.1.0
File Interpreter ABI Platform
django_admin_radio_select-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 30.8 kB

Release files / django_admin_radio_select-0.1.0.tar.gz

Download URL django_admin_radio_select-0.1.0.tar.gz
Size 19.9 kB
Tags Source
SHA-256 checksum
How to use checksums
ca175bbd01de363f4b0095b18cda16b53e47ff59eb41b14f1589709781657196
BLAKE2b-256 checksum
How to use checksums
4ff6036c0015ca7bb5ad565c836f1336515384d014603d786061f88d1f419034
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release files / django_admin_radio_select-0.1.0-py3-none-any.whl

Download URL django_admin_radio_select-0.1.0-py3-none-any.whl
Size 10.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
94cb0ac09dcc3e9ef1eae2f100bfc4ea325298c3f2ace5b63e433ec481e40aa4
BLAKE2b-256 checksum
How to use checksums
b9277b88d0cd6a32f621cfd95bb28dc8d019f6420c5593ffc7c2d0a292688c63
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.2

2 release files

0.1.1

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page