Actions#
An action is a registered entry point for a form POST.
Either a form class or a plain function can act as an action.
Form classes register automatically through __init_subclass__.
Plain functions register through the @action decorator.
Class-bound forms#
Declaring a subclass of next.forms.Form or next.forms.ModelForm registers the class automatically.
No decorator is needed.
from django.http import HttpRequest
import next.forms
from next.forms import redirect_to_origin
class NoteForm(next.forms.ModelForm):
class Meta:
model = Note
fields = ["title", "body"]
def on_valid(self, request: HttpRequest):
self.save()
return redirect_to_origin(request)
The framework derives the action name from the class name and infers the scope from the file where the class is declared.
Name derivation#
The action name is the CamelCase class name converted to snake_case by inserting underscores before each uppercase letter that is preceded by a lowercase letter or a digit.
Class name |
Action name |
|---|---|
|
|
|
|
|
|
|
|
|
|
The conversion collapses consecutive uppercase runs, so an acronym stays a single word.
HTTPLoginForm becomes http_login_form and HTMLForm becomes html_form.
Warning
Renaming a form class changes its action name.
Any {% form "old_name" %} tag or reverse URL that used the old name will fail at render time with FormActionNotFoundError.
The exception message lists the closest registered names, so a rename typo is usually visible in the error itself.
Update every template and reverse call when renaming a class.
Anchor files and scope#
The scope of a form class depends on the file it is declared in.
- Page scope.
A class declared in
page.pyorcomponent.pyreceivespagescope. The framework keys it to the absolute path of that file. A page-scoped name is local to its directory, the way a name is local to a Python module. Two pages may each declare aNoteFormwith no coordination, and a form opts into a project-wide name by moving to a shared file.- Shared scope.
A class declared in any other file receives
sharedscope. The framework keys it to the dotted module name. The name is reachable project-wide.- Override with
Meta.scope. Set
class Meta: scope = "page"orclass Meta: scope = "shared"to pin the scope explicitly, regardless of file name. Any other value triggersnext.E047and the class is not registered.
Customise the set of anchor file names through NEXT_FRAMEWORK["FORM_ANCHOR_FILES"].
The default set is ["page.py", "component.py"].
- Lookup order in templates.
{% form %}and{% action_url %}resolve a name against the nearest anchor first. Inside a component’s own template the chain is the component’scomponent.py, then the enclosing page’spage.py, then the shared registry. In a page or layout template the chain is the page’spage.py, then the shared registry. Slot bodies and free children passed to a component render in the page context, so they resolve against the page anchor.
The file is the one the class statement is written in, not the file that imports the class.
A class built by a factory such as next.forms.modelform_factory is attributed to the module that calls the factory, not to the module that runs the underlying type() call.
UID stability#
Each action gets a stable URL at /_next/form/<uid>/.
The UID is the first 16 hex characters of SHA-256("next:form:{scope_key}:{name}"), where name is the derived action name and scope_key depends on the scope.
- Page scope.
scope_keyis the absolute filesystem path of the declaringpage.pyorcomponent.py. The UID is therefore stable only as long as the file stays where it is.- Shared scope.
scope_keyis the dotted module name, walked up from the file while an__init__.pyexists. The UID is stable as long as the module path stays the same.
This has one practical consequence.
Warning
Moving a page-scoped form’s file or renaming its class changes the UID, and so changes the POST URL.
A bookmarked or cached /_next/form/<uid>/ URL from the old location stops resolving.
The same holds for a shared form when its module moves.
Treat a file move or a class rename as a URL change and expect old action URLs to 404.
The UID is derived, never stored in a template by hand.
A {% form "name" %} tag reverses the current UID at render time, so a freshly rendered page always posts to the right URL.
Only out-of-band references to a stale UID break.
The on_valid method#
on_valid runs after the framework validates the submitted form.
The method signature uses the same dependency-injection rules as any other DI-resolved callable.
def on_valid(self, request: HttpRequest):
...
self is the bound, validated form instance.
request receives the current HttpRequest only when the parameter is annotated HttpRequest, an unannotated request parameter resolves to None.
Any additional parameter is resolved through the DI injector, through DUrl[...] markers, Depends providers, and similar mechanisms.
The default implementation on BaseForm redirects to Meta.success_url when declared, otherwise it returns redirect_to_origin(request).
The default implementation on BaseModelForm calls self.save() then follows the same redirect rule.
See Success feedback for the success_url contract.
The return value follows the same contract as a handler function, checked in a fixed order.
An HttpResponse instance passes through unchanged, and this check runs first, so every rich response type the framework ships subclasses HttpResponse.
A string becomes the body of an HTTP 200 response, never a redirect target.
None triggers a re-render of the origin page with HTTP 200.
A model instance with a get_absolute_url method redirects to that URL, the CreateView-style idiom for a handler that saves and shows the result.
Any other object with a truthy url attribute redirects to that URL, a last-resort convenience for model-like objects.
Any other return value emits a RuntimeWarning and is treated as None.
get_initial pre-populates the form#
Override get_initial as a classmethod to provide initial data before the first render.
The classmethod is DI-resolved, so it can receive request, URL parameters, or providers.
@classmethod
def get_initial(cls, request: HttpRequest, note_id: int | None = None):
if note_id is None:
return {}
return Note.objects.get(pk=note_id)
BaseModelForm.get_initial may return a model instance.
The framework uses it as the instance kwarg when constructing the form.
BaseForm.get_initial must return a dict.
You never call get_initial yourself.
The dispatcher calls it through the dependency injector before the initial render.
request is supplied by the framework only to a parameter annotated HttpRequest, an unannotated request parameter resolves to None.
A parameter whose name matches a captured URL segment is filled from the URL, so note_id above receives the note_id route kwarg.
Any other parameter resolves through a registered provider, the same as on a handler.
The base signatures carry no positional arguments of their own.
BaseForm.get_initial(cls) takes none, and BaseModelForm.get_initial(cls, **url_kwargs) accepts the URL kwargs as keywords.
Declare only the parameters an override actually reads.
Form-less actions#
Use @action to register a plain callable when no form fields are needed.
Typical use cases include logout buttons, delete confirmations, and any simple POST with no user input.
The name is optional.
A bare @action or an empty @action() registers the function under its own name, and @action("custom_name") overrides it.
from django.http import HttpRequest
from next import action
from next.forms import redirect_to_origin
from next.urls import DUrl
@action("delete_note")
def delete_note(note_id: DUrl["id", int], request: HttpRequest):
Note.objects.filter(pk=note_id).delete()
return redirect_to_origin(request)
A form-less handler that returns None answers with HTTP 204 and never re-renders the origin page, unlike the None return of a form-bound handler.
The scope of a form-less action follows the same anchor-file rule.
page.py and component.py produce page-scoped actions.
All other files produce shared actions.
Pass scope="page" or scope="shared" to override the file-derived scope, the same override Meta.scope provides for a form class.
Any other value triggers next.E047 and the action is not registered.
The login_required and permission_required keywords guard the endpoint, see Access guards.
Applying @action to a class registers no action.
The decorator records the misuse, returns the class unchanged, and manage.py check reports it as next.E053.
Form classes register through __init_subclass__ and must not use @action.
Stacking decorators on @action#
@action keys the registration on the file where the decorated function is declared, not on the file that runs the decorator.
Decorating an imported function registers it under the module that defines that function.
A shared helper therefore needs a thin wrapper in the page module that uses it, the same wrapper Context shows for context functions.
Keep @action outermost when other decorators apply to the same handler.
from django.db import transaction
from django.http import HttpRequest
from next import action
from next.forms import redirect_to_origin
from next.urls import DUrl
@action("publish_note")
@transaction.atomic
def publish_note(request: HttpRequest, note_id: DUrl["id", int]):
Note.objects.filter(pk=note_id).update(published=True)
return redirect_to_origin(request)
transaction.atomic sets __wrapped__ through functools.wraps(), so the action still registers under this page.py.
A hand-written decorator that omits functools.wraps hides the wrapped function, and the action registers under the decorator’s own module instead.
Injecting the form into a handler#
A handler registered with @action("name", form_class=...) receives the bound, validated form.
A parameter named form resolves to it, untyped.
Annotate the parameter with DForm[FormClass] to type the form for editors and type checkers.
form_class= accepts the form class directly when that class does not register an endpoint of its own.
A next.forms base marked Meta.abstract = True is the canonical case.
It skips auto-registration yet keeps the get_initial classmethod the dispatcher calls.
Passing a class that already registered itself raises TypeError at decoration time.
Mark such a class abstract, or move the handler logic into its on_valid.
from django.shortcuts import redirect
import next.forms
from next import action
from next.forms.markers import DForm
class ContactForm(next.forms.ModelForm):
class Meta:
model = Contact
fields = ["name", "email"]
abstract = True
@action("create_contact", form_class=ContactForm)
def create_contact(form: DForm[ContactForm]):
form.save()
return redirect("/contacts/")
A handler that returns a bare string sends it as the response body, so a redirect must come back as a response object.
The marker only types the parameter.
The framework still injects the same bound form a parameter named form would receive.
See Decorators and markers for DForm and FormProvider.
Dynamic form classes#
A form_class= argument may be a factory callable instead of a form class.
The factory is dependency-injected, so it can read request and URL kwargs, and it returns one of two shapes.
- A plain form class.
The dispatcher then calls
get_initialon it and binds the form as usual.- A
(FormClass, init_kwargs)tuple. When
init_kwargsis non-empty, the dispatcher passes**init_kwargsstraight to the form constructor and skipsget_initial. An empty dict behaves like returning the bare class, so a class with noget_initialneeds at least the neutral{"initial": {}}. Use the tuple when the constructor needs arguments thatget_initialcannot supply, such as a preloaded modelinstanceor a formsetqueryset.
from django.shortcuts import get_object_or_404, redirect
from next import action
from next.urls import DUrl
def edit_form_factory(note_id: DUrl["id", int]) -> tuple:
note = get_object_or_404(Note, pk=note_id)
return NoteForm, {"instance": note}
@action("edit_note", form_class=edit_form_factory)
def edit_note(form: NoteForm):
form.save()
return redirect("/notes/")
The tuple path bypasses get_initial only while init_kwargs stays non-empty, so do not rely on the skip when a factory may return an empty dict.
See Formsets for the same pattern applied to formset and inline-formset actions.
Integrate django-allauth forms shows the {"initial": {}} idiom on a class without get_initial.
Preventing registration#
A project base class that other forms subclass should not register as an action of its own.
Set Meta.abstract = True to skip auto-registration.
from django.http import HttpRequest
import next.forms
from next.forms import redirect_to_origin
class TenantForm(next.forms.Form):
class Meta:
abstract = True
def on_valid(self, request: HttpRequest):
return redirect_to_origin(request)
class InviteForm(TenantForm):
email = next.forms.EmailField()
TenantForm is skipped at the __init_subclass__ hook and never appears in the registry.
InviteForm registers normally as invite_form and inherits the base behaviour.
The same Meta.abstract flag works on a FormWizard base class.
Access guards#
The dispatch endpoint of an action lives at /_next/form/<uid>/, outside the page URL space, so page-level protection does not cover it.
Declare the access requirements on the action itself.
Meta.login_required and Meta.permission_required guard a class-bound form, and the same names are keyword arguments on @action.
from django.http import HttpRequest
import next.forms
from next import action
from next.forms import redirect_to_origin
from next.urls import DUrl
class NoteDeleteForm(next.forms.Form):
class Meta:
login_required = True
permission_required = "notes.delete_note"
@action("purge_note", login_required=True)
def purge_note(note_id: DUrl["id", int], request: HttpRequest):
Note.objects.filter(pk=note_id).delete()
return redirect_to_origin(request)
The semantics mirror Django’s PermissionRequiredMixin.
permission_required accepts a single permission string or an iterable of them, the user must hold every listed permission, and declaring a permission implicitly requires authentication.
The static guard runs before the form is built, ahead of get_initial, form binding, and any database access, so a request denied by the static guard runs no application code.
An anonymous user is redirected to LOGIN_URL with next set to the posted origin page.
An authenticated user missing a permission gets PermissionDenied, which Django renders as HTTP 403.
Unlike Meta.abstract, which is own-class-only, the guard keys survive subclassing through plain class-attribute lookup.
A subclass that declares no Meta of its own inherits the base Meta and stays guarded.
A subclass that declares its own Meta shadows the base one entirely and registers unguarded.
A concrete ModelForm subclass is the usual trap, because its Meta must carry model and fields.
Extend the inherited Meta there, class Meta(Base.Meta):, or re-declare the guard keys in the new Meta.
The same Meta keys work on a FormWizard, where the guard is enforced on every step submission.
Rendering a guarded form on a public page is not blocked. The guard protects the mutation, not the markup, exactly as a rendered Django form knows nothing about authorisation. Hide the form in the template when the page should not show it to anonymous visitors.
The guard is stored as an ActionGuard on the action’s registry metadata, so a custom backend that delegates to the standard pipeline inherits the enforcement.
The next.W060 check warns when permission_required is declared while django.contrib.auth is not installed.
Dynamic permission hooks#
The static guard answers a fixed question frozen at import time, whether the user is authenticated and holds a named permission. A permission that depends on the request, the database, the current tenant, or the target row needs a decision taken per request. Two opt-in hooks resolve such a decision, layered on top of the static guard rather than replacing it.
check_permissions is a view-level @classmethod.
has_object_permission is an object-level instance method.
Both are dependency-injected exactly like get_initial and on_valid.
The base signatures take no parameters of their own, and an override declares only what it reads.
The signature is open, so an override pulls request, captured URL kwargs, DUrl[...] markers, Depends(...) providers, or nothing at all, following the same rule as the other DI hooks.
Unlike the static guard, the dynamic hooks intentionally run application code.
check_permissions runs after the origin and the dependency cache are set up and after the form class is resolved, before get_initial and form binding.
has_object_permission runs after the form is bound, so self.instance is the loaded target on a ModelForm, and before is_valid and the handler.
import next.forms
from notes.models import Note
class TenantNoteForm(next.forms.ModelForm):
class Meta:
model = Note
fields = ["title", "body"]
@classmethod
def check_permissions(cls):
return None
A parameterless override always allows.
Declare request to read the user, and any further parameter the injector resolves.
from django.http import HttpRequest
from notes.models import Note
import next.forms
from next import Depends
from next.urls import DUrl
class WorkspaceNoteForm(next.forms.ModelForm):
class Meta:
model = Note
fields = ["title", "body"]
@classmethod
def check_permissions(
cls,
request: HttpRequest,
workspace_id: DUrl["workspace_id", int],
flags=Depends("feature_flags"),
):
if not flags.notes_enabled:
return False
return request.user.memberships.filter(workspace_id=workspace_id).exists()
The object-level hook reads the bound instance.
from django.http import HttpRequest
from notes.models import Note
import next.forms
class NoteEditForm(next.forms.ModelForm):
class Meta:
model = Note
fields = ["title", "body"]
instance_from_url = "slug"
def has_object_permission(self, request: HttpRequest):
return self.instance.owner_id == request.user.id
Both hooks share one return contract.
NoneorTrueallows and the pipeline continues.Falsedenies with HTTP 403.Raising
PermissionDenieddenies with HTTP 403.Returning an
HttpResponse, including a redirect, short-circuits and that response is returned verbatim.Any other return type raises
TypeError, so a misconfigured gate fails loudly.
The HttpResponse return is how an anonymous visitor is sent to a login page or a paywall instead of receiving a bare 403,
a decision the static login_required redirect cannot express per request.
The hook return type alias is next.forms.PermissionOutcome, equal to bool | HttpResponse | None, for annotating an override.
The two hooks combine with the static guard in a fixed order.
The static guard runs first and pre-database.
A request it denies never reaches a dynamic hook, so the static guard stays the cheap default for the authentication-and-named-permission case.
check_permissions runs next, then the form binds, then has_object_permission runs on the bound form.
The view hook resolves on the resolved form class, so a factory form_class is covered as well, see Dynamic form classes.
A handler-only @action carries no class to host a method, so it has no dynamic hook.
Such a handler runs its own check in the body and raises PermissionDenied or returns a redirect,
the same pattern shown for messages.success under Success messages.
An object-level denial returns a bare HTTP 403. It does not re-render the origin page. The hook runs on the bound form before validation, so the 403 is a deliberate authorization refusal independent of whether the submitted data is valid. This differs from a validation failure, which re-renders the origin with field errors, see Validation and re-render.
The hooks share the per-request dependency cache with get_initial and on_valid.
A provider resolved inside a hook is not resolved again downstream in the same dispatch, so a tenant or permission lookup runs once across the hook and the rest of the pipeline.
The form_access_denied signal fires only on a dynamic-hook denial, never on the static guard path.
Its sender is FormActionDispatch and its keyword arguments are action_name, uid, request, layer, and reason.
layer is "view" for a check_permissions denial or "object" for a has_object_permission denial.
reason is "raised", "denied", or "response".
See Form signals for the payload and the receiver rules.
On a FormWizard the check_permissions classmethod runs per step POST, before the step form binds, so a denied step writes no storage.
The wizard class has no object-level hook of its own.
A step form that declares has_object_permission has it enforced after the step binds and before is_valid.
A wizard step binds from posted data and get_form_kwargs and never runs get_initial or Meta.instance_from_url, so a ModelForm step sees a fresh unsaved self.instance rather than a URL-addressed row, unlike a standalone form.
Note
The dynamic hooks are not statically inspectable, so the next.W060 check covers only the static permission_required declaration.
A hook that calls request.user without an upstream login requirement is the author’s responsibility, the same boundary the static guard draws.
Success feedback#
Two Meta keys describe what a valid submission tells the user, a flash message and a redirect target.
Success messages#
Meta.success_message flashes a message through django.contrib.messages after a valid submission.
The template is interpolated with % formatting over cleaned_data, the exact contract of Django’s SuccessMessageMixin.
class NoteForm(next.forms.ModelForm):
class Meta:
model = Note
fields = ["title", "body"]
success_message = "Note %(title)s saved."
Override get_success_message(cleaned_data) for a dynamic message, for example to name attributes of the saved instance on a ModelForm.
An empty return value sends nothing.
The message is sent only when the action outcome shapes into a response with a status below 400, so a failed validation flashes nothing.
On a FormWizard the message is sent once, after done succeeds, interpolated over the merged step data.
The messages framework must be fully installed, with django.contrib.messages in INSTALLED_APPS and MessageMiddleware in MIDDLEWARE.
Without it a valid submission raises MessageFailure rather than silently dropping the message, and the next.W061 check reports the gap at manage.py check time.
A handler-only action has no cleaned_data to interpolate, so @action takes no success_message keyword.
Call messages.success in the handler body instead.
from django.contrib import messages
from django.http import HttpRequest
from next import action
from next.forms import redirect_to_origin
from next.urls import DUrl
@action("archive_note")
def archive_note(note_id: DUrl["id", int], request: HttpRequest):
Note.objects.filter(pk=note_id).update(archived=True)
messages.success(request, "Note archived.")
return redirect_to_origin(request)
Success redirects#
Meta.success_url names where the default on_valid redirects after a valid submission.
An explicit success_url wins over the redirect_to_origin default.
The value is a path string, a lazy object, or a zero-argument callable returning the path, evaluated when the response is built.
next.urls.page_reverse_lazy() is the lazy companion of page_reverse for exactly this position, because Meta evaluates at class definition, before the URLconf is ready.
from next.urls import page_reverse_lazy
class AttachmentForm(next.forms.ModelForm):
class Meta:
model = Attachment
fields = ("title", "file")
success_url = page_reverse_lazy("attachments")
On a ModelForm the default on_valid saves first, then follows success_url.
A custom on_valid or handler that saves an instance can lean on the model itself.
Returning the instance redirects to its get_absolute_url(), the CreateView idiom.
def on_valid(self, request: HttpRequest):
return self.save()
System checks#
The forms subsystem contributes Django system checks that run through python manage.py check.
next.E041Two or more registrations share the same action name but come from different handlers. Rename one of them or move one to a different scope.
next.E046Two distinct modules declare a shared form action with the same derived name. Bare-name lookups resolve to whichever module imported first. Rename one class or set
Meta.scope = 'page'on one of them.next.W046(Warning)A form class was declared in a file outside
BASE_DIR. The class is not registered automatically.next.E047A form class
Meta.scopeor an@actionscopekeyword is set to a value other than"page"or"shared". The class or action is not registered.next.E048Meta.instance_from_urlreferences a field name that does not exist on the model.next.E049Meta.instance_from_urlis set on a class that does not subclassnext.forms.ModelForm.next.E052FORM_ANCHOR_FILESis not None or a list of strings. Only a list round-trips through the settings merge, so a tuple or set is rejected instead of silently falling back to the defaults.next.E053@actionwas applied to a class. Remove the decorator and let the class register through__init_subclass__.next.W060(Warning)An action declares
permission_requiredwhiledjango.contrib.authis not inINSTALLED_APPS, so the permission check cannot resolve users or permissions.next.W061(Warning)An action declares
Meta.success_messagewhile the messages framework is not fully installed. A valid submission raisesMessageFailureuntildjango.contrib.messagesandMessageMiddlewareare both configured.
A UID hash collision between two distinct registrations is not reported as a system check.
It raises ImproperlyConfigured at import time when two different (scope_key, name) pairs hash to the same UID.
This is distinct from the next.E041 name collision above, which fires when one name is registered from two different handlers.
See also#
See also
Forms overview for the mental model.
Form templates for the {% form %} tag.
Validation and re-render for what happens on a failing submission.
Forms reference for the public API.