Multiple forms/actions in single view

Hello,

I am building a ledger-like app where users can buy products with their accounts. Users also need to transfer money into their accounts, so a custom transaction is also possible. Because this is infrequent, it is part of the account-update page.

In the spirit of progressive enhancement, the account-update-page (and all other pages for that matter) should also be usable without JavaScript, which means, all interactions must go through forms. On the other hand, if JavaScript is available, some forms should be submitted without page refreshes via fetch(). The aforementioned account page might look like this:

<h2>User Settings</h2>
<form method="POST">
  {{ form }}
  <button type="submit">Save</button>
</form>

<h2>Deposit</h2>
<form method="POST">
  <input type="hidden" name="action" value="deposit">
  {{ deposit_form }}
  <button type="submit">Confirm</button>
</form>

The associated view:

class AccountUpdate(UpdateView):
    model = Account
  
    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
        return super().get_context_data(**kwargs) | {
            'deposit_form': TransactionForm(),
        }

# url: /accounts/<pk>/
class AccountView(View):
    def get(self, request, *args, **kwargs):
        view = AccountUpdate.as_view()
        return view(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        extra_content = None
        if 'action' in request.POST:
          form = TransactionForm(self.request.POST)
          if form.is_valid():
              return custom_transaction(request)
          
          extra_context = {f'deposit_form': form}

        view = AccountUpdate.as_view(extra_context=extra_context)
        return view(request, *args, **kwargs)

# url: /transaction/custom 
def custom_transaction(request: HttpRequest):
    try:		
        if request.content_type == 'application/json':
            data = loads(request.body)
        else:
            data = request.POST

        form = TransactionForm(data)
        if not form.is_valid():
            raise ValidationError('')

        # Processing form
        
        if request.accepts('text/html'):
            return HttpResponseRedirect(reverse('account_view', args=[account.pk]))
        else:
            return JsonResponse({'transaction_id': new_transaction.pk})
    except ValidationError:
        return HttpResponseBadRequest("Invalid form submission: " + form.errors.as_text())

The idea being that if the submitted transaction is valid, you get redirected (to itself); if it is invalid, you get the same page but with form errors.

Now, with JS enabled, the form should instead be submitted via fetch() with a JSON body (no particular reason other than the ergonomics of using it in JS). Because both modes of operation still need the same processing behind the scenes, I use the “API”-function custom_transaction in both cases.

Generally I wonder if this is the best approach. Specifically:

  • The logic to distinguish between an “API”-Call and a form submission
  • The double-validation of the transaction form in the HTML-case (once on /accounts/<pk> to see if I need to display the template again, and then on /transaction/custom to actually process the request)
  • The logic to distinguish between a “legitimate” account update request and a deposit request

Which brings me to my questions:

  • What is the best way to handle multiple actions on a single view?
  • How can/should I design views that are used by both regular and fetch()-submitted forms?

For the first question, I can think of a few alternatives:

  1. Use a separate url for every action (e.g. /accounts/<pk> for update, /accounts/<pk>/deposit for deposit transactions, etc.)
  2. Use distinguishing hidden fields like in the example and detect them in the post()-handler, delegate accordingly
  3. Use distinguishing submit buttons (e.g. <button type="submit" name="deposit">) and detect their presence in post()
  4. Use form prefixes and detect them somehow

My main problem with these approaches is

  • For 1, that the form errors are not redirected to the page they originated from
    (think of a user submitting an invalid deposit, landing on the same page under a different domain and from there submitting an account update)
  • For 2, 3, 4 the required nontivial logic to distinguish forms and insert them, if invalid, back into the template, but branch to an API-route if valid.

Both main strategies feel like unintended (one might say bad) design.

I’d appreciate some guidance.

Separate views. I see no benefit in trying to have one view handle the different sets of actions.

If the field names used in two different models are different, this is how it can be implemented.

# views.py
context['formA'] = {a form}
context['formB'] = {b form}
# template
<form>
{{ formA }}
{{ formB }}
</form>
# views.py
a = {a form}(request.POST)
b = {b form}(request.POST)
if a.is_valid() and b.is_valid(): {valid action}
else: {invalid action}

But that’s the thing (with question 2): The action performed is the same, as the underlying form is the same in both cases. However, for fetch() the view should return an error (400, 404, whatever) whereas a regular submitted form should return 200 with the form errors in HTML.

Currently I rely on the client expecting a JSON response (Accept: application/json) to differentiate these two response behaviors.

Question 1 is more about different actions triggered from the same page. My problem here is mainly that form errors cannot be easily shown in context (=within the same page) without sacrificing either a) complexity in the view post() or b) continuity with the page URL (the page showing form errors is served under a different URL than the one where the form was entered)

I may not be following what you’re trying to describe here, but I can’t reconcile these two statements:

and

The action is not the same.

Now, some of the functionality performed within the view might be the same - in which case that code could be factored out into a different function.

But fundamentally, these are two different views with different behavior. I do not see a benefit with trying to wedge these behaviors into a single view with a common url.

This also includes the situation in the very first code block, where you have two completely different forms being submitted. I find no value in trying to have each of those forms posting to the same URL, and trying to make a distinction within that view between the two.

URLs and views are “cheap”. Let Django do as much work as possible for you - don’t write code that you don’t need to write.

Just to support Ken’s point of view here - I find myself that I tend to think of a view being equal to “a page”, so it should not only render a particular template, but also do all of the form processing and requests for that “page”. view == page makes some intuitive sense.

And when it’s a simple page, with a single simple form, that’s fine.

But as soon as it gets more complicated like this, you (and I) have to remember that “a page” can use multiple views. And that’s often better.

One view to render its GET request. Maybe one each to process the POST for each of its forms. Another for each JS-submitted form.

Splitting the views up so they each have a single clear purpose can make things simpler than one, or two, sprawling views with many conditionals.

Okay, thank you for this advice.

If I got it right, my solution should have all forms displayed with the view, but those with other actions processed by other views under a different url.

If that is the case, how would you handle form errors to those additional actions?

This is kind of what i currently do; Granted, somewhat convoluted.
When using multiple views for one page, there still needs to be one „entrypoint“ that delegates the request to the correct view, right?

So with that idea, I‘d have one view evaluating the correct view to present/call:

def delegate(request):
    form = TransactionForm(request.POST)
    if form.is_valid():
        return transaction_view(request)
    # Now how do i distinguish between `form` really being invalid
    # Or just another form being submitted?
    return AccountUpdate.as_view()(request)

In your “mental model”, separate the concept of a “page” from a “view” as @philgyford points out.

Think of the views as handling actions or events. Decide what needs to happen for each case.

If I’m understanding what all you’re trying to do here, it looks like you’ve got eight distinct events.

  • View 1 - returns a page:
    • First form submitted via http post, form is correct.
    • First form submitted via http post, form is incorrect.
  • View 2 - returns json or html:
    • First form submitted via ajax, form is correct.
    • First form submitted via ajax, form is incorrect.
  • View 3 - returns a page:
    • Second form submitted via http post, form is correct.
    • Second form submitted via http post, form is incorrect.
  • View 4 - returns json or html:
    • Second form submitted via ajax, form is correct.
    • Second form submitted via ajax, form is incorrect.

If this is a correct summary, then you need to identify what needs to be returned for each of those 8 cases.

After you have that figured out, then you can (if it appears to be desirable) factor out the common code that might be useful as a “utility function”.

No. As Ken says, different views for the different events or actions.

To try and avoid thinking in terms of a “page”, maybe you should try coming at the idea from URLs and events.

  • GET /accounts/<pk>/: AccountView renders account.html containing the two forms. One form submits to /account/settings/ the other to /account/deposit/
  • POST to /accounts/<pk>/settings/: SettingsAccountView processes the settings form.
    • If errors, renders account.html
    • If success, redirects to /accounts/<pk>/
  • POST to /accounts/<pk>/deposit/: DepositAccountView processes the deposit form.
    • If errors, renders account.html
    • If success, redirects to /accounts/<pk>/
  • Follow a similar pattern for the AJAX-submitted forms

I’m not saying that’s the only or best way to do it, but it’s one way.

This clears it up for me, thank you. My first draft was quite similar in structure, but somewhere along the way it seems I lost track :sweat_smile:.

Just so i don’t start overthinking a non-issue again, in the case of, lets say, a form error where account.html should be rendered, is it sensible to call out to the regular GET view for that, passing the form (somehow?) to the context?
I am asking because my context data for accounts.html is a bit more than just some forms, and it feels like a lot of code duplication to have that in multiple views. Or would the context data be a candidate for a separate function in that case? (I mean, both are possible, but what is the “right” way to go about it / how would you decide which way to go?)

Yes, this.

As a starting point, there’s nothing wrong with this.

I would actually start by duplicating the code as necessary. Get the views working as needed. Then, once you have functional code, look to see what’s identical between the views, and then decide what can be factored out into other functions.

There’s a lot to be said for actually seeing the replicated code to identify what is in common, and what is different between the different views.

Okay, got it.

Thank you a lot for your help (and patience)