Harden security

This commit is contained in:
2026-03-05 05:42:19 +00:00
parent 06735bdfb1
commit 438e561da0
75 changed files with 6260 additions and 278 deletions

163
vendor/django-crud-mixins/.gitignore vendored Normal file
View File

@@ -0,0 +1,163 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
*_ref*

View File

@@ -0,0 +1,30 @@
repos:
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: black
exclude: ^core/migrations
- repo: https://github.com/PyCQA/isort
rev: 5.11.5
hooks:
- id: isort
args: ["--profile", "black"]
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
args: [--max-line-length=88]
exclude: ^core/migrations
- repo: https://github.com/rtts/djhtml
rev: v2.0.0
hooks:
- id: djhtml
args: [-t 2]
- id: djcss
exclude : ^core/static/css # slow
- id: djjs
exclude: ^core/static/js # slow
- repo: https://github.com/sirwart/ripsecrets.git
rev: v0.1.5
hooks:
- id: ripsecrets

9
vendor/django-crud-mixins/LICENSE vendored Normal file
View File

@@ -0,0 +1,9 @@
MIT License
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

3
vendor/django-crud-mixins/MANIFEST.in vendored Normal file
View File

@@ -0,0 +1,3 @@
include README.md
recursive-include mixins/templates *
recursive-include mixins/static *

393
vendor/django-crud-mixins/README.md vendored Normal file
View File

@@ -0,0 +1,393 @@
# 🚀 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.

View File

View File

View File

@@ -0,0 +1,100 @@
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.core.paginator import Paginator
from django.db.models import QuerySet
class RestrictedViewMixin:
"""
This mixin overrides two helpers in order to pass the user object to the filters.
get_queryset alters the objects returned for list views.
get_form_kwargs passes the request object to the form class. Remaining permissions
checks are in forms.py
"""
allow_empty = True
queryset = None
model = None
paginate_by = None
paginate_orphans = 0
context_object_name = None
paginator_class = Paginator
page_kwarg = "page"
ordering = None
def set_extra_args(self, user):
"""
This function is overriden to filter the objects by the requesting user.
"""
self.extra_permission_args = {}
def get_queryset(self, **kwargs):
"""
This function is overriden to filter the objects by the requesting user.
"""
self.set_extra_args(self.request.user)
if self.queryset is not None:
queryset = self.queryset
if isinstance(queryset, QuerySet):
# queryset = queryset.all()
queryset = queryset.filter(
user=self.request.user, **self.extra_permission_args
)
elif self.model is not None:
queryset = self.model._default_manager.filter(
user=self.request.user, **self.extra_permission_args
)
else:
raise ImproperlyConfigured(
"%(cls)s is missing a QuerySet. Define "
"%(cls)s.model, %(cls)s.queryset, or override "
"%(cls)s.get_queryset()." % {"cls": self.__class__.__name__}
)
if hasattr(self, "get_ordering"):
ordering = self.get_ordering()
if ordering:
if isinstance(ordering, str):
ordering = (ordering,)
queryset = queryset.order_by(*ordering)
return queryset
def get_form_kwargs(self):
"""Passes the request object to the form class.
This is necessary to only display members that belong to a given user"""
kwargs = super().get_form_kwargs()
kwargs["request"] = self.request
return kwargs
class RestrictedFormMixin:
"""
This mixin is used to restrict the queryset of a form to the current user.
The request object is passed from the view.
Fieldargs is used to pass additional arguments to the queryset filter.
"""
fieldargs = {}
# TODO: implement set_extra_args to check more permissions here
# for completeness, however as views open forms, the permissions
# are already checked there, so it may not be necessary.
def __init__(self, *args, **kwargs):
# self.fieldargs = {}
self.request = kwargs.pop("request")
super().__init__(*args, **kwargs)
for field in self.fields:
# Check it's not something like a CharField which has no queryset
if not hasattr(self.fields[field], "queryset"):
continue
model = self.fields[field].queryset.model
# Check if the model has a user field
try:
model._meta.get_field("user")
# Add the user to the queryset filters
self.fields[field].queryset = model.objects.filter(
user=self.request.user, **self.fieldargs.get(field, {})
)
except FieldDoesNotExist:
pass

View File

@@ -0,0 +1 @@
<button class="modal-close is-large" aria-label="close"></button>

View File

@@ -0,0 +1,3 @@
<i
class="fa-solid fa-xmark has-text-grey-light float-right"
onclick='grid.removeWidget("widget-{{ unique }}");grid.compact();'></i>

View File

@@ -0,0 +1,3 @@
<i
class="fa-solid fa-xmark has-text-grey-light float-right"
data-script="on click remove the closest <nav/>"></i>

View File

@@ -0,0 +1,77 @@
{% load pretty %}
{% load cache %}
{% cache 600 generic_detail request.user.id object live %}
{% include 'mixins/partials/notify.html' %}
{% if live is not None %}
<h1 class="title">Live {{ context_object_name_singular }} info</h1>
<table class="table is-fullwidth is-hoverable">
<thead>
<th>attribute</th>
<th>value</th>
</thead>
<tbody>
{% block live_tbody %}
{% for key, item in live.items %}
{% if key in pretty %}
<tr>
<th>{{ key }}</th>
<td>
{% if item is not None %}
<pre>{{ item|pretty }}</pre>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<th>{{ key }}</th>
<td>
{% if item is not None %}
{{ item }}
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
{% endblock %}
</tbody>
</table>
{% endif %}
{% if object is not None %}
<h1 class="title">{{ title_singular }} info</h1>
<table class="table is-fullwidth is-hoverable">
<thead>
<th>attribute</th>
<th>value</th>
</thead>
<tbody>
{% block tbody %}
{% for key, item in object.items %}
{% if key in pretty %}
<tr>
<th>{{ key }}</th>
<td>
{% if item is not None %}
<pre>{{ item|pretty }}</pre>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<th>{{ key }}</th>
<td>
{% if item is not None %}
{{ item }}
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
{% endblock %}
</tbody>
</table>
{% endif %}
{% endcache %}

View File

@@ -0,0 +1,7 @@
<div id="notification">
{% if message is not None %}
<div class="notification is-{{ class }}" hx-ext="remove-me" remove-me="3s">
{{ message }}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,26 @@
{% include 'mixins/partials/notify.html' %}
{% if page_title is not None %}
<h1 class="title is-4">{{ page_title }}</h1>
{% endif %}
{% if page_subtitle is not None %}
<h1 class="subtitle">{{ page_subtitle }}</h1>
{% endif %}
{% load crispy_forms_tags %}
{% load crispy_forms_bulma_field %}
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{{ submit_url }}"
hx-target="#modals-here"
hx-swap="innerHTML">
{% csrf_token %}
{{ form|crispy }}
{% if hide_cancel is not True %}
<button
type="button"
class="button is-light modal-close-button">
Cancel
</button>
{% endif %}
<button type="submit" class="button modal-close-button">Submit</button>
</form>

View File

@@ -0,0 +1,61 @@
{% include 'mixins/partials/notify.html' %}
{% if page_title is not None %}
<h1 class="title is-4">{{ page_title }}</h1>
{% endif %}
{% if page_subtitle is not None %}
<h1 class="subtitle">{{ page_subtitle }}</h1>
{% endif %}
<div class="buttons">
{% if submit_url is not None %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ submit_url }}"
hx-trigger="click"
hx-target="#modals-here"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-plus"></i>
</span>
<span>{{ title_singular }}</span>
</span>
</button>
{% endif %}
{% if delete_all_url is not None %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{{ delete_all_url }}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete all {{ context_object_name }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
<span>Delete all {{ context_object_name }} </span>
</span>
</button>
{% endif %}
{% 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 %}
</div>
{% include detail_template %}

View File

@@ -0,0 +1,61 @@
{% include 'mixins/partials/notify.html' %}
{% if page_title is not None %}
<h1 class="title is-4">{{ page_title }}</h1>
{% endif %}
{% if page_subtitle is not None %}
<h1 class="subtitle">{{ page_subtitle }}</h1>
{% endif %}
<div class="buttons">
{% if submit_url is not None %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ submit_url }}"
hx-trigger="click"
hx-target="#modals-here"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-plus"></i>
</span>
<span>{{ title_singular }}</span>
</span>
</button>
{% endif %}
{% if delete_all_url is not None %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{{ delete_all_url }}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete all {{ context_object_name }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
<span>Delete all {{ context_object_name }} </span>
</span>
</button>
{% endif %}
{% 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 %}
</div>
{% include list_template %}

View File

@@ -0,0 +1,20 @@
{% load static %}
<script src="{% static 'modal.js' %}"></script>
{% block scripts %}
{% endblock %}
{% block styles %}
{% endblock %}
<div id="modal" class="modal is-active is-clipped">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
{% block modal_content %}
{% include window_content %}
{% endblock %}
{% include 'mixins/partials/close-modal.html' %}
</div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block content %}
{% include window_content %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
<nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
{% block close_button %}
{% include 'mixins/partials/close-window.html' %}
{% endblock %}
{% block heading %}
{% endblock %}
</p>
<article class="panel-block is-active">
<div class="control">
{% block panel_content %}
{% endblock %}
</div>
</article>
</nav>

View File

@@ -0,0 +1,37 @@
<div id="widget">
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div class="grid-stack-item-content">
<nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
{% block close_button %}
{% include 'mixins/partials/close-widget.html' %}
{% endblock %}
<i
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
onclick='grid.compact();'></i>
{% block heading %}
{{ title }}
{% endblock %}
</p>
<article class="panel-block is-active">
<div class="control">
{% block panel_content %}
{% include window_content %}
{% endblock %}
</div>
</article>
</nav>
</div>
</div>
</div>
<script>
{% block custom_script %}
{% endblock %}
var widget_event = new Event('load-widget');
document.dispatchEvent(widget_event);
</script>
{% block custom_end %}
{% endblock %}

View File

@@ -0,0 +1,10 @@
<magnet-block attract-distance="10" align-to="outer|center" class="floating-window">
{% extends 'mixins/wm/panel.html' %}
{% block heading %}
{{ title }}
{% endblock %}
{% block panel_content %}
{% include window_content %}
{% endblock %}
</magnet-block>

View File

@@ -0,0 +1,466 @@
import uuid
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.urls import reverse
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from django.views.generic.list import ListView
from rest_framework.parsers import FormParser
from mixins.restrictions import RestrictedViewMixin
class AbortSave(Exception):
pass
class ObjectNameMixin(object):
def __init__(self, *args, **kwargs):
if self.model is None:
self.title = self.context_object_name.title()
self.title_singular = self.context_object_name_singular.title()
else:
self.title_singular = self.model._meta.verbose_name.title() # Hook
self.context_object_name_singular = self.title_singular.lower() # hook
self.title = self.model._meta.verbose_name_plural.title() # Hooks
self.context_object_name = self.title.lower() # hooks
self.context_object_name = self.context_object_name.replace(" ", "")
self.context_object_name_singular = (
self.context_object_name_singular.replace(" ", "")
)
super().__init__(*args, **kwargs)
class ObjectList(RestrictedViewMixin, ObjectNameMixin, ListView):
allowed_types = ["modal", "widget", "window", "page"]
window_content = "mixins/window-content/objects.html"
list_template = None
page_title = None
page_subtitle = None
list_url_name = None
# WARNING: TAKEN FROM locals()
list_url_args = ["type"]
submit_url_name = None
submit_url_args = ["type"]
delete_all_url_name = None
widget_options = None
extra_buttons = None
def queryset_mutate(self, queryset):
pass
# copied from BaseListView
def get(self, request, *args, **kwargs):
type = kwargs.get("type", None)
if not type:
return HttpResponseBadRequest("No type specified")
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
self.request = request
self.object_list = self.get_queryset(**kwargs)
if isinstance(self.object_list, HttpResponse):
return self.object_list
if isinstance(self.object_list, HttpResponseBadRequest):
return self.object_list
self.queryset_mutate(self.object_list)
allow_empty = self.get_allow_empty()
self.template_name = f"mixins/wm/{type}.html"
unique = str(uuid.uuid4())[:8]
list_url_args = {}
for arg in self.list_url_args:
if arg in locals():
list_url_args[arg] = locals()[arg]
elif arg in kwargs:
list_url_args[arg] = kwargs[arg]
orig_type = type
if type == "page":
type = "modal"
if not allow_empty:
# When pagination is enabled and object_list is a queryset,
# it's better to do a cheap query than to load the unpaginated
# queryset in memory.
if self.get_paginate_by(self.object_list) is not None and hasattr(
self.object_list, "exists"
):
is_empty = not self.object_list.exists()
else:
is_empty = not self.object_list
if is_empty:
raise Http404("Empty list")
submit_url_args = {}
for arg in self.submit_url_args:
if arg in locals():
submit_url_args[arg] = locals()[arg]
elif arg in kwargs:
submit_url_args[arg] = kwargs[arg]
context = self.get_context_data()
context["title"] = self.title + f" ({type})"
context["title_singular"] = self.title_singular
context["unique"] = unique
context["window_content"] = self.window_content
context["list_template"] = self.list_template
context["page_title"] = self.page_title
context["page_subtitle"] = self.page_subtitle
context["type"] = type
context["context_object_name"] = self.context_object_name
context["context_object_name_singular"] = self.context_object_name_singular
if self.submit_url_name is not None:
context["submit_url"] = reverse(
self.submit_url_name, kwargs=submit_url_args
)
if self.list_url_name is not None:
context["list_url"] = reverse(self.list_url_name, kwargs=list_url_args)
if self.delete_all_url_name:
context["delete_all_url"] = reverse(self.delete_all_url_name)
if self.widget_options:
context["widget_options"] = self.widget_options
if self.extra_buttons is not None:
context["extra_buttons"] = self.extra_buttons
# Return partials for HTMX
if self.request.htmx:
if request.headers["HX-Target"] == self.context_object_name + "-table":
self.template_name = self.list_template
elif orig_type == "page":
self.template_name = self.list_template
else:
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"]
window_content = "mixins/window-content/object-form.html"
parser_classes = [FormParser]
page_title = None
page_subtitle = None
model = None
submit_url_name = None
submit_url_args = ["type"]
request = None
# Whether to hide the cancel button in the form
hide_cancel = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.title = "Create " + self.context_object_name_singular
def post_save(self, obj):
pass
def pre_save_mutate(self, user, obj):
pass
def form_valid(self, form):
obj = form.save(commit=False)
if self.request is None:
raise Exception("Request is None")
obj.user = self.request.user
try:
self.pre_save_mutate(self.request.user, obj)
except AbortSave as e:
context = {"message": f"Failed to save: {e}", "class": "danger"}
return self.render_to_response(context)
obj.save()
form.save_m2m()
self.post_save(obj)
context = {"message": "Object created", "class": "success"}
response = self.render_to_response(context)
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response
def form_invalid(self, form):
"""If the form is invalid, render the invalid form."""
return self.get(self.request, **self.kwargs, form=form)
def get(self, request, *args, **kwargs):
type = kwargs.get("type", None)
if not type:
return HttpResponseBadRequest("No type specified")
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
self.template_name = f"mixins/wm/{type}.html"
unique = str(uuid.uuid4())[:8]
self.request = request
self.kwargs = kwargs
if type == "widget":
self.hide_cancel = True
if type == "page":
type = "modal"
self.object = None
submit_url_args = {}
for arg in self.submit_url_args:
if arg in locals():
submit_url_args[arg] = locals()[arg]
elif arg in kwargs:
submit_url_args[arg] = kwargs[arg]
submit_url = reverse(self.submit_url_name, kwargs=submit_url_args)
context = self.get_context_data()
form = kwargs.get("form", None)
if form:
context["form"] = form
context["unique"] = unique
context["window_content"] = self.window_content
context["context_object_name"] = self.context_object_name
context["context_object_name_singular"] = self.context_object_name_singular
context["submit_url"] = submit_url
context["type"] = type
context["hide_cancel"] = self.hide_cancel
if self.page_title:
context["page_title"] = self.page_title
if self.page_subtitle:
context["page_subtitle"] = self.page_subtitle
response = self.render_to_response(context)
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response
def post(self, request, *args, **kwargs):
self.request = request
self.template_name = "mixins/partials/notify.html"
return super().post(request, *args, **kwargs)
class ObjectRead(RestrictedViewMixin, ObjectNameMixin, DetailView):
allowed_types = ["modal", "widget", "window", "page"]
window_content = "mixins/window-content/object.html"
detail_template = "mixins/partials/generic-detail.html"
page_title = None
page_subtitle = None
model = None
# submit_url_name = None
detail_url_name = None
# WARNING: TAKEN FROM locals()
detail_url_args = ["type"]
request = None
extra_buttons = None
def get(self, request, *args, **kwargs):
type = kwargs.get("type", None)
if not type:
return HttpResponseBadRequest("No type specified")
if type not in self.allowed_types:
return HttpResponseBadRequest()
self.template_name = f"mixins/wm/{type}.html"
unique = str(uuid.uuid4())[:8]
detail_url_args = {}
for arg in self.detail_url_args:
if arg in locals():
detail_url_args[arg] = locals()[arg]
elif arg in kwargs:
detail_url_args[arg] = kwargs[arg]
self.request = request
self.kwargs = kwargs
self.object = self.get_object(**kwargs)
if isinstance(self.object, HttpResponse):
return self.object
orig_type = type
if type == "page":
type = "modal"
context = self.get_context_data()
context["title"] = self.title + f" ({type})"
context["title_singular"] = self.title_singular
context["unique"] = unique
context["window_content"] = self.window_content
context["detail_template"] = self.detail_template
if self.page_title:
context["page_title"] = self.page_title
if self.page_subtitle:
context["page_subtitle"] = self.page_subtitle
context["type"] = type
context["context_object_name"] = self.context_object_name
context["context_object_name_singular"] = self.context_object_name_singular
if self.detail_url_name is not None:
context["detail_url"] = reverse(
self.detail_url_name, kwargs=detail_url_args
)
if self.extra_buttons is not None:
context["extra_buttons"] = self.extra_buttons
# Return partials for HTMX
if self.request.htmx:
if request.headers["HX-Target"] == self.context_object_name + "-info":
self.template_name = self.detail_template
elif orig_type == "page":
self.template_name = self.detail_template
else:
context["window_content"] = self.detail_template
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"]
window_content = "mixins/window-content/object-form.html"
parser_classes = [FormParser]
page_title = None
page_subtitle = None
model = None
submit_url_name = None
submit_url_args = ["type", "pk"]
request = None
# Whether pk is required in the get request
pk_required = True
# Whether to hide the cancel button in the form
hide_cancel = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.title = "Update " + self.context_object_name_singular
def post_save(self, obj):
pass
def form_valid(self, form):
obj = form.save(commit=False)
if self.request is None:
raise Exception("Request is None")
obj.save()
form.save_m2m()
self.post_save(obj)
context = {"message": "Object updated", "class": "success"}
response = self.render_to_response(context)
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response
def form_invalid(self, form):
"""If the form is invalid, render the invalid form."""
return self.get(self.request, **self.kwargs, form=form)
def get(self, request, *args, **kwargs):
self.request = request
type = kwargs.get("type", None)
pk = kwargs.get("pk", None)
if not type:
return HttpResponseBadRequest("No type specified")
if not pk:
if self.pk_required:
return HttpResponseBadRequest("No pk specified")
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
self.template_name = f"mixins/wm/{type}.html"
unique = str(uuid.uuid4())[:8]
if type == "widget":
self.hide_cancel = True
if type == "page":
type = "modal"
self.object = self.get_object()
submit_url_args = {}
for arg in self.submit_url_args:
if arg in locals():
submit_url_args[arg] = locals()[arg]
elif arg in kwargs:
submit_url_args[arg] = kwargs[arg]
submit_url = reverse(self.submit_url_name, kwargs=submit_url_args)
context = self.get_context_data()
form = kwargs.get("form", None)
if form:
context["form"] = form
context["title"] = self.title + f" ({type})"
context["title_singular"] = self.title_singular
context["unique"] = unique
context["window_content"] = self.window_content
context["context_object_name"] = self.context_object_name
context["context_object_name_singular"] = self.context_object_name_singular
context["submit_url"] = submit_url
context["type"] = type
context["hide_cancel"] = self.hide_cancel
if self.page_title:
context["page_title"] = self.page_title
if self.page_subtitle:
context["page_subtitle"] = self.page_subtitle
response = self.render_to_response(context)
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response
def post(self, request, *args, **kwargs):
self.request = request
self.template_name = "mixins/partials/notify.html"
return super().post(request, *args, **kwargs)
class ObjectDelete(RestrictedViewMixin, ObjectNameMixin, DeleteView):
model = None
template_name = "mixins/partials/notify.html"
# Overriden to prevent success URL from being used
def delete(self, request, *args, **kwargs):
"""
Call the delete() method on the fetched object and then redirect to the
success URL.
"""
self.object = self.get_object()
# success_url = self.get_success_url()
self.object.delete()
context = {"message": "Object deleted", "class": "success"}
response = self.render_to_response(context)
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response
# This will be used in newer Django versions, until then we get a warning
def form_valid(self, form):
"""
Call the delete() method on the fetched object.
"""
self.object = self.get_object()
self.object.delete()
context = {"message": "Object deleted", "class": "success"}
response = self.render_to_response(context)
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response

View File

@@ -0,0 +1,5 @@
# pyproject.toml
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

28
vendor/django-crud-mixins/setup.cfg vendored Normal file
View File

@@ -0,0 +1,28 @@
[metadata]
name = django-crud-mixins
version = 1.0.3
author = Mark Veidemanis
author_email = m@zm.is
url = https://git.zm.is/XF/django-crud-mixins
description = CRUD mixins for Django class-based views.
long_description = file: README.md
long_description_content_type = text/markdown
keywords = django, mixins, helpers, crud
license = MIT
classifiers =
License :: OSI Approved :: BSD License
Programming Language :: Python :: 3
Framework :: Django :: 4.1
Intended Audience :: Developers
[options]
packages = find:
zip_safe = True
include_package_data = True
install_requires =
django
django-rest-framework
[options.package_data]
mixins = templates/mixins/*, README.md
* = README.md