Django app to implement Bootstrap nested modals
Project description
django-nested-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.
Installation
pip install django-nested-modals
Add to INSTALLED_APPS:
INSTALLED_APPS = [
...
'bootstrap_modals',
]
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()
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)
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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_nested_modals-0.0.25.tar.gz.
File metadata
- Download URL: django_nested_modals-0.0.25.tar.gz
- Upload date:
- Size: 828.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.0.1 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
656f1abc55abdfd2509ea8be73ad205a77aca8159688fa5c2a850703404108f0
|
|
| MD5 |
e03910021bd1e9881837c39b083c78f4
|
|
| BLAKE2b-256 |
ed84ad77a74623705e1fb2aee09c341aefb97b7388cef28b3288882c1de9a5ca
|
File details
Details for the file django_nested_modals-0.0.25-py3-none-any.whl.
File metadata
- Download URL: django_nested_modals-0.0.25-py3-none-any.whl
- Upload date:
- Size: 897.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.0.1 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
042597e4b453700107b2f19ab8e2cac36288a9009378c777418cc8e07993b0cf
|
|
| MD5 |
54e1e6b61d30dcc04bfb5805f13d3601
|
|
| BLAKE2b-256 |
3d0ecfb70a101fdeafddc0b400d77cfb47b4135bf2844f568079b9fa5621fbf1
|