From 435f32191373cf262fadc62a832e051586206eee Mon Sep 17 00:00:00 2001 From: tizot Date: Tue, 26 Jan 2016 14:56:10 +0100 Subject: [PATCH 01/10] Create School model --- sigma/urls.py | 2 ++ sigma_core/admin.py | 2 ++ sigma_core/migrations/0010_school.py | 24 ++++++++++++++++++++++++ sigma_core/models/school.py | 7 +++++++ sigma_core/serializers/school.py | 22 ++++++++++++++++++++++ sigma_core/views/school.py | 15 +++++++++++++++ 6 files changed, 72 insertions(+) create mode 100644 sigma_core/migrations/0010_school.py create mode 100644 sigma_core/models/school.py create mode 100644 sigma_core/serializers/school.py create mode 100644 sigma_core/views/school.py diff --git a/sigma/urls.py b/sigma/urls.py index ba67e6e..6e717c7 100644 --- a/sigma/urls.py +++ b/sigma/urls.py @@ -19,6 +19,7 @@ from sigma_core.views.user import UserViewSet from sigma_core.views.group import GroupViewSet +from sigma_core.views.school import SchoolViewSet from sigma_core.views.group_user import GroupUserViewSet from sigma_core.views.group_member import GroupMemberViewSet @@ -26,6 +27,7 @@ router.register(r'user', UserViewSet) router.register(r'group', GroupViewSet) +router.register(r'school', SchoolViewSet) router.register(r'group-member', GroupMemberViewSet) urlpatterns = [ diff --git a/sigma_core/admin.py b/sigma_core/admin.py index 3afa9b0..6e84e64 100644 --- a/sigma_core/admin.py +++ b/sigma_core/admin.py @@ -3,10 +3,12 @@ from sigma_core.models.user import User from sigma_core.models.group import Group +from sigma_core.models.school import School from sigma_core.models.group_member import GroupMember admin.site.unregister(AuthGroup) admin.site.register(User) admin.site.register(Group) +admin.site.register(School) admin.site.register(GroupMember) diff --git a/sigma_core/migrations/0010_school.py b/sigma_core/migrations/0010_school.py new file mode 100644 index 0000000..a7f0252 --- /dev/null +++ b/sigma_core/migrations/0010_school.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-01-26 13:41 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sigma_core', '0009_auto_20160111_0057'), + ] + + operations = [ + migrations.CreateModel( + name='School', + fields=[ + ('group_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sigma_core.Group')), + ('design', models.CharField(max_length=255)), + ], + bases=('sigma_core.group',), + ), + ] diff --git a/sigma_core/models/school.py b/sigma_core/models/school.py new file mode 100644 index 0000000..280f003 --- /dev/null +++ b/sigma_core/models/school.py @@ -0,0 +1,7 @@ +from django.db import models + +from sigma_core.models.group import Group + + +class School(Group): + design = models.CharField(max_length=255) diff --git a/sigma_core/serializers/school.py b/sigma_core/serializers/school.py new file mode 100644 index 0000000..9f70e44 --- /dev/null +++ b/sigma_core/serializers/school.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from sigma_core.models.school import School + + + +class BasicSchoolSerializer(serializers.ModelSerializer): + """ + Serialize School model without memberships. + """ + class Meta: + model = School + + +class SchoolSerializer(BasicSchoolSerializer): + """ + Serialize School model with memberships. + """ + class Meta: + model = School + + memberships = serializers.PrimaryKeyRelatedField(read_only=True, many=True) diff --git a/sigma_core/views/school.py b/sigma_core/views/school.py new file mode 100644 index 0000000..324c02e --- /dev/null +++ b/sigma_core/views/school.py @@ -0,0 +1,15 @@ +from django.http import Http404 + +from rest_framework import viewsets, decorators, status +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from dry_rest_permissions.generics import DRYPermissions + +from sigma_core.models.school import School +from sigma_core.serializers.school import SchoolSerializer + + +class SchoolViewSet(viewsets.ModelViewSet): + queryset = School.objects.all() + serializer_class = SchoolSerializer + permission_classes = [IsAuthenticated, ]#DRYPermissions, ] From 12af553e205cebeee811e900a869d31e4792ae31 Mon Sep 17 00:00:00 2001 From: tizot Date: Wed, 27 Jan 2016 09:06:27 +0100 Subject: [PATCH 02/10] Add School tests --- sigma_core/tests/factories.py | 9 +++ sigma_core/tests/test_school.py | 136 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 sigma_core/tests/test_school.py diff --git a/sigma_core/tests/factories.py b/sigma_core/tests/factories.py index bc62d30..dd67ce3 100644 --- a/sigma_core/tests/factories.py +++ b/sigma_core/tests/factories.py @@ -5,6 +5,7 @@ from sigma_core.models.user import User from sigma_core.models.group import Group +from sigma_core.models.school import School from sigma_core.models.group_member import GroupMember faker = FakerFactory.create('fr_FR') @@ -30,6 +31,14 @@ class Meta: name = factory.Sequence(lambda n: 'Group %d' % n) +class SchoolFactory(factory.django.DjangoModelFactory): + class Meta: + model = School + + name = factory.Sequence(lambda n: 'School %d' % n) + design = "default" + + class GroupMemberFactory(factory.django.DjangoModelFactory): class Meta: model = GroupMember diff --git a/sigma_core/tests/test_school.py b/sigma_core/tests/test_school.py new file mode 100644 index 0000000..bfb22da --- /dev/null +++ b/sigma_core/tests/test_school.py @@ -0,0 +1,136 @@ +from rest_framework import status +from rest_framework.test import APITestCase, force_authenticate + +from sigma_core.models.group import Group +from sigma_core.models.school import School +from sigma_core.serializers.group import GroupSerializer +from sigma_core.serializers.school import SchoolSerializer +from sigma_core.tests.factories import UserFactory, GroupFactory, SchoolFactory, GroupMemberFactory + + +def reload(obj): + return obj.__class__.objects.get(pk=obj.pk) + + +class SchoolTests(APITestCase): + @classmethod + def setUpTestData(self): + super(SchoolTests, self).setUpTestData() + + # Schools + self.schools = SchoolFactory.create_batch(2) + + # Users + self.users = UserFactory.create_batch(4) + self.users[2].is_staff = True # Sigma admin + self.users[2].save() + + # Memberships + self.member1 = GroupMemberFactory(user=self.users[0], group=self.schools[0], perm_rank=Group.ADMINISTRATOR_RANK) + self.member2 = GroupMemberFactory(user=self.users[1], group=self.schools[0], perm_rank=1) + + serializer = SchoolSerializer(self.schools[0]) + self.school_data = serializer.data + self.schools_url = "/school/" + self.school_url = self.schools_url + "%d/" + + self.new_school_data = {"name": "Ecole polytechnique", "design": "default"} + # self.invite_data = {"user": self.users[0].id} + + #### List requests + def test_get_schools_list_unauthed(self): + # Client not authenticated but can see schools list + response = self.client.get(self.schools_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), len(self.schools)) + + def test_get_schools_list_ok(self): + self.client.force_authenticate(user=self.users[0]) + response = self.client.get(self.schools_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), len(self.schools)) + + #### Get requests + def test_get_school_unauthed(self): + # Client is not authenticated and cannot see school details (especially members) + response = self.client.get(self.school_url % self.schools[0].id) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_get_school_forbidden(self): + # Client wants to see a a school whose he is not member of + self.client.force_authenticate(user=self.users[0]) + response = self.client.get(self.school_url % self.schools[1].id) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_school_ok(self): + # Client wants to see a school to which he belongs + self.client.force_authenticate(user=self.users[0]) + response = self.client.get(self.school_url % self.schools[0].id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.school_data) + + #### Create requests + def test_create_school_unauthed(self): + response = self.client.post(self.schools_url, self.new_school_data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_create_school_forbidden(self): + self.client.force_authenticate(user=self.users[0]) + response = self.client.post(self.schools_url, self.new_school_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_school_wrong_data(self): + self.client.force_authenticate(user=self.users[2]) + response = self.client.post(self.schools_url, {"name": ""}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_school_ok(self): + self.client.force_authenticate(user=self.users[2]) + response = self.client.post(self.schools_url, self.new_school_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['name'], "Ecole polytechnique") + self.assertEqual(response.data['visibility'], Group.VIS_PUBLIC) + self.assertEqual(response.data['default_member_rank'], -1) + self.assertEqual(response.data['req_rank_invite'], Group.ADMINISTRATOR_RANK) + + #### Modification requests + def test_update_school_unauthed(self): + self.school_data['name'] = "Ecole polytechnique" + response = self.client.put(self.school_url % self.school_data['id'], self.school_data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_school_forbidden_1(self): + self.client.force_authenticate(user=self.users[3]) + self.school_data['name'] = "Ecole polytechnique" + response = self.client.put(self.school_url % self.school_data['id'], self.school_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_school_forbidden_2(self): + self.client.force_authenticate(user=self.users[1]) + self.school_data['name'] = "Ecole polytechnique" + response = self.client.put(self.school_url % self.school_data['id'], self.school_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_school_wrong_data(self): + self.client.force_authenticate(user=self.users[2]) + self.school_data['name'] = "" + response = self.client.put(self.school_url % self.school_data['id'], self.school_data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_update_school_ok_staff(self): + self.client.force_authenticate(user=self.users[2]) + self.school_data['name'] = "Ecole polytechnique" + response = self.client.put(self.school_url % self.school_data['id'], self.school_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], "Ecole polytechnique") + + def test_update_school_ok_school_admin(self): + self.client.force_authenticate(user=self.users[0]) + self.school_data['name'] = "Ecole polytechnique" + response = self.client.put(self.school_url % self.school_data['id'], self.school_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], "Ecole polytechnique") + + #### Invitation process + + #### Deletion requests From f83ee02de532c2a6caac7ba165121ebb352345ad Mon Sep 17 00:00:00 2001 From: tizot Date: Wed, 27 Jan 2016 09:37:44 +0100 Subject: [PATCH 03/10] Add School permissions --- sigma_core/models/school.py | 40 ++++++++++++++++++++++++++++++++ sigma_core/serializers/school.py | 1 - sigma_core/tests/test_school.py | 2 +- sigma_core/views/school.py | 10 ++++++-- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/sigma_core/models/school.py b/sigma_core/models/school.py index 280f003..782dc3c 100644 --- a/sigma_core/models/school.py +++ b/sigma_core/models/school.py @@ -1,7 +1,47 @@ from django.db import models +from dry_rest_permissions.generics import allow_staff_or_superuser + from sigma_core.models.group import Group class School(Group): design = models.CharField(max_length=255) + + def save(self, *args, **kwargs): + """ + Schools are special groups: some params cannot be specified by user. + """ + self.visibility = Group.VIS_PUBLIC + self.type = Group.TYPE_SCHOOL + self.default_member_rank = -1 + self.req_rank_invite = Group.ADMINISTRATOR_RANK + self.req_rank_kick = Group.ADMINISTRATOR_RANK + self.req_rank_accept_join_requests = Group.ADMINISTRATOR_RANK + self.req_rank_promote = Group.ADMINISTRATOR_RANK + self.req_rank_demote = Group.ADMINISTRATOR_RANK + self.req_rank_modify_group_infos = Group.ADMINISTRATOR_RANK + + return super(School, self).save(*args, **kwargs) + + # Permissions + @staticmethod + def has_read_permission(request): + """ + Schools list is visible by everybody. + """ + return True + + def has_object_read_permission(self, request): + """ + Schools are only visible by members. + """ + return request.user.is_group_member(self) + + @staticmethod + @allow_staff_or_superuser + def has_create_permission(request): + """ + Schools can be created by Sigma admin only. + """ + return False diff --git a/sigma_core/serializers/school.py b/sigma_core/serializers/school.py index 9f70e44..88ee9e6 100644 --- a/sigma_core/serializers/school.py +++ b/sigma_core/serializers/school.py @@ -3,7 +3,6 @@ from sigma_core.models.school import School - class BasicSchoolSerializer(serializers.ModelSerializer): """ Serialize School model without memberships. diff --git a/sigma_core/tests/test_school.py b/sigma_core/tests/test_school.py index bfb22da..f056e21 100644 --- a/sigma_core/tests/test_school.py +++ b/sigma_core/tests/test_school.py @@ -64,7 +64,7 @@ def test_get_school_forbidden(self): def test_get_school_ok(self): # Client wants to see a school to which he belongs - self.client.force_authenticate(user=self.users[0]) + self.client.force_authenticate(user=self.users[1]) response = self.client.get(self.school_url % self.schools[0].id) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, self.school_data) diff --git a/sigma_core/views/school.py b/sigma_core/views/school.py index 324c02e..72dba38 100644 --- a/sigma_core/views/school.py +++ b/sigma_core/views/school.py @@ -2,7 +2,7 @@ from rest_framework import viewsets, decorators, status from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from dry_rest_permissions.generics import DRYPermissions from sigma_core.models.school import School @@ -12,4 +12,10 @@ class SchoolViewSet(viewsets.ModelViewSet): queryset = School.objects.all() serializer_class = SchoolSerializer - permission_classes = [IsAuthenticated, ]#DRYPermissions, ] + permission_classes = [IsAuthenticated, DRYPermissions, ] + + def get_permissions(self): + if self.action == 'list': + self.permission_classes = [AllowAny, ] + + return super(SchoolViewSet, self).get_permissions() From cce09c5f86c41ccb6f409ef478b97a195224a5a9 Mon Sep 17 00:00:00 2001 From: tizot Date: Wed, 27 Jan 2016 15:45:17 +0100 Subject: [PATCH 04/10] Create SchoolGroup relationship, with validation possibility --- sigma_core/admin.py | 3 ++- sigma_core/migrations/0011_schoolgroup.py | 27 +++++++++++++++++++++++ sigma_core/models/school.py | 22 ++++++++++++++++++ sigma_core/serializers/school.py | 2 ++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 sigma_core/migrations/0011_schoolgroup.py diff --git a/sigma_core/admin.py b/sigma_core/admin.py index 6e84e64..e9fcd18 100644 --- a/sigma_core/admin.py +++ b/sigma_core/admin.py @@ -3,7 +3,7 @@ from sigma_core.models.user import User from sigma_core.models.group import Group -from sigma_core.models.school import School +from sigma_core.models.school import School, SchoolGroup from sigma_core.models.group_member import GroupMember @@ -11,4 +11,5 @@ admin.site.register(User) admin.site.register(Group) admin.site.register(School) +admin.site.register(SchoolGroup) admin.site.register(GroupMember) diff --git a/sigma_core/migrations/0011_schoolgroup.py b/sigma_core/migrations/0011_schoolgroup.py new file mode 100644 index 0000000..bbd42b1 --- /dev/null +++ b/sigma_core/migrations/0011_schoolgroup.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-01-27 14:24 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sigma_core', '0010_school'), + ] + + operations = [ + migrations.CreateModel( + name='SchoolGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('validated', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_schools', to='sigma_core.Group')), + ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='school_groups', to='sigma_core.School')), + ], + ), + ] diff --git a/sigma_core/models/school.py b/sigma_core/models/school.py index 782dc3c..d831138 100644 --- a/sigma_core/models/school.py +++ b/sigma_core/models/school.py @@ -24,6 +24,14 @@ def save(self, *args, **kwargs): return super(School, self).save(*args, **kwargs) + @property + def acknowledged_groups(self): + return self.school_groups.filter(validated=True) + + @property + def awaiting_groups(self): + return self.school_groups.filter(validated=False) + # Permissions @staticmethod def has_read_permission(request): @@ -45,3 +53,17 @@ def has_create_permission(request): Schools can be created by Sigma admin only. """ return False + + +class SchoolGroup(models.Model): + school = models.ForeignKey(School, related_name='school_groups') + group = models.ForeignKey(Group, related_name='group_schools') + validated = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + if self.validated: + return "Group %s acknowledged by %s" % (self.group.__str__(), self.school.__str__()) + else: + return "Group %s awaiting for acknowledgment by %s since %s" % (self.group.__str__(), self.school.__str__(), self.created.strftime("%Y-%m-%d %H:%M")) diff --git a/sigma_core/serializers/school.py b/sigma_core/serializers/school.py index 88ee9e6..70131f4 100644 --- a/sigma_core/serializers/school.py +++ b/sigma_core/serializers/school.py @@ -10,6 +10,8 @@ class BasicSchoolSerializer(serializers.ModelSerializer): class Meta: model = School + acknowledged_groups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) + class SchoolSerializer(BasicSchoolSerializer): """ From da68238045aa1a496b97ad04f6213beb48f82eef Mon Sep 17 00:00:00 2001 From: tizot Date: Wed, 27 Jan 2016 20:00:57 +0100 Subject: [PATCH 05/10] Change acknowledgments from School to general Group. Add field Group.resp_school --- sigma_core/admin.py | 6 +-- .../migrations/0012_auto_20160127_1848.py | 51 +++++++++++++++++++ sigma_core/models/group.py | 39 ++++++++++++-- sigma_core/models/school.py | 23 +-------- sigma_core/serializers/group.py | 1 + sigma_core/serializers/school.py | 2 - 6 files changed, 91 insertions(+), 31 deletions(-) create mode 100644 sigma_core/migrations/0012_auto_20160127_1848.py diff --git a/sigma_core/admin.py b/sigma_core/admin.py index e9fcd18..79a52be 100644 --- a/sigma_core/admin.py +++ b/sigma_core/admin.py @@ -2,8 +2,8 @@ from django.contrib.auth.models import Group as AuthGroup from sigma_core.models.user import User -from sigma_core.models.group import Group -from sigma_core.models.school import School, SchoolGroup +from sigma_core.models.group import Group, GroupAcknowledgment +from sigma_core.models.school import School from sigma_core.models.group_member import GroupMember @@ -11,5 +11,5 @@ admin.site.register(User) admin.site.register(Group) admin.site.register(School) -admin.site.register(SchoolGroup) +admin.site.register(GroupAcknowledgment) admin.site.register(GroupMember) diff --git a/sigma_core/migrations/0012_auto_20160127_1848.py b/sigma_core/migrations/0012_auto_20160127_1848.py new file mode 100644 index 0000000..38fe434 --- /dev/null +++ b/sigma_core/migrations/0012_auto_20160127_1848.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-01-27 17:48 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sigma_core', '0011_schoolgroup'), + ] + + operations = [ + migrations.CreateModel( + name='GroupAcknowledgment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('validated', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.RemoveField( + model_name='schoolgroup', + name='group', + ), + migrations.RemoveField( + model_name='schoolgroup', + name='school', + ), + migrations.AddField( + model_name='group', + name='resp_school', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='sigma_core.School'), + ), + migrations.DeleteModel( + name='SchoolGroup', + ), + migrations.AddField( + model_name='groupacknowledgment', + name='asking_group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_recognizers', to='sigma_core.Group'), + ), + migrations.AddField( + model_name='groupacknowledgment', + name='validator_group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_acknowledgments', to='sigma_core.Group'), + ), + ] diff --git a/sigma_core/models/group.py b/sigma_core/models/group.py index 190e904..e45b27b 100644 --- a/sigma_core/models/group.py +++ b/sigma_core/models/group.py @@ -9,9 +9,9 @@ class Group(models.Model): - class Meta: - pass - + ######################### + # Constants and choices # + ######################### ADMINISTRATOR_RANK = 10 VIS_PUBLIC = 'public' @@ -34,10 +34,16 @@ class Meta: (TYPE_SCHOOL, 'School') ) + ########## + # Fields # + ########## name = models.CharField(max_length=254) visibility = models.CharField(max_length=64, choices=VISIBILITY_CHOICES, default=VIS_PRIVATE) type = models.CharField(max_length=64, choices=TYPE_CHOICES, default=TYPE_BASIC) + # The school responsible of the group in case of admin conflict (can be null for non-school-related groups) + resp_school = models.ForeignKey('School', null=True, blank=True, on_delete=models.SET_NULL) + # The permission a member has upon joining # A value of -1 means that no one can join the group. # A value of 0 means that anyone can request to join the group @@ -61,12 +67,23 @@ class Meta: # objects = GroupManager() + @property + def acknowledged_groups(self): + return self.group_acknowledgments.filter(validated=True).value('asking_group') + + ################# + # Model methods # + ################# def can_anyone_join(self): return self.default_member_rank >= 0 def __str__(self): return "%s (%s)" % (self.name, self.get_type_display()) + ############### + # Permissions # + ############### + # Perms for admin site def has_perm(self, perm, obj=None): return True @@ -74,7 +91,7 @@ def has_perm(self, perm, obj=None): def has_module_perms(self, app_label): return True - # Permissions + # DRY Permissions @staticmethod def has_read_permission(request): """ @@ -115,3 +132,17 @@ def has_object_update_permission(self, request): @allow_staff_or_superuser def has_object_invite_permission(self, request): return request.user.can_invite(self) + + +class GroupAcknowledgment(models.Model): + asking_group = models.ForeignKey(Group, related_name='group_recognizers') + validator_group = models.ForeignKey(Group, related_name='group_acknowledgments') + validated = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + if self.validated: + return "Group %s acknowledged by Group %s" % (self.asking_group.__str__(), self.validator_group.__str__()) + else: + return "Group %s awaiting for acknowledgment by Group %s since %s" % (self.asking_group.__str__(), self.validator_group.__str__(), self.created.strftime("%Y-%m-%d %H:%M")) diff --git a/sigma_core/models/school.py b/sigma_core/models/school.py index d831138..6482575 100644 --- a/sigma_core/models/school.py +++ b/sigma_core/models/school.py @@ -21,17 +21,10 @@ def save(self, *args, **kwargs): self.req_rank_promote = Group.ADMINISTRATOR_RANK self.req_rank_demote = Group.ADMINISTRATOR_RANK self.req_rank_modify_group_infos = Group.ADMINISTRATOR_RANK + self.resp_school = None return super(School, self).save(*args, **kwargs) - @property - def acknowledged_groups(self): - return self.school_groups.filter(validated=True) - - @property - def awaiting_groups(self): - return self.school_groups.filter(validated=False) - # Permissions @staticmethod def has_read_permission(request): @@ -53,17 +46,3 @@ def has_create_permission(request): Schools can be created by Sigma admin only. """ return False - - -class SchoolGroup(models.Model): - school = models.ForeignKey(School, related_name='school_groups') - group = models.ForeignKey(Group, related_name='group_schools') - validated = models.BooleanField(default=False) - created = models.DateTimeField(auto_now_add=True) - updated = models.DateTimeField(auto_now=True) - - def __str__(self): - if self.validated: - return "Group %s acknowledged by %s" % (self.group.__str__(), self.school.__str__()) - else: - return "Group %s awaiting for acknowledgment by %s since %s" % (self.group.__str__(), self.school.__str__(), self.created.strftime("%Y-%m-%d %H:%M")) diff --git a/sigma_core/serializers/group.py b/sigma_core/serializers/group.py index fa6ea4c..f4ee6fa 100644 --- a/sigma_core/serializers/group.py +++ b/sigma_core/serializers/group.py @@ -18,4 +18,5 @@ class GroupSerializer(BasicGroupSerializer): class Meta: model = Group + acknowledged_groups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) memberships = serializers.PrimaryKeyRelatedField(read_only=True, many=True) diff --git a/sigma_core/serializers/school.py b/sigma_core/serializers/school.py index 70131f4..88ee9e6 100644 --- a/sigma_core/serializers/school.py +++ b/sigma_core/serializers/school.py @@ -10,8 +10,6 @@ class BasicSchoolSerializer(serializers.ModelSerializer): class Meta: model = School - acknowledged_groups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) - class SchoolSerializer(BasicSchoolSerializer): """ From 699f91ea8a5c81aeef2b21c6b3244a52196309b9 Mon Sep 17 00:00:00 2001 From: tizot Date: Thu, 28 Jan 2016 14:29:40 +0100 Subject: [PATCH 06/10] Bugfix with acknowledged_groups --- sigma_core/models/group.py | 2 +- sigma_core/serializers/school.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sigma_core/models/group.py b/sigma_core/models/group.py index e45b27b..4f91192 100644 --- a/sigma_core/models/group.py +++ b/sigma_core/models/group.py @@ -69,7 +69,7 @@ class Group(models.Model): @property def acknowledged_groups(self): - return self.group_acknowledgments.filter(validated=True).value('asking_group') + return [ga.asking_group for ga in self.group_acknowledgments.filter(validated=True).select_related('asking_group')] ################# # Model methods # diff --git a/sigma_core/serializers/school.py b/sigma_core/serializers/school.py index 88ee9e6..f2c96f8 100644 --- a/sigma_core/serializers/school.py +++ b/sigma_core/serializers/school.py @@ -18,4 +18,5 @@ class SchoolSerializer(BasicSchoolSerializer): class Meta: model = School + acknowledged_groups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) memberships = serializers.PrimaryKeyRelatedField(read_only=True, many=True) From 64e8a200b20f07698dc327e873a91feb574b5762 Mon Sep 17 00:00:00 2001 From: tizot Date: Sun, 31 Jan 2016 22:24:25 +0100 Subject: [PATCH 07/10] Add correct permissions on Group --- sigma_core/models/group.py | 12 ++++-- sigma_core/models/user.py | 16 ++++++++ sigma_core/tests/test_group.py | 72 +++++++++++++++++++++++++++++----- sigma_core/views/group.py | 12 +++++- 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/sigma_core/models/group.py b/sigma_core/models/group.py index 4f91192..0027c03 100644 --- a/sigma_core/models/group.py +++ b/sigma_core/models/group.py @@ -115,12 +115,18 @@ def has_create_permission(request): """ Everybody can create a private group. For other types, user must be school admin or sigma admin. """ - #TODO: Adapt after School model implementation. + from sigma_core.models.school import School group_type = request.data.get('type', None) - return group_type == Group.TYPE_BASIC + resp_school = request.data.get('resp_school', None) + try: + school = School.objects.get(pk=resp_school) + except School.DoesNotExist: + school = None + return group_type == Group.TYPE_BASIC or (school is not None and request.user.has_group_admin_perm(school)) + @allow_staff_or_superuser def has_object_write_permission(self, request): - return False + return request.user.has_group_admin_perm(self) @allow_staff_or_superuser def has_object_update_permission(self, request): diff --git a/sigma_core/models/user.py b/sigma_core/models/user.py index 5768714..e7c0ed4 100644 --- a/sigma_core/models/user.py +++ b/sigma_core/models/user.py @@ -79,6 +79,14 @@ def get_short_name(self): def is_sigma_admin(self): return self.is_staff or self.is_superuser + def get_group_membership(self, group): + from sigma_core.models.group_member import GroupMember + from sigma_core.models.group import Group + try: + return self.memberships.get(group=group) + except GroupMember.DoesNotExist: + return None + def is_group_member(self, g): from sigma_core.models.group_member import GroupMember try: @@ -111,6 +119,14 @@ def can_modify_group_infos(self, group): return False return mem.perm_rank >= group.req_rank_modify_group_infos + def has_group_admin_perm(self, group): + from sigma_core.models.group_member import GroupMember + from sigma_core.models.group import Group + if self.is_sigma_admin(): + return True + mem = self.get_group_membership(group) + return mem is not None and mem.perm_rank == Group.ADMINISTRATOR_RANK + # Perms for admin site def has_perm(self, perm, obj=None): diff --git a/sigma_core/tests/test_group.py b/sigma_core/tests/test_group.py index ddb5220..39f1d45 100644 --- a/sigma_core/tests/test_group.py +++ b/sigma_core/tests/test_group.py @@ -5,7 +5,7 @@ from sigma_core.models.group import Group from sigma_core.serializers.group import GroupSerializer -from sigma_core.tests.factories import UserFactory, GroupFactory, GroupMemberFactory +from sigma_core.tests.factories import UserFactory, GroupFactory, GroupMemberFactory, SchoolFactory def reload(obj): @@ -17,6 +17,9 @@ class GroupTests(APITestCase): def setUpTestData(self): super(GroupTests, self).setUpTestData() + # Schools + self.schools = SchoolFactory.create_batch(1) + # Groups self.groups = GroupFactory.create_batch(2) self.groups[0].visibility = Group.VIS_PUBLIC @@ -31,13 +34,19 @@ def setUpTestData(self): # Memberships self.member1 = GroupMemberFactory(user=self.users[1], group=self.groups[1], perm_rank=1) self.member2 = GroupMemberFactory(user=self.users[2], group=self.groups[1], perm_rank=Group.ADMINISTRATOR_RANK) + self.student1 = GroupMemberFactory(user=self.users[0], group=self.schools[0], perm_rank=1) + self.student2 = GroupMemberFactory(user=self.users[1], group=self.schools[0], perm_rank=Group.ADMINISTRATOR_RANK) # School admin + self.student3 = GroupMemberFactory(user=self.users[2], group=self.schools[0], perm_rank=1) serializer = GroupSerializer(self.groups[0]) self.group_data = serializer.data + self.update_group_data = self.group_data.copy() + self.update_group_data['name'] = "Another name" self.groups_url = "/group/" self.group_url = self.groups_url + "%d/" - self.new_group_data = {"name": "New group"} + self.new_private_group_data = {"name": "New group", "type": Group.TYPE_BASIC, "visibility": Group.VIS_PRIVATE} + self.new_association_group_data = {"name": "New group", "type": Group.TYPE_ASSO, "visibility": Group.VIS_PUBLIC, "resp_school": self.schools[0].id} self.invite_data = {"user": self.users[0].id} #### List requests @@ -46,18 +55,21 @@ def test_get_groups_list_unauthed(self): response = self.client.get(self.groups_url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - # def test_get_groups_list_forbidden(self): - # # Client authenticated but has no permission - # self.client.force_authenticate(group=self.users[0]) - # response = self.client.get(self.group_url % self.groups[1].id) - # self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_get_groups_list_limited(self): + # Client authenticated and can see public groups + self.client.force_authenticate(user=self.users[0]) + response = self.client.get(self.groups_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(self.groups[0].id, [d['id'] for d in response.data]) # User can only see groups[0] + self.assertNotIn(self.groups[1].id, [d['id'] for d in response.data]) def test_get_groups_list_ok(self): # Client has permissions - self.client.force_authenticate(user=self.users[0]) + self.client.force_authenticate(user=self.users[1]) response = self.client.get(self.groups_url) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), len(self.groups)) + self.assertIn(self.groups[0].id, [d['id'] for d in response.data]) # groups[0] is public and user is member of groups[1] + self.assertIn(self.groups[1].id, [d['id'] for d in response.data]) #### Get requests def test_get_group_unauthed(self): @@ -97,7 +109,49 @@ def test_invite_ok(self): self.assertIn(self.groups[1], reload(self.users[0]).invited_to_groups.all()) #### Create requests + def test_create_unauthed(self): + # Client is not authenticated + response = self.client.post(self.groups_url, self.new_private_group_data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_create_private_group(self): + # Everybody can create a private group + self.client.force_authenticate(user=self.users[0]) + response = self.client.post(self.groups_url, self.new_private_group_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['name'], self.new_private_group_data['name']) + self.assertEqual(response.data['visibility'], Group.VIS_PRIVATE) + Group.objects.get(pk=response.data['id']).delete() + + def test_create_association_group_forbidden(self): + # Only school andmins and Sigma admins can create association groups + self.client.force_authenticate(user=self.users[0]) + response = self.client.post(self.groups_url, self.new_association_group_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_association_group_ok(self): + # Only school andmins and Sigma admins can create association groups + self.client.force_authenticate(user=self.users[1]) + response = self.client.post(self.groups_url, self.new_association_group_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['type'], Group.TYPE_ASSO) + self.assertEqual(response.data['visibility'], Group.VIS_PUBLIC) + Group.objects.get(pk=response.data['id']).delete() #### Modification requests + def test_update_unauthed(self): + response = self.client.put(self.group_url % self.groups[1].id, self.update_group_data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_forbidden(self): + self.client.force_authenticate(user=self.users[1]) + response = self.client.put(self.group_url % self.groups[1].id, self.update_group_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_ok(self): + self.client.force_authenticate(user=self.users[2]) + response = self.client.put(self.group_url % self.groups[1].id, self.update_group_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(reload(self.groups[1]).name, self.update_group_data['name']) #### Deletion requests diff --git a/sigma_core/views/group.py b/sigma_core/views/group.py index 023953a..33d125a 100644 --- a/sigma_core/views/group.py +++ b/sigma_core/views/group.py @@ -1,19 +1,29 @@ from django.http import Http404 +from django.db.models import Q from rest_framework import viewsets, decorators, status from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from dry_rest_permissions.generics import DRYPermissions +from dry_rest_permissions.generics import DRYPermissions, DRYPermissionFiltersBase from sigma_core.models.user import User from sigma_core.models.group import Group from sigma_core.serializers.group import GroupSerializer +class GroupFilterBackend(DRYPermissionFiltersBase): + def filter_list_queryset(self, request, queryset, view): + """ + Limits all list requests to only be seen by the members or public groups. + """ + return queryset.prefetch_related('memberships__user').filter(Q(visibility=Group.VIS_PUBLIC) | Q(memberships__user=request.user)).distinct() + + class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAuthenticated, DRYPermissions, ] + filter_backends = (GroupFilterBackend, ) @decorators.detail_route(methods=['put']) def invite(self, request, pk=None): From d0a27bdd979a2595632058721264b79e5d7869e3 Mon Sep 17 00:00:00 2001 From: tizot Date: Mon, 1 Feb 2016 10:46:04 +0100 Subject: [PATCH 08/10] Improve perm for group --- sigma_core/models/group.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sigma_core/models/group.py b/sigma_core/models/group.py index 0027c03..dfcd4c4 100644 --- a/sigma_core/models/group.py +++ b/sigma_core/models/group.py @@ -117,12 +117,15 @@ def has_create_permission(request): """ from sigma_core.models.school import School group_type = request.data.get('type', None) + if group_type == Group.TYPE_BASIC: + return True + resp_school = request.data.get('resp_school', None) try: school = School.objects.get(pk=resp_school) except School.DoesNotExist: school = None - return group_type == Group.TYPE_BASIC or (school is not None and request.user.has_group_admin_perm(school)) + return school is not None and request.user.has_group_admin_perm(school) @allow_staff_or_superuser def has_object_write_permission(self, request): From ba267d5460cad7ec27ad6fa0901089c68af9a751 Mon Sep 17 00:00:00 2001 From: tizot Date: Mon, 1 Feb 2016 10:54:57 +0100 Subject: [PATCH 09/10] Change field names in AcknowledgedGroup --- sigma_core/models/group.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sigma_core/models/group.py b/sigma_core/models/group.py index dfcd4c4..49a302c 100644 --- a/sigma_core/models/group.py +++ b/sigma_core/models/group.py @@ -144,14 +144,14 @@ def has_object_invite_permission(self, request): class GroupAcknowledgment(models.Model): - asking_group = models.ForeignKey(Group, related_name='group_recognizers') - validator_group = models.ForeignKey(Group, related_name='group_acknowledgments') + subgroup = models.ForeignKey(Group, related_name='group_parents') + parent_group = models.ForeignKey(Group, related_name='subgroups') validated = models.BooleanField(default=False) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) def __str__(self): if self.validated: - return "Group %s acknowledged by Group %s" % (self.asking_group.__str__(), self.validator_group.__str__()) + return "Group %s acknowledged by Group %s" % (self.subgroup.__str__(), self.parent_group.__str__()) else: - return "Group %s awaiting for acknowledgment by Group %s since %s" % (self.asking_group.__str__(), self.validator_group.__str__(), self.created.strftime("%Y-%m-%d %H:%M")) + return "Group %s awaiting for acknowledgment by Group %s since %s" % (self.subgroup.__str__(), self.parent_group.__str__(), self.created.strftime("%Y-%m-%d %H:%M")) From 5f1adf288b884dea4d4e58777ce4eaa28d88afe5 Mon Sep 17 00:00:00 2001 From: tizot Date: Mon, 1 Feb 2016 11:14:55 +0100 Subject: [PATCH 10/10] Fix migrations and tests --- .../migrations/0013_auto_20160201_1108.py | 28 +++++++++++++++++++ .../migrations/0014_auto_20160201_1109.py | 22 +++++++++++++++ .../migrations/0015_auto_20160201_1109.py | 23 +++++++++++++++ sigma_core/models/group.py | 4 +-- sigma_core/serializers/group.py | 2 +- sigma_core/serializers/school.py | 2 +- 6 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 sigma_core/migrations/0013_auto_20160201_1108.py create mode 100644 sigma_core/migrations/0014_auto_20160201_1109.py create mode 100644 sigma_core/migrations/0015_auto_20160201_1109.py diff --git a/sigma_core/migrations/0013_auto_20160201_1108.py b/sigma_core/migrations/0013_auto_20160201_1108.py new file mode 100644 index 0000000..713ea6b --- /dev/null +++ b/sigma_core/migrations/0013_auto_20160201_1108.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-02-01 10:08 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sigma_core', '0012_auto_20160127_1848'), + ] + + operations = [ + migrations.AddField( + model_name='groupacknowledgment', + name='parent_group', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='subgroups', to='sigma_core.Group'), + preserve_default=False, + ), + migrations.AddField( + model_name='groupacknowledgment', + name='subgroup', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='group_parents', to='sigma_core.Group'), + preserve_default=False, + ), + ] diff --git a/sigma_core/migrations/0014_auto_20160201_1109.py b/sigma_core/migrations/0014_auto_20160201_1109.py new file mode 100644 index 0000000..3f42d1e --- /dev/null +++ b/sigma_core/migrations/0014_auto_20160201_1109.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-02-01 10:09 +from __future__ import unicode_literals + +from django.db import migrations + +def move_foreignkeys(apps, schema_editor): + GroupAcknowledgment = apps.get_model("sigma_core", "GroupAcknowledgment") + for ga in GroupAcknowledgment.objects.all(): + ga.subgroup = ga.asking_group + ga.parent_group = ga.validator_group + ga.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('sigma_core', '0013_auto_20160201_1108'), + ] + + operations = [ + migrations.RunPython(move_foreignkeys), + ] diff --git a/sigma_core/migrations/0015_auto_20160201_1109.py b/sigma_core/migrations/0015_auto_20160201_1109.py new file mode 100644 index 0000000..edbf4be --- /dev/null +++ b/sigma_core/migrations/0015_auto_20160201_1109.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-02-01 10:09 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sigma_core', '0014_auto_20160201_1109'), + ] + + operations = [ + migrations.RemoveField( + model_name='groupacknowledgment', + name='asking_group', + ), + migrations.RemoveField( + model_name='groupacknowledgment', + name='validator_group', + ), + ] diff --git a/sigma_core/models/group.py b/sigma_core/models/group.py index 49a302c..ed0519d 100644 --- a/sigma_core/models/group.py +++ b/sigma_core/models/group.py @@ -68,8 +68,8 @@ class Group(models.Model): # objects = GroupManager() @property - def acknowledged_groups(self): - return [ga.asking_group for ga in self.group_acknowledgments.filter(validated=True).select_related('asking_group')] + def subgroups(self): + return [ga.subgroup for ga in self.subgroups.filter(validated=True).select_related('subgroup')] ################# # Model methods # diff --git a/sigma_core/serializers/group.py b/sigma_core/serializers/group.py index f4ee6fa..77f8257 100644 --- a/sigma_core/serializers/group.py +++ b/sigma_core/serializers/group.py @@ -18,5 +18,5 @@ class GroupSerializer(BasicGroupSerializer): class Meta: model = Group - acknowledged_groups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) + subgroups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) memberships = serializers.PrimaryKeyRelatedField(read_only=True, many=True) diff --git a/sigma_core/serializers/school.py b/sigma_core/serializers/school.py index f2c96f8..f1bf8bf 100644 --- a/sigma_core/serializers/school.py +++ b/sigma_core/serializers/school.py @@ -18,5 +18,5 @@ class SchoolSerializer(BasicSchoolSerializer): class Meta: model = School - acknowledged_groups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) + subgroups = serializers.PrimaryKeyRelatedField(read_only=True, many=True) memberships = serializers.PrimaryKeyRelatedField(read_only=True, many=True)