diff --git a/Pipfile b/Pipfile index 2f459ba7..12ae7248 100644 --- a/Pipfile +++ b/Pipfile @@ -60,6 +60,6 @@ python_version = "3.8" isort = "isort api -c --skip migrations" flake8 = "flake8 --filename \"*.py\" --extend-exclude \"server,api/migrations,api/views/__init__.py,authentification,api/apps.py\"" wait_for_db = "python manage.py wait_for_db" -test = "python manage.py test api.tests" +test = "python manage.py test api.tests roles.tests" migrate = "python manage.py migrate" collectstatic = "python manage.py collectstatic --noinput" diff --git a/backend/api/admin.py b/backend/api/admin.py index b5eac7fe..7bac9d23 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -1,9 +1,8 @@ from django.contrib import admin from .models import (AutoLabelingConfig, Category, CategoryType, Comment, - Example, Project, Role, RoleMapping, Seq2seqProject, - SequenceLabelingProject, Span, SpanType, Tag, - TextClassificationProject, TextLabel) + Example, Project, Seq2seqProject, SequenceLabelingProject, + Span, SpanType, Tag, TextClassificationProject, TextLabel) class LabelAdmin(admin.ModelAdmin): @@ -47,18 +46,6 @@ class TextLabelAdmin(admin.ModelAdmin): ordering = ('example',) -class RoleAdmin(admin.ModelAdmin): - list_display = ('name', 'description') - ordering = ('name',) - search_fields = ('name',) - - -class RoleMappingAdmin(admin.ModelAdmin): - list_display = ('user', 'role', 'project', ) - ordering = ('user',) - search_fields = ('user__username',) - - class TagAdmin(admin.ModelAdmin): list_display = ('project', 'text', ) ordering = ('project', 'text', ) @@ -86,7 +73,6 @@ admin.site.register(AutoLabelingConfig, AutoLabelingConfigAdmin) admin.site.register(Category, CategoryAdmin) admin.site.register(Span, SpanAdmin) admin.site.register(TextLabel, TextLabelAdmin) -# admin.site.register(Label, LabelAdmin) admin.site.register(CategoryType, CategoryTypeAdmin) admin.site.register(SpanType, SpanTypeAdmin) admin.site.register(Example, ExampleAdmin) @@ -94,7 +80,5 @@ admin.site.register(Project, ProjectAdmin) admin.site.register(TextClassificationProject, ProjectAdmin) admin.site.register(SequenceLabelingProject, ProjectAdmin) admin.site.register(Seq2seqProject, ProjectAdmin) -admin.site.register(Role, RoleAdmin) -admin.site.register(RoleMapping, RoleMappingAdmin) admin.site.register(Comment, CommentAdmin) admin.site.register(Tag, TagAdmin) diff --git a/backend/api/exceptions.py b/backend/api/exceptions.py index cac3c279..bb5b0e94 100644 --- a/backend/api/exceptions.py +++ b/backend/api/exceptions.py @@ -53,13 +53,3 @@ class AnnotationRelationValidationError(APIException): class RelationTypesValidationError(APIException): status_code = status.HTTP_400_BAD_REQUEST default_detail = 'You cannot create a relation type with same name or color.' - - -class RoleConstraintException(APIException): - status_code = status.HTTP_400_BAD_REQUEST - default_detail = 'The project needs at least one administrator.' - - -class RoleAlreadyAssignedException(APIException): - status_code = status.HTTP_400_BAD_REQUEST - default_detail = 'This user is already assigned to a role in this project.' diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 3623c390..b5553dd9 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -11,7 +11,7 @@ from .models import (DOCUMENT_CLASSIFICATION, IMAGE_CLASSIFICATION, SEQ2SEQ, AutoLabelingConfig, Category, CategoryType, Comment, Example, ExampleState, ImageClassificationProject, IntentDetectionAndSlotFillingProject, Label, Project, - RelationTypes, Role, RoleMapping, Seq2seqProject, + RelationTypes, RoleMapping, Seq2seqProject, SequenceLabelingProject, Span, SpanType, Speech2textProject, Tag, TextClassificationProject, TextLabel) @@ -306,31 +306,6 @@ class TextLabelSerializer(serializers.ModelSerializer): read_only_fields = ('user',) -class RoleSerializer(serializers.ModelSerializer): - class Meta: - model = Role - fields = ('id', 'name') - - -class RoleMappingSerializer(serializers.ModelSerializer): - username = serializers.SerializerMethodField() - rolename = serializers.SerializerMethodField() - - @classmethod - def get_username(cls, instance): - user = instance.user - return user.username if user else None - - @classmethod - def get_rolename(cls, instance): - role = instance.role - return role.name if role else None - - class Meta: - model = RoleMapping - fields = ('id', 'user', 'role', 'username', 'rolename') - - class AutoLabelingConfigSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/api/tests/api/test_role.py b/backend/api/tests/api/test_role.py deleted file mode 100644 index cde324e8..00000000 --- a/backend/api/tests/api/test_role.py +++ /dev/null @@ -1,116 +0,0 @@ -from django.conf import settings -from rest_framework import status -from rest_framework.reverse import reverse - -from ...models import Role, RoleMapping -from .utils import CRUDMixin, create_default_roles, make_user, prepare_project - - -class TestRoleAPI(CRUDMixin): - - @classmethod - def setUpTestData(cls): - create_default_roles() - cls.user = make_user() - cls.url = reverse(viewname='roles') - - def test_allows_authenticated_user_to_get_roles(self): - self.assert_fetch(self.user, status.HTTP_200_OK) - - def test_disallows_unauthenticated_user_to_get_roles(self): - self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) - - -class TestRoleMappingListAPI(CRUDMixin): - - def setUp(self): - self.project = prepare_project() - self.non_member = make_user() - admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) - self.data = {'user': self.non_member.id, 'role': admin_role.id, 'project': self.project.item.id} - self.url = reverse(viewname='rolemapping_list', args=[self.project.item.id]) - - def test_allows_project_admin_to_get_mappings(self): - self.assert_fetch(self.project.users[0], status.HTTP_200_OK) - - def test_denies_non_project_admin_to_get_mappings(self): - for member in self.project.users[1:]: - self.assert_fetch(member, status.HTTP_403_FORBIDDEN) - - def test_denies_non_project_member_to_get_mappings(self): - self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) - - def test_denies_unauthenticated_user_to_get_mappings(self): - self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) - - def test_allows_project_admin_to_create_mapping(self): - self.assert_create(self.project.users[0], status.HTTP_201_CREATED) - - def test_denies_non_project_admin_to_create_mapping(self): - for member in self.project.users[1:]: - self.assert_create(member, status.HTTP_403_FORBIDDEN) - - def test_denies_non_project_member_to_create_mapping(self): - self.assert_create(self.non_member, status.HTTP_403_FORBIDDEN) - - def test_denies_unauthenticated_user_to_create_mapping(self): - self.assert_create(expected=status.HTTP_403_FORBIDDEN) - - def assert_bulk_delete(self, user=None, expected=status.HTTP_403_FORBIDDEN): - if user: - self.client.force_login(user) - ids = [item.id for item in self.project.item.role_mappings.all()] - response = self.client.delete(self.url, data={'ids': ids}, format='json') - self.assertEqual(response.status_code, expected) - - def test_allows_project_admin_to_bulk_delete(self): - self.assert_bulk_delete(self.project.users[0], status.HTTP_204_NO_CONTENT) - response = self.client.get(self.url) - self.assertEqual(len(response.data), 1) - - def test_denies_non_project_admin_to_bulk_delete(self): - for member in self.project.users[1:]: - self.assert_bulk_delete(member, status.HTTP_403_FORBIDDEN) - - def test_denies_non_project_member_to_bulk_delete(self): - self.assert_bulk_delete(self.non_member, status.HTTP_403_FORBIDDEN) - - def test_denies_unauthenticated_user_to_bulk_delete(self): - self.assert_bulk_delete(expected=status.HTTP_403_FORBIDDEN) - - -class TestRoleMappingDetailAPI(CRUDMixin): - - def setUp(self): - self.project = prepare_project() - self.non_member = make_user() - admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) - mapping = RoleMapping.objects.get(user=self.project.users[1]) - self.url = reverse(viewname='rolemapping_detail', args=[self.project.item.id, mapping.id]) - self.data = {'role': admin_role.id} - - def test_allows_project_admin_to_get_mapping(self): - self.assert_fetch(self.project.users[0], status.HTTP_200_OK) - - def test_denies_non_project_admin_to_get_mapping(self): - for member in self.project.users[1:]: - self.assert_fetch(member, status.HTTP_403_FORBIDDEN) - - def test_denies_non_project_member_to_get_mapping(self): - self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) - - def test_denies_unauthenticated_user_to_get_mapping(self): - self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) - - def test_allows_project_admin_to_update_mapping(self): - self.assert_update(self.project.users[0], status.HTTP_200_OK) - - def test_denies_non_project_admin_to_update_mapping(self): - for member in self.project.users[1:]: - self.assert_update(member, status.HTTP_403_FORBIDDEN) - - def test_denies_non_project_member_to_update_mapping(self): - self.assert_update(self.non_member, status.HTTP_403_FORBIDDEN) - - def test_denies_unauthenticated_user_to_update_mapping(self): - self.assert_update(expected=status.HTTP_403_FORBIDDEN) diff --git a/backend/api/urls.py b/backend/api/urls.py index 5bc2afd7..b7c86678 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -3,7 +3,7 @@ from django.urls import include, path from .views import (annotation, annotation_relations, auto_labeling, comment, example, example_state, export_dataset, health, import_dataset, import_export, label, project, - relation_types, role, statistics, tag, task, user) + relation_types, statistics, tag, task, user) from .views.tasks import category, span, text urlpatterns_project = [ @@ -204,16 +204,6 @@ urlpatterns_project = [ view=example_state.ExampleStateList.as_view(), name='example_state_list' ), - path( - route='roles', - view=role.RoleMappingList.as_view(), - name='rolemapping_list' - ), - path( - route='roles/', - view=role.RoleMappingDetail.as_view(), - name='rolemapping_detail' - ), path( route='auto-labeling-templates', view=auto_labeling.AutoLabelingTemplateListAPI.as_view(), @@ -289,11 +279,6 @@ urlpatterns = [ view=user.Users.as_view(), name='user_list' ), - path( - route='roles', - view=role.Roles.as_view(), - name='roles' - ), path( route='tasks/status/', view=task.TaskStatus.as_view(), diff --git a/backend/api/views/role.py b/backend/api/views/role.py deleted file mode 100644 index ba91b42d..00000000 --- a/backend/api/views/role.py +++ /dev/null @@ -1,61 +0,0 @@ -from django.db import IntegrityError -from django.shortcuts import get_object_or_404 -from rest_framework import generics, status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response - -from ..exceptions import RoleAlreadyAssignedException, RoleConstraintException -from ..models import Project, Role, RoleMapping -from ..permissions import IsProjectAdmin -from ..serializers import RoleMappingSerializer, RoleSerializer - - -class Roles(generics.ListAPIView): - serializer_class = RoleSerializer - pagination_class = None - permission_classes = [IsAuthenticated] - queryset = Role.objects.all() - - -class RoleMappingList(generics.ListCreateAPIView): - serializer_class = RoleMappingSerializer - pagination_class = None - permission_classes = [IsAuthenticated & IsProjectAdmin] - - @property - def project(self): - return get_object_or_404(Project, pk=self.kwargs['project_id']) - - def get_queryset(self): - return self.project.role_mappings - - def perform_create(self, serializer): - try: - serializer.save(project=self.project) - except IntegrityError: - raise RoleAlreadyAssignedException - - def delete(self, request, *args, **kwargs): - delete_ids = request.data['ids'] - RoleMapping.objects.filter(project=self.project, pk__in=delete_ids)\ - .exclude(user=self.request.user)\ - .delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class RoleMappingDetail(generics.RetrieveUpdateAPIView): - queryset = RoleMapping.objects.all() - serializer_class = RoleMappingSerializer - lookup_url_kwarg = 'rolemapping_id' - permission_classes = [IsAuthenticated & IsProjectAdmin] - - def perform_update(self, serializer): - project_id = self.kwargs['project_id'] - id = self.kwargs['rolemapping_id'] - role = serializer.validated_data['role'] - if not RoleMapping.objects.can_update(project_id, id, role.name): - raise RoleConstraintException - try: - super().perform_update(serializer) - except IntegrityError: - raise RoleAlreadyAssignedException diff --git a/backend/app/settings.py b/backend/app/settings.py index cf98ae80..f455d610 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -52,6 +52,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'api.apps.ApiConfig', + 'roles.apps.RolesConfig', 'rest_framework', 'rest_framework.authtoken', 'django_filters', diff --git a/backend/app/urls.py b/backend/app/urls.py index 8a076e3b..eb910691 100644 --- a/backend/app/urls.py +++ b/backend/app/urls.py @@ -41,6 +41,7 @@ urlpatterns += [ path('social/', include('social_django.urls')), path('api-auth/', include('rest_framework.urls')), path('v1/', include('api.urls')), + path('v1/', include('roles.urls')), path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), re_path('', TemplateView.as_view(template_name='index.html')), ] diff --git a/backend/roles/admin.py b/backend/roles/admin.py index 8c38f3f3..3d2f8d62 100644 --- a/backend/roles/admin.py +++ b/backend/roles/admin.py @@ -1,3 +1,19 @@ from django.contrib import admin -# Register your models here. +from api.models import Role, RoleMapping + + +class RoleAdmin(admin.ModelAdmin): + list_display = ('name', 'description') + ordering = ('name',) + search_fields = ('name',) + + +class RoleMappingAdmin(admin.ModelAdmin): + list_display = ('user', 'role', 'project', ) + ordering = ('user',) + search_fields = ('user__username',) + + +admin.site.register(Role, RoleAdmin) +admin.site.register(RoleMapping, RoleMappingAdmin) diff --git a/backend/roles/exceptions.py b/backend/roles/exceptions.py index e69de29b..d09f99ef 100644 --- a/backend/roles/exceptions.py +++ b/backend/roles/exceptions.py @@ -0,0 +1,12 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class RoleConstraintException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = 'The project needs at least one administrator.' + + +class RoleAlreadyAssignedException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = 'This user is already assigned to a role in this project.' diff --git a/backend/roles/models.py b/backend/roles/models.py index 71a83623..e69de29b 100644 --- a/backend/roles/models.py +++ b/backend/roles/models.py @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/backend/roles/serializers.py b/backend/roles/serializers.py index e69de29b..6b37299d 100644 --- a/backend/roles/serializers.py +++ b/backend/roles/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from api.models import Role, RoleMapping + + +class RoleSerializer(serializers.ModelSerializer): + class Meta: + model = Role + fields = ('id', 'name') + + +class RoleMappingSerializer(serializers.ModelSerializer): + username = serializers.SerializerMethodField() + rolename = serializers.SerializerMethodField() + + @classmethod + def get_username(cls, instance): + user = instance.user + return user.username if user else None + + @classmethod + def get_rolename(cls, instance): + role = instance.role + return role.name if role else None + + class Meta: + model = RoleMapping + fields = ('id', 'user', 'role', 'username', 'rolename') diff --git a/backend/roles/tests.py b/backend/roles/tests.py index 7ce503c2..5b68b36d 100644 --- a/backend/roles/tests.py +++ b/backend/roles/tests.py @@ -1,3 +1,117 @@ -from django.test import TestCase +from django.conf import settings +from rest_framework import status +from rest_framework.reverse import reverse -# Create your tests here. +from api.models import Role, RoleMapping +from api.tests.api.utils import (CRUDMixin, create_default_roles, make_user, + prepare_project) + + +class TestRoleAPI(CRUDMixin): + + @classmethod + def setUpTestData(cls): + create_default_roles() + cls.user = make_user() + cls.url = reverse(viewname='roles') + + def test_allows_authenticated_user_to_get_roles(self): + self.assert_fetch(self.user, status.HTTP_200_OK) + + def test_disallows_unauthenticated_user_to_get_roles(self): + self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) + + +class TestRoleMappingListAPI(CRUDMixin): + + def setUp(self): + self.project = prepare_project() + self.non_member = make_user() + admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) + self.data = {'user': self.non_member.id, 'role': admin_role.id, 'project': self.project.item.id} + self.url = reverse(viewname='rolemapping_list', args=[self.project.item.id]) + + def test_allows_project_admin_to_get_mappings(self): + self.assert_fetch(self.project.users[0], status.HTTP_200_OK) + + def test_denies_non_project_admin_to_get_mappings(self): + for member in self.project.users[1:]: + self.assert_fetch(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_get_mappings(self): + self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_get_mappings(self): + self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) + + def test_allows_project_admin_to_create_mapping(self): + self.assert_create(self.project.users[0], status.HTTP_201_CREATED) + + def test_denies_non_project_admin_to_create_mapping(self): + for member in self.project.users[1:]: + self.assert_create(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_create_mapping(self): + self.assert_create(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_create_mapping(self): + self.assert_create(expected=status.HTTP_403_FORBIDDEN) + + def assert_bulk_delete(self, user=None, expected=status.HTTP_403_FORBIDDEN): + if user: + self.client.force_login(user) + ids = [item.id for item in self.project.item.role_mappings.all()] + response = self.client.delete(self.url, data={'ids': ids}, format='json') + self.assertEqual(response.status_code, expected) + + def test_allows_project_admin_to_bulk_delete(self): + self.assert_bulk_delete(self.project.users[0], status.HTTP_204_NO_CONTENT) + response = self.client.get(self.url) + self.assertEqual(len(response.data), 1) + + def test_denies_non_project_admin_to_bulk_delete(self): + for member in self.project.users[1:]: + self.assert_bulk_delete(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_bulk_delete(self): + self.assert_bulk_delete(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_bulk_delete(self): + self.assert_bulk_delete(expected=status.HTTP_403_FORBIDDEN) + + +class TestRoleMappingDetailAPI(CRUDMixin): + + def setUp(self): + self.project = prepare_project() + self.non_member = make_user() + admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) + mapping = RoleMapping.objects.get(user=self.project.users[1]) + self.url = reverse(viewname='rolemapping_detail', args=[self.project.item.id, mapping.id]) + self.data = {'role': admin_role.id} + + def test_allows_project_admin_to_get_mapping(self): + self.assert_fetch(self.project.users[0], status.HTTP_200_OK) + + def test_denies_non_project_admin_to_get_mapping(self): + for member in self.project.users[1:]: + self.assert_fetch(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_get_mapping(self): + self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_get_mapping(self): + self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) + + def test_allows_project_admin_to_update_mapping(self): + self.assert_update(self.project.users[0], status.HTTP_200_OK) + + def test_denies_non_project_admin_to_update_mapping(self): + for member in self.project.users[1:]: + self.assert_update(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_update_mapping(self): + self.assert_update(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_update_mapping(self): + self.assert_update(expected=status.HTTP_403_FORBIDDEN) diff --git a/backend/roles/urls.py b/backend/roles/urls.py index e69de29b..6354d458 100644 --- a/backend/roles/urls.py +++ b/backend/roles/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from .views import RoleMappingDetail, RoleMappingList, Roles + +urlpatterns = [ + path( + route='roles', + view=Roles.as_view(), + name='roles' + ), + path( + route='projects//roles', + view=RoleMappingList.as_view(), + name='rolemapping_list' + ), + path( + route='projects//roles/', + view=RoleMappingDetail.as_view(), + name='rolemapping_detail' + ) +] diff --git a/backend/roles/views.py b/backend/roles/views.py index 91ea44a2..5f65812a 100644 --- a/backend/roles/views.py +++ b/backend/roles/views.py @@ -1,3 +1,61 @@ -from django.shortcuts import render +from django.db import IntegrityError +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response -# Create your views here. +from api.models import Project, Role, RoleMapping +from api.permissions import IsProjectAdmin +from .serializers import RoleMappingSerializer, RoleSerializer +from .exceptions import RoleAlreadyAssignedException, RoleConstraintException + + +class Roles(generics.ListAPIView): + serializer_class = RoleSerializer + pagination_class = None + permission_classes = [IsAuthenticated] + queryset = Role.objects.all() + + +class RoleMappingList(generics.ListCreateAPIView): + serializer_class = RoleMappingSerializer + pagination_class = None + permission_classes = [IsAuthenticated & IsProjectAdmin] + + @property + def project(self): + return get_object_or_404(Project, pk=self.kwargs['project_id']) + + def get_queryset(self): + return self.project.role_mappings + + def perform_create(self, serializer): + try: + serializer.save(project=self.project) + except IntegrityError: + raise RoleAlreadyAssignedException + + def delete(self, request, *args, **kwargs): + delete_ids = request.data['ids'] + RoleMapping.objects.filter(project=self.project, pk__in=delete_ids)\ + .exclude(user=self.request.user)\ + .delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class RoleMappingDetail(generics.RetrieveUpdateAPIView): + queryset = RoleMapping.objects.all() + serializer_class = RoleMappingSerializer + lookup_url_kwarg = 'rolemapping_id' + permission_classes = [IsAuthenticated & IsProjectAdmin] + + def perform_update(self, serializer): + project_id = self.kwargs['project_id'] + id = self.kwargs['rolemapping_id'] + role = serializer.validated_data['role'] + if not RoleMapping.objects.can_update(project_id, id, role.name): + raise RoleConstraintException + try: + super().perform_update(serializer) + except IntegrityError: + raise RoleAlreadyAssignedException