diff --git a/app/api/models.py b/app/api/models.py index a7a88641..90ae90a3 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -4,6 +4,7 @@ from django.db import models from django.dispatch import receiver from django.db.models.signals import post_save, pre_save, pre_delete from django.urls import reverse +from django.conf import settings from django.contrib.auth.models import User from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import ValidationError @@ -270,15 +271,44 @@ class RoleMapping(models.Model): @receiver(post_save, sender=RoleMapping) def add_linked_project(sender, instance, created, **kwargs): + if not created: + return userInstance = instance.user projectInstance = instance.project - if created and userInstance and projectInstance: + if userInstance and projectInstance: user = User.objects.get(pk=userInstance.pk) project = Project.objects.get(pk=projectInstance.pk) user.projects.add(project) user.save() +@receiver(post_save) +def add_superusers_to_project(sender, instance, created, **kwargs): + if not created: + return + if sender not in Project.__subclasses__(): + return + superusers = User.objects.filter(is_superuser=True) + admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first() + if superusers and admin_role: + RoleMapping.objects.bulk_create( + [RoleMapping(role_id=admin_role.id, user_id=superuser.id, project_id=instance.id) + for superuser in superusers] + ) + + +@receiver(post_save, sender=User) +def add_new_superuser_to_projects(sender, instance, created, **kwargs): + if created and instance.is_superuser: + admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first() + projects = Project.objects.all() + if admin_role and projects: + RoleMapping.objects.bulk_create( + [RoleMapping(role_id=admin_role.id, user_id=instance.id, project_id=project.id) + for project in projects] + ) + + @receiver(pre_delete, sender=RoleMapping) def delete_linked_project(sender, instance, using, **kwargs): userInstance = instance.user diff --git a/app/api/permissions.py b/app/api/permissions.py index b17f327f..fb469e63 100644 --- a/app/api/permissions.py +++ b/app/api/permissions.py @@ -12,13 +12,6 @@ class ProjectMixin: return view.kwargs.get('project_id') or request.query_params.get('project_id') -class IsProjectUser(ProjectMixin, BasePermission): - - def has_permission(self, request, view): - project = get_object_or_404(Project, pk=self.get_project_id(request, view)) - return user in project.users.all() - - class IsAdminUserAndWriteOnly(BasePermission): def has_permission(self, request, view): @@ -74,19 +67,20 @@ class IsProjectAdmin(RolePermission): role_name = settings.ROLE_PROJECT_ADMIN -class IsAnnotatorAndCreator(RolePermission): - unsafe_methods_check = False +class IsAnnotatorAndReadOnly(RolePermission): role_name = settings.ROLE_ANNOTATOR - class IsAnnotator(RolePermission): + unsafe_methods_check = False role_name = settings.ROLE_ANNOTATOR +class IsAnnotationApproverAndReadOnly(RolePermission): + role_name = settings.ROLE_ANNOTATION_APPROVER class IsAnnotationApprover(RolePermission): + unsafe_methods_check = False role_name = settings.ROLE_ANNOTATION_APPROVER - def is_in_role(role_name, user_id, project_id): return RoleMapping.objects.filter( user_id=user_id, diff --git a/app/api/serializers.py b/app/api/serializers.py index e30a16b2..24747b54 100644 --- a/app/api/serializers.py +++ b/app/api/serializers.py @@ -1,4 +1,6 @@ +from django.conf import settings from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 from rest_framework import serializers from rest_polymorphic.serializers import PolymorphicSerializer from rest_framework.exceptions import ValidationError @@ -77,39 +79,56 @@ class DocumentSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer): + current_users_role = serializers.SerializerMethodField() + + def get_current_users_role(self, instance): + role_abstractor = { + "is_project_admin": settings.ROLE_PROJECT_ADMIN, + "is_annotator": settings.ROLE_ANNOTATOR, + "is_annotation_approver": settings.ROLE_ANNOTATION_APPROVER, + } + queryset = RoleMapping.objects.values("role_id__name") + if queryset: + users_role = get_object_or_404( + queryset, project=instance.id, user=self.context.get("request").user.id + ) + for key, val in role_abstractor.items(): + role_abstractor[key] = users_role["role_id__name"] == val + return role_abstractor class Meta: model = Project - fields = ('id', 'name', 'description', 'guideline', 'users', 'project_type', 'image', 'updated_at', - 'randomize_document_order', 'collaborative_annotation') - read_only_fields = ('image', 'updated_at') + fields = ('id', 'name', 'description', 'guideline', 'users', 'current_users_role', 'project_type', 'image', + 'updated_at', 'randomize_document_order', 'collaborative_annotation') + read_only_fields = ('image', 'updated_at', 'current_users_role') -class TextClassificationProjectSerializer(serializers.ModelSerializer): +class TextClassificationProjectSerializer(ProjectSerializer): class Meta: model = TextClassificationProject - fields = ('id', 'name', 'description', 'guideline', 'users', 'project_type', 'image', 'updated_at', - 'randomize_document_order') - read_only_fields = ('image', 'updated_at', 'users') + fields = ('id', 'name', 'description', 'guideline', 'users', 'current_users_role', 'project_type', 'image', + 'updated_at', 'randomize_document_order') + read_only_fields = ('image', 'updated_at', 'users', 'current_users_role') -class SequenceLabelingProjectSerializer(serializers.ModelSerializer): +class SequenceLabelingProjectSerializer(ProjectSerializer): + class Meta: model = SequenceLabelingProject - fields = ('id', 'name', 'description', 'guideline', 'users', 'project_type', 'image', 'updated_at', - 'randomize_document_order') - read_only_fields = ('image', 'updated_at', 'users') + fields = ('id', 'name', 'description', 'guideline', 'users', 'current_users_role', 'project_type', 'image', + 'updated_at', 'randomize_document_order') + read_only_fields = ('image', 'updated_at', 'users', 'current_users_role') -class Seq2seqProjectSerializer(serializers.ModelSerializer): +class Seq2seqProjectSerializer(ProjectSerializer): class Meta: model = Seq2seqProject - fields = ('id', 'name', 'description', 'guideline', 'users', 'project_type', 'image', 'updated_at', - 'randomize_document_order') - read_only_fields = ('image', 'updated_at', 'users') + fields = ('id', 'name', 'description', 'guideline', 'users', 'current_users_role', 'project_type', 'image', + 'updated_at', 'randomize_document_order') + read_only_fields = ('image', 'updated_at', 'users', 'current_users_role') class ProjectPolymorphicSerializer(PolymorphicSerializer): diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py index d71e9bd0..6f18a16f 100644 --- a/app/api/tests/test_api.py +++ b/app/api/tests/test_api.py @@ -38,29 +38,43 @@ class TestProjectListAPI(APITestCase): cls.main_project_member_pass = 'project_member_pass' cls.sub_project_member_name = 'sub_project_member_name' cls.sub_project_member_pass = 'sub_project_member_pass' + cls.approver_name = 'approver_name_name' + cls.approver_pass = 'approver_pass' cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' + create_default_roles() main_project_member = User.objects.create_user(username=cls.main_project_member_name, password=cls.main_project_member_pass) sub_project_member = User.objects.create_user(username=cls.sub_project_member_name, password=cls.sub_project_member_pass) - # Todo: change super_user to project_admin. - super_user = User.objects.create_superuser(username=cls.super_user_name, + approver = User.objects.create_user(username=cls.approver_name, + password=cls.approver_pass) + project_admin = User.objects.create_superuser(username=cls.super_user_name, password=cls.super_user_pass, email='fizz@buzz.com') cls.main_project = mommy.make('TextClassificationProject', users=[main_project_member]) cls.sub_project = mommy.make('TextClassificationProject', users=[sub_project_member]) - create_default_roles() assign_user_to_role(project_member=main_project_member, project=cls.main_project, role_name=settings.ROLE_ANNOTATOR) assign_user_to_role(project_member=sub_project_member, project=cls.sub_project, role_name=settings.ROLE_ANNOTATOR) + assign_user_to_role(project_member=approver, project=cls.main_project, + role_name=settings.ROLE_ANNOTATION_APPROVER) cls.url = reverse(viewname='project_list') cls.data = {'name': 'example', 'project_type': 'DocumentClassification', 'description': 'example', 'guideline': 'example', 'resourcetype': 'TextClassificationProject'} cls.num_project = main_project_member.projects.count() + def test_returns_main_project_to_approver(self): + self.client.login(username=self.approver_name, + password=self.approver_pass) + response = self.client.get(self.url, format='json') + project = response.data[0] + num_project = len(response.data) + self.assertEqual(num_project, self.num_project) + self.assertEqual(project['id'], self.main_project.id) + def test_returns_main_project_to_main_project_member(self): self.client.login(username=self.main_project_member_name, password=self.main_project_member_pass) @@ -105,23 +119,25 @@ class TestProjectDetailAPI(APITestCase): cls.project_member_pass = 'project_member_pass' cls.non_project_member_name = 'non_project_member_name' cls.non_project_member_pass = 'non_project_member_pass' - cls.super_user_name = 'super_user_name' - cls.super_user_pass = 'super_user_pass' + cls.admin_user_name = 'admin_user_name' + cls.admin_user_pass = 'admin_user_pass' + create_default_roles() cls.project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) non_project_member = User.objects.create_user(username=cls.non_project_member_name, password=cls.non_project_member_pass) - super_user = User.objects.create_superuser(username=cls.super_user_name, - password=cls.super_user_pass, + project_admin = User.objects.create_superuser(username=cls.admin_user_name, + password=cls.admin_user_pass, email='fizz@buzz.com') - cls.main_project = mommy.make('TextClassificationProject', users=[cls.project_member, super_user]) + cls.main_project = mommy.make('TextClassificationProject', users=[cls.project_member, project_admin]) mommy.make('TextClassificationProject', users=[non_project_member]) cls.url = reverse(viewname='project_detail', args=[cls.main_project.id]) cls.data = {'description': 'lorem'} - create_default_roles() assign_user_to_role(project_member=cls.project_member, project=cls.main_project, role_name=settings.ROLE_ANNOTATOR) + assign_user_to_role(project_member=project_admin, project=cls.main_project, + role_name=settings.ROLE_PROJECT_ADMIN) def test_returns_main_project_detail_to_main_project_member(self): self.client.login(username=self.project_member_name, @@ -135,9 +151,9 @@ class TestProjectDetailAPI(APITestCase): response = self.client.get(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_allows_superuser_to_update_project(self): - self.client.login(username=self.super_user_name, - password=self.super_user_pass) + def test_allows_admin_to_update_project(self): + self.client.login(username=self.admin_user_name, + password=self.admin_user_pass) response = self.client.patch(self.url, format='json', data=self.data) self.assertEqual(response.data['description'], self.data['description']) @@ -147,9 +163,9 @@ class TestProjectDetailAPI(APITestCase): response = self.client.patch(self.url, format='json', data=self.data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_allows_superuser_to_delete_project(self): - self.client.login(username=self.super_user_name, - password=self.super_user_pass) + def test_allows_admin_to_delete_project(self): + self.client.login(username=self.admin_user_name, + password=self.admin_user_pass) response = self.client.delete(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -172,26 +188,25 @@ class TestLabelListAPI(APITestCase): cls.project_member_pass = 'project_member_pass' cls.non_project_member_name = 'non_project_member_name' cls.non_project_member_pass = 'non_project_member_pass' - cls.super_user_name = 'super_user_name' - cls.super_user_pass = 'super_user_pass' + cls.admin_user_name = 'admin_user_name' + cls.admin_user_pass = 'admin_user_pass' + create_default_roles() cls.project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) non_project_member = User.objects.create_user(username=cls.non_project_member_name, password=cls.non_project_member_pass) - # Todo: change super_user to project_admin. - super_user = User.objects.create_superuser(username=cls.super_user_name, - password=cls.super_user_pass, + project_admin = User.objects.create_superuser(username=cls.admin_user_name, + password=cls.admin_user_pass, email='fizz@buzz.com') - cls.main_project = mommy.make('Project', users=[cls.project_member, super_user]) + cls.main_project = mommy.make('Project', users=[cls.project_member, project_admin]) cls.main_project_label = mommy.make('Label', project=cls.main_project) sub_project = mommy.make('Project', users=[non_project_member]) - other_project = mommy.make('Project', users=[super_user]) + other_project = mommy.make('Project', users=[project_admin]) mommy.make('Label', project=sub_project) cls.url = reverse(viewname='label_list', args=[cls.main_project.id]) cls.other_url = reverse(viewname='label_list', args=[other_project.id]) cls.data = {'text': 'example'} - create_default_roles() assign_user_to_role(project_member=cls.project_member, project=cls.main_project, role_name=settings.ROLE_ANNOTATOR) @@ -216,15 +231,15 @@ class TestLabelListAPI(APITestCase): self.assertEqual(num_labels, len(self.main_project.labels.all())) self.assertEqual(label['id'], self.main_project_label.id) - def test_allows_superuser_to_create_label(self): - self.client.login(username=self.super_user_name, - password=self.super_user_pass) + def test_allows_admin_to_create_label(self): + self.client.login(username=self.admin_user_name, + password=self.admin_user_pass) response = self.client.post(self.url, format='json', data=self.data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) def test_can_create_multiple_labels_without_shortcut_key(self): - self.client.login(username=self.super_user_name, - password=self.super_user_pass) + self.client.login(username=self.admin_user_name, + password=self.admin_user_pass) labels = [ {'text': 'Ruby', 'prefix_key': None, 'suffix_key': None}, {'text': 'PHP', 'prefix_key': None, 'suffix_key': None} @@ -234,8 +249,8 @@ class TestLabelListAPI(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) def test_can_create_same_label_in_multiple_projects(self): - self.client.login(username=self.super_user_name, - password=self.super_user_pass) + self.client.login(username=self.admin_user_name, + password=self.admin_user_pass) label = {'text': 'LOC', 'prefix_key': None, 'suffix_key': 'l'} response = self.client.post(self.url, format='json', data=label) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -283,6 +298,7 @@ class TestLabelDetailAPI(APITestCase): cls.non_project_member_pass = 'non_project_member_pass' cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) non_project_member = User.objects.create_user(username=cls.non_project_member_name, @@ -350,6 +366,7 @@ class TestDocumentListAPI(APITestCase): cls.non_project_member_pass = 'non_project_member_pass' cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) non_project_member = User.objects.create_user(username=cls.non_project_member_name, @@ -370,7 +387,6 @@ class TestDocumentListAPI(APITestCase): cls.url = reverse(viewname='doc_list', args=[cls.main_project.id]) cls.random_order_project_url = reverse(viewname='doc_list', args=[cls.random_order_project.id]) cls.data = {'text': 'example'} - create_default_roles() assign_user_to_role(project_member=project_member, project=cls.main_project, role_name=settings.ROLE_ANNOTATOR) assign_user_to_role(project_member=project_member, project=cls.random_order_project, @@ -448,6 +464,7 @@ class TestDocumentDetailAPI(APITestCase): cls.non_project_member_pass = 'non_project_member_pass' cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) non_project_member = User.objects.create_user(username=cls.non_project_member_name, @@ -460,7 +477,6 @@ class TestDocumentDetailAPI(APITestCase): cls.doc = mommy.make('Document', project=project) cls.url = reverse(viewname='doc_detail', args=[project.id, cls.doc.id]) cls.data = {'text': 'example'} - create_default_roles() assign_user_to_role(project_member=project_member, project=project, role_name=settings.ROLE_ANNOTATOR) @@ -508,34 +524,49 @@ class TestDocumentDetailAPI(APITestCase): class TestApproveLabelsAPI(APITestCase): @classmethod def setUpTestData(cls): - cls.project_member_name = 'project_member_name' - cls.project_member_pass = 'project_member_pass' - cls.super_user_name = 'super_user_name' - cls.super_user_pass = 'super_user_pass' - project_member = User.objects.create_user(username=cls.project_member_name, - password=cls.project_member_pass) - # Todo: change super_user to project_admin. - super_user = User.objects.create_superuser(username=cls.super_user_name, - password=cls.super_user_pass, - email='fizz@buzz.com') - project = mommy.make('TextClassificationProject', users=[project_member, super_user]) + cls.annotator_name = 'annotator_name' + cls.annotator_pass = 'annotator_pass' + cls.approver_name = 'approver_name_name' + cls.approver_pass = 'approver_pass' + cls.project_admin_name = 'project_admin_name' + cls.project_admin_pass = 'project_admin_pass' + annotator = User.objects.create_user(username=cls.annotator_name, + password=cls.annotator_pass) + approver = User.objects.create_user(username=cls.approver_name, + password=cls.approver_pass) + project_admin = User.objects.create_user(username=cls.project_admin_name, + password=cls.project_admin_pass) + project = mommy.make('TextClassificationProject', users=[annotator, approver, project_admin]) cls.doc = mommy.make('Document', project=project) cls.url = reverse(viewname='approve_labels', args=[project.id, cls.doc.id]) create_default_roles() - assign_user_to_role(project_member=project_member, project=project, + assign_user_to_role(project_member=annotator, project=project, role_name=settings.ROLE_ANNOTATOR) + assign_user_to_role(project_member=approver, project=project, + role_name=settings.ROLE_ANNOTATION_APPROVER) + assign_user_to_role(project_member=project_admin, project=project, + role_name=settings.ROLE_PROJECT_ADMIN) - def test_allows_superuser_to_approve_and_disapprove_labels(self): - self.client.login(username=self.super_user_name, password=self.super_user_pass) + def test_allow_project_admin_to_approve_and_disapprove_labels(self): + self.client.login(username=self.project_admin_name, password=self.project_admin_pass) response = self.client.post(self.url, format='json', data={'approved': True}) - self.assertEqual(response.data['annotation_approver'], self.super_user_name) + self.assertEqual(response.data['annotation_approver'], self.project_admin_name) + + response = self.client.post(self.url, format='json', data={'approved': False}) + self.assertIsNone(response.data['annotation_approver']) + + def test_allow_approver_to_approve_and_disapprove_labels(self): + self.client.login(username=self.approver_name, password=self.approver_pass) + + response = self.client.post(self.url, format='json', data={'approved': True}) + self.assertEqual(response.data['annotation_approver'], self.approver_name) response = self.client.post(self.url, format='json', data={'approved': False}) self.assertIsNone(response.data['annotation_approver']) def test_disallows_non_annotation_approver_to_approve_and_disapprove_labels(self): - self.client.login(username=self.project_member_name, password=self.project_member_pass) + self.client.login(username=self.annotator_name, password=self.annotator_pass) response = self.client.post(self.url, format='json', data={'approved': True}) @@ -556,6 +587,7 @@ class TestAnnotationListAPI(APITestCase): cls.another_project_member_pass = 'another_project_member_pass' cls.non_project_member_name = 'non_project_member_name' cls.non_project_member_pass = 'non_project_member_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) another_project_member = User.objects.create_user(username=cls.another_project_member_name, @@ -581,8 +613,6 @@ class TestAnnotationListAPI(APITestCase): document=main_project_doc, user=another_project_member).count() cls.main_project = main_project - - create_default_roles() assign_user_to_role(project_member=project_member, project=main_project, role_name=settings.ROLE_ANNOTATOR) @@ -651,6 +681,7 @@ class TestAnnotationDetailAPI(APITestCase): cls.another_project_member_pass = 'another_project_member_pass' cls.non_project_member_name = 'non_project_member_name' cls.non_project_member_pass = 'non_project_member_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) another_project_member = User.objects.create_user(username=cls.another_project_member_name, @@ -677,7 +708,6 @@ class TestAnnotationDetailAPI(APITestCase): main_project_doc.id, another_entity.id]) cls.post_data = {'start_offset': 0, 'end_offset': 10} - create_default_roles() assign_user_to_role(project_member=project_member, project=main_project, role_name=settings.ROLE_ANNOTATOR) @@ -748,6 +778,7 @@ class TestSearch(APITestCase): cls.project_member_pass = 'project_member_pass' cls.non_project_member_name = 'non_project_member_name' cls.non_project_member_pass = 'non_project_member_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) non_project_member = User.objects.create_user(username=cls.non_project_member_name, @@ -766,7 +797,6 @@ class TestSearch(APITestCase): mommy.make('Document', text=cls.search_term, project=sub_project) cls.url = reverse(viewname='doc_list', args=[cls.main_project.id]) cls.data = {'q': cls.search_term} - create_default_roles() assign_user_to_role(project_member=project_member, project=cls.main_project, role_name=settings.ROLE_ANNOTATOR) @@ -825,6 +855,7 @@ class TestFilter(APITestCase): def setUpTestData(cls): cls.project_member_name = 'project_member_name' cls.project_member_pass = 'project_member_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) cls.main_project = mommy.make('SequenceLabelingProject', users=[project_member]) @@ -837,7 +868,6 @@ class TestFilter(APITestCase): mommy.make('SequenceAnnotation', document=doc2, user=project_member, label=cls.label2) cls.url = reverse(viewname='doc_list', args=[cls.main_project.id]) cls.params = {'seq_annotations__label__id': cls.label1.id} - create_default_roles() assign_user_to_role(project_member=project_member, project=cls.main_project, role_name=settings.ROLE_ANNOTATOR) @@ -882,6 +912,7 @@ class TestUploader(APITestCase): cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' # Todo: change super_user to project_admin. + create_default_roles() super_user = User.objects.create_superuser(username=cls.super_user_name, password=cls.super_user_pass, email='fizz@buzz.com') @@ -890,7 +921,6 @@ class TestUploader(APITestCase): cls.labeling_project = mommy.make('SequenceLabelingProject', users=[super_user], project_type=SEQUENCE_LABELING) cls.seq2seq_project = mommy.make('Seq2seqProject', users=[super_user], project_type=SEQ2SEQ) - create_default_roles() assign_user_to_role(project_member=super_user, project=cls.classification_project, role_name=settings.ROLE_PROJECT_ADMIN) assign_user_to_role(project_member=super_user, project=cls.labeling_project, @@ -1119,7 +1149,7 @@ class TestFeatures(APITestCase): def setUpTestData(cls): cls.user_name = 'user_name' cls.user_pass = 'user_pass' - + create_default_roles() cls.user = User.objects.create_user(username=cls.user_name, password=cls.user_pass, email='fizz@buzz.com') def setUp(self): @@ -1187,6 +1217,7 @@ class TestDownloader(APITestCase): cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' # Todo: change super_user to project_admin. + create_default_roles() super_user = User.objects.create_superuser(username=cls.super_user_name, password=cls.super_user_pass, email='fizz@buzz.com') @@ -1259,6 +1290,7 @@ class TestStatisticsAPI(APITestCase): def setUpTestData(cls): cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' + create_default_roles() # Todo: change super_user to project_admin. super_user = User.objects.create_superuser(username=cls.super_user_name, password=cls.super_user_pass, @@ -1301,6 +1333,7 @@ class TestUserAPI(APITestCase): def setUpTestData(cls): cls.super_user_name = 'super_user_name' cls.super_user_pass = 'super_user_pass' + create_default_roles() User.objects.create_superuser(username=cls.super_user_name, password=cls.super_user_pass, email='fizz@buzz.com') @@ -1321,11 +1354,12 @@ class TestRoleAPI(APITestCase): cls.user_pass = 'user_pass' cls.project_admin_name = 'project_admin_name' cls.project_admin_pass = 'project_admin_pass' + create_default_roles() cls.user = User.objects.create_user(username=cls.user_name, password=cls.user_pass) - project_admin = User.objects.create_superuser(username=cls.project_admin_name, - password=cls.project_admin_pass, - email='fizz@buzz.com') + User.objects.create_superuser(username=cls.project_admin_name, + password=cls.project_admin_pass, + email='fizz@buzz.com') cls.url = reverse(viewname='roles') def test_cannot_create_multiple_roles_with_same_name(self): @@ -1370,19 +1404,19 @@ class TestRoleMappingListAPI(APITestCase): cls.second_project_member_pass = 'second_project_member_pass' cls.project_admin_name = 'project_admin_name' cls.project_admin_pass = 'project_admin_pass' + create_default_roles() project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) cls.second_project_member = User.objects.create_user(username=cls.second_project_member_name, password=cls.second_project_member_pass) - project_admin = User.objects.create_superuser(username=cls.project_admin_name, - password=cls.project_admin_pass, - email='fizz@buzz.com') + project_admin = User.objects.create_user(username=cls.project_admin_name, + password=cls.project_admin_pass) cls.main_project = mommy.make('Project', users=[project_member, project_admin, cls.second_project_member]) cls.other_project = mommy.make('Project', users=[cls.second_project_member, project_admin]) - - cls.role = mommy.make('Role', name=settings.ROLE_PROJECT_ADMIN) - rolemapping = mommy.make('RoleMapping', role=cls.role, project=cls.main_project, user=project_admin) - cls.data = {'user': project_member.id, 'role': cls.role.id, 'project': cls.main_project.id} + cls.admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) + cls.role = mommy.make('Role', name='otherrole') + rolemapping = mommy.make('RoleMapping', role=cls.admin_role, project=cls.main_project, user=project_admin) + cls.data = {'user': project_member.id, 'role': cls.admin_role.id, 'project': cls.main_project.id} cls.other_url = reverse(viewname='rolemapping_list', args=[cls.other_project.id]) cls.url = reverse(viewname='rolemapping_list', args=[cls.main_project.id]) @@ -1410,18 +1444,6 @@ class TestRoleMappingListAPI(APITestCase): response = self.client.get(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_can_create_same_mapping_in_multiple_projects(self): - self.client.login(username=self.project_admin_name, - password=self.project_admin_pass) - mapping = [ - {'user': self.second_project_member.id, 'role': self.role.id, 'project': self.main_project.id}, - {'user': self.second_project_member.id, 'role': self.role.id, 'project': self.other_project.id} - ] - response = self.client.post(self.url, format='json', data=mapping[0]) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - response = self.client.post(self.other_url, format='json', data=mapping[1]) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - class TestRoleMappingDetailAPI(APITestCase): @@ -1433,19 +1455,19 @@ class TestRoleMappingDetailAPI(APITestCase): cls.project_member_pass = 'project_member_pass' cls.non_project_member_name = 'non_project_member_name' cls.non_project_member_pass = 'non_project_member_pass' - project_admin = User.objects.create_superuser(username=cls.project_admin_name, - password=cls.project_admin_pass, - email='fizz@buzz.com') + create_default_roles() + project_admin = User.objects.create_user(username=cls.project_admin_name, + password=cls.project_admin_pass) project_member = User.objects.create_user(username=cls.project_member_name, password=cls.project_member_pass) non_project_member = User.objects.create_user(username=cls.non_project_member_name, password=cls.non_project_member_pass) project = mommy.make('Project', users=[project_admin, project_member]) - role = mommy.make('Role', name=settings.ROLE_PROJECT_ADMIN) - change_role = mommy.make('Role', name=settings.ROLE_ANNOTATOR) - cls.rolemapping = mommy.make('RoleMapping', role=role, project=project, user=project_admin) + admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) + annotator_role = Role.objects.get(name=settings.ROLE_ANNOTATOR) + cls.rolemapping = mommy.make('RoleMapping', role=admin_role, project=project, user=project_admin) cls.url = reverse(viewname='rolemapping_detail', args=[project.id, cls.rolemapping.id]) - cls.data = {'role': change_role.id } + cls.data = {'role': annotator_role.id } def test_returns_rolemapping_to_project_member(self): self.client.login(username=self.project_admin_name, diff --git a/app/api/views.py b/app/api/views.py index 4fa62956..3392f2c0 100644 --- a/app/api/views.py +++ b/app/api/views.py @@ -15,7 +15,7 @@ from rest_framework_csv.renderers import CSVRenderer from .filters import DocumentFilter from .models import Project, Label, Document, RoleMapping, Role -from .permissions import IsProjectAdmin, IsAnnotator, IsAnnotationApprover, IsAnnotatorAndCreator, IsOwnAnnotation +from .permissions import IsProjectAdmin, IsAnnotatorAndReadOnly, IsAnnotator, IsAnnotationApproverAndReadOnly, IsOwnAnnotation, IsAnnotationApprover from .serializers import ProjectSerializer, LabelSerializer, DocumentSerializer, UserSerializer from .serializers import ProjectPolymorphicSerializer, RoleMappingSerializer, RoleSerializer from .utils import CSVParser, ExcelParser, JSONParser, PlainTextParser, CoNLLParser, iterable_to_io @@ -23,6 +23,8 @@ from .serializers import ProjectPolymorphicSerializer, RoleMappingSerializer, Ro from .utils import JSONLRenderer from .utils import JSONPainter, CSVPainter +IsInProjectReadOnlyOrAdmin = (IsAnnotatorAndReadOnly | IsAnnotationApproverAndReadOnly | IsProjectAdmin) +IsInProjectOrAdmin = (IsAnnotator | IsAnnotationApprover | IsProjectAdmin) class Me(APIView): permission_classes = (IsAuthenticated,) @@ -44,7 +46,7 @@ class Features(APIView): class ProjectList(generics.ListCreateAPIView): serializer_class = ProjectPolymorphicSerializer pagination_class = None - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] def get_queryset(self): return self.request.user.projects @@ -57,12 +59,12 @@ class ProjectDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Project.objects.all() serializer_class = ProjectSerializer lookup_url_kwarg = 'project_id' - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] class StatisticsAPI(APIView): pagination_class = None - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] def get(self, request, *args, **kwargs): p = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -90,7 +92,7 @@ class StatisticsAPI(APIView): class ApproveLabelsAPI(APIView): - permission_classes = (IsAuthenticated, IsAnnotationApprover) + permission_classes = [IsAuthenticated & (IsAnnotationApprover | IsProjectAdmin)] def post(self, request, *args, **kwargs): approved = self.request.data.get('approved', True) @@ -103,7 +105,7 @@ class ApproveLabelsAPI(APIView): class LabelList(generics.ListCreateAPIView): serializer_class = LabelSerializer pagination_class = None - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] def get_queryset(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -118,7 +120,7 @@ class LabelDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Label.objects.all() serializer_class = LabelSerializer lookup_url_kwarg = 'label_id' - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] class DocumentList(generics.ListCreateAPIView): @@ -128,7 +130,7 @@ class DocumentList(generics.ListCreateAPIView): ordering_fields = ('created_at', 'updated_at', 'doc_annotations__updated_at', 'seq_annotations__updated_at', 'seq2seq_annotations__updated_at') filter_class = DocumentFilter - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] def get_queryset(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -148,12 +150,12 @@ class DocumentDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Document.objects.all() serializer_class = DocumentSerializer lookup_url_kwarg = 'doc_id' - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] class AnnotationList(generics.ListCreateAPIView): pagination_class = None - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) + permission_classes = [IsAuthenticated & IsInProjectOrAdmin] def get_serializer_class(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -178,17 +180,10 @@ class AnnotationList(generics.ListCreateAPIView): doc = get_object_or_404(Document, pk=self.kwargs['doc_id']) serializer.save(document=doc, user=self.request.user) - def get_permissions(self): - if self.request.method in ('POST'): - self.permission_classes = (IsAnnotatorAndCreator,) - else: - self.permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) - return super(AnnotationList, self).get_permissions() - class AnnotationDetail(generics.RetrieveUpdateDestroyAPIView): lookup_url_kwarg = 'annotation_id' - permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover) and IsOwnAnnotation,) + permission_classes = [IsAuthenticated & (((IsAnnotator | IsAnnotationApprover) & IsOwnAnnotation) | IsProjectAdmin)] def get_serializer_class(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -204,7 +199,7 @@ class AnnotationDetail(generics.RetrieveUpdateDestroyAPIView): class TextUploadAPI(APIView): parser_classes = (MultiPartParser,) - permission_classes = (IsAuthenticated, IsProjectAdmin) + permission_classes = [IsAuthenticated & IsProjectAdmin] def post(self, request, *args, **kwargs): if 'file' not in request.data: @@ -295,7 +290,7 @@ class CloudUploadAPI(APIView): class TextDownloadAPI(APIView): - permission_classes = (IsAuthenticated, IsProjectAdmin) + permission_classes = TextUploadAPI.permission_classes renderer_classes = (CSVRenderer, JSONLRenderer) @@ -324,7 +319,7 @@ class TextDownloadAPI(APIView): class Users(APIView): - permission_classes = (IsAuthenticated, IsProjectAdmin) + permission_classes = [IsAuthenticated & IsProjectAdmin] def get(self, request, *args, **kwargs): queryset = User.objects.all() @@ -335,14 +330,14 @@ class Users(APIView): class Roles(generics.ListCreateAPIView): serializer_class = RoleSerializer pagination_class = None - permission_classes = (IsAuthenticated, IsProjectAdmin) + permission_classes = [IsAuthenticated & IsProjectAdmin] queryset = Role.objects.all() class RoleMappingList(generics.ListCreateAPIView): serializer_class = RoleMappingSerializer pagination_class = None - permission_classes = (IsAuthenticated, IsProjectAdmin) + permission_classes = [IsAuthenticated & IsProjectAdmin] def get_queryset(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -357,4 +352,4 @@ class RoleMappingDetail(generics.RetrieveUpdateDestroyAPIView): queryset = RoleMapping.objects.all() serializer_class = RoleMappingSerializer lookup_url_kwarg = 'rolemapping_id' - permission_classes = (IsAuthenticated, IsProjectAdmin) + permission_classes = [IsAuthenticated & IsProjectAdmin] diff --git a/app/server/static/components/annotation.pug b/app/server/static/components/annotation.pug index 28044eee..91b90a7a 100644 --- a/app/server/static/components/annotation.pug +++ b/app/server/static/components/annotation.pug @@ -114,7 +114,7 @@ div.columns(v-cloak="") div.column.is-1.has-text-right a.button.tooltip.is-tooltip-bottom( - v-if="isSuperuser" + v-if="isAnnotationApprover" v-on:click="approveDocumentAnnotations" v-bind:data-tooltip="documentAnnotationsApprovalTooltip" ) diff --git a/app/server/static/components/annotationMixin.js b/app/server/static/components/annotationMixin.js index 7a9975a4..03b9ab67 100644 --- a/app/server/static/components/annotationMixin.js +++ b/app/server/static/components/annotationMixin.js @@ -1,7 +1,7 @@ import * as marked from 'marked'; import VueJsonPretty from 'vue-json-pretty'; import isEmpty from 'lodash.isempty'; -import HTTP, { defaultHttpClient } from './http'; +import HTTP from './http'; import Preview from './preview.vue'; const getOffsetFromUrl = (url) => { @@ -91,7 +91,7 @@ export default { prevLimit: 0, paginationPages: 0, paginationPage: 0, - isSuperuser: false, + isAnnotationApprover: false, isMetadataActive: false, isAnnotationGuidelineActive: false, }; @@ -253,9 +253,8 @@ export default { }); HTTP.get().then((response) => { this.guideline = response.data.guideline; - }); - defaultHttpClient.get('/v1/me').then((response) => { - this.isSuperuser = response.data.is_superuser; + const roles = response.data.current_users_role; + this.isAnnotationApprover = roles.is_annotation_approver || roles.is_project_admin; }); this.submit(); }, diff --git a/app/server/static/components/projects.vue b/app/server/static/components/projects.vue index f8845e9a..a692ce4a 100644 --- a/app/server/static/components/projects.vue +++ b/app/server/static/components/projects.vue @@ -122,10 +122,10 @@ td.is-vertical span.tag.is-normal {{ project.project_type }} - td.is-vertical(v-if="isSuperuser") + td.is-vertical(v-if="isProjectAdmin.get(project.id)") a(v-bind:href="'/projects/' + project.id + '/docs'") Edit - td.is-vertical(v-if="isSuperuser") + td.is-vertical(v-if="isProjectAdmin.get(project.id)") a.has-text-danger(v-on:click="setProject(project)") Delete @@ -152,6 +152,7 @@ export default { isSuperuser: false, randomizeDocumentOrder: false, collaborativeAnnotation: false, + isProjectAdmin: null, }), computed: { @@ -168,6 +169,10 @@ export default { this.items = projects.data; this.username = me.data.username; this.isSuperuser = me.data.is_superuser; + this.isProjectAdmin = new Map(this.items.map((project) => { + const isProjectAdmin = project.current_users_role.is_project_admin; + return [project.id, isProjectAdmin]; + })); }); }, diff --git a/app/server/templates/annotation.html b/app/server/templates/annotation.html index cd071d1a..cd3a2752 100644 --- a/app/server/templates/annotation.html +++ b/app/server/templates/annotation.html @@ -5,7 +5,7 @@ {% endblock %} {% block navigation %} -{% if user.is_superuser and 'project_id' in view.kwargs %} +{% if is_project_admin and 'project_id' in view.kwargs %} diff --git a/app/server/views.py b/app/server/views.py index a729bde1..e8be32ba 100644 --- a/app/server/views.py +++ b/app/server/views.py @@ -9,7 +9,7 @@ from django.views.generic.list import ListView from django.contrib.auth.mixins import LoginRequiredMixin from api.permissions import SuperUserMixin -from api.models import Project +from api.models import Project, RoleMapping from app import settings logger = logging.getLogger(__name__) @@ -25,6 +25,11 @@ class ProjectView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) project = get_object_or_404(Project, pk=self.kwargs['project_id']) + context['is_project_admin'] = RoleMapping.objects.filter( + role_id__name=settings.ROLE_PROJECT_ADMIN, + project=project.id, + user=self.request.user.id + ).exists() context['bundle_name'] = project.get_bundle_name() return context diff --git a/requirements.txt b/requirements.txt index 6625b265..66a5f372 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ django-widget-tweaks==1.4.2 django-polymorphic==2.0.3 django-pyodbc-azure==2.1.0.0 django-rest-polymorphic==0.1.8 -djangorestframework==3.8.2 +djangorestframework==3.10 djangorestframework-csv==2.1.0 djangorestframework-filters==0.10.2 environs==4.1.0