Source code for next.forms.backends
"""Form action backend contract and registry-based default implementation."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, TypedDict, cast
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseNotFound
from django.urls import path, reverse
from django.urls.exceptions import NoReverseMatch
from django.views.decorators.http import require_http_methods
from next.conf import import_class_cached
from .checks import record_possible_collision
from .dispatch import FormActionDispatch
from .rendering import render_form_page_with_errors
from .signals import action_registered
from .uid import (
FORM_ACTION_REVERSE_NAME,
URL_NAME_FORM_ACTION,
_make_uid,
validated_next_form_page_path,
)
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from django import forms as django_forms
from django.http import HttpRequest, HttpResponse
from django.urls import URLPattern
[docs]
class ActionMeta(TypedDict, total=False):
"""Per-action data stored in the registry backend."""
handler: Callable[..., Any]
form_class: type[django_forms.Form] | Callable[..., type[django_forms.Form]] | None
uid: str
[docs]
@dataclass
class FormActionOptions:
"""Options passed to `register_action` (used by the `@action` decorator).
`form_class` may be a `Form` subclass or a zero-or-more-argument
callable that returns one. Callables are resolved per request at
dispatch time, enabling factories like `ModelAdmin.get_form()`.
"""
form_class: (
type[django_forms.Form] | Callable[..., type[django_forms.Form]] | None
) = None
namespace: str | None = None
[docs]
class FormActionBackend(ABC):
"""Storage and HTTP dispatch for `@action` handlers."""
[docs]
@abstractmethod
def register_action(
self,
name: str,
handler: Callable[..., Any],
*,
options: FormActionOptions | None = None,
) -> None:
"""Record an action from the decorator."""
[docs]
@abstractmethod
def get_action_url(self, action_name: str) -> str:
"""Return the reverse URL for `action_name`."""
[docs]
@abstractmethod
def generate_urls(self) -> list[URLPattern]:
"""Return URLconf entries for this backend."""
[docs]
@abstractmethod
def dispatch(self, request: HttpRequest, uid: str) -> HttpResponse:
"""Run the handler for `uid`."""
[docs]
def get_meta(self, action_name: str) -> dict[str, Any] | None: # noqa: ARG002
"""Return optional per-action metadata for subclasses."""
return None
[docs]
def render_form_fragment(
self,
request: HttpRequest, # noqa: ARG002
action_name: str, # noqa: ARG002
form: django_forms.Form | None, # noqa: ARG002
template_fragment: str | None = None, # noqa: ARG002
*,
page_file_path: Path | None = None, # noqa: ARG002
) -> str:
"""Return custom HTML for validation errors (override in subclasses)."""
return ""
[docs]
class RegistryFormActionBackend(FormActionBackend):
"""In-memory actions behind one dispatcher path keyed by UID."""
[docs]
def __init__(self, config: dict[str, Any] | None = None) -> None: # noqa: ARG002
"""Create an empty action map. `config` is accepted for factory parity."""
self._registry: dict[str, ActionMeta] = {}
self._uid_to_name: dict[str, str] = {}
[docs]
def clear_registry(self) -> None:
"""Drop every registered action and reset the UID index.
Intended for test isolation. Use this to clear actions between
independent test sessions that register overlapping names.
"""
self._registry.clear()
self._uid_to_name.clear()
[docs]
def register_action(
self,
name: str,
handler: Callable[..., Any],
*,
options: FormActionOptions | None = None,
) -> None:
"""Store handler, optional form class, and stable uid for the action name."""
opts = options or FormActionOptions()
uid = _make_uid(name)
existing = self._uid_to_name.get(uid)
if existing is not None and existing != name:
msg = (
f"Form action UID collision: {existing!r} and {name!r} both "
f"hash to {uid!r}. Rename one of them."
)
raise ImproperlyConfigured(msg)
self._uid_to_name[uid] = name
previous = self._registry.get(name)
if previous is not None:
record_possible_collision(name, previous["handler"], handler)
self._registry[name] = {
"handler": handler,
"form_class": opts.form_class,
"uid": uid,
}
action_registered.send(
sender=self.__class__,
action_name=name,
uid=uid,
form_class=opts.form_class,
namespace=opts.namespace,
handler=handler,
)
[docs]
def get_action_url(self, action_name: str) -> str:
"""Return the reverse URL for a registered action name."""
if action_name not in self._registry:
msg = f"Unknown form action: {action_name}"
raise KeyError(msg)
uid = self._registry[action_name]["uid"]
kwargs = {"uid": uid}
try:
return reverse(FORM_ACTION_REVERSE_NAME, kwargs=kwargs)
except NoReverseMatch:
return reverse(URL_NAME_FORM_ACTION, kwargs=kwargs)
[docs]
def generate_urls(self) -> list[URLPattern]:
"""Return one catch-all route when at least one action is registered."""
if not self._registry:
return []
view = require_http_methods(["GET", "POST"])(self.dispatch)
return [path("_next/form/<str:uid>/", view, name=URL_NAME_FORM_ACTION)]
[docs]
def dispatch(self, request: HttpRequest, uid: str) -> HttpResponse:
"""Forward a POST request to `FormActionDispatch.dispatch`."""
action_name = self._uid_to_name.get(uid)
if action_name not in self._registry:
return HttpResponseNotFound()
meta = self._registry[action_name]
return FormActionDispatch.dispatch(
self, request, action_name, cast("dict[str, Any]", meta)
)
[docs]
def get_meta(self, action_name: str) -> dict[str, Any] | None:
"""Return stored `ActionMeta` for the name, if any."""
return cast("dict[str, Any] | None", self._registry.get(action_name))
[docs]
def render_form_fragment(
self,
request: HttpRequest,
action_name: str,
form: django_forms.Form | None,
template_fragment: str | None = None,
*,
page_file_path: Path | None = None,
) -> str:
"""Render validation-error HTML for a page module path."""
target_path = page_file_path
if target_path is None:
target_path = validated_next_form_page_path(request)
if target_path is None:
return ""
return render_form_page_with_errors(
self,
request,
action_name,
form,
template_fragment,
target_path,
)
[docs]
class FormActionFactory:
"""Instantiates backends from merged `DEFAULT_FORM_ACTION_BACKENDS` entries."""
[docs]
@classmethod
def create_backend(cls, config: dict[str, Any]) -> FormActionBackend:
"""Return a single backend instance for one settings entry.
The `BACKEND` key must be present and resolve to a `FormActionBackend`
subclass. The `next.E044` system check guarantees the key is present
and importable. The `next.E045` system check guarantees the imported
class subclasses `FormActionBackend`. Both run before the factory does
in production.
"""
backend_path = config["BACKEND"]
backend_class = import_class_cached(backend_path)
return cast("FormActionBackend", backend_class(config))
__all__ = [
"ActionMeta",
"FormActionBackend",
"FormActionFactory",
"FormActionOptions",
"RegistryFormActionBackend",
]