394 lines
13 KiB
Markdown
394 lines
13 KiB
Markdown
# 🚀 django-crud-mixins
|
||
|
||
**Reusable Django CRUD mixins** for rapid development of single-page applications (SPA) and grid-based UIs.
|
||
Designed for **HTMX**, **Gridstack.js**, and **Django ORM** with built-in **access control** and **permissions**.
|
||
|
||
## 📌 Features
|
||
- **Django 4+ support** with modular mixins
|
||
- **HTMX-friendly partials**
|
||
- **Pre-configured Gridstack.js widget support**
|
||
- **RBAC enforcement with per-user filtering**
|
||
- **Reusable mixins for List, Create, Read, Update, Delete**
|
||
- **Automatic permission enforcement on querysets**
|
||
- **Seamless integration with Django Forms and QuerySets**
|
||
- **Prevents unauthorized data access**
|
||
- **Extra Buttons for dynamic action-based UI extensions**
|
||
- **Context-aware detail views with Pretty Print formatting**
|
||
|
||
## 📑 CRUD Mixins Overview
|
||
|
||
| Mixin | Description |
|
||
|----------------------|--------------------------------------------------|
|
||
| `ObjectList` | List view with pagination and permission-based filtering |
|
||
| `ObjectCreate` | Create new objects with form validation |
|
||
| `ObjectRead` | Read-only detail view with pre-fetching |
|
||
| `ObjectUpdate` | Edit existing objects with permission enforcement |
|
||
| `ObjectDelete` | Soft-delete or hard-delete objects securely |
|
||
| `RestrictedViewMixin` | Enforces user-based filtering on querysets |
|
||
| `RestrictedFormMixin` | Auto-filters form choices based on user permissions |
|
||
|
||
---
|
||
|
||
## ⚙️ Installation
|
||
|
||
Add to your `requirements.txt`:
|
||
```shell
|
||
git+https://git.zm.is/XF/django-crud-mixins
|
||
```
|
||
|
||
Or install via pip:
|
||
```shell
|
||
pip install git+https://git.zm.is/XF/django-crud-mixins
|
||
```
|
||
|
||
## 🔧 Usage
|
||
|
||
### 📂 Import the CRUD mixins
|
||
```python
|
||
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectRead, ObjectUpdate
|
||
```
|
||
|
||
### 📝 Define CRUD Views
|
||
```python
|
||
class AccountList(LoginRequiredMixin, ObjectList):
|
||
list_template = "partials/account-list.html"
|
||
model = Account
|
||
page_title = "List of accounts"
|
||
|
||
list_url_name = "accounts"
|
||
list_url_args = ["type"]
|
||
|
||
submit_url_name = "account_create"
|
||
|
||
|
||
class AccountCreate(LoginRequiredMixin, ObjectCreate):
|
||
model = Account
|
||
form_class = AccountForm
|
||
|
||
submit_url_name = "account_create"
|
||
|
||
|
||
class AccountUpdate(LoginRequiredMixin, ObjectUpdate):
|
||
model = Account
|
||
form_class = AccountForm
|
||
|
||
submit_url_name = "account_update"
|
||
|
||
|
||
class AccountDelete(LoginRequiredMixin, ObjectDelete):
|
||
model = Account
|
||
```
|
||
|
||
### Add to `urls.py`
|
||
|
||
```python
|
||
path("accounts/<str:type>/", accounts.AccountList.as_view(), name="accounts"),
|
||
path(
|
||
"accounts/<str:type>/create/",
|
||
accounts.AccountCreate.as_view(),
|
||
name="account_create",
|
||
),
|
||
path(
|
||
"accounts/<str:type>/update/<str:pk>/",
|
||
accounts.AccountUpdate.as_view(),
|
||
name="account_update",
|
||
),
|
||
path(
|
||
"accounts/<str:type>/delete/<str:pk>/",
|
||
accounts.AccountDelete.as_view(),
|
||
name="account_delete",
|
||
),
|
||
```
|
||
|
||
## ⚙️ Configuration Options
|
||
|
||
### General Options
|
||
| Variable | Description |
|
||
|-----------------|----------------------------------|
|
||
| `list_template` | Template name for list view |
|
||
| `model` | Django model used for the view |
|
||
| `page_title` | Title of the page |
|
||
| `page_subtitle` | Subtitle of the page |
|
||
|
||
### List URLs
|
||
| Variable | Description |
|
||
|----------------|----------------------------------|
|
||
| `list_url_name` | URL name for listing objects |
|
||
| `list_url_args` | URL arguments for list endpoint |
|
||
|
||
### Submit URLs (Forms)
|
||
| Variable | Description |
|
||
|----------------|------------------------------|
|
||
| `submit_url_name` | URL for submitting forms |
|
||
| `submit_url_args` | URL args for submitting |
|
||
|
||
### ObjectList-Specific Options
|
||
| Variable | Description |
|
||
|----------------------|------------------------------|
|
||
| `delete_all_url_name` | URL for bulk deletion |
|
||
| `widget_options` | Gridstack widget config |
|
||
|
||
### ObjectCreate & ObjectUpdate
|
||
| Variable | Description |
|
||
|--------------|----------------------------------|
|
||
| `hide_cancel` | Hide cancel button in forms |
|
||
|
||
### ObjectUpdate-Only
|
||
| Variable | Description |
|
||
|--------------|-----------------------------------|
|
||
| `pk_required` | Enforce primary key requirement |
|
||
|
||
### 🔄 Extra Buttons
|
||
|
||
The mixins allow dynamic **extra buttons** to be added to views for custom actions.
|
||
Extra buttons are rendered inside the `<div class="buttons">` section of each view:
|
||
```html
|
||
{% for button in extra_buttons %}
|
||
<button
|
||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||
hx-{{ button.method }}="{{ button.url }}"
|
||
hx-trigger="click"
|
||
hx-target="#modals-here"
|
||
hx-swap="innerHTML"
|
||
{% if button.confirm %}hx-confirm="Are you sure you wish to {{ button.action }}?"{% endif %}
|
||
class="button">
|
||
<span class="icon-text">
|
||
<span class="icon">
|
||
<i class="{{ button.icon }}"></i>
|
||
</span>
|
||
<span>{{ button.label }}</span>
|
||
</span>
|
||
</button>
|
||
{% endfor %}
|
||
```
|
||
|
||
#### Structure of Extra Buttons
|
||
|
||
Each extra button is a dictionary with:
|
||
| Key | Description |
|
||
|-----------|----------------------------------------------|
|
||
| `method` | HTMX method (`get`, `post`, `delete`, etc.) |
|
||
| `url` | Endpoint to be triggered |
|
||
| `action` | Action name (for confirmation) |
|
||
| `icon` | CSS class for icon (FontAwesome recommended) |
|
||
| `label` | Display text for the button |
|
||
| `confirm` | Optional confirmation prompt |
|
||
|
||
|
||
#### Example Usage in Views
|
||
```python
|
||
class CustomListView(ObjectList):
|
||
extra_buttons = [
|
||
{
|
||
"method": "post",
|
||
"url": reverse_lazy("custom-action"),
|
||
"icon": "fa-solid fa-bolt",
|
||
"label": "Trigger Action",
|
||
"confirm": True,
|
||
"action": "trigger this action"
|
||
}
|
||
]
|
||
```
|
||
|
||
### 🖼️ Pretty Print Context (pretty)
|
||
|
||
The **Pretty Print** (pretty) context is used in `ObjectRead` detail views to enhance object representation.
|
||
It is loaded via `{% load pretty %}` and ensures **formatted output of complex data**.
|
||
|
||
Implement it into your project's `templatetags` folder to use:
|
||
```python
|
||
import orjson
|
||
from django import template
|
||
|
||
register = template.Library()
|
||
|
||
@register.filter
|
||
def pretty(data):
|
||
return orjson.dumps(data, option=orjson.OPT_INDENT_2).decode("utf-8")
|
||
```
|
||
|
||
#### When to Use Pretty Print
|
||
- **Complex JSON Fields** (e.g., structured responses)
|
||
- **Nested Dictionaries** (e.g., API outputs)
|
||
- **Formatted Logs & Debugging**
|
||
|
||
## 🔥 Extending Functionality
|
||
|
||
### ✅ Enforce Query Restrictions (RBAC)
|
||
|
||
Add extra permission filters via `extra_permission_args`:
|
||
```python
|
||
class CustomListView(ObjectList):
|
||
def set_extra_args(self, user):
|
||
self.extra_permission_args = {"organization": user.organization}
|
||
```
|
||
This ensures users can only see objects from their organization.
|
||
|
||
### 📍 Hook Into Save Events
|
||
Modify or abort saves via `pre_save_mutate` and `post_save`:
|
||
```python
|
||
class SecureObjectCreate(ObjectCreate):
|
||
def pre_save_mutate(self, user, obj):
|
||
if not user.is_admin:
|
||
raise AbortSave("You do not have permission to save this object.")
|
||
|
||
def post_save(self, obj):
|
||
send_notification(f"Object {obj.id} was created.")
|
||
```
|
||
|
||
## 🔒 Secure Form QuerySets
|
||
```python
|
||
from mixins.forms import RestrictedFormMixin
|
||
from django.forms import ModelForm
|
||
|
||
class YourModelForm(RestrictedFormMixin, ModelForm):
|
||
class Meta:
|
||
model = YourModel
|
||
fields = ["name"]
|
||
```
|
||
|
||
This ensures users **only see objects they have access** to in form dropdowns.
|
||
|
||
## 🔗 Nested Objects & Relationships
|
||
|
||
### Overview
|
||
Some objects exist **within a parent object**. In this case, views and URLs must be structured to reflect this relationship. The example below demonstrates managing **Person Identifiers**, which are linked to a **Person**.
|
||
|
||
### How It Works
|
||
1. **Filtering:** The `IdentifierPermissionMixin` ensures only identifiers belonging to a specific `Person` (and owned by the user) are retrieved.
|
||
2. **View Permissions:** The `set_extra_args` method filters data by enforcing **user ownership** and **parent object constraints**.
|
||
3. **Validation:**
|
||
- `PersonIdentifierCreate` ensures an identifier cannot be added twice.
|
||
- `PersonIdentifierUpdate` restricts edits to only valid, existing identifiers.
|
||
- `PersonIdentifierDelete` removes identifiers that belong to the requesting user.
|
||
4. **URL Structure:** Each identifier action (list, create, update, delete) includes the **Person ID**, ensuring operations are always scoped to the correct entity.
|
||
|
||
### Example: Managing Person Identifiers
|
||
|
||
#### 📌 Views
|
||
| View | Description |
|
||
|------|------------|
|
||
| `PersonIdentifierList` | Displays all identifiers linked to a specific `Person`. |
|
||
| `PersonIdentifierCreate` | Adds a new identifier for a `Person`, preventing duplicates. |
|
||
| `PersonIdentifierUpdate` | Edits an existing identifier, enforcing access control. |
|
||
| `PersonIdentifierDelete` | Deletes an identifier, ensuring ownership validation. |
|
||
|
||
#### 📌 URL Routing
|
||
| Route | Description |
|
||
|-------|------------|
|
||
| `/person/<str:type>/identifiers/<str:person>/` | Lists identifiers for a `Person`. |
|
||
| `/person/<str:type>/identifiers/create/<str:person>` | Creates a new identifier under a `Person`. |
|
||
| `/person/<str:type>/identifiers/update/<str:person>/<str:pk>/` | Updates a specific identifier. |
|
||
| `/person/<str:type>/identifiers/delete/<str:person>/<str:pk>/` | Deletes an identifier for a `Person`. |
|
||
|
||
|
||
### View
|
||
|
||
```python
|
||
class IdentifierPermissionMixin:
|
||
def set_extra_args(self, user):
|
||
self.extra_permission_args = {
|
||
"person__user": user,
|
||
"person__pk": self.kwargs["person"],
|
||
}
|
||
|
||
class PersonIdentifierList(LoginRequiredMixin, IdentifierPermissionMixin, ObjectList):
|
||
list_template = "partials/identifier-list.html"
|
||
model = PersonIdentifier
|
||
page_title = "Person Identifiers"
|
||
|
||
list_url_name = "person_identifiers"
|
||
list_url_args = ["type", "person"]
|
||
|
||
submit_url_name = "person_identifier_create"
|
||
submit_url_args = ["type", "person"]
|
||
|
||
|
||
class PersonIdentifierCreate(LoginRequiredMixin, IdentifierPermissionMixin, ObjectCreate):
|
||
model = PersonIdentifier
|
||
form_class = PersonIdentifierForm
|
||
|
||
submit_url_name = "person_identifier_create"
|
||
submit_url_args = ["type", "person"]
|
||
|
||
def form_valid(self, form):
|
||
"""If the form is invalid, render the invalid form."""
|
||
try:
|
||
return super().form_valid(form)
|
||
except IntegrityError as e:
|
||
if "UNIQUE constraint failed" in str(e):
|
||
form.add_error("identifier", "Identifier rule already exists")
|
||
return self.form_invalid(form)
|
||
else:
|
||
raise e
|
||
|
||
def pre_save_mutate(self, user, obj):
|
||
try:
|
||
person = Person.objects.get(pk=self.kwargs["person"], user=user)
|
||
obj.person = person
|
||
except Person.DoesNotExist:
|
||
log.error(f"Person {self.kwargs['person']} does not exist")
|
||
raise AbortSave("person does not exist or you don't have access")
|
||
|
||
class PersonIdentifierUpdate(LoginRequiredMixin, IdentifierPermissionMixin, ObjectUpdate):
|
||
model = PersonIdentifier
|
||
form_class = PersonIdentifierForm
|
||
|
||
submit_url_name = "person_identifier_update"
|
||
submit_url_args = ["type", "pk", "person"]
|
||
|
||
|
||
class PersonIdentifierDelete(LoginRequiredMixin, IdentifierPermissionMixin, ObjectDelete):
|
||
model = PersonIdentifier
|
||
```
|
||
|
||
#### 📌 Button Example (Template)
|
||
To display a button linking to a **Person’s Identifiers**:
|
||
```html
|
||
<a href="{% url 'person_identifiers' type='page' person=item.id %}">
|
||
<button class="button">
|
||
<span class="icon-text">
|
||
<span class="icon">
|
||
<i class="fa-solid fa-eye"></i>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</a>
|
||
```
|
||
|
||
|
||
## 🚀 Example Templates
|
||
### 🔥 List Template (partials/account-list.html)
|
||
```html
|
||
<table class="table is-striped">
|
||
<thead>
|
||
<tr>
|
||
<th>Account</th>
|
||
<th>Email</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for account in object_list %}
|
||
<tr>
|
||
<td>{{ account.name }}</td>
|
||
<td>{{ account.email }}</td>
|
||
<td>
|
||
<a href="{% url 'account_update' pk=account.pk %}" class="button is-small">Edit</a>
|
||
<a href="{% url 'account_delete' pk=account.pk %}" class="button is-danger is-small">Delete</a>
|
||
</td>
|
||
</tr>
|
||
{% empty %}
|
||
<tr>
|
||
<td colspan="3">No accounts found.</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
```
|
||
|
||
## ⚠️ Legal Disclaimer
|
||
|
||
**django-crud-mixins is provided "as is", without warranty of any kind.**
|
||
Use responsibly and comply with local data privacy laws. The maintainers are **not responsible** for misuse.
|