From c1082e14e97e8f3df3bfea3c65e2785b8b8bf399 Mon Sep 17 00:00:00 2001 From: Razi Rais Date: Mon, 5 Aug 2019 16:34:36 -0400 Subject: [PATCH] Role and RoleMapping Models --- app/api/migrations/0004_roles.py | 41 ++++++ app/api/models.py | 48 +++++++ app/api/permissions.py | 69 +++++++-- app/api/tests/test_api.py | 136 ++++++++++++++++-- app/api/views.py | 39 ++--- app/app/settings.py | 4 + .../commands/create_role_mapping.py | 33 +++++ .../management/commands/create_roles.py | 23 +++ tools/dev-django.sh | 1 + tools/run.sh | 1 + 10 files changed, 356 insertions(+), 39 deletions(-) create mode 100644 app/api/migrations/0004_roles.py create mode 100644 app/server/management/commands/create_role_mapping.py create mode 100644 app/server/management/commands/create_roles.py diff --git a/app/api/migrations/0004_roles.py b/app/api/migrations/0004_roles.py new file mode 100644 index 00000000..9b95adda --- /dev/null +++ b/app/api/migrations/0004_roles.py @@ -0,0 +1,41 @@ +# Generated by Django 2.1.7 on 2019-07-25 03:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0003_support_sql_server'), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(default='', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='RoleMapping', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_mapping', to='api.Project')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.Role')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_mapping', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='rolemapping', + unique_together={('user', 'project', 'role')}, + ), + ] diff --git a/app/api/models.py b/app/api/models.py index 90e9d17e..a1c03e1a 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -1,6 +1,8 @@ import string 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.contrib.auth.models import User from django.contrib.staticfiles.storage import staticfiles_storage @@ -237,3 +239,49 @@ class Seq2seqAnnotation(Annotation): class Meta: unique_together = ('document', 'user', 'text') + + +class Role(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(default='') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class RoleMapping(models.Model): + user = models.ForeignKey(User, related_name='role_mapping', on_delete=models.CASCADE) + project = models.ForeignKey(Project, related_name='role_mapping', on_delete=models.CASCADE) + role = models.ForeignKey(Role, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + other_rolemappings = self.project.role_mappings.exclude(id=self.id) + + if other_rolemappings.filter(user=self.user, project=self.project).exists(): + raise ValidationError('This user is already assigned to a role in this project.') + + class Meta: + unique_together = ("user", "project", "role") + + +@receiver(post_save, sender=RoleMapping) +def add_linked_project(sender, instance, created, **kwargs): + userInstance = instance.user + projectInstance = instance.project + if created and userInstance and projectInstance: + user = User.objects.get(pk=userInstance.pk) + project = Project.objects.get(pk=projectInstance.pk) + user.projects.add(project) + user.save() + + +@receiver(pre_delete, sender=RoleMapping) +def delete_linked_project(sender, instance, using, **kwargs): + userInstance = instance.user + projectInstance = instance.project + if userInstance and projectInstance: + user = User.objects.get(pk=userInstance.pk) + project = Project.objects.get(pk=projectInstance.pk) + user.projects.remove(project) + user.save() diff --git a/app/api/permissions.py b/app/api/permissions.py index 99f7bc58..b17f327f 100644 --- a/app/api/permissions.py +++ b/app/api/permissions.py @@ -1,17 +1,21 @@ +from django.conf import settings from django.contrib.auth.mixins import UserPassesTestMixin +from django.db.models import Subquery from django.shortcuts import get_object_or_404 from rest_framework.permissions import BasePermission, SAFE_METHODS, IsAdminUser -from .models import Project +from .models import Project, Role, RoleMapping -class IsProjectUser(BasePermission): +class ProjectMixin: + def get_project_id(self, request, view): + return view.kwargs.get('project_id') or request.query_params.get('project_id') - def has_permission(self, request, view): - user = request.user - project_id = view.kwargs.get('project_id') or request.query_params.get('project_id') - project = get_object_or_404(Project, pk=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() @@ -30,13 +34,62 @@ class SuperUserMixin(UserPassesTestMixin): return self.request.user.is_superuser -class IsOwnAnnotation(BasePermission): +class IsOwnAnnotation(ProjectMixin, BasePermission): def has_permission(self, request, view): - project_id = view.kwargs.get('project_id') + project_id = self.get_project_id(request, view) annotation_id = view.kwargs.get('annotation_id') project = get_object_or_404(Project, pk=project_id) model = project.get_annotation_class() annotation = model.objects.filter(id=annotation_id, user=request.user) return annotation.exists() + + +class RolePermission(ProjectMixin, BasePermission): + UNSAFE_METHODS = ('POST', 'PATCH', 'DELETE') + unsafe_methods_check = True + role_name = '' + + def is_super_user(self, user): + return user.is_superuser + + def has_permission(self, request, view): + is_super_user = self.is_super_user(request.user) + if is_super_user: + return True + + if self.unsafe_methods_check and request.method in self.UNSAFE_METHODS: + return is_super_user + + project_id = self.get_project_id(request, view) + if not project_id and request.method in SAFE_METHODS: + return True + + return is_in_role(self.role_name, request.user.id, project_id) + + +class IsProjectAdmin(RolePermission): + unsafe_methods_check = False + role_name = settings.ROLE_PROJECT_ADMIN + + +class IsAnnotatorAndCreator(RolePermission): + unsafe_methods_check = False + role_name = settings.ROLE_ANNOTATOR + + +class IsAnnotator(RolePermission): + role_name = settings.ROLE_ANNOTATOR + + +class IsAnnotationApprover(RolePermission): + role_name = settings.ROLE_ANNOTATION_APPROVER + + +def is_in_role(role_name, user_id, project_id): + return RoleMapping.objects.filter( + user_id=user_id, + project_id=project_id, + role_id=Subquery(Role.objects.filter(name=role_name).values('id')), + ).exists() diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py index 703abbe1..efaf1b8a 100644 --- a/app/api/tests/test_api.py +++ b/app/api/tests/test_api.py @@ -1,18 +1,34 @@ import os +from django.conf import settings from django.test import override_settings from rest_framework import status from rest_framework.reverse import reverse from rest_framework.test import APITestCase from model_mommy import mommy -from ..models import User, SequenceAnnotation, Document +from ..models import User, SequenceAnnotation, Document, Role, RoleMapping from ..models import DOCUMENT_CLASSIFICATION, SEQUENCE_LABELING, SEQ2SEQ from ..utils import PlainTextParser, CoNLLParser, JSONParser, CSVParser from ..exceptions import FileParseException DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') +def create_default_roles(): + Role.objects.get_or_create(name=settings.ROLE_PROJECT_ADMIN) + Role.objects.get_or_create(name=settings.ROLE_ANNOTATOR) + Role.objects.get_or_create(name=settings.ROLE_ANNOTATION_APPROVER) + + +def assign_user_to_role(project_member, project, role_name): + role, created = Role.objects.get_or_create(name=role_name) + RoleMapping.objects.get_or_create(role_id=role.id, user_id=project_member.id, project_id=project.id) + + +def remove_all_role_mappings(): + RoleMapping.objects.all().delete() + + @override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') class TestProjectListAPI(APITestCase): @@ -32,10 +48,13 @@ class TestProjectListAPI(APITestCase): super_user = 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) cls.url = reverse(viewname='project_list') cls.data = {'name': 'example', 'project_type': 'DocumentClassification', 'description': 'example', 'guideline': 'example', @@ -72,6 +91,10 @@ class TestProjectListAPI(APITestCase): response = self.client.post(self.url, format='json', data=self.data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + @override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') class TestProjectDetailAPI(APITestCase): @@ -88,14 +111,17 @@ class TestProjectDetailAPI(APITestCase): 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, email='fizz@buzz.com') + cls.main_project = mommy.make('TextClassificationProject', users=[cls.project_member, super_user]) 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) def test_returns_main_project_detail_to_main_project_member(self): self.client.login(username=self.project_member_name, @@ -115,9 +141,9 @@ class TestProjectDetailAPI(APITestCase): response = self.client.patch(self.url, format='json', data=self.data) self.assertEqual(response.data['description'], self.data['description']) - def test_disallows_project_member_to_update_project(self): - self.client.login(username=self.project_member_name, - password=self.project_member_pass) + def test_disallows_non_project_member_to_update_project(self): + self.client.login(username=self.non_project_member_name, + password=self.non_project_member_pass) response = self.client.patch(self.url, format='json', data=self.data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -127,12 +153,16 @@ class TestProjectDetailAPI(APITestCase): response = self.client.delete(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - def test_disallows_project_member_to_delete_project(self): - self.client.login(username=self.project_member_name, - password=self.project_member_pass) + def test_disallows_non_project_member_to_delete_project(self): + self.client.login(username=self.non_project_member_name, + password=self.non_project_member_pass) response = self.client.delete(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestLabelListAPI(APITestCase): @@ -144,15 +174,15 @@ class TestLabelListAPI(APITestCase): cls.non_project_member_pass = 'non_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) + 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, email='fizz@buzz.com') - cls.main_project = mommy.make('Project', users=[project_member, super_user]) + cls.main_project = mommy.make('Project', users=[cls.project_member, super_user]) cls.main_project_label = mommy.make('Label', project=cls.main_project) sub_project = mommy.make('Project', users=[non_project_member]) @@ -161,6 +191,9 @@ class TestLabelListAPI(APITestCase): 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) def test_returns_labels_to_project_member(self): self.client.login(username=self.project_member_name, @@ -235,6 +268,10 @@ class TestLabelListAPI(APITestCase): response = self.client.post(self.url, format='json', data=self.data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestLabelDetailAPI(APITestCase): @@ -258,6 +295,9 @@ class TestLabelDetailAPI(APITestCase): cls.label = mommy.make('Label', project=project) cls.url = reverse(viewname='label_detail', args=[project.id, cls.label.id]) cls.data = {'text': 'example'} + create_default_roles() + assign_user_to_role(project_member=project_member, project=project, + role_name=settings.ROLE_ANNOTATOR) def test_returns_label_to_project_member(self): self.client.login(username=self.project_member_name, @@ -295,6 +335,10 @@ class TestLabelDetailAPI(APITestCase): response = self.client.delete(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestDocumentListAPI(APITestCase): @@ -310,7 +354,6 @@ class TestDocumentListAPI(APITestCase): 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, email='fizz@buzz.com') @@ -327,6 +370,11 @@ 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, + role_name=settings.ROLE_ANNOTATOR) def test_returns_docs_to_project_member(self): self.client.login(username=self.project_member_name, @@ -385,6 +433,10 @@ class TestDocumentListAPI(APITestCase): response = self.client.post(self.url, format='json', data=self.data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestDocumentDetailAPI(APITestCase): @@ -408,6 +460,9 @@ 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) def test_returns_doc_to_project_member(self): self.client.login(username=self.project_member_name, @@ -445,6 +500,10 @@ class TestDocumentDetailAPI(APITestCase): response = self.client.delete(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestApproveLabelsAPI(APITestCase): @classmethod @@ -462,6 +521,9 @@ class TestApproveLabelsAPI(APITestCase): project = mommy.make('TextClassificationProject', users=[project_member, super_user]) 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, + role_name=settings.ROLE_ANNOTATOR) def test_allows_superuser_to_approve_and_disapprove_labels(self): self.client.login(username=self.super_user_name, password=self.super_user_pass) @@ -472,13 +534,17 @@ class TestApproveLabelsAPI(APITestCase): response = self.client.post(self.url, format='json', data={'approved': False}) self.assertIsNone(response.data['annotation_approver']) - def test_disallows_project_member_to_approve_and_disapprove_labels(self): + 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) response = self.client.post(self.url, format='json', data={'approved': True}) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestAnnotationListAPI(APITestCase): @@ -516,6 +582,10 @@ class TestAnnotationListAPI(APITestCase): 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) + def test_returns_annotations_to_project_member(self): self.client.login(username=self.project_member_name, password=self.project_member_pass) @@ -566,6 +636,10 @@ class TestAnnotationListAPI(APITestCase): self.addCleanup(cleanup_project) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestAnnotationDetailAPI(APITestCase): @@ -603,6 +677,9 @@ 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) def test_returns_annotation_to_project_member(self): self.client.login(username=self.project_member_name, @@ -658,6 +735,10 @@ class TestAnnotationDetailAPI(APITestCase): response = self.client.delete(self.another_url, format='json', data=self.post_data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestSearch(APITestCase): @@ -685,6 +766,9 @@ 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) def test_can_filter_doc_by_term(self): self.client.login(username=self.project_member_name, @@ -730,6 +814,10 @@ class TestSearch(APITestCase): for d1, d2 in zip(response.data['results'], docs): self.assertEqual(d1['id'], d2['id']) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestFilter(APITestCase): @@ -749,6 +837,9 @@ 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) def test_can_filter_by_label(self): self.client.login(username=self.project_member_name, @@ -779,6 +870,10 @@ class TestFilter(APITestCase): for d1, d2 in zip(response.data['results'], docs): self.assertEqual(d1['id'], d2['id']) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + class TestUploader(APITestCase): @@ -795,6 +890,13 @@ 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, + role_name=settings.ROLE_PROJECT_ADMIN) + assign_user_to_role(project_member=super_user, project=cls.seq2seq_project, + role_name=settings.ROLE_PROJECT_ADMIN) def setUp(self): self.client.login(username=self.super_user_name, @@ -956,6 +1058,10 @@ class TestUploader(APITestCase): file_format='json', expected_status=status.HTTP_201_CREATED) + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + @override_settings(CLOUD_BROWSER_APACHE_LIBCLOUD_PROVIDER='LOCAL') @override_settings(CLOUD_BROWSER_APACHE_LIBCLOUD_ACCOUNT=os.path.dirname(DATA_DIR)) diff --git a/app/api/views.py b/app/api/views.py index b221496e..89a05f0d 100644 --- a/app/api/views.py +++ b/app/api/views.py @@ -6,7 +6,7 @@ from libcloud.base import DriverType, get_driver from libcloud.storage.types import ContainerDoesNotExistError, ObjectDoesNotExistError from rest_framework import generics, filters, status from rest_framework.exceptions import ParseError, ValidationError -from rest_framework.permissions import IsAuthenticated, IsAdminUser +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.parsers import MultiPartParser @@ -14,7 +14,7 @@ from rest_framework_csv.renderers import CSVRenderer from .filters import DocumentFilter from .models import Project, Label, Document -from .permissions import IsAdminUserAndWriteOnly, IsProjectUser, IsOwnAnnotation +from .permissions import IsProjectAdmin, IsAnnotator, IsAnnotationApprover, IsAnnotatorAndCreator, IsOwnAnnotation from .serializers import ProjectSerializer, LabelSerializer, DocumentSerializer, UserSerializer from .serializers import ProjectPolymorphicSerializer from .utils import CSVParser, ExcelParser, JSONParser, PlainTextParser, CoNLLParser, iterable_to_io @@ -42,7 +42,7 @@ class Features(APIView): class ProjectList(generics.ListCreateAPIView): serializer_class = ProjectPolymorphicSerializer pagination_class = None - permission_classes = (IsAuthenticated, IsAdminUserAndWriteOnly) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) def get_queryset(self): return self.request.user.projects @@ -55,12 +55,12 @@ class ProjectDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Project.objects.all() serializer_class = ProjectSerializer lookup_url_kwarg = 'project_id' - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUserAndWriteOnly) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) class StatisticsAPI(APIView): pagination_class = None - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUserAndWriteOnly) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) def get(self, request, *args, **kwargs): p = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -77,7 +77,7 @@ class StatisticsAPI(APIView): annotation_class = project.get_annotation_class() total = docs.count() done = annotation_class.objects.filter(document_id__in=docs.all(), - user_id=self.request.user).\ + user_id=self.request.user).\ aggregate(Count('document', distinct=True))['document__count'] remaining = total - done return {'total': total, 'remaining': remaining} @@ -88,7 +88,7 @@ class StatisticsAPI(APIView): class ApproveLabelsAPI(APIView): - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUser) + permission_classes = (IsAuthenticated, IsAnnotationApprover) def post(self, request, *args, **kwargs): approved = self.request.data.get('approved', True) @@ -101,7 +101,7 @@ class ApproveLabelsAPI(APIView): class LabelList(generics.ListCreateAPIView): serializer_class = LabelSerializer pagination_class = None - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUserAndWriteOnly) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) def get_queryset(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -116,7 +116,7 @@ class LabelDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Label.objects.all() serializer_class = LabelSerializer lookup_url_kwarg = 'label_id' - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUserAndWriteOnly) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) class DocumentList(generics.ListCreateAPIView): @@ -126,13 +126,12 @@ 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, IsProjectUser, IsAdminUserAndWriteOnly) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) def get_queryset(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) queryset = project.documents - if project.randomize_document_order: queryset = queryset.annotate(sort_id=F('id') % self.request.user.id).order_by('sort_id') @@ -147,12 +146,12 @@ class DocumentDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Document.objects.all() serializer_class = DocumentSerializer lookup_url_kwarg = 'doc_id' - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUserAndWriteOnly) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) class AnnotationList(generics.ListCreateAPIView): pagination_class = None - permission_classes = (IsAuthenticated, IsProjectUser) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover),) def get_serializer_class(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -177,10 +176,17 @@ 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, IsProjectUser, IsOwnAnnotation) + permission_classes = (IsAuthenticated and (IsAnnotator or IsAnnotationApprover) and IsOwnAnnotation,) def get_serializer_class(self): project = get_object_or_404(Project, pk=self.kwargs['project_id']) @@ -196,7 +202,7 @@ class AnnotationDetail(generics.RetrieveUpdateDestroyAPIView): class TextUploadAPI(APIView): parser_classes = (MultiPartParser,) - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUser) + permission_classes = (IsAuthenticated, IsProjectAdmin) def post(self, request, *args, **kwargs): if 'file' not in request.data: @@ -287,7 +293,8 @@ class CloudUploadAPI(APIView): class TextDownloadAPI(APIView): - permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUser) + permission_classes = (IsAuthenticated, IsProjectAdmin) + renderer_classes = (CSVRenderer, JSONLRenderer) def get(self, request, *args, **kwargs): diff --git a/app/app/settings.py b/app/app/settings.py index daca7d34..c4f0a8fa 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -175,6 +175,10 @@ SOCIAL_AUTH_PIPELINE = [ 'server.social_auth.fetch_azuread_permissions', ] +ROLE_PROJECT_ADMIN = env('ROLE_PROJECT_ADMIN', 'project_admin') +ROLE_ANNOTATOR = env('ROLE_ANNOTATOR', 'annotator') +ROLE_ANNOTATION_APPROVER = env('ROLE_ANNOTATION_APPROVER', 'annotation_approver') + # Database # https://docs.djangoproject.com/en/2.0/ref/settings/#databases diff --git a/app/server/management/commands/create_role_mapping.py b/app/server/management/commands/create_role_mapping.py new file mode 100644 index 00000000..458657b3 --- /dev/null +++ b/app/server/management/commands/create_role_mapping.py @@ -0,0 +1,33 @@ +from api.models import Project, Role, RoleMapping, User +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = 'Non-interactively create a rolemapping' + + def add_arguments(self, parser): + parser.add_argument('--rolename', default=None, + help='The name of the role.') + parser.add_argument('--username', default=None, + help='The name of the user.') + parser.add_argument('--projectname', default=None, + help='The name of the project.') + + def handle(self, *args, **options): + rolename = options.get('rolename') + username = options.get('username') + projectname = options.get('projectname') + + if not rolename or not username or not projectname: + raise CommandError('--rolename --projectname --username are required for the rolemapping') + + if rolename and projectname and username: + try: + role = Role.objects.get(name=rolename) + user = User.objects.get(username=username) + project = Project.objects.get(name=projectname) + rolemapping = RoleMapping.objects.create(role_id=role.id, user_id=user.id, project_id=project.id) + except Exception as ex: + self.stderr.write(self.style.ERROR('Error occurred while creating rolemapping "%s"' % ex)) + else: + self.stdout.write(self.style.SUCCESS('Rolemapping created successfully "%s"' % rolemapping.id)) diff --git a/app/server/management/commands/create_roles.py b/app/server/management/commands/create_roles.py new file mode 100644 index 00000000..6f01f81a --- /dev/null +++ b/app/server/management/commands/create_roles.py @@ -0,0 +1,23 @@ +from api.models import Role +from django.core.management.base import BaseCommand +from django.db import DatabaseError +from django.conf import settings + + +class Command(BaseCommand): + help = 'Non-interactively create default roles' + + def handle(self, *args, **options): + try: + role_names = [settings.ROLE_PROJECT_ADMIN, settings.ROLE_ANNOTATOR, settings.ROLE_ANNOTATION_APPROVER] + except KeyError as key_error: + self.stderr.write(self.style.ERROR(f'Missing Key: "{key_error}"')) + for role_name in role_names: + role = Role() + role.name = role_name + try: + role.save() + except DatabaseError as db_error: + self.stderr.write(self.style.ERROR(f'Datbase Error: "{db_error}"')) + else: + self.stdout.write(self.style.SUCCESS(f'Role created successfully "{role_name}"')) diff --git a/tools/dev-django.sh b/tools/dev-django.sh index a6d70df6..9a4d988a 100755 --- a/tools/dev-django.sh +++ b/tools/dev-django.sh @@ -19,6 +19,7 @@ echo "Installing dependencies" echo "Initializing database" "${venv}/bin/python" "${app}/manage.py" wait_for_db "${venv}/bin/python" "${app}/manage.py" migrate +"${venv}/bin/python" "${app}/manage.py" create_roles if [[ -n "${ADMIN_USERNAME}" ]] && [[ -n "${ADMIN_PASSWORD}" ]] && [[ -n "${ADMIN_EMAIL}" ]]; then "${venv}/bin/python" "${app}/manage.py" create_admin \ diff --git a/tools/run.sh b/tools/run.sh index ee756865..3dadf806 100755 --- a/tools/run.sh +++ b/tools/run.sh @@ -6,6 +6,7 @@ if [[ ! -d "app/staticfiles" ]]; then python app/manage.py collectstatic --noinp python app/manage.py wait_for_db python app/manage.py migrate +python app/manage.py create_roles if [[ -n "${ADMIN_USERNAME}" ]] && [[ -n "${ADMIN_EMAIL}" ]] && [[ -n "${ADMIN_PASSWORD}" ]]; then python app/manage.py create_admin --noinput --username="${ADMIN_USERNAME}" --email="${ADMIN_EMAIL}" --password="${ADMIN_PASSWORD}"