Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ModelForm with a reverse ManytoMany field

I'm having trouble getting ModelMultipleChoiceField to display the initial values of a model instance. I haven't been able to find any documentation about the field, and the examples I've been reading are too confusing. Django: ModelMultipleChoiceField doesn't select initial choices seems to be similar, but the solution that was given there is not dynamic to the model instance.

Here is my case (each database user is connected to one or more projects):

models.py

from django.contrib.auth.models import User
class Project(Model):
    users = ManyToManyField(User, related_name='projects', blank=True)

forms.py

from django.contrib.admin.widgets import FilteredSelectMultiple
class AssignProjectForm(ModelForm):
    class Meta:
        model = User
        fields = ('projects',)

    projects = ModelMultipleChoiceField(
        queryset=Project.objects.all(),
        required=False,
        widget=FilteredSelectMultiple('projects', False),
    )

views.py

def assign(request):
    if request.method == 'POST':
        form = AssignProjectForm(request.POST, instance=request.user)
        if form.is_valid():
            form.save()
            return HttpResponseRedirect('/index/')
    else:
        form = AssignProjectForm(instance=request.user)

    return render_to_response('assign.html', {'form': form})

The form that it returns is not selecting the instance's linked projects (it looks like: Django multi-select widget?). In addition, it doesn't update the user with any selections made when the form is saved.

Edit: Managed to solve this using the approach here: http://code-blasphemies.blogspot.com/2009/04/dynamically-created-modelmultiplechoice.html

like image 774
Edd Avatar asked Aug 10 '11 21:08

Edd


2 Answers

Here's a solution that is better than the older ones, which really don't work.

You have to both load the existing related values from the database when creating the form, and save them back when saving the form. I use the set() method on the related name (manager) which does all the work for you: taking away existing relations that are not selected anymore, and adding new ones which have become selected. So you don't have to do any looping or checking.

class AssignProjectForm(ModelForm):

    def __init__(self, *args, **kwargs):
        super(AssignProjectForm, self).__init__(*args, **kwargs)

        # Here we fetch your currently related projects into the field,     
        # so that they will display in the form.
        self.fields['projects'].initial = self.instance.projects.all(
            ).values_list('id', flat=True)

    def save(self, *args, **kwargs):
        instance = super(AssignProjectForm, self).save(*args, **kwargs)

        # Here we save the modified project selection back into the database
        instance.projects.set(self.cleaned_data['projects'])

        return instance

Aside from simplicity, using the set() method has another advantage that comes into play if you use Django signals (eg. post_save etc) on your m2m relation: If you add and remove entries one at a time in a loop, you'll get signals for each object. But if you do it in one operation using set(), you'll get just one signal with a list of objects. If the code in your signal handler does significant work, this is a big deal.

like image 51
little_birdie Avatar answered Nov 18 '22 22:11

little_birdie


ModelForm's don't automatically work for reverse relationships.

Nothing is happening on save() because a ModelForm only knows what to do with its own fields - projects is not a field on the User model, it's just a field on your form.

You'll have to tell your form how to save itself with this new field of yours.

def save(self, *args, **kwargs):
    for project in self.cleaned_data.get('projects'):
        project.users.add(self.instance)
    return super(AssignProjectForm, self).save(*args, **kwargs)
like image 7
Yuji 'Tomita' Tomita Avatar answered Nov 18 '22 22:11

Yuji 'Tomita' Tomita