Search code examples
pythondjangodjango-formsdjango-generic-views

Django CreateView not updating context data


I have a form in a bootstrap modal, and I want to keep that modal open after submission. I am using CreateView and trying to pass an additional variable to the template in front-end where I could check if the flag is set or not, but the flag is always False even after submission. Here is what I have:

url.py

from django.urls import path
from .views import MescData

urlpatterns = [
    path('mesc', MescData.as_view(), name='mesc')
]

views.py

from django.urls import reverse
from django.views.generic.edit import CreateView

from .forms import MescForm
from .models import Mesc


class MescData(CreateView):
    model = Mesc
    form_class = MescForm
    template_name = 'Materials/mesc_data.html'
    successful_submit = False  # Flag to keep the add entry modal open

    def get_context_data(self, *args, **kwargs):
        context = super().get_context_data(*args, **kwargs)
        context['successful_submit'] = self.successful_submit
        return context

    def get_success_url(self):
        return reverse('mesc')

    def post(self, request, *args, **kwargs):
        form_class = self.get_form_class()
        form = self.get_form(form_class)
        self.successful_submit = True

        if form.is_valid():
            return self.form_valid(form)
        else:
            return self.form_invalid(form)

    def form_valid(self, form, **kwargs):
        # self.successful_submit = True
        return super(MescData, self).form_valid(form, **kwargs)

And in Template, I'm checking it like this:

{% if successful_submit %}
    <h1>Flag is set</h1>
{% endif %}

Is there a way to pass non-form-related data in CreateView to the template without having to change the url.py (i.e. adding variable data to url path)?

EDIT:

I tried printing the self.successful_submit in form_valid() and post() methods and it is indeed being updated to True, but in the template it is still being passed on as False.


Solution

  • This is the core problem: "I have a form in a bootstrap modal, and I want to keep that modal open after submission."

    Simple answer: Use Ajax.

    We do now have HTML over the wire as a paradigm gaining popularity, but I'm not familiar enough with it to discuss it. So I will use Ajax to show a solution. This particular solution uses a general ajax template, and the result of a post is a rendered Django template that you can use to just replace the HTML in your already rendered page.

    Also, few people like JavaScript, but that is not a good enough reason to avoid using it. It is basically mandatory in any web application you're running. Even HTML over the wire uses a minimal amount of JavaScript to accomplish its goals.

    First, write your Ajax view. I'm using django-rest-framework classes for this, and I'm providing an example for filling out a Calibration record. You can use whatever form you want; this is just my recipe for handling modals that you want keep open. In this view, I return a JSON response if the POST was successful. Otherwise, I return a rendered Django template.

    from rest_framework.generics import (CreateAPIView, RetrieveAPIView,
                                         UpdateAPIView, get_object_or_404)
    from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
    from rest_framework.response import Response
    
    class CalibrationCreateAjaxView(CreateAPIView, UpdateAPIView, RetrieveAPIView):
        renderer_classes = (TemplateHTMLRenderer,)
        template_name = "documents/form/cal.html"
    
        def post(self, request, *args, **kwargs):
            context = self.get_context(request)
            calibration_form = context['calibration_form']
            if calibration_form.is_valid():
                calibration_form.save()
                request.accepted_renderer = JSONRenderer()
                return Response(status=201)
            return Response(context, status=400)
    
        def get(self, request, *args, **kwargs):
            return Response(self.get_context(request))
    
        @staticmethod
        def get_context(request):
            pk = request.GET.get("pk")
            calibration_entry = get_object_or_404(CalibrationEntry, pk=pk) if pk else None
            return {
                'calibration_form': CalibrationFormAjax(request.POST or None, instance=calibration_entry)
            }
    

    I have my view template as well. It takes advantage of request.is_ajax, which is being deprecated. You'll need to add some middleware to keep using it. Here's my middleware. Add it to your settings file as well.

    class IsAjaxMiddleware(object):
        def __init__(self, get_response):
            self.get_response = get_response
    
        """
        request.is_ajax is being removed in Django 4
        Since we depend on this in our templates, we are adding this attribute to request
        Please review:
        https://docs.djangoproject.com/en/4.0/releases/3.1/#id2
        """
        def __call__(self, request):
            request.is_ajax = request.headers.get('x-requested-with') == 'XMLHttpRequest'
            return self.get_response(request)
    

    general/ajax_modal.html

    <!-- {% block modal_id %}{% endblock %}{% block modal_title %}{% endblock %} -->
    {% block modal_body %}
    {% endblock modal_body %}
    

    general/modal.html

    <div class="modal fade" id="{% block modal_id %}{{ modal_id }}{% endblock modal_id %}" tabindex="-1" role="dialog">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal">
                       <span aria-hidden="true">&times;</span>
                       <span class="sr-only">Close</span>
                    </button>
                    <h4 class="modal-title">
                        {% block modal_title %}{{ modal_title }}{% endblock modal_title %}
                    </h4>
                </div>
                <div class="modal-body">
                    {% block modal_body %}
                    {% endblock modal_body %}
                </div>
            </div>
        </div>
    </div>
    

    Even though, we're using Crispy Forms, you can get away without using it. I also have a general templatetag library that renders any errors on a form. You can write your own.

    documents/form/cal.html

    {% extends request.is_ajax|yesno:'general\ajax_modal.html,general\modal.html' %}
    {% load crispy_forms_tags general %}
    {% block modal_id %}new-cal-modal{% endblock modal_id %}
    {% block modal_title %}Enter Calibration Information{% endblock modal_title %}
    {% block modal_body %}
    <div id="new-cal-form-container">
        <form action="{% url 'calibration-create' %}" method="post" id="new-cal-modal-form" autocomplete="off" novalidate>
            {% if request.is_ajax %}
                {% crispy calibration_form %}
                {% form_errors calibration_form %}
            {% endif %}
            <button type="submit" name="Submit" value="Save" class="btn btn-success button" id="submit">save</button>
        </form>
    </div>
    {% endblock modal_body %}
    

    So now that the Ajax view is all set up, I go back to the main page that will render the modal dialog when the user clicks a button. I have a block called "extraContent" in which I include the template of the modal form.

    {% block extraContent %}
        {% include 'documents/form/cal.html' %}
    {% endblock extraContent %}
    

    And now, the JavaScript, which requires jQuery, that I've added to the template. I guess I made my own jQuery plugin on top of that...

    $.fn.modalFormContainer = function(optionsObject) {
        //call it on the modal div (the div that contains a modal-dialog, which contains modal-header, modal-body, etc
        //  we expect there to be a form within that div, and that form should have an action url
        //  when buttons that trigger the modal are clicked, the form is fetched from that action url and replaced.
        // optionsObject has formAfterLoadFunction and ajaxDoneFunction
        var options = $.extend({}, $.fn.modalFormContainer.defaults, optionsObject);
        var $modalFormContainer = $(this);
        // get the buttons that trigger this modal to open
        //  add a click event so that the form is fetched whenever the buttons are clicked
        //  if data-pk is an attribute on the button, apply that to the querystring of the
        //      ajaxURL when fetching the form
        var modalID = $modalFormContainer.prop("id");
        var modalFormButtonSelector = "[data-target=#" + modalID + "][data-toggle=modal]";
    
        function handleModalButtonClick(event) {
            //does the button have an associated pk? if so add the pk to the querystring of the ajax url
            //   this is wrapped in a form so that it gets replaced by the ajax response.
            var $button = $(this);
            if (!$button.hasClass("disabled") && !$button.prop("disabled")) { //only do it if the button is "enabled"
                var $placeholder = $("<form><h1>loading...</h1></form>");
                var $modalForm = $modalFormContainer.find("form");
                var ajaxURL = $modalForm.prop("action");
                $modalForm.replaceWith($placeholder);
                var pk = $button.data().pk;
                if (pk) {
                    if (ajaxURL.indexOf("?") > 0) {
                        ajaxURL += "&pk=" + pk;
                    } else {
                        ajaxURL += "?pk=" + pk;
                    }
                }
                //fetch the form and replace $modalFormContainer's contents with it
                $.ajax({
                    type: "GET",
                    url: ajaxURL
                }).done(function(response) {
                    // re-create the form from the response
                    $modalFormContainer.find(".modal-body").html(response);
                    $modalForm = $modalFormContainer.find("form"); //we would still need to find the form
                    options.formAfterLoadFunction($modalForm);
                });
            } else {
                return false; //don't trigger the modal.
            }
    
        }
        //using delegation here so that dynamically added buttons will still have the behavior.
        // maybe use something more specific than '.main-panel' to help with performance?
        $(".main-panel").on("click", modalFormButtonSelector, handleModalButtonClick);
    
        $modalFormContainer.on("submit", "form", function(event) {
            // Stop the browser from submitting the form
            event.preventDefault();
            var $modalForm = $(event.target);
            var ajaxURL = $modalForm.prop("action");
            $modalForm.find("[type=submit]").addClass("disabled").prop("disabled", true);
            var formData = $modalForm.serialize();
            var internal_options = {
                url: ajaxURL,
                type: "POST",
                data: formData
            };
            // file upload forms have and enctype attribute
            //    we should not process files to be converted into strings
            if ($modalForm.attr("enctype") === "multipart/form-data") {
                internal_options.processData = false;
                internal_options.contentType = false;
                internal_options.cache = false;
                formData = new FormData($modalForm.get(0));
                internal_options.data = formData;
            }
            $.ajax(internal_options).done(function(response) {
                // blank out the form
                $modalForm.find("input:visible, select:visible, textarea:visible").val("");
                // remove errors on the form
                $modalForm.find(".has-error").removeClass("has-error");
                $modalForm.find("[id^=error]").remove();
                $modalForm.find(".alert.alert-block.alert-danger").remove();
                // hide the modal
                $(".modal-header .close").click();
                options.ajaxDoneFunction(response);
            }).fail(function(data) {
                // re-create the form from the response
                $modalFormContainer.find(".modal-body").html(data.responseText);
                options.formAfterLoadFunction($modalForm);
            });
        });
    
        return this;
    };
    
    $.fn.modalFormContainer.defaults = {
        formAfterLoadFunction: function($form) { return; },
        ajaxDoneFunction: function(response) { return; }
    };
    
    $("#new-cal-modal").modalFormContainer({
        formAfterLoadFunction: function($modalForm) {
            $(".datetimeinput").datepicker('destroy');
            $(".datetimeinput").datepicker();
        },
        ajaxDoneFunction: function(event) {
            location.reload();
        }
    });
    

    So upon reviewing this, I've realized that this recipe is far more complicated than I have tricked myself into believing. I sincerely apologize for that. I hope that you can review the code and get an idea of what is happening. There are some edge cases, such as dealing with dates and file uploads, that this recipe handles right now, but you may not actually need them. I should mention that the application that this came from is using Bootstrap 3, so its styling is not updated to the current Bootstrap 5 as of this writing. I should add that the main content of the app has a class of "main-panel" as used in this not-so-generic jQuery plugin.

    I'm worried that I've gone and overwhelmed you into maintaining your position of trying to keep using a standard POST request. I guess you could just re-render the template with your POST since it'll be standard practice in your project. You could still get away without using a query string that way.