Skip to content

Instantly share code, notes, and snippets.

@akaihola
Created December 9, 2010 10:20
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save akaihola/734572 to your computer and use it in GitHub Desktop.
Save akaihola/734572 to your computer and use it in GitHub Desktop.
Many-to-many checkbox formsets for Django
from m2m_formsets.forms import m2m_inlineformset_factory, ManyToManyModelForm
Many-to-many formsets for Django
"""Requires Django r12690 or newer"""
from django import forms
from django.forms.util import ErrorDict
from django.forms.models import (
_get_foreign_key, ModelForm, BaseInlineFormSet, modelformset_factory)
class ManyToManyModelForm(ModelForm):
def _post_clean(self):
super(ManyToManyModelForm, self)._post_clean()
for name, field in self.fields.items():
raw_value = self._raw_value(name)
if (field.required and
not field.widget.is_hidden and
raw_value and raw_value.strip()):
# a visible non-empty field is found, so the whole
# form is non-empty
self.is_empty = False
def full_clean(self):
"""
Cleans all of self.data and populates self._errors and
self.cleaned_data.
This is essentially a copy of Django's original
BaseForm.full_clean() but it also treats blank forms as marked
to be deleted. This has been implemented by keeping track of
empty forms with the `is_empty` attribute.
A form is empty if all its visible required fields are blank.
"""
self._errors = ErrorDict()
if not self.is_bound: # Stop further processing.
return
self.cleaned_data = {}
# Assume the form is empty until visible non-empty fields are found
self.is_empty = True
# If the form is permitted to be empty, and none of the form data has
# changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed():
return
self._clean_fields() # self.is_empty may be set here
self._clean_form()
self._post_clean()
if self.is_empty:
self._errors = ErrorDict()
else:
if self._errors:
delattr(self, 'cleaned_data')
class ManyToManyBaseInlineFormSet(BaseInlineFormSet):
def total_form_count(self):
return self.choices_queryset.count()
def _get_initial_choices(self):
"Return keys of choices already selected on the form"
return [getattr(i, self.choice_fk.get_attname())
for i in self.get_queryset()]
def _get_missing_choices(self, initial_choices):
"Return keys of choices not yet selected on the form"
return list(self.choices_queryset
.exclude(pk__in=initial_choices)
.values_list('pk', flat=True))
def _construct_forms(self):
"""Instantiate all the forms and put them in self.forms
This is modified from forms.formsets.BaseFormSet._construct_forms to
always include extra forms for missing choices.
"""
initial_choices = self._get_initial_choices()
choices = initial_choices + self._get_missing_choices(initial_choices)
self.forms = []
for i in xrange(self.total_form_count()):
kwargs = {}
if i >= self.initial_form_count():
kwargs['initial'] = {self.choice_fk.name: choices[i]}
self.forms.append(self._construct_form(i, **kwargs))
def add_fields(self, form, index):
"Change the choice field widget from a select to a hidden input"
super(ManyToManyBaseInlineFormSet, self).add_fields(form, index)
form.fields[self.choice_fk.name].widget = forms.HiddenInput()
def is_valid(self):
"""
Returns True if form.errors is empty for every form in self.forms.
"""
if not self.is_bound:
return False
# We loop over every form.errors here rather than short circuiting on the
# first failure to make sure validation gets triggered for every form.
forms_valid = True
for i in range(0, self.total_form_count()):
form = self.forms[i]
if self.can_delete:
# The way we lookup the value of the deletion field here takes
# more code than we'd like, but the form's cleaned_data will
# not exist if the form is invalid.
field = form.fields[DELETION_FIELD_NAME]
raw_value = form._raw_value(DELETION_FIELD_NAME)
should_delete = field.clean(raw_value)
else:
# Forms with no delete checkbox are deleted when all visible
# fields are empty
form.errors
should_delete = form.is_empty
if should_delete:
# This form is going to be deleted so any of its errors
# should not cause the entire formset to be invalid.
continue
if bool(self.errors[i]):
forms_valid = False
return forms_valid and not bool(self.non_form_errors())
def save_existing_objects(self, commit=True):
self.changed_objects = []
self.deleted_objects = []
if not self.get_queryset():
return []
saved_instances = []
for form in self.initial_forms:
pk_name = self._pk_field.name
raw_pk_value = form._raw_value(pk_name)
# clean() for different types of PK fields can sometimes return
# the model instance, and sometimes the PK. Handle either.
pk_value = form.fields[pk_name].clean(raw_pk_value)
pk_value = getattr(pk_value, 'pk', pk_value)
obj = self._existing_object(pk_value)
if self.can_delete:
raw_delete_value = form._raw_value(DELETION_FIELD_NAME)
should_delete = (
form.fields[DELETION_FIELD_NAME].clean(raw_delete_value))
else:
# forms with all visible fields blank should be
# considered marked for deletion
should_delete = form.is_empty
if should_delete:
self.deleted_objects.append(obj)
obj.delete()
continue
if form.has_changed():
self.changed_objects.append((obj, form.changed_data))
saved_instances.append(
self.save_existing(form, obj, commit=commit))
if not commit:
self.saved_forms.append(form)
return saved_instances
def m2m_inlineformset_factory(
parent_model, model, choices_queryset,
form=ManyToManyModelForm,
formset=ManyToManyBaseInlineFormSet, fk_name=None, choice_fk_name=None,
fields=None, exclude=None,
formfield_callback=lambda f: f.formfield()):
"""
Returns an ``ManyToManyInlineFormSet`` for the given kwargs.
You must provide ``fk_name`` if ``model`` has more than one ``ForeignKey``
to ``parent_model``.
"""
fk = _get_foreign_key(parent_model, model, fk_name=fk_name)
choice_fk = _get_foreign_key(choices_queryset.model, model,
fk_name=choice_fk_name)
kwargs = {
'form': form,
'formfield_callback': formfield_callback,
'formset': formset,
'extra': 0,
'can_delete': False,
'can_order': False,
'fields': fields,
'exclude': exclude,
'max_num': 0,
}
FormSet = modelformset_factory(model, **kwargs)
FormSet.fk = fk
FormSet.choices_queryset = choices_queryset
FormSet.choice_fk = choice_fk
return FormSet
# stub needed for an app to be included in test suite
[
{
"pk": 1,
"model": "m2m_formsets.person",
"fields": {
"name": "Bob"
}
},
{
"pk": 3,
"model": "m2m_formsets.person",
"fields": {
"name": "Jane"
}
},
{
"pk": 2,
"model": "m2m_formsets.person",
"fields": {
"name": "Jim"
}
},
{
"pk": 1,
"model": "m2m_formsets.group",
"fields": {
"name": "Rock"
}
},
{
"pk": 2,
"model": "m2m_formsets.group",
"fields": {
"name": "Roll"
}
},
{
"pk": 1,
"model": "m2m_formsets.membership",
"fields": {
"person": 2,
"group": 1,
"invite_reason": "Badass drummer"
}
},
{
"pk": 2,
"model": "m2m_formsets.membership",
"fields": {
"person": 3,
"group": 1,
"invite_reason": "Cool guy"
}
},
{
"pk": 3,
"model": "m2m_formsets.membership",
"fields": {
"person": 1,
"group": 2,
"invite_reason": "No alternative"
}
},
{
"pk": 4,
"model": "m2m_formsets.membership",
"fields": {
"person": 2,
"group": 2,
"invite_reason": "Weak moment"
}
},
{
"pk": 5,
"model": "m2m_formsets.membership",
"fields": {
"person": 3,
"group": 2,
"invite_reason": "Personal reason"
}
}
]
from django.db import models
from django.test import TestCase
from django.forms.util import ErrorDict
from django.forms.models import modelform_factory
from m2m_formsets import ManyToManyModelForm, m2m_inlineformset_factory
# models copied from django.tests.modeltests.m2m_through.models
# M2M described on one of the models
class Person(models.Model):
name = models.CharField(max_length=128)
class Meta:
ordering = ('name',)
def __unicode__(self):
return self.name
class Group(models.Model):
name = models.CharField(max_length=128)
members = models.ManyToManyField(Person, through='Membership')
custom_members = models.ManyToManyField(
Person, through='CustomMembership',
related_name="custom")
nodefaultsnonulls = models.ManyToManyField(
Person, through='TestNoDefaultsOrNulls',
related_name="testnodefaultsnonulls")
class Meta:
ordering = ('name',)
def __unicode__(self):
return self.name
class Membership(models.Model):
person = models.ForeignKey(Person)
group = models.ForeignKey(Group)
invite_reason = models.CharField(max_length=64, null=True)
class Meta:
ordering = ('invite_reason', 'group')
def __unicode__(self):
return "%s is a member of %s" % (self.person.name, self.group.name)
class CustomMembership(models.Model):
person = models.ForeignKey(
Person, db_column="custom_person_column",
related_name="custom_person_related_name")
group = models.ForeignKey(Group)
weird_fk = models.ForeignKey(Membership, null=True)
def __unicode__(self):
return "%s is a member of %s" % (self.person.name, self.group.name)
class Meta:
db_table = "test_table"
class TestNoDefaultsOrNulls(models.Model):
person = models.ForeignKey(Person)
group = models.ForeignKey(Group)
nodefaultnonull = models.CharField(max_length=5)
class PersonSelfRefM2M(models.Model):
name = models.CharField(max_length=5)
friends = models.ManyToManyField(
'self', through="Friendship", symmetrical=False)
def __unicode__(self):
return self.name
class Friendship(models.Model):
first = models.ForeignKey(PersonSelfRefM2M, related_name="rel_from_set")
second = models.ForeignKey(PersonSelfRefM2M, related_name="rel_to_set")
date_friended = models.DateTimeField()
class TestManyToManyFormsetBase(TestCase):
def setUp(self):
self.FormSet = m2m_inlineformset_factory(
Group, Membership, Person.objects.all())
class TestManyToManyFormsetBaseWithData(TestManyToManyFormsetBase):
fixtures = ['test_fixture']
def setUp(self):
super(TestManyToManyFormsetBaseWithData, self).setUp()
self.bob = Person.objects.get(name='Bob')
self.jim = Person.objects.get(name='Jim')
self.jane = Person.objects.get(name='Jane')
self.rock = Group.objects.get(name='Rock')
self.roll = Group.objects.get(name='Roll')
self.m1 = Membership.objects.get(person=self.jim, group=self.rock)
self.m2 = Membership.objects.get(person=self.jane, group=self.rock)
self.m3 = Membership.objects.get(person=self.bob, group=self.roll)
self.m4 = Membership.objects.get(person=self.jim, group=self.roll)
self.m5 = Membership.objects.get(person=self.jane, group=self.roll)
class TestManyToManyModelForm(TestManyToManyFormsetBaseWithData):
def test_clean_fields(self):
data = {'form-person_id': 1,
'form-group_id': 1,
'form-invite_reason': u'Cool guy'}
F = modelform_factory(Membership, ManyToManyModelForm)
f = F(data)
f._errors = ErrorDict()
f.is_empty = True
f.cleaned_data = {}
f._clean_fields()
self.assertEqual(f.cleaned_data, {})
self.assertEqual(f.is_empty, True)
class TestBasics(TestManyToManyFormsetBase):
def test_choices_queryset(self):
qs = self.FormSet.choices_queryset
self.assertEqual(qs.model, Person)
self.assertEqual(qs.count(), 0)
Person.objects.create(name='Jim')
self.assertEqual(qs.count(), 1)
def test_choice_fk(self):
self.assertEqual(self.FormSet.choice_fk.get_attname(), 'person_id')
def test_initial_choices(self):
rock = Group.objects.create(name='Rock')
jim = Person.objects.create(name='Jim')
Membership.objects.create(person=jim, group=rock)
formset = self.FormSet(instance=rock)
self.assertEqual(formset._get_initial_choices(), [jim.pk])
def test_missing_choices(self):
rock = Group.objects.create(name='Rock')
jim = Person.objects.create(name='Jim')
Membership.objects.create(person=jim, group=rock)
formset = self.FormSet(instance=rock)
self.assertEqual(formset._get_missing_choices([]), [jim.pk])
self.assertEqual(formset._get_missing_choices([jim.pk]), [])
class TestWithData(TestManyToManyFormsetBaseWithData):
def setUp(self):
"""Create some model instances and a formset
Create people, groups, memberships and a formset for assigning
memberships to people.
"""
super(TestWithData, self).setUp()
self.formset = self.FormSet(instance=self.rock)
def test_db_people(self):
self.assertEqual(sorted(Person.objects.values_list('pk', 'name')),
[(1, u'Bob'), (2, u'Jim'), (3, u'Jane')])
def test_db_groups(self):
self.assertEqual(sorted(Group.objects.values_list('pk', 'name')),
[(1, u'Rock'), (2, u'Roll')])
def test_db_memberships(self):
self.assertEqual(
sorted(Membership.objects.values_list('person', 'group')),
[(1, 2), (2, 1), (2, 2), (3, 1), (3, 2)])
def test_choices_queryset(self):
self.assertEqual(self.formset.choices_queryset.count(), 3)
def test_form_count(self):
self.assertEqual(self.formset.total_form_count(), 3)
def test_real_form_count(self):
self.assertEqual(len(self.formset.forms), 3)
def test_form_initial(self):
initials = [f.initial for f in self.formset.forms]
self.assertEqual([i['person'] for i in initials],
[self.jim.pk, self.jane.pk, self.bob.pk])
self.assertEqual([i.get('invite_reason', 'missing') for i in initials],
[u'Badass drummer', u'Cool guy', 'missing'])
self.assertEqual([i.get('group', 'missing') for i in initials],
[self.rock.pk, self.rock.pk, 'missing'])
self.assertEqual([i.get('id', 'missing') for i in initials],
[self.m1.pk, self.m2.pk, 'missing'])
def test_form0_html(self):
self.assertEqual(
self.formset.forms[0].as_p(),
u'<p><label for="id_membership_set-0-invite_reason">'
u'Invite reason:</label> '
u'<input id="id_membership_set-0-invite_reason" type="text"'
u' name="membership_set-0-invite_reason" value="Badass drummer"'
u' maxlength="64" />'
u'<input type="hidden" name="membership_set-0-person" value="2"'
u' id="id_membership_set-0-person" />'
u'<input type="hidden" name="membership_set-0-group" value="1"'
u' id="id_membership_set-0-group" />'
u'<input type="hidden" name="membership_set-0-id" value="1"'
u' id="id_membership_set-0-id" /></p>')
def test_form1_html(self):
self.assertEqual(
self.formset.forms[1].as_p(),
u'<p><label for="id_membership_set-1-invite_reason">Invite reason:'
u'</label> '
u'<input id="id_membership_set-1-invite_reason" type="text"'
u' name="membership_set-1-invite_reason" value="Cool guy"'
u' maxlength="64" />'
u'<input type="hidden" name="membership_set-1-person" value="3"'
u' id="id_membership_set-1-person" />'
u'<input type="hidden" name="membership_set-1-group" value="1"'
u' id="id_membership_set-1-group" />'
u'<input type="hidden" name="membership_set-1-id" value="2"'
u' id="id_membership_set-1-id" /></p>')
def test_form2_html(self):
self.assertEqual(
self.formset.forms[2].as_p(),
u'<p><label for="id_membership_set-2-invite_reason">'
u'Invite reason:</label> '
u'<input id="id_membership_set-2-invite_reason" type="text"'
u' name="membership_set-2-invite_reason" maxlength="64" />'
u'<input type="hidden" name="membership_set-2-person" value="1"'
u' id="id_membership_set-2-person" />'
u'<input type="hidden" name="membership_set-2-group" value="1"'
u' id="id_membership_set-2-group" />'
u'<input type="hidden" name="membership_set-2-id"'
u' id="id_membership_set-2-id" /></p>')
def test_management_form_html(self):
self.assertEqual(
self.formset.management_form.as_p(),
u'<input type="hidden" name="membership_set-TOTAL_FORMS"'
u' value="3" id="id_membership_set-TOTAL_FORMS" />'
u'<input type="hidden" name="membership_set-INITIAL_FORMS"'
u' value="2" id="id_membership_set-INITIAL_FORMS" />')
def test_post_no_change(self):
data = {'membership_set-0-invite_reason': u'Badass drummer',
'membership_set-0-person': u'2',
'membership_set-0-group': u'1',
'membership_set-0-id': u'1',
'membership_set-1-invite_reason': u'Cool guy',
'membership_set-1-person': u'3',
'membership_set-1-group': u'1',
'membership_set-1-id': u'2',
'membership_set-2-invite_reason': u'',
'membership_set-2-person': u'1',
'membership_set-2-group': u'1',
'membership_set-2-id': u'',
'membership_set-TOTAL_FORMS': u'3',
'membership_set-INITIAL_FORMS': u'2'}
formset = self.FormSet(data=data, instance=self.rock)
self.assertTrue(formset.is_valid())
self.assertEqual(formset.save(), [])
self.assertEqual(
sorted(self.rock.membership_set.values_list()),
[(1, 2, 1, u'Badass drummer'), (2, 3, 1, u'Cool guy')])
def test_post_invite(self):
data = {'membership_set-0-invite_reason': u'Badass drummer',
'membership_set-0-person': u'2',
'membership_set-0-group': u'1',
'membership_set-0-id': u'1',
'membership_set-1-invite_reason': u'Cool guy',
'membership_set-1-person': u'3',
'membership_set-1-group': u'1',
'membership_set-1-id': u'2',
'membership_set-2-invite_reason': u'Gotta get him',
'membership_set-2-person': u'1',
'membership_set-2-group': u'1',
'membership_set-2-id': u'',
'membership_set-TOTAL_FORMS': u'3',
'membership_set-INITIAL_FORMS': u'2'}
formset = self.FormSet(data=data, instance=self.rock)
self.assertTrue(formset.is_valid())
self.assertEqual(repr(formset.save()),
'[<Membership: Bob is a member of Rock>]')
self.assertEqual(sorted(self.rock.membership_set.values_list()),
[(1, 2, 1, u'Badass drummer'),
(2, 3, 1, u'Cool guy'),
(6, 1, 1, u'Gotta get him')])
def test_post_kick_out(self):
data = {'membership_set-0-invite_reason': u'',
'membership_set-0-person': u'2',
'membership_set-0-group': u'1',
'membership_set-0-id': u'1',
'membership_set-1-invite_reason': u'Cool guy',
'membership_set-1-person': u'3',
'membership_set-1-group': u'1',
'membership_set-1-id': u'2',
'membership_set-2-invite_reason': u'',
'membership_set-2-person': u'1',
'membership_set-2-group': u'1',
'membership_set-2-id': u'',
'membership_set-TOTAL_FORMS': u'3',
'membership_set-INITIAL_FORMS': u'2'}
formset = self.FormSet(data=data, instance=self.rock)
self.assertEqual(formset.errors, [{}, {}, {}])
self.assertTrue(formset.is_valid())
self.assertEqual(formset.save(), [])
self.assertEqual(sorted(self.rock.membership_set.values_list()),
[(2, 3, 1, u'Cool guy')])
def test_post_modify(self):
data = {'membership_set-0-invite_reason': u'Badass drummer',
'membership_set-0-person': u'2',
'membership_set-0-group': u'1',
'membership_set-0-id': u'1',
'membership_set-1-invite_reason': u'The King',
'membership_set-1-person': u'3',
'membership_set-1-group': u'1',
'membership_set-1-id': u'2',
'membership_set-2-invite_reason': u'',
'membership_set-2-person': u'1',
'membership_set-2-group': u'1',
'membership_set-2-id': u'',
'membership_set-TOTAL_FORMS': u'3',
'membership_set-INITIAL_FORMS': u'2'}
formset = self.FormSet(data=data, instance=self.rock)
self.assertTrue(formset.is_valid())
self.assertEqual(repr(formset.save()),
'[<Membership: Jane is a member of Rock>]')
self.assertEqual(sorted(self.rock.membership_set.values_list()),
[(1, 2, 1, u'Badass drummer'),
(2, 3, 1, u'The King')])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment