Merge branch 'aureplop/kfet-auth_perms' into 'aureplop/kfet-auth'

Cleaner use of Group in kfet app

See merge request !257
This commit is contained in:
Martin Pepin 2017-10-12 11:30:44 +02:00
commit c17ed416c4
16 changed files with 500 additions and 147 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

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

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,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")

View file

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

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.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')

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

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

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

61
utils/forms.py Normal file
View file

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

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