from __future__ import annotations
import logging
from copy import deepcopy
from typing import TYPE_CHECKING, Any, cast
from braces.views import (
FormInvalidMessageMixin,
FormValidMessageMixin,
LoginRequiredMixin,
MessageMixin,
)
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import redirect
from django.views.generic import CreateView as DjangoCreateView
from django.views.generic import TemplateView, View
from django.views.generic import UpdateView as DjangoUpdateView
from django.views.generic.edit import (
BaseDeleteView,
BaseUpdateView,
)
from ..forms import AbstractRelatedFieldForm, ToggleableManyToManyFieldForm
from ..models import (
ViewSetMixin,
model_logger_name,
model_name,
model_verbose_name,
model_verbose_name_plural,
)
from ..views import TableActionFormView, WidgetInitKwargsMixin
from ..widgets import (
BaseDataTable,
Block,
BreadcrumbBlock,
CardWidget,
CrispyFormWidget,
LookupModelTable,
NavbarMixin,
RowDeleteButton,
RowEditButton,
TwoColumnLayout,
Widget,
WidgetListLayoutHeader,
)
from .mixins import StandardWidgetMixin
from .permission import PermissionRequiredMixin
if TYPE_CHECKING:
from django.forms import BaseModelForm, Form, ModelForm
from django.http import HttpRequest, HttpResponse, HttpResponseBase, QueryDict
from ..widgets.tables import RowActionButton
logger = logging.getLogger(__name__)
# --------------------------------------------
# Abastract Widgets
# --------------------------------------------
class AbstractFormPageLayout(Block):
"""
An abstract class for making whole page layouts for a form page.
This class provides a structured layout for form pages (create/update pages),
with a title, optional subtitle, and form content area. It automatically handles
the setup of these elements and provides an extensible framework for customized
form layouts.
The layout will automatically infer appropriate titles and subtitles based on
the view context, but these can be overridden when needed.
Note:
Subclasses must override the :py:meth`get_form` method to provide the
actual form rendering implementation.
Example:
.. code-block:: python
from wildewidgets import (
AbstractFormPageLayout,
TwoColumnLayout,
CrispyFormWidget,
)
class HelpTextWidget(Block):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.add_block(
Block(
"This is some help text for the form.",
name="help-text",
css_class="text-muted",
)
)
class MyFormLayout(AbstractFormPageLayout):
def get_form(self):
layout = TwoColumnLayout()
layout.add_to_left(CrispyFormWidget(form=self.view.get_form()))
layout.add_to_right(HelpTextWidget())
return layout
"""
block: str = "form-layout"
#: Use this as our page ittle
title: str | None = None
#: Use this as our page subtitle. This will appear directly below
#: the page title. Typically set this to a string naming the object
#: we're updating
subtitle: str | None = None
def __init__(
self,
view: GenericViewMixin,
title: str | None = None,
subtitle: str | None = None,
**kwargs,
):
self.view = view
self.title = title or self.title
self.subtitle = subtitle or self.subtitle
if not self.title:
if isinstance(self.view, DjangoCreateView):
self.title = f"Create {self.view.model_verbose_name}"
elif isinstance(self.view, DjangoUpdateView):
self.title = f"Update {self.view.model_verbose_name}"
else:
self.title = f"Manage {self.view.model_verbose_name}"
if not self.subtitle and view.object is not None:
self.subtitle = str(view.object)
super().__init__(**kwargs)
self.add_block(self.get_title())
if self.subtitle:
self.add_block(self.get_subtitle())
self.add_block(self.get_form())
def get_title(self) -> Block:
"""
Create a title block for the form layout.
Returns:
Block: A Block widget containing the page title with appropriate styling
Raises:
AssertionError: If title is not set
"""
assert self.title, "You must set a title for your form layout" # noqa: S101
title = Block(
self.title,
name=f"{self.block}__title",
tag="h1",
)
if not self.subtitle:
title.add_class("mb-5")
return title
def get_subtitle(self) -> Block:
"""
Create a subtitle block for the form layout.
Returns:
Block: A Block widget containing the page subtitle with appropriate styling
Raises:
AssertionError: If subtitle is not set
"""
assert self.subtitle, "You must set a subtitle for your form layout" # noqa: S101
return Block(
self.subtitle,
name=f"{self.block}__subtitle",
css_class="text-muted fs-6 text-uppercase mb-5",
)
def get_form(self) -> Block:
"""
Create the main form content for the layout.
This method must be overridden by subclasses to provide the specific form
rendering implementation.
Returns:
Block: A Block widget containing the form content
Raises:
NotImplementedError: Always, as this is an abstract method
"""
raise NotImplementedError
# --------------------------------------------
# Widgets
# --------------------------------------------
class IndexTableWidget(Block):
"""
A widget for displaying a model listing table with a header.
This block renders a table for displaying model instances with a header that
includes the model's plural name, a count badge, and optionally a create button.
It's primarily used in list views to provide a consistent interface for
viewing and managing model collections.
Attributes:
model: The Django model class being displayed
show_create_button: Whether to show a "New" button that links to the create view
Example:
.. code-block:: python
from django.db.models import Model
from wildewidgets import IndexTableWidget, BasicModelTable, ViewSetMixin
class Book(Model, ViewSetMixin):
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
published_date = models.DateField()
class Meta:
verbose_name = "book"
verbose_name_plural = "books"
class BookTable(BasicModelTable):
model = Book
fields = ["title", "author", "published_date"]
widget = IndexTableWidget(
model=Book,
table=BookTable(),
show_create_button=True
)
Args:
model: The model class being displayed in the table
table: The data table instance to display
show_create_button: Whether to show a button to create new instances
Keyword Args:
**kwargs: Additional arguments passed to the parent Block
"""
def __init__(
self,
model: type[ViewSetMixin],
table: BaseDataTable,
show_create_button: bool = True,
**kwargs,
):
self.model = model
self.show_create_button = show_create_button
super().__init__(**kwargs)
self.add_block(self.get_title())
self.add_block(CardWidget(widget=table))
def get_title(self) -> WidgetListLayoutHeader:
"""
Create a header widget for the table.
This creates a header with the model's verbose_name_plural, a badge showing
the count of records, and optionally a "New" button for creating instances.
Returns:
WidgetListLayoutHeader: The header widget
Raises:
ImproperlyConfigured: If the model doesn't have verbose_name or
verbose_name_plural
"""
if not self.model._meta.verbose_name_plural:
msg = (
"This model has no verbose_name_plural set, so we can't display a "
"title."
)
raise ImproperlyConfigured(msg)
if not self.model._meta.verbose_name:
msg = "This model has no verbose_name set, so we can't display a title."
raise ImproperlyConfigured(msg)
header = WidgetListLayoutHeader(
header_text=self.model._meta.verbose_name_plural.capitalize(),
badge_text=self.model.objects.count(),
)
if self.show_create_button:
header.add_link_button(
text=f"New {self.model._meta.verbose_name.capitalize()}",
color="primary",
url=self.model.get_create_url(),
)
return header
class FormPageLayout(AbstractFormPageLayout):
"""
A standard two-column layout for form pages.
This class implements a common layout pattern for form pages with a
two-column structure. The form is placed in the left column (which takes
9/12 of the width by default) and leaves space for additional content in the
right column if needed.
Attributes:
left_column_width: Width of the left column (1-12 in Bootstrap grid
system)
Example:
.. code-block:: python
from django.views.generic.edit import CreateView
from wildewidgets import FormPageLayout
class MyFormView(CreateView):
layout_widget = FormPageLayout
# The form will be rendered in a two-column layout
"""
left_column_width: int = 9
def get_form(self) -> Block:
"""
Create a two-column layout with the form in the left column.
The form is rendered with a crispy form widget and given a shadow and white
background for visual distinction.
Returns:
Block: A TwoColumnLayout containing the form
"""
layout = TwoColumnLayout(left_column_width=9)
form_widget = CrispyFormWidget(
form=self.view.get_form(), css_class="shadow bg-white p-4"
)
layout.add_to_left(form_widget)
return layout
# --------------------------------------------
# View mixins
# --------------------------------------------
[docs]class GenericViewMixin:
"""
Mixin providing common functionality for views that work with models.
This mixin adds methods and properties that simplify working with models in
views, including logging utilities, permission handling, and UI helpers like
breadcrumbs. It's designed to be used with Django class-based views and is
a core component of the generic views system.
Example:
.. code-block:: python
from django.views.generic import TemplateView
from wildewidgets.views.generic import GenericViewMixin
from myapp.models import MyModel
class MyView(GenericViewMixin, TemplateView):
model = MyModel
required_model_permissions = ['view']
def get_content(self):
# Use self.model_verbose_name, self.logging_extra, etc.
"""
#: Set this to the model permissions the user must have in order
#: to be authorized for this view:
required_model_permissions: list[str] = [] # noqa: RUF012
#: Set this to the logger of your choice
logger: Any = logger
#: Set this to your :py:class:`wildewidgets.widgets.navigation.BreadcrumbBlock`
#: to manage breadcrumbs automatically.
breadcrumbs: BreadcrumbBlock | None = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.breadcrumbs is not None:
self.breadcrumbs = deepcopy(self.breadcrumbs)
@property
def model_name(self) -> str:
"""
Get the name of the model class.
Returns:
str: The model class name
"""
if hasattr(self.model, "model_name"):
return self.model.model_name()
return model_name(self.model)
@property
def model_logger_name(self) -> str:
"""
Get a suitable name for logging messages about this model.
Returns:
str: The model name in lowercase, suitable for logging
"""
if hasattr(self.model, "model_logger_name"):
return self.model.model_logger_name()
return model_logger_name(self.model)
@property
def model_verbose_name(self) -> str:
"""
Get the verbose name of the model, properly capitalized.
Returns:
str: The model's verbose name
"""
if hasattr(self.model, "model_verbose_name"):
return self.model.model_verbose_name()
return model_verbose_name(self.model)
@property
def model_verbose_name_plural(self) -> str:
"""
Get the plural verbose name of the model, properly capitalized.
Returns:
str: The model's plural verbose name
"""
if hasattr(self.model, "model_verbose_name_plural"):
return self.model.model_verbose_name_plural()
return model_verbose_name_plural(self.model)
[docs] def get_client_ip(self) -> str:
"""
Get the IP address of the client making the request.
This method checks for ``X-Forwarded-For`` headers first (for clients
behind proxies) and falls back to ``REMOTE_ADDR`` if needed.
Returns:
str: The client's IP address
"""
x_forwarded_for = self.request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
return x_forwarded_for.split(",")[0]
return self.request.META.get("REMOTE_ADDR")
@property
def logging_extra(self) -> str:
"""
Get extra contextual information for log messages.
This property builds a string containing:
- The client's IP address
- The current user's username
- The primary key of the current object (if applicable)
Returns:
str: A space-separated string of key=value pairs
"""
data = {}
data["remote_ip"] = self.get_client_ip()
data["username"] = self.request.user.username
if hasattr(self, "object"):
data["pk"] = self.object.pk
return " ".join([f"{key}={value}" for key, value in data.items()])
[docs] def get_permissions_required(self) -> list[str]:
"""
Get the list of permissions required to access this view.
This method combines permissions from the parent class with
model-specific permissions based on the required_model_permissions
attribute.
Returns:
list[str]: List of permission strings
"""
required_perms = set(super().get_permissions_required()) # type: ignore[misc]
required_perms.update(
self.get_model_permissions(self.model, self.required_model_permissions)
)
return list(required_perms)
[docs] def get_breadcrumbs(self) -> BreadcrumbBlock | None:
"""
Get the breadcrumbs for this view, adding the current page title.
Returns:
BreadcrumbBlock | None: The updated breadcrumb block or None
"""
if self.breadcrumbs:
self.breadcrumbs.add_breadcrumb(title=self.get_title())
return self.breadcrumbs
[docs]class GenericDatatableMixin:
"""
Mixin providing datatable functionality for views that display model tables.
This mixin provides methods for configuring and instantiating DataTable
widgets with appropriate settings and row actions. It handles common tasks
like setting up action buttons based on user permissions and configuring
table options.
Example:
.. code-block:: python
from django.views.generic import TemplateView
from wildewidgets import GenericDatatableMixin
from myapp.models import MyModel
from myapp.wildewidgets import MyCustomTable
class MyListView(GenericDatatableMixin, TemplateView):
model = MyModel
table_class = MyCustomTable
def get_content(self):
return self.get_table()
"""
model: type[ViewSetMixin] | None
#: The :py:class:`wildewidgets.widgets.tables.base.BaseDataTable` subclass
#: to use for the listing table
table_class: type[BaseDataTable] = LookupModelTable
#: A dictionary that we will use as the ``**kwargs`` for the constructor of
#: :py:attr:`table_class`
table_kwargs: dict[str, Any] = { # noqa: RUF012
"striped": True,
"page_length": 25,
"buttons": True,
}
[docs] def get_table_kwargs(self) -> dict[str, Any]:
"""
Get keyword arguments for table initialization.
This method builds a dictionary of options to pass to the table constructor.
If bulk actions are enabled but no specific actions are defined, it adds
a default "delete" action.
Returns:
dict[str, Any]: Keyword arguments for the table constructor
"""
table_kwargs = self.table_kwargs
if "form_actions" not in table_kwargs and self.bulk_action_url_name:
table_kwargs["form_actions"] = [
("delete", f"Delete {self.model_verbose_name}")
]
return table_kwargs
[docs] def get_table(self, *args, **kwargs) -> BaseDataTable:
"""
Create and configure a datatable instance.
This method instantiates the table_class with appropriate options and
row action buttons based on user permissions.
Args:
*args: Positional arguments passed to the table constructor
Keyword Args:
**kwargs: Keyword arguments passed to the table constructor
Returns:
BaseDataTable: The configured datatable instance
"""
kwargs.update(self.get_table_kwargs())
kwargs["model"] = self.model
actions: list[RowActionButton] = []
if self.user_can_update():
actions.append(RowEditButton())
if self.user_can_delete():
actions.append(
RowDeleteButton(
confirm_text=(
"Are you sure you want to delete this "
f"{self.model._meta.verbose_name}?"
)
)
)
if actions:
kwargs["actions"] = actions
return self.table_class(*args, **kwargs)
# --------------------------------------------
# Views
# --------------------------------------------
[docs]class IndexView(
LoginRequiredMixin,
PermissionRequiredMixin,
GenericViewMixin,
GenericDatatableMixin,
NavbarMixin,
StandardWidgetMixin,
TemplateView,
):
"""
Generic view for displaying a list of model instances in a table.
This view provides a complete implementation for displaying model data in a
table with support for sorting, filtering, pagination, and row actions. It
requires minimal configuration - typically just setting the model attribute.
The view automatically handles:
- User authentication and permission checking
- Table configuration and rendering
- Create button based on user permissions
Attributes:
model: The model class to display
required_model_permissions: List of permissions required to access this view
Example:
.. code-block:: python
from django.db import models
from wildewidgets import IndexView
class Book(models.Model, ViewSetMixin):
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
published_date = models.DateField()
class Meta:
verbose_name = "book"
verbose_name_plural = "books"
class BookListView(IndexView):
model = Book
# That's it! The view is ready to use.
"""
#: The model we're describing in this view. It must have
#: :py:class:`wildewidgets.models.ViewSetMixin` in its class heirarchy
model: type[ViewSetMixin] | None = None
required_model_permissions: list[str] = [ # noqa: RUF012
"view",
"add",
"change",
"delete",
]
[docs] def get_title(self) -> str:
"""
Get the page title for this view.
Returns:
str: The plural verbose name of the model
"""
return self.model_verbose_name_plural
[docs] def get_content(self) -> Widget:
"""
Get the main content for this view.
Returns:
Widget: An IndexTableWidget configured for the model
Raises:
ImproperlyConfigured: If model is not set
"""
if not self.model:
msg = "You must set the model attribute on this view to use it."
raise ImproperlyConfigured(msg)
return IndexTableWidget(
self.model, self.get_table(), show_create_button=self.user_can_create()
)
[docs]class TableAJAXView(
LoginRequiredMixin,
PermissionRequiredMixin,
WidgetInitKwargsMixin,
GenericDatatableMixin,
View,
):
"""
View that handles AJAX requests for datatable data.
This view processes AJAX requests from the client-side DataTables library,
returning filtered, sorted, and paginated data as JSON. It handles all the
server-side processing required by DataTables.
This view is typically used on its own, without a template, to provide
data source for the model.
By default we use :py:class:`wildewidgets.LookupModelTable` as the
:py:attr:`table_class`, but you can override this to use any
:py:class:`wildewidgets.widgets.tables.base.BaseDataTable` subclass.
Example:
.. code-block:: python
from wildewidgets.views.generic import TableAJAXView
from myapp.models import MyModel
class MyModelTableAJAXView(TableAJAXView):
model = MyModel
"""
#: The model we're describing in this view. It must have
#: :py:class:`wildewidgets.models.ViewSetMixin` in its class heirarchy
model: type[ViewSetMixin] | None = None
required_model_permissions: list[str] = ["view", "add", "change", "delete"] # noqa: RUF012
[docs] def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase:
"""
Process incoming requests and delegate to the table's dispatch method.
This method extracts CSRF token and initialization parameters from the
request, sets them on the table instance, and then delegates to the
table's own dispatch method for handling the AJAX request.
Args:
request: The HTTP request
*args: Additional positional arguments
Kwargs:
**kwargs: Additional keyword arguments
Returns:
HttpResponse: The JSON response from the table
"""
csrf_token = request.GET.get("csrf_token", "")
extra_data = self.get_decoded_extra_data(request)
initargs = extra_data.get("args", [])
initkwargs = extra_data.get("kwargs", {})
table = self.get_table(*initargs, **initkwargs)
table.request = request
table.csrf_token = csrf_token
table.args = initargs
table.kwargs = initkwargs
return table.dispatch(request, *args, **kwargs)
[docs]class TableBulkActionView(
LoginRequiredMixin,
PermissionRequiredMixin,
GenericViewMixin,
GenericDatatableMixin,
MessageMixin,
TableActionFormView,
):
"""
View that handles bulk actions on multiple table rows.
This view processes form submissions for bulk actions on datatable rows,
such as deleting multiple items at once. It provides a default implementation
for bulk deletion and can be extended for other bulk actions.
Example:
.. code-block:: python
from wildewidgets import TableBulkActionView
class BookBulkActionView(TableBulkActionView):
model = Book
def process_archive_action(self, items):
# Custom bulk action implementation
Book.objects.filter(id__in=items).update(archived=True)
"""
#: The model we're describing in this view. It must have
#: :py:class:`wildewidgets.models.ViewSetMixin` in its class heirarchy
model: type[ViewSetMixin] | None = None
#: Set this to the logger of your choice
logger: Any = logger
required_model_permissions: list[str] = ["delete"] # noqa: RUF012
[docs] def process_delete_action(self, items: list[str]) -> None:
"""
Process a bulk delete action.
This method deletes all model instances with IDs in the provided list,
logs the action, and displays a success message.
Args:
items: List of primary key strings for the items to delete
"""
qs = self.model.objects.filter(id__in=items) # type: ignore[union-attr]
count = qs.count()
qs.delete()
self.logger.info(
"%s.bulk.delete ids=%s %s",
self.model_logger_name,
",".join(items),
self.logging_extra,
)
self.messages.success(
f"Deleted {count} {self.model.model_verbose_name_plural()}." # type: ignore[union-attr]
)
[docs]class CreateView( # type: ignore[misc]
LoginRequiredMixin,
PermissionRequiredMixin,
FormInvalidMessageMixin,
FormValidMessageMixin,
GenericViewMixin,
NavbarMixin,
StandardWidgetMixin,
DjangoCreateView,
):
"""
Generic view for creating new model instances.
This view provides a complete implementation for creating new model instances
with form validation, permission checking, and success/error messaging. It
uses the :py:class:`wildewidgets.FormPageLayout` by default to render the form in an
attractive layout.
Example:
.. code-block:: python
from django.db import models
from wildewidgets import CreateView, ViewSetMixin
class Book(models.Model, ViewSetMixin):
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
published_date = models.DateField()
class Meta:
verbose_name = "book"
verbose_name_plural = "books"
class BookCreateView(CreateView):
model = Book
fields = ['title', 'author', 'published_date']
"""
required_model_permissions: list[str] = ["add"] # noqa: RUF012
#: Use this :py:class:`AbstractFormPageLayout` subclass to render our page
layout_widget: type[AbstractFormPageLayout] = FormPageLayout
[docs] def get_title(self) -> str:
"""
Get the page title for this view.
Returns:
str: A title indicating creation of a new instance
"""
return f"Create {self.model_verbose_name}"
[docs] def get_content(self) -> Widget:
"""
Get the main content for this view.
Returns:
Widget: A form layout widget with the create form
"""
return self.layout_widget(self, modifier="create")
[docs]class UpdateView( # type: ignore[misc]
LoginRequiredMixin,
PermissionRequiredMixin,
FormInvalidMessageMixin,
FormValidMessageMixin,
GenericViewMixin,
NavbarMixin,
StandardWidgetMixin,
DjangoUpdateView,
):
"""
Generic view for updating existing model instances.
This view provides a complete implementation for updating model instances
with form validation, permission checking, and success/error messaging. It
uses the FormPageLayout by default to render the form in an attractive layout.
Example:
.. code-block:: python
from django.db import models
from wildewidgets import CreateView, ViewSetMixin
class Book(models.Model, ViewSetMixin):
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
published_date = models.DateField()
class Meta:
verbose_name = "book"
verbose_name_plural = "books"
class BookUpdateView(UpdateView):
model = Book
fields = ['title', 'author', 'published_date']
"""
required_model_permissions: list[str] = ["change"] # noqa: RUF012
#: Use this :py:class:`AbstractFormPageLayout` subclass to render our page
layout_widget: type[AbstractFormPageLayout] = FormPageLayout
[docs] def get_title(self) -> str:
"""
Get the page title for this view.
Returns:
str: A title indicating update of the instance
"""
return f'Update {self.model_verbose_name} "{self.object!s}"'
[docs] def get_content(self) -> Widget:
"""
Get the main content for this view.
Returns:
Widget: A form layout widget with the update form
"""
return self.layout_widget(self, modifier="update")
[docs]class DeleteView(
LoginRequiredMixin,
PermissionRequiredMixin,
GenericViewMixin,
FormInvalidMessageMixin,
FormValidMessageMixin,
BaseDeleteView,
):
"""
Generic view for deleting model instances.
This view provides a complete implementation for deleting model instances
with permission checking and success/error messaging. It only responds to
POST requests for security reasons.
Example:
.. code-block:: python
from django.db import models
from wildewidgets import DeleteView, ViewSetMixin
class Book(models.Model, ViewSetMixin):
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
published_date = models.DateField()
class Meta:
verbose_name = "book"
verbose_name_plural = "books"
class BookDeleteView(DeleteView):
model = Book
success_url = reverse_lazy('book-list')
"""
#: The model we're describing in this view. It must have
#: :py:class:`wildewidgets.models.ViewSetMixin` in its class heirarchy
model: type[ViewSetMixin] | None = None # type: ignore[assignment]
#: The HTTP methods this view responds to. We only allow POST for security.
http_method_names: list[str] = ["post"] # noqa: RUF012
#: The required model permissions for this view.
required_model_permissions: list[str] = ["delete"] # noqa: RUF012
class ManyToManyRelatedFieldView( # type: ignore[misc]
LoginRequiredMixin,
PermissionRequiredMixin,
FormValidMessageMixin,
MessageMixin,
GenericViewMixin,
BaseUpdateView,
):
"""
View for managing many-to-many relationships on a model.
This view provides a specialized interface for editing many-to-many relationships
on a model instance. It uses the ToggleableManyToManyFieldForm by default,
which provides a user-friendly interface for adding and removing related objects.
Example:
.. code-block:: python
from django.db import models
from wildewidgets import ManyToManyRelatedFieldView, ViewSetMixin
class Book(models.Model, ViewSetMixin):
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
published_date = models.DateField()
class Meta:
verbose_name = "book"
verbose_name_plural = "books"
class BookAuthorsView(ManyToManyRelatedFieldView):
model = Book
field_name = 'authors'
"""
#: The name of the many-to-many field to edit
field_name: str | None = None
#: The form class to use for editing the relationship
form_class: type[AbstractRelatedFieldForm] = ToggleableManyToManyFieldForm
#: The required model permissions for this view.
required_model_permissions: list[str] = ["change"] # noqa: RUF012
@property
def model_logger_name(self) -> str:
"""
Get a suitable name for logging messages about this field.
Returns:
str: The model logger name with the field name appended
"""
return f"{super().model_logger_name}.{self.field_name}"
@property
def field_verbose_name(self) -> str:
"""
Get the verbose name of the field being edited.
Returns:
str: The field's verbose name, capitalized
Raises:
AssertionError: If field_name is not set
"""
assert self.field_name, "You must set field_name on this view" # noqa: S101
return self.model._meta.get_field(self.field_name).verbose_name.capitalize() # type: ignore[union-attr]
def get_form_kwargs(self) -> dict[str, Any]:
"""
Get keyword arguments for form initialization.
This method adds the field_name to the form kwargs.
Returns:
dict: Keyword arguments for the form constructor
"""
kwargs = super().get_form_kwargs()
kwargs["field_name"] = self.field_name
return kwargs
def form_invalid(self, form: Form) -> HttpResponse:
"""
Handle form validation failures.
This method logs the validation failure, adds error messages for each
validation error, and redirects to the success URL.
Args:
form: The form that failed validation
Returns:
HttpResponse: Redirect to success_url
"""
self.logger.warning(
"%s.update.failed.validation %s", self.model_logger_name, self.logging.extra
)
self.messages.error(
f"Couldn't update {self.field_verbose_name} on {self.model_verbose_name} "
f'"{self.object!s}"'
)
for k, errors in form.errors.as_data().items():
for error in errors:
self.messages.error(f"{k}: {error.message}")
return redirect(self.get_success_url())
def get_form_valid_message(self):
"""
Get the message to display when the relationship is successfully updated.
This method also logs the successful update.
Returns:
str: The success message
"""
self.logger.info(
"%s.update.success %s", self.model_logger_name, self.logging_extra
)
return (
f"Updated {self.field_verbose_name} on {self.model_verbose_name} "
f'"{self.object!s}"!'
)
def get_success_url(self) -> str:
"""
Get the URL to redirect to after successful form submission.
If no explicit success_url is set, try to get the update URL or absolute URL
of the object.
Returns:
str: The URL to redirect to
"""
success_url = super().get_success_url()
if not success_url:
if hasattr(object, "get_update_url"):
return self.object.get_update_url()
return self.object.get_absolute_url()
return success_url
class HTMXView(TemplateView):
"""
View for rendering a single widget in response to HTMX requests.
This view is designed to work with HTMX (https://htmx.org/) for partial page
updates. It renders a single widget with the request parameters passed as
constructor arguments to the widget.
Example:
The python code:
.. code-block:: python
from django.db import models
from wildewidgets import ManyToManyRelatedFieldView, Block
class UserProfileWidget(Block):
title: str = "User Profile"
icon: str = "user"
def __init__(self, *blocks, **kwargs):
super().__init__(**kwargs)
self.add_block("This is the user profile widget")
class UserProfileHTMXView(HTMXView):
widget_class = UserProfileWidget
In your template, you can use HTMX to load this view:
.. code-block:: html
<div hx-get="/book/123/" hx-trigger="click">Load book</div>
If you want to include the request object in the widget kwargs, set
`include_request` to `True`:
.. code-block:: python
class UserProfileWidget(Block):
title: str = "User Profile"
icon: str = "user"
def __init__(
self,
*blocks,
request: HttpRequest | None = None,
**kwargs
):
self.request = request
# Do something with the request if needed
super().__init__(**kwargs)
self.add_block("This is the user profile widget")
class UserProfileHTMXView(HTMXView):
widget_class = UserProfileWidget
include_request = True
Keyword Args:
**kwargs: Additional keyword arguments to pass to the
:py:class:`django.views.generic.base.TemplateView`
"""
#: The Django template to use for rendering the widget
template_name: str = "wildewidgets/htmx_base.html"
#: The widget class to instantiate and render
widget_class: type[Block | Widget] | None = None
#: Whether to include the request object in the widget kwargs, and
#: in the context data passed to the template.
include_request: bool = False
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.data: dict[str, Any] = {}
def get_content(self, **kwargs) -> Widget: # noqa: ARG002
"""
Get the widget instance to render.
This method instantiates the widget_class with the request parameters.
Keyword Args:
**kwargs: Additional arguments (unused)
Returns:
Widget: The instantiated widget
Raises:
ImproperlyConfigured: If widget_class is not set
"""
if self.widget_class:
if self.include_request:
self.data["request"] = self.request
return self.widget_class(**self.data)
msg = "You must set widget_class on this view"
raise ImproperlyConfigured(msg)
def get_context_data(self, **kwargs) -> dict[str, Any]:
"""
Get the context data for rendering the template.
This method adds the widget instance to the template context.
Args:
**kwargs: Additional context data
Returns:
dict: The template context
"""
context = super().get_context_data(**kwargs)
context["content"] = self.get_content(**self.data)
return context
def set_data(self, request_data: QueryDict) -> None:
"""
Extract and process data from the request.
This method converts the request QueryDict to a regular dictionary and
removes the 'submit' key if present.
Args:
request_data: The request.GET or request.POST data
"""
self.data = request_data.dict()
if "submit" in self.data:
del self.data["submit"]
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""
Handle GET requests.
This method extracts data from the GET parameters and renders the template.
Args:
request: The HTTP request
*args: Additional positional arguments
**kwargs: Additional keyword arguments
Returns:
HttpResponse: The rendered template
"""
self.set_data(request.GET)
return super().get(request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""
Handle POST requests.
This method extracts data from the POST parameters and renders the template.
Args:
request: The HTTP request
*args: Additional positional arguments
**kwargs: Additional keyword arguments
Returns:
HttpResponse: The rendered template
"""
self.set_data(request.POST)
return super().get(request, *args, **kwargs)