mirror of https://github.com/doccano/doccano.git
39 changed files with 401 additions and 313 deletions
Split View
Diff Options
-
2Pipfile
-
3backend/api/tests/api/utils.py
-
2backend/api/views/annotation.py
-
2backend/api/views/annotation_relations.py
-
2backend/api/views/auto_labeling.py
-
2backend/api/views/comment.py
-
2backend/api/views/example.py
-
2backend/api/views/example_state.py
-
2backend/api/views/export_dataset.py
-
2backend/api/views/import_dataset.py
-
2backend/api/views/label.py
-
2backend/api/views/project.py
-
2backend/api/views/relation_types.py
-
2backend/api/views/statistics.py
-
2backend/api/views/tag.py
-
2backend/api/views/tasks/base.py
-
2backend/api/views/user.py
-
1backend/app/settings.py
-
1backend/app/urls.py
-
11backend/members/admin.py
-
5backend/members/apps.py
-
0backend/members/exceptions.py
-
43backend/members/migrations/0001_initial.py
-
65backend/members/models.py
-
0backend/members/permissions.py
-
22backend/members/serializers.py
-
3backend/members/signals.py
-
132backend/members/tests.py
-
16backend/members/urls.py
-
56backend/members/views.py
-
9backend/roles/admin.py
-
5backend/roles/apps.py
-
3backend/roles/management/commands/create_member.py
-
26backend/roles/migrations/0003_delete_member.py
-
63backend/roles/models.py
-
21backend/roles/serializers.py
-
129backend/roles/tests.py
-
12backend/roles/urls.py
-
56backend/roles/views.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) |
@ -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') |
@ -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=[] |
|||
) |
|||
] |
@ -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') |
@ -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') |
@ -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) |
@ -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/<int:member_id>', |
|||
view=MemberDetail.as_view(), |
|||
name='member_detail' |
|||
) |
|||
] |
@ -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 |
@ -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') |
@ -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', |
|||
), |
|||
] |
|||
) |
|||
] |
@ -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') |
@ -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/<int:project_id>/members', |
|||
view=MemberList.as_view(), |
|||
name='member_list' |
|||
), |
|||
path( |
|||
route='projects/<int:project_id>/members/<int:member_id>', |
|||
view=MemberDetail.as_view(), |
|||
name='member_detail' |
|||
) |
|||
] |
Write
Preview
Loading…
Cancel
Save