Files
django-crud-mixins/README.md

394 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🚀 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 **Persons 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.