diff --git a/kfet/auth/fields.py b/kfet/auth/fields.py index 28ba1c9e..045667a8 100644 --- a/kfet/auth/fields.py +++ b/kfet/auth/fields.py @@ -1,20 +1,26 @@ from django import forms -from django.contrib.auth.models import Permission -from django.contrib.contenttypes.models import ContentType from django.forms import widgets +from .models import Group, Permission -class KFetPermissionsField(forms.ModelMultipleChoiceField): - def __init__(self, *args, **kwargs): - queryset = Permission.objects.filter( - content_type__in=ContentType.objects.filter(app_label="kfet"), - ) - super().__init__( - queryset=queryset, - widget=widgets.CheckboxSelectMultiple, - *args, **kwargs - ) +class GroupsField(forms.ModelMultipleChoiceField): + def __init__(self, **kwargs): + kwargs.setdefault('queryset', Group.objects.all()) + kwargs.setdefault('widget', widgets.CheckboxSelectMultiple) + super().__init__(**kwargs) + + +class BasePermissionsField(forms.ModelMultipleChoiceField): + def __init__(self, **kwargs): + kwargs.setdefault('widget', widgets.CheckboxSelectMultiple) + super().__init__(**kwargs) def label_from_instance(self, obj): return obj.name + + +class CorePermissionsField(BasePermissionsField): + def __init__(self, **kwargs): + kwargs.setdefault('queryset', Permission.kfetcore.all()) + super().__init__(**kwargs) diff --git a/kfet/auth/forms.py b/kfet/auth/forms.py index 0c9fa53b..0de47664 100644 --- a/kfet/auth/forms.py +++ b/kfet/auth/forms.py @@ -1,43 +1,28 @@ from django import forms -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ -from .fields import KFetPermissionsField +from utils.forms import KeepUnselectableModelFormMixin -class GroupForm(forms.ModelForm): - permissions = KFetPermissionsField() +from .fields import GroupsField, CorePermissionsField +from .models import Group - def clean_name(self): - name = self.cleaned_data['name'] - return 'K-Fêt %s' % name - def clean_permissions(self): - kfet_perms = self.cleaned_data['permissions'] - # TODO: With Django >=1.11, the QuerySet method 'difference' can be - # used. - # other_groups = self.instance.permissions.difference( - # self.fields['permissions'].queryset - # ) - other_perms = self.instance.permissions.exclude( - pk__in=[p.pk for p in self.fields['permissions'].queryset], - ) - return list(kfet_perms) + list(other_perms) +class GroupForm(KeepUnselectableModelFormMixin, forms.ModelForm): + permissions = CorePermissionsField(label=_("Permissions"), required=False) + + keep_unselectable_fields = ['permissions'] class Meta: model = Group fields = ['name', 'permissions'] -class UserGroupForm(forms.ModelForm): - groups = forms.ModelMultipleChoiceField( - Group.objects.filter(name__icontains='K-Fêt'), - label='Statut équipe', - required=False) +class UserGroupForm(KeepUnselectableModelFormMixin, forms.ModelForm): + groups = GroupsField(label=_("Statut équipe"), required=False) - def clean_groups(self): - kfet_groups = self.cleaned_data.get('groups') - other_groups = self.instance.groups.exclude(name__icontains='K-Fêt') - return list(kfet_groups) + list(other_groups) + keep_unselectable_fields = ['groups'] class Meta: model = User diff --git a/kfet/auth/migrations/0001_initial.py b/kfet/auth/migrations/0001_initial.py index 061570a8..036a30c6 100644 --- a/kfet/auth/migrations/0001_initial.py +++ b/kfet/auth/migrations/0001_initial.py @@ -21,4 +21,26 @@ class Migration(migrations.Migration): ('token', models.CharField(unique=True, max_length=50)), ], ), + migrations.CreateModel( + name='Group', + fields=[ + ('group_ptr', models.OneToOneField(parent_link=True, serialize=False, primary_key=True, auto_created=True, to='auth.Group')), + ], + options={ + 'verbose_name': 'Groupe', + 'verbose_name_plural': 'Groupes', + }, + bases=('auth.group',), + ), + migrations.CreateModel( + name='Permission', + fields=[ + ], + options={ + 'verbose_name': 'Permission', + 'verbose_name_plural': 'Permissions', + 'proxy': True, + }, + bases=('auth.permission',), + ), ] diff --git a/kfet/auth/migrations/0002_existing_groups.py b/kfet/auth/migrations/0002_existing_groups.py new file mode 100644 index 00000000..f816429e --- /dev/null +++ b/kfet/auth/migrations/0002_existing_groups.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +def existing_groups(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + KFetGroup = apps.get_model('kfetauth', 'Group') + + for group in Group.objects.filter(name__icontains='K-Fêt'): + kf_group = KFetGroup(group_ptr=group, pk=group.pk, name=group.name) + kf_group.save() + + +class Migration(migrations.Migration): + """ + Data migration. + + Previously, K-Fêt groups were identified with the presence of 'K-Fêt' in + their name. + """ + + dependencies = [ + ('kfetauth', '0001_initial'), + ] + + operations = [ + migrations.RunPython(existing_groups), + ] diff --git a/kfet/auth/models.py b/kfet/auth/models.py index ecd40091..0dfd26fd 100644 --- a/kfet/auth/models.py +++ b/kfet/auth/models.py @@ -1,5 +1,9 @@ +from django.contrib.auth.models import ( + Group as DjangoGroup, Permission as DjangoPermission, +) from django.db import models from django.utils.crypto import get_random_string +from django.utils.translation import ugettext_lazy as _ class GenericTeamTokenManager(models.Manager): @@ -15,3 +19,37 @@ class GenericTeamToken(models.Model): token = models.CharField(max_length=50, unique=True) objects = GenericTeamTokenManager() + + +class Group(DjangoGroup): + + def give_admin_access(self): + perms = Permission.kfetcore.all() + self.permissions.add(*perms) + + def give_staff_access(self): + perms = Permission.kfetcore.filter( + codename__in=['is_team', 'perform_deposit']) + self.permissions.add(*perms) + + class Meta: + verbose_name = _("Groupe") + verbose_name_plural = _("Groupes") + + +KFET_CORE_APP_LABELS = ['kfet', 'kfetauth'] + + +class CorePermissionManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter( + content_type__app_label__in=KFET_CORE_APP_LABELS) + + +class Permission(DjangoPermission): + kfetcore = CorePermissionManager() + + class Meta: + proxy = True + verbose_name = _("Permission") + verbose_name_plural = _("Permissions") diff --git a/kfet/auth/tests.py b/kfet/auth/tests.py index c2f183cd..d207ced6 100644 --- a/kfet/auth/tests.py +++ b/kfet/auth/tests.py @@ -1,76 +1,194 @@ # -*- coding: utf-8 -*- from unittest import mock +from django.contrib.auth.models import ( + AnonymousUser, Group as DjangoGroup, User, +) +from django.contrib.contenttypes.models import ContentType from django.core import signing from django.core.urlresolvers import reverse -from django.contrib.auth.models import AnonymousUser, Group, Permission, User from django.test import RequestFactory, TestCase -from kfet.forms import UserGroupForm from kfet.models import Account from . import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME from .backends import AccountBackend, GenericBackend +from .fields import GroupsField, CorePermissionsField +from .forms import GroupForm, UserGroupForm from .middleware import TemporaryAuthMiddleware -from .models import GenericTeamToken +from .models import GenericTeamToken, Group, Permission from .utils import get_kfet_generic_user from .views import GenericLoginView ## -# Forms +# Forms and form fields ## + +class CorePermissionsFieldTests(TestCase): + + def setUp(self): + kf_ct = ContentType.objects.get_for_model(GenericTeamToken) + self.kf_perm1 = Permission.objects.create( + content_type=kf_ct, codename='code1') + self.kf_perm2 = Permission.objects.create( + content_type=kf_ct, codename='code2') + self.kf_perm3 = Permission.objects.create( + content_type=kf_ct, codename='code3') + + ot_ct = ContentType.objects.get_for_model(Permission) + self.ot_perm = Permission.objects.create( + content_type=ot_ct, codename='code') + + def test_choices(self): + """ + Only K-Fêt permissions are selectable. + """ + field = CorePermissionsField() + for p in [self.kf_perm1, self.kf_perm2, self.kf_perm3]: + self.assertIn(p, field.queryset) + self.assertNotIn(self.ot_perm, field.queryset) + + +class GroupsFieldTests(TestCase): + + def setUp(self): + self.kf_group1 = Group.objects.create(name='KF Group1') + self.kf_group2 = Group.objects.create(name='KF Group2') + self.kf_group3 = Group.objects.create(name='KF Group3') + self.ot_group = DjangoGroup.objects.create(name='OT Group') + + def test_choices(self): + """ + Only K-Fêt groups are selectable. + """ + field = GroupsField() + self.assertQuerysetEqual( + field.queryset, + map(repr, [self.kf_group1, self.kf_group2, self.kf_group3]), + ordered=False, + ) + + +class GroupFormTests(TestCase): + + def setUp(self): + self.user = User.objects.create(username='user') + self.kf_group = Group.objects.create(name='A Group') + + self.kf_perm1 = Permission.kfetcore.get(codename='is_team') + self.kf_perm2 = Permission.kfetcore.get(codename='add_checkout') + + ot_ct = ContentType.objects.get_for_model(Permission) + self.ot_perm = Permission.objects.create( + content_type=ot_ct, codename='cool') + + def test_creation(self): + data = { + 'name': 'Another Group', + 'permissions': [self.kf_perm1.pk], + } + form = GroupForm(data) + instance = form.save() + + self.assertEqual(instance.name, 'Another Group') + self.assertQuerysetEqual( + instance.permissions.all(), map(repr, [self.kf_perm1])) + + def test_keep_others(self): + """ + Non-kfet permissions of Group are kept when the form is submitted. + Regression test for #168. + """ + self.kf_group.permissions.add(self.ot_perm) + + selected = [self.kf_perm1, self.kf_perm2] + + data = { + 'name': 'A Group', + 'permissions': [str(p.pk) for p in selected], + } + + form = GroupForm(data, instance=self.kf_group) + form.save() + + self.assertQuerysetEqual( + self.kf_group.permissions.all(), + map(repr, [self.ot_perm] + selected), + ordered=False, + ) + + class UserGroupFormTests(TestCase): - """Test suite for UserGroupForm.""" def setUp(self): # create user self.user = User.objects.create(username="foo", password="foo") # create some K-Fêt groups - prefix_name = "K-Fêt " - names = ["Group 1", "Group 2", "Group 3"] - self.kfet_groups = [ - Group.objects.create(name=prefix_name+name) - for name in names - ] + self.kf_group1 = Group.objects.create(name='KF Group1') + self.kf_group2 = Group.objects.create(name='KF Group2') + self.kf_group3 = Group.objects.create(name='KF Group3') # create a non-K-Fêt group - self.other_group = Group.objects.create(name="Other group") - - def test_choices(self): - """Only K-Fêt groups are selectable.""" - form = UserGroupForm(instance=self.user) - groups_field = form.fields['groups'] - self.assertQuerysetEqual( - groups_field.queryset, - [repr(g) for g in self.kfet_groups], - ordered=False, - ) + self.ot_group = DjangoGroup.objects.create(name="OT Group") def test_keep_others(self): - """User stays in its non-K-Fêt groups.""" - user = self.user - + """ + User stays in its non-K-Fêt groups. + Regression test for #161. + """ # add user to a non-K-Fêt group - user.groups.add(self.other_group) + self.user.groups.add(self.ot_group) # add user to some K-Fêt groups through UserGroupForm - data = { - 'groups': [group.pk for group in self.kfet_groups], - } - form = UserGroupForm(data, instance=user) - - form.is_valid() + selected = [self.kf_group1, self.kf_group2] + data = {'groups': [str(g.pk) for g in selected]} + form = UserGroupForm(data, instance=self.user) form.save() + + transform = lambda g: g.pk self.assertQuerysetEqual( - user.groups.all(), - [repr(g) for g in [self.other_group] + self.kfet_groups], - ordered=False, + self.user.groups.all(), + map(transform, [self.ot_group] + selected), + ordered=False, transform=transform, ) +## +# Models +## + +class PermissionTests(TestCase): + + def test_manager_kfet(self): + """ + 'kfet' manager only returns K-Fêt permissions. + """ + kf_ct = ContentType.objects.get_for_model(GenericTeamToken) + kf_perm1 = Permission.objects.create( + content_type=kf_ct, codename='code1') + kf_perm2 = Permission.objects.create( + content_type=kf_ct, codename='code2') + kf_perm3 = Permission.objects.create( + content_type=kf_ct, codename='code3') + + self.assertEqual(Permission.kfetcore.get(codename='code1'), kf_perm1) + self.assertEqual(Permission.kfetcore.get(codename='code2'), kf_perm2) + self.assertEqual(Permission.kfetcore.get(codename='code3'), kf_perm3) + + ot_ct = ContentType.objects.get_for_model(Permission) + Permission.objects.create(content_type=ot_ct, codename='code') + + with self.assertRaises(Permission.DoesNotExist): + Permission.kfetcore.get(codename='code') + + +## +# K-Fêt generic user object +## + class KFetGenericUserTests(TestCase): def test_exists(self): diff --git a/kfet/auth/views.py b/kfet/auth/views.py index 7b9f4099..073c558f 100644 --- a/kfet/auth/views.py +++ b/kfet/auth/views.py @@ -2,7 +2,7 @@ from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.contrib.auth import authenticate, login from django.contrib.auth.decorators import permission_required -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import User from django.contrib.auth.views import redirect_to_login from django.core.urlresolvers import reverse, reverse_lazy from django.db.models import Prefetch @@ -15,7 +15,7 @@ from django.views.decorators.http import require_http_methods from django.views.generic.edit import CreateView, UpdateView from .forms import GroupForm -from .models import GenericTeamToken +from .models import GenericTeamToken, Group class GenericLoginView(View): @@ -112,7 +112,6 @@ def account_group(request): ) groups = ( Group.objects - .filter(name__icontains='K-Fêt') .prefetch_related('permissions', user_pre) ) return render(request, 'kfet/account_group.html', { @@ -120,17 +119,23 @@ def account_group(request): }) -class AccountGroupCreate(SuccessMessageMixin, CreateView): +class BaseAccountGroupFormViewMixin: model = Group - template_name = 'kfet/account_group_form.html' form_class = GroupForm + template_name = 'kfet/account_group_form.html' + success_url = reverse_lazy('kfet.account.group') + + +class AccountGroupFormViewMixin( + SuccessMessageMixin, + BaseAccountGroupFormViewMixin, +): + pass + + +class AccountGroupCreate(AccountGroupFormViewMixin, CreateView): success_message = 'Nouveau groupe : %(name)s' - success_url = reverse_lazy('kfet.account.group') -class AccountGroupUpdate(SuccessMessageMixin, UpdateView): - queryset = Group.objects.filter(name__icontains='K-Fêt') - template_name = 'kfet/account_group_form.html' - form_class = GroupForm +class AccountGroupUpdate(AccountGroupFormViewMixin, UpdateView): success_message = 'Groupe modifié : %(name)s' - success_url = reverse_lazy('kfet.account.group') diff --git a/kfet/management/commands/loadkfetdevdata.py b/kfet/management/commands/loadkfetdevdata.py index 6dd25f29..332e7dfb 100644 --- a/kfet/management/commands/loadkfetdevdata.py +++ b/kfet/management/commands/loadkfetdevdata.py @@ -7,13 +7,15 @@ import random from datetime import timedelta from django.utils import timezone -from django.contrib.auth.models import User, Group, Permission, ContentType +from django.contrib.auth.models import User from django.core.management import call_command from gestioncof.management.base import MyBaseCommand from gestioncof.models import CofProfile -from kfet.models import (Account, Checkout, CheckoutStatement, Supplier, - SupplierArticle, Article) +from kfet.models import ( + Account, Article, Checkout, CheckoutStatement, Group, Supplier, + SupplierArticle, Article, +) # Où sont stockés les fichiers json DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), @@ -28,22 +30,13 @@ class Command(MyBaseCommand): # Groupes # --- - Group.objects.filter(name__icontains='K-Fêt').delete() + group_chef, _ = Group.objects.get_or_create( + name="K-Fêt César") + group_boy, _ = Group.objects.get_or_create( + name="K-Fêt Légionnaire") - group_chef = Group(name="K-Fêt César") - group_boy = Group(name="K-Fêt Légionnaire") - - group_chef.save() - group_boy.save() - - permissions_chef = Permission.objects.filter( - content_type__in=ContentType.objects.filter( - app_label='kfet')) - permissions_boy = Permission.objects.filter( - codename__in=['is_team', 'perform_deposit']) - - group_chef.permissions.add(*permissions_chef) - group_boy.permissions.add(*permissions_boy) + group_chef.give_admin_access() + group_boy.give_staff_access() # --- # Comptes diff --git a/kfet/models.py b/kfet/models.py index e547d248..b255a2e2 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -14,7 +14,7 @@ from datetime import date import re from .auth import KFET_GENERIC_TRIGRAMME -from .auth.models import GenericTeamToken # noqa +from .auth.models import GenericTeamToken, Group, Permission # noqa from .config import kfet_config from .utils import to_ukf diff --git a/kfet/templates/kfet/account_group_form.html b/kfet/templates/kfet/account_group_form.html index 90e3aa36..f0581b42 100644 --- a/kfet/templates/kfet/account_group_form.html +++ b/kfet/templates/kfet/account_group_form.html @@ -1,49 +1,10 @@ {% extends 'kfet/base_form.html' %} -{% load staticfiles %} -{% load widget_tweaks %} - -{% block extra_head %} - - -{% endblock %} {% block title %}Permissions - Édition{% endblock %} {% block header-title %}Modification des permissions{% endblock %} {% block main %} -
- {% csrf_token %} -
- -
-
- K-Fêt - {{ form.name|add_class:"form-control" }} -
- {% if form.name.errors %} - {{ form.name.errors }} - {% endif %} - {% if form.name.help_text %} - {{ form.name.help_text }} - {% endif %} -
-
- {% include "kfet/form_field_snippet.html" with field=form.permissions %} - {% if not perms.kfet.manage_perms %} - {% include "kfet/form_authentication_snippet.html" %} - {% endif %} - {% include "kfet/form_submit_snippet.html" with value="Enregistrer" %} -
- - +{% include "kfet/form_full_snippet.html" with authz=perms.kfet.manage_perms submit_text="Enregistrer" %} {% endblock %} diff --git a/kfet/templates/kfet/form_field_snippet.html b/kfet/templates/kfet/form_field_snippet.html index a2aa087f..6a4efa16 100644 --- a/kfet/templates/kfet/form_field_snippet.html +++ b/kfet/templates/kfet/form_field_snippet.html @@ -5,7 +5,7 @@
{% if field|widget_type == "checkboxselectmultiple" %}
diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..29425ce2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[flake8] +# E731: lambda expressions +ignore = E731 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/forms.py b/utils/forms.py new file mode 100644 index 00000000..367a6921 --- /dev/null +++ b/utils/forms.py @@ -0,0 +1,61 @@ +class KeepUnselectableModelFormMixin: + """ + Keep unselectable items of queryset-based fields. + + Mixin for 'ModelForm'. + + Attribute + keep_unselectable_fields (list of field names) + + This adding is performed in 'save' method. Specifically, if 'commit' arg + is False, it's done in 'save_m2m' method. + + These fields must have a 'queryset' attribute (the selectable items), like + 'ModelMultipleChoiceField' (default field for ManyToMany model fields). + """ + keep_unselectable_fields = [] + + def get_unselectable(self, field_name): + """ + Returns 'field_name' model field items of instance which can't be + selected with the corresponding form field. + + Should be used before 'form.save' call, or 'form.save_m2m' call if + 'commit=False' was passed as argument to 'form.save'. + """ + if self.instance.pk: + previous = getattr(self.instance, field_name).all() + selectable = self.fields[field_name].queryset + return previous.exclude(pk__in=[o.pk for o in selectable]) + else: + # Instance is being created, there is no previous item. + return [] + + def save(self, commit=True): + # Use 'commit=False' to get the 'save_m2m' method. + instance = super().save(commit=False) + + _save_m2m = self.save_m2m + + def save_m2m(): + # Get the unselectable items. + # Force evaluate because those items are going to change. + unselectable_f = { + f_name: list(self.get_unselectable(f_name)) + for f_name in self.keep_unselectable_fields + } + # Default 'save_m2m' use 'set' method of m2m relationships with + # fields' cleaned data. + _save_m2m() + # Add the unselectable elements. + for f_name, unselectable in unselectable_f.items(): + getattr(instance, f_name).add(*unselectable) + + # Implement the default behavior of 'save' method, with our own + # 'save_m2m'. + if commit: + instance.save() + save_m2m() + else: + self.save_m2m = save_m2m + return instance diff --git a/utils/tests/__init__.py b/utils/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/tests/test_forms.py b/utils/tests/test_forms.py new file mode 100644 index 00000000..07fc951e --- /dev/null +++ b/utils/tests/test_forms.py @@ -0,0 +1,131 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from utils.forms import KeepUnselectableModelFormMixin + +User = get_user_model() + + +class KeepUnselectableModelFormMixinTests(TestCase): + + class ExampleForm(KeepUnselectableModelFormMixin, forms.ModelForm): + user_permissions = forms.ModelMultipleChoiceField( + queryset=Permission.objects.filter( + codename__startswith='selec'), + ) + + keep_unselectable_fields = ['user_permissions'] + + class Meta: + model = User + fields = ('username', 'user_permissions') + + def setUp(self): + ct = ContentType.objects.get_for_model(Permission) + + self.unselec_perm1 = Permission.objects.create( + content_type=ct, codename='unselec_perm1') + self.unselec_perm2 = Permission.objects.create( + content_type=ct, codename='unselec_perm2') + + # These two perms are the only selectable permissions from + # 'permissions' field of ExampleForm. + self.selec_perm1 = Permission.objects.create( + content_type=ct, codename='selec_perm1') + self.selec_perm2 = Permission.objects.create( + content_type=ct, codename='selec_perm2') + + def test_creation(self): + """ + The mixin functions properly when instance is being created. + """ + data = { + 'username': 'user', + 'user_permissions': [self.selec_perm1.pk], + } + form = self.ExampleForm(data) + + instance = form.save() + + self.assertQuerysetEqual( + instance.user_permissions.all(), + map(repr, [self.selec_perm1]), + ) + + def test_creation_commit_false(self): + """ + When instance is being created and 'save' method is called with + 'commit=False', 'save_m2m' method functions properly. + + https://docs.djangoproject.com/en/1.11/topics/forms/modelforms/#the-save-method + """ + data = { + 'username': 'user', + 'user_permissions': [self.selec_perm1.pk], + } + form = self.ExampleForm(data) + + instance = form.save(commit=False) + + with self.assertRaises(User.DoesNotExist): + User.objects.get(username='user') + + instance.save() + form.save_m2m() + + self.assertQuerysetEqual( + instance.user_permissions.all(), + map(repr, [self.selec_perm1]), + ) + + def test_existing(self): + """ + Unselectable items of an instance are kept. + """ + instance = User.objects.create(username='user') + # Link instance with an unselectable and a selectable permissions. + instance.user_permissions.add(self.unselec_perm1, self.selec_perm2) + + data = { + 'username': 'user', + 'user_permissions': [self.selec_perm1.pk], + } + form = self.ExampleForm(data, instance=instance) + + instance = form.save() + + self.assertQuerysetEqual( + instance.user_permissions.all(), + map(repr, [self.selec_perm1, self.unselec_perm1]), + ) + + def test_existing_commit_false(self): + """ + When 'save' is called with 'commit=False', unselectable items of an + instance are kept by 'save_m2m'. + """ + instance = User.objects.create(username='user') + # Link instance with an unselectable and a selectable permissions. + instance.user_permissions.add(self.unselec_perm1, self.selec_perm2) + + data = { + 'username': 'changed', + 'user_permissions': [self.selec_perm1.pk], + } + form = self.ExampleForm(data, instance=instance) + + instance = form.save(commit=False) + + with self.assertRaises(User.DoesNotExist): + User.objects.get(username='changed') + + instance.save() + form.save_m2m() + + self.assertQuerysetEqual( + instance.user_permissions.all(), + map(repr, [self.selec_perm1, self.unselec_perm1]), + )