Created
December 9, 2010 10:20
-
-
Save akaihola/734572 to your computer and use it in GitHub Desktop.
Many-to-many checkbox formsets for Django
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from m2m_formsets.forms import m2m_inlineformset_factory, ManyToManyModelForm |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Many-to-many formsets for Django |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# stub needed for an app to be included in test suite |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ | |
"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" | |
} | |
} | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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