From 1d0420975d5c2755917c3be8269dc2d200cf90bf Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Fri, 7 Feb 2025 22:46:08 +0000 Subject: [PATCH] Vastly improve documentation --- README.md | 415 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 352 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 3fe0cc8..efcf327 100644 --- a/README.md +++ b/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//", accounts.AccountList.as_view(), name="accounts"), + path( + "accounts//create/", + accounts.AccountCreate.as_view(), + name="account_create", + ), + path( + "accounts//update//", + accounts.AccountUpdate.as_view(), + name="account_update", + ), + path( + "accounts//delete//", + 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 `
` section of each view: +```html +{% for button in extra_buttons %} + +{% 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//identifiers//` | Lists identifiers for a `Person`. | +| `/person//identifiers/create/` | Creates a new identifier under a `Person`. | +| `/person//identifiers/update///` | Updates a specific identifier. | +| `/person//identifiers/delete///` | 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 + + + +``` + + +## πŸš€ Example Templates +### πŸ”₯ List Template (partials/account-list.html) +```html + + + + + + + + + + {% for account in object_list %} + + + + + + {% empty %} + + + + {% endfor %} + +
AccountEmailActions
{{ account.name }}{{ account.email }} + Edit + Delete +
No accounts found.
+``` + +## ⚠️ 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.