diff --git a/Pipfile b/Pipfile index 12ae7248..6fee9310 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 roles.tests" +test = "python manage.py test api.tests roles.tests members.tests" migrate = "python manage.py migrate" collectstatic = "python manage.py collectstatic --noinput" diff --git a/backend/api/tests/api/utils.py b/backend/api/tests/api/utils.py index dd552c50..4ecf5bf3 100644 --- a/backend/api/tests/api/utils.py +++ b/backend/api/tests/api/utils.py @@ -8,7 +8,8 @@ from model_mommy import mommy from rest_framework import status from rest_framework.test import APITestCase -from roles.models import Member, Role +from members.models import Member +from roles.models import Role from ...models import (DOCUMENT_CLASSIFICATION, IMAGE_CLASSIFICATION, INTENT_DETECTION_AND_SLOT_FILLING, SEQ2SEQ, diff --git a/backend/api/views/annotation.py b/backend/api/views/annotation.py index 365b2a43..503ce8f2 100644 --- a/backend/api/views/annotation.py +++ b/backend/api/views/annotation.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsAnnotationApprover, IsProjectAdmin +from members.permissions import IsAnnotationApprover, IsProjectAdmin from ..models import Example from ..serializers import ApproverSerializer diff --git a/backend/api/views/annotation_relations.py b/backend/api/views/annotation_relations.py index 455fac95..1c4e1d6b 100644 --- a/backend/api/views/annotation_relations.py +++ b/backend/api/views/annotation_relations.py @@ -9,7 +9,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin +from members.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin from ..exceptions import AnnotationRelationValidationError from ..models import AnnotationRelations, Project diff --git a/backend/api/views/auto_labeling.py b/backend/api/views/auto_labeling.py index 4d5c0c29..6f9bbc31 100644 --- a/backend/api/views/auto_labeling.py +++ b/backend/api/views/auto_labeling.py @@ -16,7 +16,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsInProjectOrAdmin, IsProjectAdmin +from members.permissions import IsInProjectOrAdmin, IsProjectAdmin from ..exceptions import (AutoLabelingException, AutoLabelingPermissionDenied, AWSTokenError, SampleDataException, diff --git a/backend/api/views/comment.py b/backend/api/views/comment.py index ace61f0e..2871349d 100644 --- a/backend/api/views/comment.py +++ b/backend/api/views/comment.py @@ -3,7 +3,7 @@ from rest_framework import filters, generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from roles.permissions import IsInProjectOrAdmin +from members.permissions import IsInProjectOrAdmin from ..models import Comment from ..permissions import IsOwnComment diff --git a/backend/api/views/example.py b/backend/api/views/example.py index fe1ba364..8568626f 100644 --- a/backend/api/views/example.py +++ b/backend/api/views/example.py @@ -7,7 +7,7 @@ from rest_framework import filters, generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from roles.permissions import IsInProjectReadOnlyOrAdmin +from members.permissions import IsInProjectReadOnlyOrAdmin from ..filters import DocumentFilter, ExampleFilter from ..models import Example, Project diff --git a/backend/api/views/example_state.py b/backend/api/views/example_state.py index bfa9a11a..7dd232bb 100644 --- a/backend/api/views/example_state.py +++ b/backend/api/views/example_state.py @@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics from rest_framework.permissions import IsAuthenticated -from roles.permissions import IsInProjectOrAdmin +from members.permissions import IsInProjectOrAdmin from ..models import Example, ExampleState, Project from ..serializers import ExampleStateSerializer diff --git a/backend/api/views/export_dataset.py b/backend/api/views/export_dataset.py index 137b21f1..06732fcd 100644 --- a/backend/api/views/export_dataset.py +++ b/backend/api/views/export_dataset.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsProjectAdmin +from members.permissions import IsProjectAdmin from ..models import Project from ..tasks import export_dataset diff --git a/backend/api/views/import_dataset.py b/backend/api/views/import_dataset.py index 89db5245..e4019ced 100644 --- a/backend/api/views/import_dataset.py +++ b/backend/api/views/import_dataset.py @@ -8,7 +8,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsProjectAdmin +from members.permissions import IsProjectAdmin from ..models import Project from ..tasks import ingest_data diff --git a/backend/api/views/label.py b/backend/api/views/label.py index 4243fdd7..87fc5ff1 100644 --- a/backend/api/views/label.py +++ b/backend/api/views/label.py @@ -11,7 +11,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin +from members.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin from ..exceptions import LabelValidationError from ..models import CategoryType, Label, Project, SpanType diff --git a/backend/api/views/project.py b/backend/api/views/project.py index 07541d0f..7e789f5a 100644 --- a/backend/api/views/project.py +++ b/backend/api/views/project.py @@ -3,7 +3,7 @@ from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from roles.permissions import IsInProjectReadOnlyOrAdmin +from members.permissions import IsInProjectReadOnlyOrAdmin from ..models import Project from ..permissions import IsStaff diff --git a/backend/api/views/relation_types.py b/backend/api/views/relation_types.py index 78bff62d..fe5186b4 100644 --- a/backend/api/views/relation_types.py +++ b/backend/api/views/relation_types.py @@ -9,7 +9,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin +from members.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin from ..exceptions import RelationTypesValidationError from ..models import Project, RelationTypes diff --git a/backend/api/views/statistics.py b/backend/api/views/statistics.py index 1b8a152c..89da3c89 100644 --- a/backend/api/views/statistics.py +++ b/backend/api/views/statistics.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsInProjectReadOnlyOrAdmin +from members.permissions import IsInProjectReadOnlyOrAdmin from ..models import (Annotation, Category, CategoryType, Example, ExampleState, Label, Project, Span, SpanType) diff --git a/backend/api/views/tag.py b/backend/api/views/tag.py index 6f6e47ea..4e7e00a6 100644 --- a/backend/api/views/tag.py +++ b/backend/api/views/tag.py @@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics from rest_framework.permissions import IsAuthenticated -from roles.permissions import IsInProjectReadOnlyOrAdmin +from members.permissions import IsInProjectReadOnlyOrAdmin from ..models import Project, Tag from ..serializers import TagSerializer diff --git a/backend/api/views/tasks/base.py b/backend/api/views/tasks/base.py index e2143a77..dce93c7b 100644 --- a/backend/api/views/tasks/base.py +++ b/backend/api/views/tasks/base.py @@ -6,7 +6,7 @@ from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from roles.permissions import IsInProjectOrAdmin +from members.permissions import IsInProjectOrAdmin from ...models import Project from ...permissions import CanEditAnnotation diff --git a/backend/api/views/user.py b/backend/api/views/user.py index 0e35d17d..2761fcd0 100644 --- a/backend/api/views/user.py +++ b/backend/api/views/user.py @@ -5,7 +5,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from roles.permissions import IsProjectAdmin +from members.permissions import IsProjectAdmin from ..serializers import UserSerializer diff --git a/backend/app/settings.py b/backend/app/settings.py index f455d610..dee11d8b 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -53,6 +53,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'api.apps.ApiConfig', 'roles.apps.RolesConfig', + 'members.apps.MembersConfig', 'rest_framework', 'rest_framework.authtoken', 'django_filters', diff --git a/backend/app/urls.py b/backend/app/urls.py index eb910691..bb9b8dc1 100644 --- a/backend/app/urls.py +++ b/backend/app/urls.py @@ -42,6 +42,7 @@ urlpatterns += [ path('api-auth/', include('rest_framework.urls')), path('v1/', include('api.urls')), path('v1/', include('roles.urls')), + path('v1/projects//', include('members.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/members/admin.py b/backend/members/admin.py index 8c38f3f3..0d18a671 100644 --- a/backend/members/admin.py +++ b/backend/members/admin.py @@ -1,3 +1,12 @@ from django.contrib import admin -# Register your models here. +from .models import Member + + +class MemberAdmin(admin.ModelAdmin): + list_display = ('user', 'role', 'project', ) + ordering = ('user',) + search_fields = ('user__username',) + + +admin.site.register(Member, MemberAdmin) diff --git a/backend/members/apps.py b/backend/members/apps.py index c775af84..5379ddf5 100644 --- a/backend/members/apps.py +++ b/backend/members/apps.py @@ -1,6 +1,11 @@ +import importlib + from django.apps import AppConfig class MembersConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'members' + + def ready(self): + importlib.import_module('members.signals') diff --git a/backend/roles/exceptions.py b/backend/members/exceptions.py similarity index 100% rename from backend/roles/exceptions.py rename to backend/members/exceptions.py diff --git a/backend/members/migrations/0001_initial.py b/backend/members/migrations/0001_initial.py new file mode 100644 index 00000000..c4bcccef --- /dev/null +++ b/backend/members/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.8 on 2022-01-13 01:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('roles', '0003_delete_member'), + ('api', '0028_auto_20220111_0655'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='Member', + fields=[ + ('id', + models.BigAutoField(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_mappings', + to='api.project')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='roles.role')), + ('user', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_mappings', + to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'project')}, + }, + ), + ], + database_operations=[] + ) + ] diff --git a/backend/members/models.py b/backend/members/models.py index 71a83623..f3082af9 100644 --- a/backend/members/models.py +++ b/backend/members/models.py @@ -1,3 +1,66 @@ +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.db import models -# Create your models here. +from django.db.models import Manager + +from api.models import Project +from roles.models import Role + + +class MemberManager(Manager): + + def can_update(self, project: int, mapping_id: int, new_role: str) -> bool: + """The project needs at least 1 admin. + + Args: + project: The project id. + mapping_id: The mapping id. + new_role: The new role name. + + Returns: + Whether the mapping can be updated or not. + """ + queryset = self.filter( + project=project, role__name=settings.ROLE_PROJECT_ADMIN + ) + if queryset.count() > 1: + return True + else: + mapping = queryset.first() + if mapping.id == mapping_id and new_role != settings.ROLE_PROJECT_ADMIN: + return False + return True + + def has_role(self, project_id: int, user: User, role_name: str): + return self.filter(project=project_id, user=user, role__name=role_name).exists() + + +class Member(models.Model): + user = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + related_name='role_mappings' + ) + project = models.ForeignKey( + to=Project, + on_delete=models.CASCADE, + related_name='role_mappings' + ) + role = models.ForeignKey( + to=Role, + on_delete=models.CASCADE + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + objects = MemberManager() + + def clean(self): + members = self.objects.exclude(id=self.id) + if members.filter(user=self.user, project=self.project).exists(): + message = 'This user is already assigned to a role in this project.' + raise ValidationError(message) + + class Meta: + unique_together = ('user', 'project') diff --git a/backend/roles/permissions.py b/backend/members/permissions.py similarity index 100% rename from backend/roles/permissions.py rename to backend/members/permissions.py diff --git a/backend/members/serializers.py b/backend/members/serializers.py new file mode 100644 index 00000000..5b514652 --- /dev/null +++ b/backend/members/serializers.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from .models import Member + + +class MemberSerializer(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 = Member + fields = ('id', 'user', 'role', 'username', 'rolename') diff --git a/backend/roles/signals.py b/backend/members/signals.py similarity index 96% rename from backend/roles/signals.py rename to backend/members/signals.py index f90315b0..8245b2df 100644 --- a/backend/roles/signals.py +++ b/backend/members/signals.py @@ -4,7 +4,8 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver from api.models import Project -from .models import Role, Member +from roles.models import Role +from .models import Member @receiver(post_save, sender=Member) diff --git a/backend/members/tests.py b/backend/members/tests.py index 7ce503c2..4ab43985 100644 --- a/backend/members/tests.py +++ b/backend/members/tests.py @@ -1,3 +1,131 @@ -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 roles.models import Role +from .models import Member +from api.tests.api.utils import (CRUDMixin, prepare_project, make_user) + + +class TestMemberListAPI(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='member_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 TestMemberRoleDetailAPI(CRUDMixin): + + def setUp(self): + self.project = prepare_project() + self.non_member = make_user() + admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) + mapping = Member.objects.get(user=self.project.users[1]) + self.url = reverse(viewname='member_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) + + +class TestMemberFilter(CRUDMixin): + + def setUp(self): + self.project = prepare_project() + self.url = reverse(viewname='member_list', args=[self.project.item.id]) + self.url += f'?user={self.project.users[0].id}' + + def test_filter_role_by_user_id(self): + response = self.assert_fetch(self.project.users[0], status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + +class TestMemberManager(CRUDMixin): + + def setUp(self): + pass + + def test_has_role(self): + project = prepare_project() + admin = project.users[0] + expected = [ + (settings.ROLE_PROJECT_ADMIN, True), + (settings.ROLE_ANNOTATION_APPROVER, False), + (settings.ROLE_ANNOTATOR, False) + ] + for role, expect in expected: + self.assertEqual(Member.objects.has_role(project.item, admin, role), expect) diff --git a/backend/members/urls.py b/backend/members/urls.py new file mode 100644 index 00000000..f9236ca9 --- /dev/null +++ b/backend/members/urls.py @@ -0,0 +1,16 @@ +from django.urls import path + +from .views import MemberList, MemberDetail + +urlpatterns = [ + path( + route='members', + view=MemberList.as_view(), + name='member_list' + ), + path( + route='members/', + view=MemberDetail.as_view(), + name='member_detail' + ) +] diff --git a/backend/members/views.py b/backend/members/views.py index 91ea44a2..2d9c8ee1 100644 --- a/backend/members/views.py +++ b/backend/members/views.py @@ -1,3 +1,55 @@ -from django.shortcuts import render +from django.db import IntegrityError +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response -# Create your views here. +from .permissions import IsProjectAdmin +from .serializers import MemberSerializer +from .exceptions import RoleAlreadyAssignedException, RoleConstraintException +from .models import Member + + +class MemberList(generics.ListCreateAPIView): + filter_backends = [DjangoFilterBackend] + filterset_fields = ['user'] + queryset = Member.objects.all() + serializer_class = MemberSerializer + pagination_class = None + permission_classes = [IsAuthenticated & IsProjectAdmin] + + def filter_queryset(self, queryset): + queryset = queryset.filter(project=self.kwargs['project_id']) + return super().filter_queryset(queryset) + + def perform_create(self, serializer): + try: + serializer.save(project_id=self.kwargs['project_id']) + except IntegrityError: + raise RoleAlreadyAssignedException + + def delete(self, request, *args, **kwargs): + delete_ids = request.data['ids'] + project_id = self.kwargs['project_id'] + Member.objects.filter(project=project_id, pk__in=delete_ids)\ + .exclude(user=self.request.user)\ + .delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class MemberDetail(generics.RetrieveUpdateAPIView): + queryset = Member.objects.all() + serializer_class = MemberSerializer + lookup_url_kwarg = 'member_id' + permission_classes = [IsAuthenticated & IsProjectAdmin] + + def perform_update(self, serializer): + project_id = self.kwargs['project_id'] + member_id = self.kwargs['member_id'] + role = serializer.validated_data['role'] + if not Member.objects.can_update(project_id, member_id, role.name): + raise RoleConstraintException + try: + super().perform_update(serializer) + except IntegrityError: + raise RoleAlreadyAssignedException diff --git a/backend/roles/admin.py b/backend/roles/admin.py index e8f7ffdf..697b6b71 100644 --- a/backend/roles/admin.py +++ b/backend/roles/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Role, Member +from .models import Role class RoleAdmin(admin.ModelAdmin): @@ -9,11 +9,4 @@ class RoleAdmin(admin.ModelAdmin): search_fields = ('name',) -class MemberAdmin(admin.ModelAdmin): - list_display = ('user', 'role', 'project', ) - ordering = ('user',) - search_fields = ('user__username',) - - admin.site.register(Role, RoleAdmin) -admin.site.register(Member, MemberAdmin) diff --git a/backend/roles/apps.py b/backend/roles/apps.py index 1021b23d..9f64c395 100644 --- a/backend/roles/apps.py +++ b/backend/roles/apps.py @@ -1,11 +1,6 @@ -import importlib - from django.apps import AppConfig class RolesConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'roles' - - def ready(self): - importlib.import_module('roles.signals') diff --git a/backend/roles/management/commands/create_member.py b/backend/roles/management/commands/create_member.py index d520d4c9..d8158e92 100644 --- a/backend/roles/management/commands/create_member.py +++ b/backend/roles/management/commands/create_member.py @@ -2,7 +2,8 @@ from django.core.management.base import BaseCommand, CommandError from django.contrib.auth.models import User from api.models import Project -from ...models import Member, Role +from ...models import Role +from backend.members.models import Member class Command(BaseCommand): diff --git a/backend/roles/migrations/0003_delete_member.py b/backend/roles/migrations/0003_delete_member.py new file mode 100644 index 00000000..c2bda528 --- /dev/null +++ b/backend/roles/migrations/0003_delete_member.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.8 on 2022-01-13 01:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('roles', '0002_rename_rolemapping_member'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.DeleteModel( + name='Member', + ), + ], + database_operations=[ + migrations.AlterModelTable( + name='Member', + table='members_member', + ), + ] + ) + ] diff --git a/backend/roles/models.py b/backend/roles/models.py index dc54fe75..679d7ce0 100644 --- a/backend/roles/models.py +++ b/backend/roles/models.py @@ -1,10 +1,4 @@ -from django.conf import settings -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Manager - -from api.models import Project class Role(models.Model): @@ -15,60 +9,3 @@ class Role(models.Model): def __str__(self): return self.name - - -class MemberManager(Manager): - - def can_update(self, project: int, mapping_id: int, new_role: str) -> bool: - """The project needs at least 1 admin. - - Args: - project: The project id. - mapping_id: The mapping id. - new_role: The new role name. - - Returns: - Whether the mapping can be updated or not. - """ - queryset = self.filter( - project=project, role__name=settings.ROLE_PROJECT_ADMIN - ) - if queryset.count() > 1: - return True - else: - mapping = queryset.first() - if mapping.id == mapping_id and new_role != settings.ROLE_PROJECT_ADMIN: - return False - return True - - def has_role(self, project_id: int, user: User, role_name: str): - return self.filter(project=project_id, user=user, role__name=role_name).exists() - - -class Member(models.Model): - user = models.ForeignKey( - to=User, - on_delete=models.CASCADE, - related_name='role_mappings' - ) - project = models.ForeignKey( - to=Project, - on_delete=models.CASCADE, - related_name='role_mappings' - ) - role = models.ForeignKey( - to=Role, - on_delete=models.CASCADE - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - objects = MemberManager() - - def clean(self): - members = self.objects.exclude(id=self.id) - if members.filter(user=self.user, project=self.project).exists(): - message = 'This user is already assigned to a role in this project.' - raise ValidationError(message) - - class Meta: - unique_together = ('user', 'project') diff --git a/backend/roles/serializers.py b/backend/roles/serializers.py index 77225127..c2d72a7c 100644 --- a/backend/roles/serializers.py +++ b/backend/roles/serializers.py @@ -1,28 +1,9 @@ from rest_framework import serializers -from .models import Role, Member +from .models import Role class RoleSerializer(serializers.ModelSerializer): class Meta: model = Role fields = ('id', 'name') - - -class MemberSerializer(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 = Member - fields = ('id', 'user', 'role', 'username', 'rolename') diff --git a/backend/roles/tests.py b/backend/roles/tests.py index fd5a0449..82cd4beb 100644 --- a/backend/roles/tests.py +++ b/backend/roles/tests.py @@ -1,10 +1,7 @@ -from django.conf import settings from rest_framework import status from rest_framework.reverse import reverse -from .models import Role, Member -from api.tests.api.utils import (CRUDMixin, create_default_roles, make_user, - prepare_project) +from api.tests.api.utils import (CRUDMixin, create_default_roles, make_user) class TestRoleAPI(CRUDMixin): @@ -20,127 +17,3 @@ class TestRoleAPI(CRUDMixin): def test_disallows_unauthenticated_user_to_get_roles(self): self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) - - -class TestMemberListAPI(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='member_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 TestMemberRoleDetailAPI(CRUDMixin): - - def setUp(self): - self.project = prepare_project() - self.non_member = make_user() - admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN) - mapping = Member.objects.get(user=self.project.users[1]) - self.url = reverse(viewname='member_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) - - -class TestMemberRoleFilter(CRUDMixin): - - def setUp(self): - self.project = prepare_project() - self.url = reverse(viewname='member_list', args=[self.project.item.id]) - self.url += f'?user={self.project.users[0].id}' - - def test_filter_role_by_user_id(self): - response = self.assert_fetch(self.project.users[0], status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - -class TestMemberManager(CRUDMixin): - - def setUp(self): - pass - - def test_has_role(self): - project = prepare_project() - admin = project.users[0] - expected = [ - (settings.ROLE_PROJECT_ADMIN, True), - (settings.ROLE_ANNOTATION_APPROVER, False), - (settings.ROLE_ANNOTATOR, False) - ] - for role, expect in expected: - self.assertEqual(Member.objects.has_role(project.item, admin, role), expect) diff --git a/backend/roles/urls.py b/backend/roles/urls.py index d5a979ac..7e295490 100644 --- a/backend/roles/urls.py +++ b/backend/roles/urls.py @@ -1,21 +1,11 @@ from django.urls import path -from .views import MemberDetail, MemberList, Roles +from .views import Roles urlpatterns = [ path( route='roles', view=Roles.as_view(), name='roles' - ), - path( - route='projects//members', - view=MemberList.as_view(), - name='member_list' - ), - path( - route='projects//members/', - view=MemberDetail.as_view(), - name='member_detail' ) ] diff --git a/backend/roles/views.py b/backend/roles/views.py index 199f73c4..bd639393 100644 --- a/backend/roles/views.py +++ b/backend/roles/views.py @@ -1,13 +1,8 @@ -from django.db import IntegrityError -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import generics, status +from rest_framework import generics from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from .permissions import IsProjectAdmin -from .models import Role, Member -from .serializers import MemberSerializer, RoleSerializer -from .exceptions import RoleAlreadyAssignedException, RoleConstraintException +from .models import Role +from .serializers import RoleSerializer class Roles(generics.ListAPIView): @@ -15,48 +10,3 @@ class Roles(generics.ListAPIView): pagination_class = None permission_classes = [IsAuthenticated] queryset = Role.objects.all() - - -class MemberList(generics.ListCreateAPIView): - filter_backends = [DjangoFilterBackend] - filterset_fields = ['user'] - queryset = Member.objects.all() - serializer_class = MemberSerializer - pagination_class = None - permission_classes = [IsAuthenticated & IsProjectAdmin] - - def filter_queryset(self, queryset): - queryset = queryset.filter(project=self.kwargs['project_id']) - return super().filter_queryset(queryset) - - def perform_create(self, serializer): - try: - serializer.save(project_id=self.kwargs['project_id']) - except IntegrityError: - raise RoleAlreadyAssignedException - - def delete(self, request, *args, **kwargs): - delete_ids = request.data['ids'] - project_id = self.kwargs['project_id'] - Member.objects.filter(project=project_id, pk__in=delete_ids)\ - .exclude(user=self.request.user)\ - .delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class MemberDetail(generics.RetrieveUpdateAPIView): - queryset = Member.objects.all() - serializer_class = MemberSerializer - lookup_url_kwarg = 'member_id' - permission_classes = [IsAuthenticated & IsProjectAdmin] - - def perform_update(self, serializer): - project_id = self.kwargs['project_id'] - member_id = self.kwargs['member_id'] - role = serializer.validated_data['role'] - if not Member.objects.can_update(project_id, member_id, role.name): - raise RoleConstraintException - try: - super().perform_update(serializer) - except IntegrityError: - raise RoleAlreadyAssignedException