Vastly improve documentation
This commit is contained in:
415
README.md
415
README.md
@@ -1,27 +1,56 @@
|
|||||||
# django-crud-mixins
|
# 🚀 django-crud-mixins
|
||||||
|
|
||||||
CRUD and form mixins for Django.
|
**Reusable Django CRUD mixins** for rapid development of single-page applications (SPA) and grid-based UIs.
|
||||||
Useful for single-page-applications using Gridstack.
|
Designed for **HTMX**, **Gridstack.js**, and **Django ORM** with built-in **access control** and **permissions**.
|
||||||
|
|
||||||
# Usage
|
## 📌 Features
|
||||||
Add to your `requirements.txt` file:
|
- **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
|
git+https://git.zm.is/XF/django-crud-mixins
|
||||||
```
|
```
|
||||||
|
|
||||||
## View helpers
|
Or install via pip:
|
||||||
The view helpers help create simple CRUD views for your application. They are geared towards a single-page application using Gridstack, but can be used on full pages as well by specifying a `type` of `page` always.
|
```shell
|
||||||
|
pip install git+https://git.zm.is/XF/django-crud-mixins
|
||||||
The view permission helpers add a `user=request.user` argument to all queryset filters, ensuring your views can only access the data of the requesting user. Additional filters can be set by overriding the `set_extra_args` method (detailed below).
|
|
||||||
|
|
||||||
Import the helpers from your view:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🔧 Usage
|
||||||
|
|
||||||
|
### 📂 Import the CRUD mixins
|
||||||
|
```python
|
||||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectRead, ObjectUpdate
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectRead, ObjectUpdate
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, add the views:
|
### 📝 Define CRUD Views
|
||||||
```python
|
```python
|
||||||
class AccountList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
|
class AccountList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/account-list.html"
|
list_template = "partials/account-list.html"
|
||||||
model = Account
|
model = Account
|
||||||
page_title = "List of accounts"
|
page_title = "List of accounts"
|
||||||
@@ -32,73 +61,333 @@ class AccountList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
|
|||||||
submit_url_name = "account_create"
|
submit_url_name = "account_create"
|
||||||
|
|
||||||
|
|
||||||
class AccountCreate(LoginRequiredMixin, OTPRequiredMixin, ObjectCreate):
|
class AccountCreate(LoginRequiredMixin, ObjectCreate):
|
||||||
model = Account
|
model = Account
|
||||||
form_class = AccountForm
|
form_class = AccountForm
|
||||||
|
|
||||||
submit_url_name = "account_create"
|
submit_url_name = "account_create"
|
||||||
|
|
||||||
|
|
||||||
class AccountUpdate(LoginRequiredMixin, OTPRequiredMixin, ObjectUpdate):
|
class AccountUpdate(LoginRequiredMixin, ObjectUpdate):
|
||||||
model = Account
|
model = Account
|
||||||
form_class = AccountForm
|
form_class = AccountForm
|
||||||
|
|
||||||
submit_url_name = "account_update"
|
submit_url_name = "account_update"
|
||||||
|
|
||||||
|
|
||||||
class AccountDelete(LoginRequiredMixin, OTPRequiredMixin, ObjectDelete):
|
class AccountDelete(LoginRequiredMixin, ObjectDelete):
|
||||||
model = Account
|
model = Account
|
||||||
```
|
```
|
||||||
|
|
||||||
### Variables
|
### Add to `urls.py`
|
||||||
These variables can be added to the classes to adjust functionality:
|
|
||||||
|
|
||||||
Basic:
|
|
||||||
* `list_template`: the name of the template for the list view
|
|
||||||
* `model`: the model you want to view with this helper
|
|
||||||
* `page_title`: the page title to render
|
|
||||||
* `page_subtitle`: the page subtitle to render
|
|
||||||
|
|
||||||
List URLs:
|
|
||||||
* `list_url_name`: the name of the list URL to include in the context, passed as `list_url`
|
|
||||||
* `list_url_args`: arguments for the above, taken from view kwargs, or locals
|
|
||||||
|
|
||||||
Submit URLs:
|
|
||||||
* `submit_url_name`: the name of the submit URL to include in the context, passed as `submit_url` -- used in the form
|
|
||||||
* `submit_url_args`: arguments for the above, taken from view kwargs, or locals
|
|
||||||
|
|
||||||
For `ObjectList` only:
|
|
||||||
* `delete_all_url_name`: the name of the delete-all URL to include in the context, passed as `delete_all_url` -- used in the form's "Delete all" button
|
|
||||||
* `widget_options`: options for the Gristack widget
|
|
||||||
|
|
||||||
For `ObjectCreate` and `ObjectUpdate` only:
|
|
||||||
* `hide_cancel`: whether to hide the cancel button in the form
|
|
||||||
|
|
||||||
For `ObjectUpdate` only:
|
|
||||||
* `pk_required`: whether the primary key `pk` is required in the URL kwargs
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
These methods can be added to the classes to adjust functionality:
|
|
||||||
|
|
||||||
For `ObjectCreate` and `ObjectUpdate` only:
|
|
||||||
* `post_save(self, obj)`: called after the object has been saved
|
|
||||||
|
|
||||||
For `ObjectCreate` only:
|
|
||||||
* `pre_save_mutate(self, user, obj)`: called before the object is saved, AbortSave can be raised with an error message to abort the save
|
|
||||||
|
|
||||||
|
|
||||||
These methods can be used on all classes, as they are inherited from the `RestrictedViewMixin`:
|
|
||||||
* `set_extra_args(self, user)`: adjusts the queryset filter with extra parameters, set `self.extra_permission_args` from this method to a dictionary of arguments
|
|
||||||
|
|
||||||
## Form permission helper
|
|
||||||
The form permission helper `RestrictedFormMixin` can be used as a mixin in your forms:
|
|
||||||
```python
|
```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 YourModelForm(RestrictedFormMixin, ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = YourModel
|
model = YourModel
|
||||||
fields = (
|
fields = ["name"]
|
||||||
"name",
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
It's that simple!
|
|
||||||
The form will automatically add a `user=request.user` argument to all queryset filters, ensuring your forms can only access the data of the requesting user.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user