Skip to main content

PyPI version

django-advanced-modals

Bootstrap nested modals with AJAX form handling for Django. Modals are class-based views — they handle GET (render the modal) and POST (process buttons/forms) via AJAX, with responses returned as JSON command arrays.

The django-advance-utils line of Ian Jones's django-nested-modals, forked so that releases can be cut as the downstream libraries and django-advanced-report-builder need them. The Python package is still django_modals, so existing imports and INSTALLED_APPS entries do not change; only the pip name does. It depends on ajax-advanced-helpers and django-advanced-menus.

Installation

pip install django-advanced-modals

Add to INSTALLED_APPS:

INSTALLED_APPS = [
    ...
    'bootstrap_modals',
]

Forms render with django-crispy-forms, 1.x or 2.x. crispy-forms 2 ships its template packs separately, so on 2.x also install crispy-bootstrap4 and add it to your settings:

INSTALLED_APPS = [
    ...
    'crispy_forms',
    'crispy_bootstrap4',
]
CRISPY_ALLOWED_TEMPLATE_PACKS = ('bootstrap4',)
CRISPY_TEMPLATE_PACK = 'bootstrap4'

On 2.x, prepended_text, appended_text and field labels are escaped, where 1.x printed them as HTML. Text there is unaffected, but HTML, such as an icon, has to be marked safe: FieldEx('date', appended_text=mark_safe('<i class="fas fa-calendar-alt"></i>')). Marked safe, it renders the same on 1.x; the same goes for label=. The date and month pickers' own icons are marked safe already.

Before 1.0.3 the layout objects in django_modals.fields worked on crispy-forms 1.x only, and every form modal failed on 2.x with TypeError: FieldEx.render() missing 1 required positional argument: 'context'.

Add to your base template:

<script src="{% static 'django_modals/js/modals.js' %}"></script>
<link rel="stylesheet" type="text/css" href="{% static 'django_modals/css/modals.css' %}"/>

Quick Start

1. Define a modal view

# views.py
from django_modals.modals import ModelFormModal

class CompanyModal(ModelFormModal):
    model = Company
    form_fields = ['name', 'active']
    modal_title = ['New Company', 'Edit Company']

2. Wire up a URL

# urls.py
path('modal/company/<slug:slug>/', CompanyModal.as_view(), name='company_modal'),

3. Trigger it from a template

{% load modal_tags %}
{% show_modal 'company_modal' 'pk-1' %}          {# edit pk=1 #}
{% show_modal 'company_modal' 'pk-0' %}          {# create new #}

Modal Types

Modal — simple content modal

Use for messages, confirmations, or any custom HTML content.

from django_modals.modals import Modal
from django_modals.helper import modal_button

class InfoModal(Modal):
    modal_title = 'Information'

    def modal_content(self):
        return '<p>This action cannot be undone.</p>'

    def get_modal_buttons(self):
        return [modal_button('OK', 'close', 'btn-success')]

TemplateModal — modal from a template file

from django_modals.modals import TemplateModal

class HelpModal(TemplateModal):
    modal_title = 'Help'
    modal_template = 'myapp/help_modal.html'

    def modal_context(self):
        return {'section': self.slug.get('section', 'general')}

FormModal — non-model form

from django_modals.modals import FormModal

class ContactModal(FormModal):
    form_class = ContactForm
    modal_title = 'Contact Us'

    def form_valid(self, form):
        send_email(form.cleaned_data)
        return self.command_response('reload')

ModelFormModal — CRUD modal for a model

The most common modal type. Automatically handles create, edit, view, and delete.

from django_modals.modals import ModelFormModal
from django_modals.processes import PERMISSION_AUTHENTICATED, PERMISSION_STAFF

class PersonModal(ModelFormModal):
    model = Person
    form_fields = ['first_name', 'surname', 'email', 'company']
    modal_title = ['New Person', 'Edit Person', 'View Person']
    permission_create = PERMISSION_AUTHENTICATED
    permission_edit = PERMISSION_AUTHENTICATED
    permission_delete = PERMISSION_STAFF

MultiFormModal — multiple independent forms in one modal

from django_modals.modals import MultiFormModal, MultiForm

class NewAccountModal(MultiFormModal):
    modal_title = 'Create Account'
    forms = [
        MultiForm(Company, ['name', 'active']),
        MultiForm(Person, ['first_name', 'surname', 'email']),
    ]

    def forms_valid(self, valid_forms):
        company = valid_forms['CompanyForm'].save()
        person = valid_forms['PersonForm']
        person.instance.company = company
        person.save()
        return self.command_response('reload')

URL Slug System

State is passed via a single URL slug encoded as alternating key-value pairs. The slug is parsed into self.slug dict inside the modal.

pk-1                 →  {'pk': '1'}              edit pk=1
pk-0                 →  {}                       create new
pk-1-modal-view      →  {'pk': '1', ...}         view (read-only)
pk-1-modal-viewedit  →  {'pk': '1', ...}         view with edit button
pk-1-modal-delete    →  {'pk': '1', ...}         delete confirmation
section-help         →  {'section': 'help'}      arbitrary key-value

In the URL conf, use <slug:slug> for standard slugs or <str:slug> when values contain uppercase or special characters.

urlpatterns = [
    path('modal/person/<slug:slug>/', PersonModal.as_view(), name='person_modal'),
]

Processes and Permissions

Each modal operation is a process. Permissions control who can invoke each process.

Process constants (django_modals.processes)

Constant Value Description
PROCESS_CREATE 0 Create new object
PROCESS_EDIT 1 Edit existing object
PROCESS_VIEW 2 Read-only view
PROCESS_DELETE 3 Delete object
PROCESS_EDIT_DELETE 4 Edit with delete button
PROCESS_VIEW_EDIT 5 Read-only with edit button

Permission constants

Constant Description
PERMISSION_OFF No restriction
PERMISSION_DISABLE Always disabled
PERMISSION_AUTHENTICATED Logged-in users only
PERMISSION_STAFF Staff/superusers only
PERMISSION_METHOD Delegate to permission(user, process) method
from django_modals.processes import (
    PERMISSION_OFF, PERMISSION_AUTHENTICATED,
    PERMISSION_STAFF, PERMISSION_METHOD
)

class CompanyModal(ModelFormModal):
    model = Company
    form_fields = ['name', 'active']
    permission_create = PERMISSION_AUTHENTICATED
    permission_edit = PERMISSION_AUTHENTICATED
    permission_delete = PERMISSION_STAFF
    permission_view = PERMISSION_OFF

    # Used when permission_X = PERMISSION_METHOD
    @staticmethod
    def permission(user, process):
        return user.groups.filter(name='editors').exists()

The project's permission check

Before any of the above, BaseModalMixin.dispatch can ask the project. Name a function in settings.DJANGO_ADVANCED_PERMISSION_CHECK:

DJANGO_ADVANCED_PERMISSION_CHECK = 'myproject.permissions.check'

def check(request, kind, name, obj=None):  # kind 'modal', name modal_permission_name(), obj the modal
    return True

It is asked when the modal opens, when it is submitted and for every button posted to it, and through view_permission (django-advanced-menus' hook on a link's target) for every menu item that opens it, which is hidden when refused. The name is the modal's permission_name if it has one, else the url name it was reached by (what a menu item opening it is called), else its dotted class path when it is drawn from inside another view. A refused modal shows a "Permission denied" message (modal_permission_denied, which a modal can override) instead of opening. A bare Modal -- the message boxes message() and confirm() draw, and the refusal itself -- is never asked; set check_permission = False to exempt another. With the setting unset every modal opens. ajax-advanced-helpers and django-advanced-datatables read the same setting, each with a kind of its own.


ModelFormModal Reference

Class attributes

Attribute Type Description
model Model class Required
form_fields list Field names to include
form_class Form class Override auto-generated form
modal_title str or list Single title, or [create, edit, view]
size 'sm' / 'md' / 'lg' Modal dialog size (default 'lg')
labels dict Field label overrides
help_texts dict Field help text overrides
widgets dict Widget overrides
field_classes dict Form field class overrides
error_messages dict Error message overrides
permission_create constant Create permission
permission_edit constant Edit permission
permission_view constant View permission
permission_delete constant Delete permission
delete_message str Confirmation message for delete
delete_title str Title for delete confirmation
focus bool Auto-focus first field (default True)
lazy bool Load modal content via AJAX (default False)

Hook methods

class PersonModal(ModelFormModal):
    model = Person
    form_fields = ['first_name', 'surname', 'company']

    @staticmethod
    def form_setup(form, **kwargs):
        """Customise form fields before rendering — runs on GET and invalid POST."""
        form.fields['company'].queryset = Company.objects.filter(active=True)

    def form_valid(self, form):
        """Override save logic. Must return a command response."""
        instance = form.save()
        return self.command_response('reload')

    def post_save(self, created, form):
        """Runs after save, before the response is built."""
        if created:
            notify_team(form.instance)

    def object_delete(self):
        """Runs after the object is deleted."""
        audit_log(self.object)

MultiFormModal Reference

Forms are defined with MultiForm instances. Each MultiForm accepts the same field-configuration arguments as ModelFormModal.

MultiForm(
    model,
    fields,
    form_id=None,        # Defaults to '<ModelName>Form'
    labels={},
    help_texts={},
    widgets={},
    field_classes={},
    error_messages={},
)

Override methods on the modal:

class AccountModal(MultiFormModal):
    forms = [
        MultiForm(Company, ['name']),
        MultiForm(Person, ['first_name', 'surname']),
    ]

    def form_setup(self, form, **kwargs):
        """Called for each form individually. Return a layout or None."""
        if form.form_id == 'PersonForm':
            form.fields['surname'].required = True
        return None

    def get_instances(self, form_id):
        """Return a model instance for edit mode."""
        if form_id == 'CompanyForm':
            return Company.objects.get(pk=self.slug.get('pk'))

    def forms_valid(self, valid_forms):
        company = valid_forms['CompanyForm'].save()
        person = valid_forms['PersonForm']
        person.instance.company = company
        person.save()
        return self.command_response('reload')

Forms

ModelCrispyForm

The default base form used by ModelFormModal. Inherits from ModelForm and applies crispy-forms layout automatically. Use it directly when you need a custom form_class:

from django_modals.forms import ModelCrispyForm

class PersonForm(ModelCrispyForm):
    class Meta:
        model = Person
        fields = ['first_name', 'surname', 'email']

class PersonModal(ModelFormModal):
    form_class = PersonForm

CrispyForm

For non-model forms with crispy rendering:

from django_modals.forms import CrispyForm
from django import forms

class SearchForm(CrispyForm):
    query = forms.CharField()
    active_only = forms.BooleanField(required=False)

Advanced field configuration with FieldEx

Pass a tuple (field_name, options_dict) in form_fields for per-field configuration:

from django_modals.helper import FieldEx

class CompanyModal(ModelFormModal):
    model = Company
    form_fields = [
        'name',
        ('status', {'label': 'Current Status', 'help_text': 'Active or inactive'}),
        FieldEx('notes', template='custom_field.html'),
    ]

Widgets

from django_modals.widgets.select2 import Select2, Select2Multiple
from django_modals.widgets.colour_picker import ColourPickerWidget
from django_modals.widgets.datepicker import JQueryDatepickerWidget
from django_modals.widgets.month_picker import MonthPickerWidget

Select2

class CompanyModal(ModelFormModal):
    model = Company
    form_fields = ['name', 'tags', 'owner']
    widgets = {
        'tags': Select2Multiple,
        'owner': Select2,
    }

AJAX-powered Select2 — define a select2_<fieldname> method on the modal to filter results dynamically:

def select2_owner(self, search, **kwargs):
    qs = Person.objects.filter(name__icontains=search)
    return [(p.pk, str(p)) for p in qs]

Template Tags

{% load modal_tags %}

{# Link that opens a modal #}
{% show_modal 'url_name' 'pk-1' %}
{% show_modal 'url_name' 'pk-1' css_class='btn btn-primary' label='Edit' %}

{# AJAX button that calls a method on the current modal #}
{% modal_button_method 'Save Draft' 'save_draft' %}

{# Delete button #}
{% modal_delete 'url_name' slug='pk-1' text='Delete' %}

AJAX Commands

Modal button handlers and form_valid must return a command response. Commands are processed client-side by ajax_helpers.process_commands.

# Close the modal and reload the page
return self.command_response('reload')

# Close the modal only
return self.command_response('close')

# Redirect
return self.command_response('redirect', url='/dashboard/')

# Update a DOM element without closing the modal
self.add_command('html', selector='#status', html='<span>Saved</span>')
return self.command_response()

# Replace the modal content
return self.command_response('overwrite_modal', html=rendered_html)

A close holds the commands after it in the same response until the modal has gone, so they act on the modal underneath: post_modal, and anything else that ends in send_inputs, posts to whichever modal is on top. A close that arrives while the modal is still opening, which Bootstrap would ignore, takes effect once it has opened. If the page refuses the hide by preventing hide.bs.modal, the modal stays and the commands after the close run at once.

Custom button handlers are methods named button_<name>:

class ReportModal(TemplateModal):
    modal_title = 'Report'
    modal_template = 'report.html'

    def button_export(self, **kwargs):
        data = generate_csv(self.slug.get('pk'))
        return self.command_response('save_file', filename='report.csv', data=data)

Lazy Loading

Set lazy = True on a modal to defer content loading until the modal is opened. Useful for slow queries:

class HeavyReportModal(TemplateModal):
    lazy = True
    modal_title = 'Report'
    modal_template = 'report.html'

JSON Field Mixin

JsonFieldMixin expands a JSONField into individually editable typed form fields:

from django_modals.modals import ModelFormModal, JsonFieldMixin
from django import forms

class CompanyModal(JsonFieldMixin, ModelFormModal):
    model = Company
    form_fields = ['name']
    json_field = 'extra'
    json_field_config = {
        'notes': (forms.CharField, {'required': False, 'label': 'Notes'}),
        'priority': (forms.ChoiceField, {
            'choices': [('low', 'Low'), ('high', 'High')],
            'required': False,
        }),
    }

The modal renders an "Add field" dropdown; selecting a key adds that field to the form. On save, all values are packed back into the JSON column.


Development / Example Project

docker-compose up

Django app runs at http://localhost:8007. See django_examples/modal_examples/views/ for working examples of every modal type.

# Run tests
cd django_examples
python manage.py test django_modals

Metadata

Release files for django-advanced-modals 1.0.5

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-advanced-modals 1.0.5
File Size Uploaded Provenance
django_advanced_modals-1.0.5.tar.gz 810.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for django-advanced-modals 1.0.5
File Interpreter ABI Platform
django_advanced_modals-1.0.5-py3-none-any.whl Python 3 none any Details

Total release size: 1.7 MB

Release files / django_advanced_modals-1.0.5.tar.gz

Download URL django_advanced_modals-1.0.5.tar.gz
Size 810.2 kB
Tags Source
SHA-256 checksum
How to use checksums
876251022b45e235a4792cf2f39295395cbeec72e037387a7a663d097a193e1a
BLAKE2b-256 checksum
How to use checksums
37e9e7a63cf485bc358835deb1deae43001ef0f2e9dd6be81356405f3cd0114d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Oct 22, 2026.

Transparency log

Release files / django_advanced_modals-1.0.5-py3-none-any.whl

Download URL django_advanced_modals-1.0.5-py3-none-any.whl
Size 881.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a1957f2aa1a3338db637eed158a04131f3bbc936d4a6534ea356d89d19e025e7
BLAKE2b-256 checksum
How to use checksums
0dd4351954e4f7c8ba493c6485b43234bfdfc04e382c12b4ac23265c2c0bd78a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Oct 22, 2026.

Transparency log

Security for django-advanced-modals 1.0.5

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_advanced_modals-1.0.5.tar.gz

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 Oct 22, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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