Cleaner use of Group in kfet app

KFetGroup model
- Provides a distinction from non-kfet Groups.
- Convert code appropriately.
- Initially filled from Groups containing K-Fêt (this was the previous
distinction) in the kfetauth.0002 migration.

Permission proxy model (kfetauth app)
- Proxy of the django.contrib.auth Permission model.
- Adds the 'kfet' manager which returns only kfet-related permissions.

KeepUnselectableModelFormMixin
- Helps to keep the unselectable items of many-to-many field for
ModelForm.
- 'kfetauth' forms (related to KFetGroup) use this mixin.

Using KFetGroup allows to simplify the 'kfet/account_group_form.html' template.

A bug is also fixed in 'kfet/form_field_snippet.html', which could lead to
prevent field displays if they used CheckboxSelectMultiple widget.
This commit is contained in:
Aurélien Delobelle 2017-09-26 22:18:39 +02:00
parent bf61e41b50
commit ded824bddd
16 changed files with 499 additions and 145 deletions

View file

@ -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)

View file

@ -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

View file

@ -18,4 +18,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',),
),
]

View file

@ -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),
]

View file

@ -1,5 +1,43 @@
from django.contrib.auth.models import (
Group as DjangoGroup, Permission as DjangoPermission,
)
from django.db import models
from django.utils.translation import ugettext_lazy as _
class GenericTeamToken(models.Model):
token = models.CharField(max_length=50, unique=True)
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")

View file

@ -1,56 +1,173 @@
# -*- coding: utf-8 -*-
from django.contrib.auth.models import Group as DjangoGroup, User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.contrib.auth.models import User, Group
from kfet.forms import UserGroupForm
from .fields import GroupsField, CorePermissionsField
from .forms import GroupForm, UserGroupForm
from .models import GenericTeamToken, Group, Permission
##
# Forms, and theirs 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')

View file

@ -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.core.urlresolvers import reverse_lazy
from django.db.models import Prefetch
from django.shortcuts import render
@ -14,7 +14,7 @@ from django_cas_ng.views import logout as cas_logout_view
from kfet.decorators import teamkfet_required
from .forms import GroupForm
from .models import GenericTeamToken
from .models import GenericTeamToken, Group
@teamkfet_required
@ -45,7 +45,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', {
@ -53,17 +52,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')

View file

@ -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

View file

@ -14,7 +14,7 @@ from datetime import date
import re
import hashlib
from .auth.models import GenericTeamToken # noqa
from .auth.models import GenericTeamToken, Group, Permission # noqa
from .config import kfet_config
from .utils import to_ukf

View file

@ -1,49 +1,10 @@
{% extends 'kfet/base_form.html' %}
{% load staticfiles %}
{% load widget_tweaks %}
{% block extra_head %}
<link rel="stylesheet" text="text/css" href="{% static 'kfet/css/multiple-select.css' %}">
<script src="{% static 'kfet/js/multiple-select.js' %}"></script>
{% endblock %}
{% block title %}Permissions - Édition{% endblock %}
{% block header-title %}Modification des permissions{% endblock %}
{% block main %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<div class="form-group">
<label for="{{ form.name.id_for_label }}" class="col-sm-2 control-label">{{ form.name.label }}</label>
<div class="col-sm-10">
<div class="input-group">
<span class="input-group-addon">K-Fêt</span>
{{ form.name|add_class:"form-control" }}
</div>
{% if form.name.errors %}
<span class="help-block">{{ form.name.errors }}</span>
{% endif %}
{% if form.name.help_text %}
<span class="help-block">{{ form.name.help_text }}</span>
{% endif %}
</div>
</div>
{% 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" %}
</form>
<script type="text/javascript">
$(document).ready(function() {
let $name_input = $("#id_name");
let raw_name = $name_input.val();
let prefix = "K-Fêt ";
if (raw_name.startsWith(prefix))
$name_input.val(raw_name.substring(prefix.length));
});
</script>
{% include "kfet/form_full_snippet.html" with authz=perms.kfet.manage_perms submit_text="Enregistrer" %}
{% endblock %}

View file

@ -5,7 +5,7 @@
<div class="col-sm-10">
{% if field|widget_type == "checkboxselectmultiple" %}
<ul class="list-unstyled checkbox-select-multiple">
{% for choice in form.permissions %}
{% for choice in field %}
<li class="col-sm-6 col-lg-4">
<label for="{{ choice.id_for_label }}">
{{ choice.tag }} {{ choice.choice_label }}
@ -17,10 +17,10 @@
{{ field|add_class:'form-control' }}
{% endif %}
{% if field.errors %}
<span class="help-block">{{field.errors}}</span>
<span class="help-block">{{ field.errors }}</span>
{% endif %}
{% if field.help_text %}
<span class="help-block">{{field.help_text}}</span>
<span class="help-block">{{ field.help_text }}</span>
{% endif %}
</div>
</div>

3
setup.cfg Normal file
View file

@ -0,0 +1,3 @@
[flake8]
# E731: lambda expressions
ignore = E731

0
utils/__init__.py Normal file
View file

63
utils/forms.py Normal file
View file

@ -0,0 +1,63 @@
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
if callable(selectable):
selectable = selectable()
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

0
utils/tests/__init__.py Normal file
View file

131
utils/tests/test_forms.py Normal file
View file

@ -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]),
)