Compare commits

..

4 Commits

4 changed files with 362 additions and 64 deletions

1
.gitignore vendored
View File

@@ -160,3 +160,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
*_ref*

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2025 Mark Veidemanis
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

415
README.md
View File

@@ -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 **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.

View File

@@ -145,6 +145,10 @@ class ObjectList(RestrictedViewMixin, ObjectNameMixin, ListView):
context["window_content"] = self.list_template
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.request = request
return super().get(request, *args, **kwargs)
class ObjectCreate(RestrictedViewMixin, ObjectNameMixin, CreateView):
allowed_types = ["modal", "widget", "window", "page"]
@@ -327,6 +331,10 @@ class ObjectRead(RestrictedViewMixin, ObjectNameMixin, DetailView):
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.request = request
return super().get(request, *args, **kwargs)
class ObjectUpdate(RestrictedViewMixin, ObjectNameMixin, UpdateView):
allowed_types = ["modal", "widget", "window", "page"]