views:

2143

answers:

4

I'm working on an attendance entry form for a band. My idea is to have a section of the form to enter event information for a performance or rehearsal. Here's the model for the event table:

class Event(models.Model):
    event_id = models.AutoField(primary_key=True)
    date = models.DateField()
    event_type = models.ForeignKey(EventType)
    description = models.TextField()

Then I'd like to have an inline FormSet that links the band members to the event and records whether they were present, absent, or excused:

class Attendance(models.Model):
    attendance_id = models.AutoField(primary_key=True)
    event_id = models.ForeignKey(Event)
    member_id = models.ForeignKey(Member)
    attendance_type = models.ForeignKey(AttendanceType)
    comment = models.TextField(blank=True)

Now, what I'd like to do is to pre-populate this inline FormSet with entries for all the current members and default them to being present (around 60 members). Unfortunately, Django doesn't allow initial values in this case.

Any suggestions?

+16  A: 

So, you're not going to like the answer, partly because I'm not yet done writing the code and partly because it's a lot of work.

What you need to do, as I discovered when I ran into this myself, is:

  1. Spend a lot of time reading through the formset and model-formset code to get a feel for how it all works (not helped by the fact that some of the functionality lives on the formset classes, and some of it lives in factory functions which spit them out). You will need this knowledge in the later steps.
  2. Write your own formset class which subclasses from BaseInlineFormSet and accepts initial. The really tricky bit here is that you must override __init__(), and you must make sure that it calls up to BaseFormSet.__init__() rather than using the direct parent or grandparent __init__() (since those are BaseInlineFormSet and BaseModelFormSet, respectively, and neither of them can handle initial data).
  3. Write your own subclass of the appropriate admin inline class (in my case it was TabularInline) and override its get_formset method to return the result of inlineformset_factory() using your custom formset class.
  4. On the actual ModelAdmin subclass for the model with the inline, override add_view and change_view, and replicate most of the code, but with one big change: build the initial data your formset will need, and pass it to your custom formset (which will be returned by your ModelAdmin's get_formsets() method).

I've had a few productive chats with Brian and Joseph about improving this for future Django releases; at the moment, the way the model formsets work just make this more trouble than it's usually worth, but with a bit of API cleanup I think it could be made extremely easy.

James Bennett
Yikes. I think I'll avoid the issue by doing two separate forms. Thanks!
Fred Larson
Good answer. This is actually a very messy, complicated and badly documented missing feature in django.
Rich
+2  A: 

I came accross the same problem.

You can do it through JavaScript, make a simple JS that makes an ajax call for all the band memebers, and populates the form.

This solution lacks DRY principle, because you need to write this for every inline form you have.

Danni
Thanks, I'll consider that. I really have only one form I need this for. I'm not an experienced web developer and I haven't done Ajax, so maybe this will give me an excuse to learn. 8v)
Fred Larson
A: 

Here is how I solved the problem. There's a bit of a trade-off in creating and deleting the records, but the code is clean...

def manage_event(request, event_id):
    """
    Add a boolean field 'record_saved' (default to False) to the Event model
    Edit an existing Event record or, if the record does not exist:
    - create and save a new Event record
    - create and save Attendance records for each Member
    Clean up any unsaved records each time you're using this view
    """
    # delete any "unsaved" Event records (cascading into Attendance records)
    Event.objects.filter(record_saved=False).delete()
    try:
        my_event = Event.objects.get(pk=int(event_id))
    except Event.DoesNotExist:
        # create a new Event record
        my_event = Event.objects.create()
        # create an Attendance object for each Member with the currect Event id
        for m in Members.objects.get.all():
            Attendance.objects.create(event_id=my_event.id, member_id=m.id)
    AttendanceFormSet = inlineformset_factory(Event, Attendance, 
                                        can_delete=False, 
                                        extra=0, 
                                        form=AttendanceForm)
    if request.method == "POST":
        form = EventForm(request.POST, request.FILES, instance=my_event)
        formset = AttendanceFormSet(request.POST, request.FILES, 
                                        instance=my_event)
        if formset.is_valid() and form.is_valid():
            # set record_saved to True before saving
            e = form.save(commit=False)
            e.record_saved=True
            e.save()
            formset.save()
            return HttpResponseRedirect('/')
    else:
        form = EventForm(instance=my_event)
        formset = OptieFormSet(instance=my_event)
    return render_to_response("edit_event.html", {
                            "form":form, 
                            "formset": formset,
                            }, 
                            context_instance=RequestContext(request))
daan
+4  A: 

I spent a fair amount of time trying to come up with a solution that I could re-use across sites. James' post contained the key piece of wisdom of extending BaseInlineFormSet but strategically invoking calls against BaseFormSet.

The solution below is broken into two pieces: a AdminInline and a BaseInlineFormSet.

  1. The InlineAdmin dynamically generates an initial value based on the exposed request object.
  2. It uses currying to expose the initial values to a custom BaseInlineFormSet through keyword arguments passed to the constructor.
  3. The BaseInlineFormSet constructor pops the initial values off the list of keyword arguments and constructs normally.
  4. The last piece is overriding the form construction process by changing the maximum total number of forms and using the BaseFormSet._construct_form and BaseFormSet._construct_forms methods

Here are some concrete snippets using the OP's classes. I've tested this against Django 1.2.3. I highly recommend keeping the formset and admin documentation handy while developing.

admin.py

from django.utils.functional import curry
from django.contrib import admin
from example_app.forms import *
from example_app.models import *

class AttendanceInline(admin.TabularInline):
    model           = Attendance
    formset         = AttendanceFormSet
    extra           = 5

    def get_formset(self, request, obj=None, **kwargs):
        """
        Pre-populating formset using GET params
        """
        initial = []
        if request.method == "GET":
            #
            # Populate initial based on request
            #
            initial.append({
                'foo': 'bar',
            })
        formset = super(AttendanceInline, self).get_formset(request, obj, **kwargs)
        formset.__init__ = curry(formset.__init__, initial=initial)
        return formset

forms.py

from django.forms import formsets
from django.forms.models import BaseInlineFormSet

class BaseAttendanceFormSet(BaseInlineFormSet):
    def __init__(self, *args, **kwargs):
        """
        Grabs the curried initial values and stores them into a 'private'
        variable. Note: the use of self.__initial is important, using
        self.initial or self._initial will be erased by a parent class
        """
        self.__initial = kwargs.pop('initial', [])
        super(BaseAttendanceFormSet, self).__init__(*args, **kwargs)

    def total_form_count(self):
        return len(self.__initial) + self.extra

    def _construct_forms(self):
        return formsets.BaseFormSet._construct_forms(self)

    def _construct_form(self, i, **kwargs):
        if self.__initial:
            try:
                kwargs['initial'] = self.__initial[i]
            except IndexError:
                pass
        return formsets.BaseFormSet._construct_form(self, i, **kwargs)

AttendanceFormSet = formsets.formset_factory(AttendanceForm, formset=BaseAttendanceFormSet)
Erik Karulf
Is it possible to use obj attribute in AttendanceInline's get_formset method? I have to admit that I don't fully understand what is going behind the scene. At the moment, I am trying to accomplish some application logic for generating initial values, but experiencing get_formset being called twice, once with obj set correctly and second time set to None. When providing intial vals only when it is set, it results in formset not being saved when submit it :(
xaralis
I haven't used the obj argument on my sites. You could try tracing the variable to see where it gets initialized, but I would advise trying to figure out why the save method is failing. My guess is that a hidden primary key variable is not getting pushed through.
Erik Karulf