Source code for wildewidgets.views.generic

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_menu_item(self) -> str | None: """ Get the name of the main menu item that should be set as active. Returns: str | None: The plural verbose name of the model """ return self.model_verbose_name_plural
[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_form_invalid_message(self): """ Get the message to display when form validation fails. This method also logs the validation failure. Returns: str: The error message """ self.logger.warning( "%s.create.failed.validation %s", self.model_logger_name, self.logging.extra ) return ( f"Couldn't create this {self.model_verbose_name} due to validation errors; " "see below." )
[docs] def get_form_valid_message(self): """ Get the message to display when a new instance is successfully created. This method also logs the successful creation. Returns: str: The success message """ self.logger.info( "%s.create.success %s", self.model_logger_name, self.logging_extra ) return f'Created {self.model_verbose_name} "{self.object!s}"!'
[docs] def get_form_class(self) -> type[Form | ModelForm] | None: # type: ignore[override] """ Get the form class to use for creating the model instance. If the model has a get_create_form_class method, use that form class. Otherwise, fall back to the default behavior. Returns: type: The form class to use """ if hasattr(self.model, "get_create_form_class"): return cast("type[ViewSetMixin]", self.model).get_create_form_class() return super().get_form_class()
[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_form_invalid_message(self): """ Get the message to display when form validation fails. This method also logs the validation failure. Returns: str: The error message """ self.logger.warning( "%s.update.failed.validation %s", self.model_logger_name, self.logging.extra ) return ( f"Couldn't update this {self.model_verbose_name} due to validation errors; " "see below." )
[docs] def get_form_valid_message(self): """ Get the message to display when an instance 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.model_verbose_name} "{self.object!s}"!'
[docs] def get_form_class(self) -> type[BaseModelForm]: """ Get the form class to use for updating the model instance. If the object has a get_update_form_class method, use that form class. Otherwise, fall back to the default behavior. Returns: type: The form class to use """ if hasattr(self.object, "get_update_form_class"): return self.object.get_update_form_class() return super().get_form_class()
[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
[docs] def get_form_invalid_message(self): """ Get the message to display when deletion fails. This method also logs the failure. Returns: str: The error message """ self.logger.warning("%s.delete.failed.validation", self.model_logger_name) return f'Couldn\'t delete {self.model_verbose_name} "{self.object!s}"'
[docs] def get_form_valid_message(self): """ Get the message to display when an instance is successfully deleted. This method also logs the successful deletion. Returns: str: The success message """ self.logger.info( "%s.delete.success %s", self.model_logger_name, self.logging_extra ) return f'Deleted {self.model_verbose_name} "{self.object!s}"!'
[docs] def form_invalid(self, form: BaseModelForm) -> HttpResponse: # noqa: ARG002 """ Handle form validation failures by redirecting to the success URL. Since DeleteView often doesn't render a form, we redirect even on validation failures rather than re-rendering the form. Args: form: The form that failed validation (unused) Returns: HttpResponse: Redirect to success_url """ return redirect(self.get_success_url())
[docs] def get_form_class(self) -> type[BaseModelForm]: """ Get the form class to use for deleting the model instance. If the object has a get_delete_form_class method, use that form class. Otherwise, fall back to the default behavior. Returns: type: The form class to use """ if hasattr(self.object, "get_delete_form_class"): return self.object.get_delete_form_class() return super().get_form_class()
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)