Tutorial¶
django-args
builds on the foundational elements of python-args
and allows engineers to construct forms and views directly on top of
functions.
In contrast with Django forms, which provide several mechanisms to
clean fields and validate input, django-args
promotes the philosophy
of keeping validation logic closer to the core code, which is
where python-args
comes into play.
By decorating a function with python-args
@arg.validators(...)
,
a Django form can be seamlessly adapted to a python-args
function,
ensuring that you never repeat validation logic for a function and
form fields that collect function arguments.
We will first dive into how this works with the djarg.views.FormView
class and construct a view from a python-args
function.
Creating a FormView¶
This example assumes the user has knowledge of python-args
constructs.
If not, please read the
python-args docs first.
Adapting a python-args
function to a form¶
We start by creating a function that will modify the is_staff
flag of a Django user in the database. The function takes the user
being modified, the person granting access (or revoking), and the value
of the is_staff
flag.
We validate the arguments using @arg.validators()
:
import arg
def ensure_granter_is_staff(granter):
if not granter.is_staff:
raise ValueError('Granter must be staff to grant staff access.')
@arg.validators(ensure_granter_is_staff)
def grant_staff_access(user, granter, is_staff):
user.is_staff = is_staff
user.save()
This function has a single validator that ensures the granter has sufficient privileges to grant staff access.
When functions are decorated with @args.validators()
, those validators
can be directly adapted to a Django form that collects the arguments.
For example, before we write the form view for this function, let’s write
the form:
from django import forms
class GrantStaffAccessForm(forms.Form):
user = forms.ModelChoiceField(queryset=User.objects.all())
granter = forms.ModelChoiceField(queryset=User.objects.all())
is_staff = forms.BooleanField()
Although we would likely determine the user performing the request from
some other means, this form purely serves as an example. Given this form
that collects the input needed for our grant_staff_access
function,
we can call djarg.forms.adapt
to adapt the GrantStaffAccess
form to the grant_staff_access
function:
import djarg.forms
form_instance = djarg.forms.adapt(GrantStaffAccess(), grant_staff_access)
Instead of having to specify validators for form fields or performing a custom
form clean()
method, all of the validators on the function are automatically
used when relevant. For example, any validators that only take the form
field as an argument are used as validators on the individual field, while
validators that take multiple arguments are used in the form clean()
method.
When building forms on top of complex functions with many layers of validation, this can not only help reduce the complexity of frontend code, but it also offers the ability to more easily test validation and keep the core validation code closer to the business logic.
Automatically adapting forms with djarg.views.FormView
¶
Users will typically not be using djarg.forms.adapt
directly like shown
and will instead be using djarg.views.FormView
. For example:
import djarg.views
class GrantAccessView(djarg.views.FormView):
form_class = GrantStaffAccessForm
func = grant_staff_access
The djarg.views.FormView
automatically adapts the form class to the
provided python-args
function func
. When forms are successfully
validated, func
is called in the form view’s form_valid
method. And voila, you have a Django Form View directly built on top
of a function with much less boilerplate.
django-args
tries to abide by the principle of keeping as much
validation logic as possible out of the view and form layer, a complexity
that can compound when working with sophisticated input and validation
scenarios.
So, what if we instead want to provide the currently authenticated user
as the granter
argument to our grant_staff_access
function? It
is far more common to use the currently-authenticated user as the person
performing actions in a view.
The djarg.views.FormView
class does not require that all func
arguments come from the form. Others can also dynamically come from
the get_default_args()
method. For example, assuming we are using
Django’s authentication middleware, we can update our form and view like so:
from django import forms
import djarg.views
class GrantStaffAccessForm(forms.Form):
user = forms.ModelChoiceField(queryset=User.objects.all())
is_staff = forms.BooleanField()
class GrantAccessView(djarg.views.FormView):
form_class = GrantStaffAccessForm
func = grant_staff_access
def get_default_args(self):
return {
**super().get_default_args(),
'granter': self.request.user
}
In the above, the user
and is_staff
fields will be selected by
the user. The granter
argument is provided from the get_default_args
method.
Note
The request
variable is always included in the default args and
accessible to the wrapped function in the view. The example
from above could have also used
@arg.defaults(granter=arg.val('request').user)(grant_staff_access)
as the func
attribute to accomplish the same thing.
The djarg.views.FormView
is just like any other
django.views.generic.edit.FormView
. You have access to the form
variable in the template. For example, we can render our form with
the following template:
<form action=".?{{ request.GET.urlencode }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">
Submit
</button>
</form>
Assuming that we’ve linked a URL to our view, the form view behaves like so:
In the above, the ensure_granter_is_staff
validator is failing
because the authenticated user is not a staff member. When this validator
fails, it appears as a failure of the form’s clean()
method and rendered
by Django.
When all validation passes, the view runs func
. In
this example, the form redirects back to an empty slate on success:
Lazy form fields using python-args
utilities¶
When djarg.views.FormView
adapts the form, it also ensures that
the form is compatible with various lazy execution utilities in python-args
.
A common pattern in Django forms is to dynamically modify field attributes based on data passed from the view. For example, let’s continue our example of granting staff access and dynamically render choices based on the permission level of the granter.
class GrantStaffForm(forms.Form):
reason = forms.ChoiceField(choices=[('', '---')])
user = forms.ModelChoiceField(queryset=User.objects.all())
is_staff = forms.BooleanField()
def __init__(self, **kwargs):
# Assume the user has overridden the view's ``get_form_kwargs``
# method and passed in the user
granter = kwargs.pop('granter')
super().__init__(**kwargs)
if granter.is_staff:
self.fields['reason'].choices = [
('reason_type1', 'Reason type 1'),
('reason_type2', 'Reason type 2')
]
elif granter.is_superuser:
self.fields['reason'].choices = [
('reason_type1', 'Reason type 1'),
('reason_type2', 'Reason type 2')
]
Basic user experience tweaks like these can make dynamic form processing
code more difficult to follow and unwind. Not to mention the additional
boilerplate needed in order to pass the granter
as a keyword argument
to the instantiation of the form.
django-args
integrates with python-args
arg.init
utility
that allows lazily loading a class. Use djarg.forms.Field
to wrap
a field so that field properties can be lazily evaluated.
In this example, we are still using our earlier GrantStaffView
example. When get_default_args
is implemented, all of these arguments
are available to use when using djarg.forms.Field
and associated
python-args
utilities. For example, here’s our updated form:
def get_grant_reasons(granter):
if granter.is_staff:
return [
('staff_reason_1', 'Staff Reason 1'),
('staff_reason_2', 'Staff Reason 2')
]
elif granter.is_superuser:
return [
('superuser_reason_1', 'Superuser Reason 1'),
('superuser_reason_2', 'Superuser Reason 2')
]
else:
return [('', '---')]
class GrantStaffForm(forms.Form):
reason = djarg.forms.Field(
forms.ChoiceField,
choices=arg.func(get_grant_reasons)
)
user = forms.ModelChoiceField(queryset=User.objects.all())
is_staff = forms.BooleanField()
In the above, we have used djarg.forms.Field
to lazily instantiate
a Django form field of class forms.ChoiceField
. When instantiating
the field, we lazily execute the get_grant_reasons
function to
fill in choices. Since the granter
argument is available to our
form as a default argument, it can be used in python-args
lazy
utilities (for a refresher on these, check out the
python-args docs).
With this slight change, we have less boilerplate in our forms. We can extend this concept to dynamically creating querysets for model choice fields, dynamic initial values, dynamic widgets, and any other attributes required by Django form fields.
In this example, any form field has access to the default arguments
provided by the view. In the next section of this tutorial, we expand on
these concepts with the introduction of djarg.views.WizardView
, which
along with default arguments, allows form steps and conditions to
be dynamically determined from previously entered steps.
Creating a WizardView¶
django-args
comes with an integration with
django-formtools.
django-formtools
has several wizard objects. django-args
provides the base djarg.views.WizardView
object and the
djarg.views.SessionWizardView
that uses a session storage backend to
track steps.
For those unfamiliar with django-formtools
, the wizard object
allows a user to provide a series of forms that are collected over
subsequent steps. Users can also define conditions so that steps
can be conditionally included or ignored. Consult the
django-formtools docs
for more information.
Similar to how dynamic form field instantiation can lead to a lot of boilerplate,
dynamically instantiating form fields and conditionally showing steps
in form wizards can also incur a significant amount of boilerplate.
django-args
aims to not only allow form wizards to be seamlessly
built on top of python-args
functions, but to also minimize the associated
boilerplate for various user experience patterns.
Collecting input over multiple steps¶
For example, let’s use our grant staff function and collect information over multiple steps. We are going to make a modification to our function and also take in the reason and an explanation:
def ensure_granter_is_staff(granter):
if not granter.is_staff:
raise ValueError('Granter must be staff to grant staff access.')
# Make sure we have clean text input that is stripped
@arg.defaults(
reason=arg.val('reason').strip(),
explanation=arg.val('explanation').strip()
)
# Make sure the granter is the right tier
@arg.validators(ensure_granter_is_staff)
def grant_staff_access(user, granter, is_staff, reason, explanation):
user.is_staff = is_staff
user.save()
# Log the reason and explanation associated with the change
GrantRecord.objects.create(
granter=granter,
user=user,
is_staff=is_staff,
reason=reason,
explanation=explanation
)
In this example, we extend our function to log a record of grants
with reasons and explanations. We also use @arg.defaults
to clean
our input. Although the form will clean up some of the input for us,
this extra layer of protection ensures other functions calling ours
will have clean input.
Now let’s make a form wizard that leads people through collecting information step-by-step. We do this by splitting our input into separate forms:
class GrantStaffReasonForm(forms.Form):
reason = djarg.forms.Field(
forms.ChoiceField,
choices=arg.func(get_grant_reasons)
)
class GrantStaffExplanationForm(forms.Form):
explanation = forms.CharField()
class GrantStaffUserForm(forms.Form):
user = forms.ModelChoiceField(queryset=User.objects.all())
is_staff = forms.BooleanField()
In the above, we collect the information needed for our wizard over three steps. The form wizard object looks like the following:
from django import shortcuts
import djarg.views
class GrantStaffWizard(djarg.views.SessionWizardView):
func = grant_staff_access
form_list = [
GrantStaffReasonForm,
GrantStaffExplanationForm,
GrantStaffUserForm,
]
def get_default_args(self):
return {'granter': self.request.user}
def done(self, *args, **kwargs):
self.run_func()
return shortcuts.redirect('.')
Similar to our djarg.views.FormView
example, we use the authenticated
user as the default argument for granter
. Since django-formtools
requires us to implement a done()
method, we call run_func()
to run the function with the cleaned form wizard data and redirect back
to the starting of the wizard.
djarg.views.SessionWizardView
extends the session wizard from
django-formtools
, so we can create a wizard template that behaves
similarly:
{{ form.media }}
<form action=".?{{ request.GET.urlencode }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ wizard.management_form }}
{{ form.as_p }}
{% if wizard and wizard.steps.current != wizard.steps.last %}
<button type="submit">Next</button>
{% else %}
<button type="submit">
Submit
</button>
{% endif %}
</form>
When saving this template and updating the template_name
attribute on
our wizard view, we can then go through the view after linking it in our
urls file. It looks like the following:
As shown above, our first step dynamically determines the reasons. Since the user in this example is not a superuser, the staff reasons are shown. Once the user has performed the flow, the function runs and they are redirected back to the beginning.
Form wizards are not all that different in how they perform validation in comparison to the standard form view. For example, if we attach a validator to the explanation to ensure that it’s a minimum length, this validator will only execute on the second step. For example:
def explanation_must_be_long(explanation):
if len(explanation) < 10:
raise ValueError('The explanation must be 10 or more characters')
@arg.defaults(...)
@arg.validators(ensure_granter_is_staff, explanation_must_be_long)
def grant_staff_access(user, granter, is_staff, reason, explanation):
...
Adding this validator produces a flow like the following:
Conditional step collection¶
django-args
integrates seamlessly with conditional step execution
in django-formtools
. Similar to how djarg.forms.Field
allows
for lazy loading of form fields based on default arguments and previous
steps, conditions can also utilize default arguments and any previous
steps that were successfully submitted.
For example, let’s update our grant_staff_access
to optionally
allow an explanation and remove minimum length requirements.
Let’s also update our form wizard to only show the explanation step
if the first reason is chosen:
def should_show_explanation_step(reason):
"""Only collect an explanation when the reason is the first reason"""
return reason in ('staff_reason_1', 'superuser_reason_1')
@arg.defaults(...)
@arg.validators(ensure_granter_is_staff)
def grant_staff_access(user, granter, is_staff, reason, explanation=''):
...
class GrantStaffWizard(djarg.views.SessionWizardView):
func = grant_staff_access
form_list = [
GrantStaffReasonForm,
GrantStaffExplanationForm,
GrantStaffUserForm,
]
condition_dict = {
'1': arg.func(should_show_explanation_step)
}
...
In the above, we utilize django-formtools
condition_dict
attribute, which allows us to specify functions that determine if steps
should be shown. Although django-formtools
allows you to label your steps,
not labeling them results in the first step being labeled "0"
, the
second step "1"
and so on. In our case, we have instructed that the second
step only be shown if we pick the first reason
.
Since djarg.views.SessionWizardView
makes all of the submitted arguments
available to python-args
functions, we can create conditions that will
use previously-submitted steps. In our case, that step is the reason
field that was collected.
Here’s what it looks like if we choose the first reason in the first step and the second reason in the first step. The former prompts the user for an explanation while the latter does not:
Using single and multiple object views¶
The base form and wizard views from django-args
have child subclasses
that make it easier for updating individual and multiple objects.
This follows from a similar philosophy of Django’s generic update
views (UpdateView
, CreateView
, etc).
Each main view has an associated Object
view for editing a single object
and Objects
view for editing multiple objects.
Using ObjectFormView
and ObjectWizardView
¶
The djarg.views.ObjectFormView
is functionally the same as
djarg.views.FormView
, and this is also true for djarg.views.ObjectsWizardView
.
Similar to
Django’s Generic Editing Views,
any URL constructed from a single object view has an associated primary key
field in the URL (i.e. /url-path/<int:pk>/
). Along with that, views
must provide a model
or queryset
attribute so that Django knows how to
fetch the object.
When views are constructed, the object
variable is a default argument
for func
and is available in the view like other Django edit views.
It is up to the user constructing the python-args
func
attribute
to map the object
argument to the associated object of their function.
All of these properties apply to both djarg.views.ObjectFormView
and djarg.views.ObjectWizardView
classes.
Using the ObjectsFormView
and ObjectsWizardView
¶
The djarg.views.ObjectsFormView
and djarg.views.ObjectsWizardView
are identical to their individual object counterparts with the following
differences:
Instead of an
object
default argument, there is anobjects
default argument and view variable with all of the objects.Instead of taking a primary key argument from the URL, the primary keys of all objects are determined from the URL query string. For example,
/url-path/?pk=1&pk=2
will operate over two objects. If PKs aren’t provided or if any PKs don’t exist, a 404 is raised.
Similar to the single object views, the func
attribute must map the
objects
variable to the proper argument in the function. One can
also use arg.parametrize
to parametrize a list of objects to
single-object functions.
Other django-args
view settings¶
By default, django FormView
and form-tools WizardView
classes
do not display form errors when errors happen after form or
wizard validation. django-args
defaults to rendering these errors
as normal form errors. In the case of wizards, runtime errors are
rendered on the last step.
This behavior can be suppressed by setting the raise_run_errors
attribute
to True
in the view or wizard definition.
Other django-args
utilities¶
Using djarg.qset
¶
python-args
comes with several utilities for lazily-evaluating call
arguments. django-args
also comes with a djarg.qset
utility for
coercing argument defaults into querysets.
For example, say that you want to ensure your function is always called with a queryset that has properly cached all necessary relations:
@arg.defaults(
users=djarg.qset('users', model=User).prefetch_related('groups')
)
def get_user_groups(users):
"""Return all of the groups of the users"""
return {
group
for user in users
for group in user.groups.all()
}
When using djarg.qset
, the argument (in this case users
) will be
coerced into a queryset. Similar to other python-arg
lazy utilities,
the queryset value can be lazily chained to be evaluated.
Since our function always prefetches groups, one does not have to worry about the caller prefetching the proper relations for the function.
The djarg.qset
utility automatically coerces the other values:
None
, which will return an empty queryset.A single model.
A list of models.
A single PK or list of PKs.
For example, one can also do get_user_groups([user_id1, user_id2])
with
the djarg.qset
utility.
Note
In addition to providing a model
as the argument to djarg.qset
, one
can also provide a qset
keyword argument as a queryset to use
when constructing the final queryset.
Locking objects with djarg.qset
¶
The djarg.qset
also comes with a select_for_update
parameter to
dynamically perform locking with select_for_update
if we aren’t
running in a partial python-args context (i.e. only running validators).
The select_for_update
argument can be supplied in three ways:
djarg.qset(select_for_update=True)
: Useselect_for_update
with the default parameters.djarg.qset(select_for_update=['relations']])
: Useselect_for_update
with theof
argument set to the list of relations.djarg.qset(select_for_update={'skip_locked': True})
: Pass in keyword arguments directly toselect_for_update
.
Again, the key advantage of using the select_for_update
argument in
djarg.qset
is that it will only apply the select_for_update
when
not running in partial python-args
mode. This ensures objects won’t be
locked when only running validators, for example.
Using djarg.views.SuccessMessageMixin
¶
Similar to
Django’s SuccessMessageMixin,
the djarg.views.SuccessMessageMixin
allows users to define a
success_message
attribute that is rendered on successful completion of any
django-args
views. The success message is automatically formatted with
the arguments passed into the func
of the view. One can also override
get_success_message
to dynamically construct success messages based on
the arguments and results of the func
. For example:
class GrantStaffWizard(djarg.views.SuccessMessageMixin, djarg.views.SessionWizardView):
func = grant_staff_access
success_message = 'Successfully granted staff access from {user}.'
Or:
class GrantStaffWizard(djarg.views.SuccessMessageMixin, djarg.views.SessionWizardView):
func = grant_staff_access
def get_success_message(self, args, results):
return f'Successfully granted staff access from {args["user"]}.'