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.
|
||||
Useful for single-page-applications using Gridstack.
|
||||
**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**.
|
||||
|
||||
# Usage
|
||||
Add to your `requirements.txt` file:
|
||||
```
|
||||
## 📌 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
|
||||
```
|
||||
|
||||
## View helpers
|
||||
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.
|
||||
|
||||
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:
|
||||
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
|
||||
```
|
||||
|
||||
Then, add the views:
|
||||
### 📝 Define CRUD Views
|
||||
```python
|
||||
class AccountList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
|
||||
class AccountList(LoginRequiredMixin, ObjectList):
|
||||
list_template = "partials/account-list.html"
|
||||
model = Account
|
||||
page_title = "List of accounts"
|
||||
@@ -32,73 +61,333 @@ class AccountList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
|
||||
submit_url_name = "account_create"
|
||||
|
||||
|
||||
class AccountCreate(LoginRequiredMixin, OTPRequiredMixin, ObjectCreate):
|
||||
class AccountCreate(LoginRequiredMixin, ObjectCreate):
|
||||
model = Account
|
||||
form_class = AccountForm
|
||||
|
||||
submit_url_name = "account_create"
|
||||
|
||||
|
||||
class AccountUpdate(LoginRequiredMixin, OTPRequiredMixin, ObjectUpdate):
|
||||
class AccountUpdate(LoginRequiredMixin, ObjectUpdate):
|
||||
model = Account
|
||||
form_class = AccountForm
|
||||
|
||||
submit_url_name = "account_update"
|
||||
|
||||
|
||||
class AccountDelete(LoginRequiredMixin, OTPRequiredMixin, ObjectDelete):
|
||||
class AccountDelete(LoginRequiredMixin, ObjectDelete):
|
||||
model = Account
|
||||
```
|
||||
|
||||
### Variables
|
||||
These variables can be added to the classes to adjust functionality:
|
||||
### Add to `urls.py`
|
||||
|
||||
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
|
||||
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",
|
||||
)
|
||||
fields = ["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