Search code examples
python-3.xdjango-formsdjango-formwizard

Django Model Formset in Form Wizard returning None


I have multiple formsets on different steps in my form wizard. I've run into an issue where one formset will not render. Specifically, it returns the error:

'NoneType' object does not support item assignment

I've determined that the error is that I'm trying to modify the initial formset data based on a previous step in the form wizard, but the data is not being passed to the form wizard. Instead, it is passing None, which causes the wizard to render a regular form, rather than a formset.

Looking at the code, you can see two formsets. One formset collaborators works totally fine. The other jobs is broken.

I can find no reason for the formset to pass None initial data. Aside from (unsuccessfully) tracing back to determine how the data variable is generated, I've tried numerous combinations of placing forms, formset classes, and more on the collaborators step.

The thing I've noticed using this methodology, is that the problem seems to be tied to the step. If I place all formset data from jobs in my FORMS ordered dictionary over to collaborators, the formset renders. Turning off all extra functionality tied to the jobs step does not suddenly allow the formset to render, however.

Hoping it's something stupid. Thank you in advance for your help!

Here's the relevant code:

app/views.py

FORMS = [
    ('info', project_forms.NewProjectNameForm),
    ('incomesources', project_forms.NewProjectSourcesForm),
    ('collaborators', forms.modelformset_factory(
        models.UserCollaborator,
        form=project_forms.NewProjectCollaboratorsForm)),
    ('jobs', forms.modelformset_factory(
        models.Job,
        form=project_forms.NewProjectJobForm,
        formset=project_forms.JobFormset)),
    ('checkingaccount', project_forms.NewProjectWithdrawalBankForm),
    ('review', project_forms.NewProjectReviewForm),

TEMPLATES = {
    'info': 'project/name.html',
    'incomesources': 'project/sources.html',
    'collaborators': 'project/collaborators.html',
    'jobs': 'project/jobs.html',
    'checkingaccount': 'project/checkingaccount.html',
    'review': 'project/review.html',
}

TEMPLATE_NAMES = {
    'info': "Basic Info",
    'incomesources': "Income Sources",
    'collaborators': "Collaborators",
    'jobs': "Jobs",
    'checkingaccount': "Checking Account",
    'review': "Review",

class ProjectWizard(NamedUrlSessionWizardView):
    def get_template_names(self):
        """
        Gets form template names to be rendered in page title.

        :return: Template Name
        """
        return [TEMPLATES[self.steps.current]]

    def get_collaborators_as_users(self):
        collaborators = []
        collaborators_data = self.storage.get_step_data('collaborators')
        if collaborators_data:
            total_emails = int(collaborators_data.get('collaborators-TOTAL_FORMS'))
            for email in range(total_emails):
                # Gather a list of users.
                collaborator_key = 'collaborators-' + str(email) + '-email'
                email = collaborators_data.get(collaborator_key)
                collaborators.append(User.objects.get(email__iexact=email))
        return collaborators

    def get_owner_collaborators_as_names(self, collaborators):
        collaborators_names = [self.request.user.get_full_name() + ' (Owner)']
        for collaborator in collaborators:
            if not collaborator.get_full_name():
                collaborators_names.append(collaborator.email)
            else:
                collaborators_names.append(collaborator.get_full_name())
        return collaborators_names

    def get_context_data(self, form, **kwargs):
        """
        Overrides class method.

        :param form: Current Form
        :param kwargs: Derived from class
        :return: Context is returned
        """
        context = super(ProjectWizard, self).get_context_data(form=form, **kwargs)
        step = self.steps.current
        if step == 'incomesources':
            youtube_credentials = models.YouTubeCredentials.objects.filter(user_id=self.request.user.id)
            context.update(
                {'youtube_credentials': youtube_credentials})
        elif step == 'jobs':
            collaborators = self.get_collaborators_as_users()
            collaborators_names = self.get_owner_collaborators_as_names(collaborators)
            context.update({'collaborator_names': collaborators_names})

        elif step == 'checkingaccount':
            accounts = StripeCredentials.objects.filter(id=self.request.user.id)
            if accounts.exists():
                context.update(({
                    'accounts_exist': True,
                    'stripe_public_key': settings.STRIPE_PUBLIC_KEY,
                }))
            else:
                context.update(({'accounts_exist': False}))
        elif step == 'review':
            # Get raw step data
            step_info = self.storage.get_step_data('info')
            step_income_sources = self.storage.get_step_data('incomesources')
            step_jobs = self.storage.get_step_data('jobs')
            step_checking_account = self.storage.get_step_data('checkingaccount')
            # Process collaborator objects to names
            collaborators = self.get_collaborators_as_users()
            collaborators_names = self.get_owner_collaborators_as_names(collaborators)
            # Process jobs
            total_jobs = step_jobs['jobs-TOTAL_FORMS']
            jobs = []
            for job in range(int(total_jobs)):
                # Gather a list of users.
                job_key = 'jobs-' + str(job) + '-job'
                job = step_jobs.get(job_key)
                jobs.append(job)
            print(step_checking_account)
            context.update({
                'project_name': step_info['info-name'],
                'video_id': step_income_sources['incomesources-value'],
                'collaborators_names': collaborators_names,
            })

        context.update(
            {'page_title': "New Project – " + TEMPLATE_NAMES[self.steps.current]})
        return context

    def get_form(self, step=None, data=None, files=None):
        """
        Overrides class method.

        Constructs the form for a given `step`. If no `step` is defined, the
        current step will be determined automatically.

        The form will be initialized using the `data` argument to prefill the
        new form. If needed, instance or queryset (for `ModelForm` or
        `ModelFormSet`) will be added too.
        """
        if step is None:
            step = self.steps.current
        form_class = self.form_list[step]
        # Prepare the kwargs for the form instance.
        kwargs = self.get_form_kwargs(step)

        if self.request.method == 'GET':
            if step == 'collaborators':
                assert data != None, "Formset not rendering. Data returned as: {}".format(data)
            if step == 'jobs':
                assert data != None, "Formset not rendering. Data returned as: {}".format(data)
                # Define number of forms to be registered based on previous step.
                collaborators_data = self.storage.get_step_data('collaborators')
                data['jobs-TOTAL_FORMS'] = str(int(collaborators_data['collaborators-TOTAL_FORMS']) + 1)
        kwargs.update({
            'data': data,
            'files': files,
            'prefix': self.get_form_prefix(step, form_class),
            'initial': self.get_form_initial(step),
        })
        if issubclass(form_class, (forms.ModelForm, forms.models.BaseInlineFormSet)):
            # If the form is based on ModelForm or InlineFormSet,
            # add instance if available and not previously set.
            kwargs.setdefault('instance', self.get_form_instance(step))
        elif issubclass(form_class, forms.models.BaseModelFormSet):
            # If the form is based on ModelFormSet, add queryset if available
            # and not previous set.
            kwargs.setdefault('queryset', self.get_form_instance(step))
        return form_class(**kwargs)

    def post(self, *args, **kwargs):
        """
        This method handles POST requests.

        The wizard will render either the current step (if form validation
        wasn't successful), the next step (if the current step was stored
        successful) or the done view (if no more steps are available)
        """
        # Look for a wizard_goto_step element in the posted data which
        # contains a valid step name. If one was found, render the requested
        # form. (This makes stepping back a lot easier).
        wizard_goto_step = self.request.POST.get('wizard_goto_step', None)
        if wizard_goto_step and wizard_goto_step in self.get_form_list():
            return self.render_goto_step(wizard_goto_step)

        # Check if form was refreshed
        management_form = ManagementForm(self.request.POST, prefix=self.prefix)
        if not management_form.is_valid():
            raise ValidationError(
                _('ManagementForm data is missing or has been tampered.'),
                code='missing_management_form',
            )

        form_current_step = management_form.cleaned_data['current_step']
        if (form_current_step != self.steps.current and
                self.storage.current_step is not None):
            # form refreshed, change current step
            self.storage.current_step = form_current_step

        # get the form for the current step
        form = self.get_form(data=self.request.POST, files=self.request.FILES)

        # and try to validate
        if form.is_valid():
            # if the form is valid, store the cleaned data and files.
            self.storage.set_step_data(self.steps.current, self.process_step(form))
            self.storage.set_step_files(self.steps.current, self.process_step_files(form))

            # Interact with Stripe
            if self.steps.current == 'checkingaccount':
                stripe.api_key = settings.STRIPE_SECRET_KEY
                stripe_token = self.request.POST.get('stripe_token')
                email = self.request.user.email
                description = 'Customer for ' + email
                customer = stripe.Customer.create(
                    description=description,
                    source=stripe_token
                )
                customer_id = customer.id
                stripe_credentials, created = StripeCredentials.objects.get_or_create(
                    owner_id=self.request.user)
                if created:
                    stripe_credentials.save()

            # check if the current step is the last step
            if self.steps.current == self.steps.last:
                # no more steps, render done view
                return self.render_done(form, **kwargs)
            else:
                # proceed to the next step
                return self.render_next_step(form)
        return self.render(form)

app/urls.py

project_wizard = project_views.ProjectWizard.as_view(project_views.FORMS,
    url_name='project_step', done_step_name='finished')

urlpatterns = [
    path('new/<step>', project_wizard, name='project_step'),
    path('new', project_wizard, name='new_project'),
]

Solution

  • Reading the comments from the class I overrided:

    if issubclass(form_class, (forms.ModelForm, forms.models.BaseInlineFormSet)):
            # If the form is based on ModelForm or InlineFormSet,
            # add instance if available and not previously set.
            kwargs.setdefault('instance', self.get_form_instance(step))
        elif issubclass(form_class, forms.models.BaseModelFormSet):
            # If the form is based on ModelFormSet, add queryset if available
            # and not previous set.
            kwargs.setdefault('queryset', self.get_form_instance(step))
    

    I believe those comments mean that data will only pass one time..."add queryset if available and not previous set.

    That said, I figured exactly when the formset is called: app/views.py

    ...    
    form_class = self.form_list[step]
    ...
    

    That returns modifiable data which ultimately passes along to the front end. I modified the total forms based on the previous formset's total forms, and was finally able to achieve my desired results.