From 6d2a41dde43b5e39902bf03d215670dcdf4f315c Mon Sep 17 00:00:00 2001 From: Hironsan Date: Mon, 24 Jul 2023 15:06:35 +0900 Subject: [PATCH 01/24] Add assignment model --- .../examples/migrations/0008_assignment.py | 45 +++++++++++++++++++ backend/examples/models.py | 24 ++++++++++ 2 files changed, 69 insertions(+) create mode 100644 backend/examples/migrations/0008_assignment.py diff --git a/backend/examples/migrations/0008_assignment.py b/backend/examples/migrations/0008_assignment.py new file mode 100644 index 00000000..ef0ee151 --- /dev/null +++ b/backend/examples/migrations/0008_assignment.py @@ -0,0 +1,45 @@ +# Generated by Django 4.1.10 on 2023-07-24 05:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0008_project_allow_member_to_create_label_type_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("examples", "0007_example_score"), + ] + + operations = [ + migrations.CreateModel( + name="Assignment", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "assignee", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ( + "example", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="assignments", to="examples.example" + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="assignments", to="projects.project" + ), + ), + ], + options={ + "unique_together": {("example", "assignee")}, + }, + ), + ] diff --git a/backend/examples/models.py b/backend/examples/models.py index 733e64e8..b7fa2e09 100644 --- a/backend/examples/models.py +++ b/backend/examples/models.py @@ -1,6 +1,7 @@ import uuid from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.db import models from django_drf_filepond.models import DrfFilePondStoredStorage @@ -37,6 +38,29 @@ class Example(models.Model): ordering = ["created_at"] +class Assignment(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + project = models.ForeignKey(to=Project, on_delete=models.CASCADE, related_name="assignments") + example = models.ForeignKey(to=Example, on_delete=models.CASCADE, related_name="assignments") + assignee = models.ForeignKey(to=User, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = (("example", "assignee"),) + + def clean(self): + # assignee must be a member of the project + if not self.project.members.filter(id=self.assignee.id).exists(): + raise ValidationError("Assignee must be a member of the project") + + # example must be in the project + if not self.project.examples.filter(id=self.example.id).exists(): + raise ValidationError("Example must be in the project") + + return super().clean() + + class ExampleState(models.Model): objects = ExampleStateManager() example = models.ForeignKey(to=Example, on_delete=models.CASCADE, related_name="states") From dab945b974b9e87104acb0edaefcce252410b97a Mon Sep 17 00:00:00 2001 From: Hironsan Date: Mon, 24 Jul 2023 15:06:47 +0900 Subject: [PATCH 02/24] Add endpoints for assignment --- backend/examples/serializers.py | 9 ++++++- backend/examples/urls.py | 3 +++ backend/examples/views/assignment.py | 35 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 backend/examples/views/assignment.py diff --git a/backend/examples/serializers.py b/backend/examples/serializers.py index eb6bf615..acdbcfef 100644 --- a/backend/examples/serializers.py +++ b/backend/examples/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Comment, Example, ExampleState +from .models import Assignment, Comment, Example, ExampleState class CommentSerializer(serializers.ModelSerializer): @@ -50,6 +50,13 @@ class ExampleSerializer(serializers.ModelSerializer): read_only_fields = ["filename", "is_confirmed", "upload_name"] +class AssignmentSerializer(serializers.ModelSerializer): + class Meta: + model = Assignment + fields = ("id", "assignee", "example", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") + + class ExampleStateSerializer(serializers.ModelSerializer): class Meta: model = ExampleState diff --git a/backend/examples/urls.py b/backend/examples/urls.py index 89d50561..bce25515 100644 --- a/backend/examples/urls.py +++ b/backend/examples/urls.py @@ -1,10 +1,13 @@ from django.urls import path +from .views.assignment import AssignmentDetail, AssignmentList from .views.comment import CommentDetail, CommentList from .views.example import ExampleDetail, ExampleList from .views.example_state import ExampleStateList urlpatterns = [ + path(route="assignments", view=AssignmentList.as_view(), name="assignment_list"), + path(route="assignments/", view=AssignmentDetail.as_view(), name="assignment_detail"), path(route="examples", view=ExampleList.as_view(), name="example_list"), path(route="examples/", view=ExampleDetail.as_view(), name="example_detail"), path(route="comments", view=CommentList.as_view(), name="comment_list"), diff --git a/backend/examples/views/assignment.py b/backend/examples/views/assignment.py new file mode 100644 index 00000000..51d58d1e --- /dev/null +++ b/backend/examples/views/assignment.py @@ -0,0 +1,35 @@ +from django.shortcuts import get_object_or_404 +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, generics +from rest_framework.permissions import IsAuthenticated + +from examples.models import Assignment +from examples.serializers import AssignmentSerializer +from projects.models import Project +from projects.permissions import IsProjectAdmin, IsProjectStaffAndReadOnly + + +class AssignmentList(generics.ListCreateAPIView): + serializer_class = AssignmentSerializer + permission_classes = [IsAuthenticated & (IsProjectAdmin | IsProjectStaffAndReadOnly)] + filter_backends = (DjangoFilterBackend, filters.OrderingFilter) + ordering_fields = ("created_at", "updated_at") + model = Assignment + + @property + def project(self): + return get_object_or_404(Project, pk=self.kwargs["project_id"]) + + def get_queryset(self): + queryset = self.model.objects.filter(project=self.project, assignee=self.request.user) + return queryset + + def perform_create(self, serializer): + serializer.save(project=self.project) + + +class AssignmentDetail(generics.RetrieveUpdateDestroyAPIView): + queryset = Assignment.objects.all() + serializer_class = AssignmentSerializer + lookup_url_kwarg = "assignment_id" + permission_classes = [IsAuthenticated & (IsProjectAdmin | IsProjectStaffAndReadOnly)] From b4c72af5a32123942dadcad94ecdf4deda37d56b Mon Sep 17 00:00:00 2001 From: Hironsan Date: Wed, 26 Jul 2023 16:41:37 +0900 Subject: [PATCH 03/24] Add weighted random strategy --- backend/examples/assignment/strategies.py | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 backend/examples/assignment/strategies.py diff --git a/backend/examples/assignment/strategies.py b/backend/examples/assignment/strategies.py new file mode 100644 index 00000000..36b52df8 --- /dev/null +++ b/backend/examples/assignment/strategies.py @@ -0,0 +1,29 @@ +import abc +import dataclasses +from typing import List + +import numpy as np + + +@dataclasses.dataclass +class Assignment: + user: int + example: int + + +class BaseStrategy(abc.ABC): + @abc.abstractmethod + def assign(self) -> List[Assignment]: + ... + + +class WeightedRandomStrategy: + def __init__(self, dataset_size: int, weights: List[int]): + assert sum(weights) == 100 + self.dataset_size = dataset_size + self.weights = weights + + def assign(self) -> List[Assignment]: + proba = np.array(self.weights) / 100 + assignees = np.random.choice(range(len(self.weights)), size=self.dataset_size, p=proba) + return [Assignment(user=user, example=example) for example, user in enumerate(assignees)] From e104cd3c5ef89317a57722623cd51ca76ae9b63d Mon Sep 17 00:00:00 2001 From: Hironsan Date: Wed, 26 Jul 2023 17:49:23 +0900 Subject: [PATCH 04/24] Add sampling without replacement strategy --- backend/examples/assignment/__init__.py | 0 backend/examples/assignment/strategies.py | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 backend/examples/assignment/__init__.py diff --git a/backend/examples/assignment/__init__.py b/backend/examples/assignment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/examples/assignment/strategies.py b/backend/examples/assignment/strategies.py index 36b52df8..eb3e8095 100644 --- a/backend/examples/assignment/strategies.py +++ b/backend/examples/assignment/strategies.py @@ -1,5 +1,6 @@ import abc import dataclasses +import random from typing import List import numpy as np @@ -17,7 +18,7 @@ class BaseStrategy(abc.ABC): ... -class WeightedRandomStrategy: +class WeightedRandomStrategy(BaseStrategy): def __init__(self, dataset_size: int, weights: List[int]): assert sum(weights) == 100 self.dataset_size = dataset_size @@ -27,3 +28,19 @@ class WeightedRandomStrategy: proba = np.array(self.weights) / 100 assignees = np.random.choice(range(len(self.weights)), size=self.dataset_size, p=proba) return [Assignment(user=user, example=example) for example, user in enumerate(assignees)] + + +class SamplingWithoutReplacementStrategy(BaseStrategy): + def __init__(self, dataset_size: int, weights: List[int]): + assert 0 <= sum(weights) <= 100 * len(weights) + self.dataset_size = dataset_size + self.weights = weights + + def assign(self) -> List[Assignment]: + assignments = [] + proba = np.array(self.weights) / 100 + for user, p in enumerate(proba): + count = int(self.dataset_size * p) + examples = random.sample(range(self.dataset_size), count) + assignments.extend([Assignment(user=user, example=example) for example in examples]) + return assignments From dca2af2dad979af48060bd20f9444dbc3e35bb48 Mon Sep 17 00:00:00 2001 From: Hironsan Date: Thu, 27 Jul 2023 10:25:46 +0900 Subject: [PATCH 05/24] Add weighted sequential strategy --- backend/examples/assignment/strategies.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/examples/assignment/strategies.py b/backend/examples/assignment/strategies.py index eb3e8095..0d72033c 100644 --- a/backend/examples/assignment/strategies.py +++ b/backend/examples/assignment/strategies.py @@ -18,6 +18,27 @@ class BaseStrategy(abc.ABC): ... +class WeightedSequentialStrategy(BaseStrategy): + def __init__(self, dataset_size: int, weights: List[int]): + assert sum(weights) == 100 + self.dataset_size = dataset_size + self.weights = weights + + def assign(self) -> List[Assignment]: + assignments = [] + proba = np.array(self.weights) / 100 + counts = np.round(proba * self.dataset_size).astype(int) + reminder = self.dataset_size - sum(counts) + for i in np.random.choice(range(len(self.weights)), size=reminder, p=proba): + counts[i] += 1 + + start = 0 + for user, count in enumerate(counts): + assignments.extend([Assignment(user=user, example=example) for example in range(start, start + count)]) + start += count + return assignments + + class WeightedRandomStrategy(BaseStrategy): def __init__(self, dataset_size: int, weights: List[int]): assert sum(weights) == 100 From 6ef85df00f441744d1a59624c5b1545a7717caef Mon Sep 17 00:00:00 2001 From: Hironsan Date: Thu, 27 Jul 2023 11:33:21 +0900 Subject: [PATCH 06/24] Add reset assignment endpoint --- backend/examples/urls.py | 3 ++- backend/examples/views/assignment.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/examples/urls.py b/backend/examples/urls.py index bce25515..82f4b398 100644 --- a/backend/examples/urls.py +++ b/backend/examples/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views.assignment import AssignmentDetail, AssignmentList +from .views.assignment import AssignmentDetail, AssignmentList, ResetAssignment from .views.comment import CommentDetail, CommentList from .views.example import ExampleDetail, ExampleList from .views.example_state import ExampleStateList @@ -8,6 +8,7 @@ from .views.example_state import ExampleStateList urlpatterns = [ path(route="assignments", view=AssignmentList.as_view(), name="assignment_list"), path(route="assignments/", view=AssignmentDetail.as_view(), name="assignment_detail"), + path(route="assignments/reset", view=ResetAssignment.as_view(), name="assignment_reset"), path(route="examples", view=ExampleList.as_view(), name="example_list"), path(route="examples/", view=ExampleDetail.as_view(), name="example_detail"), path(route="comments", view=CommentList.as_view(), name="comment_list"), diff --git a/backend/examples/views/assignment.py b/backend/examples/views/assignment.py index 51d58d1e..3f9767a9 100644 --- a/backend/examples/views/assignment.py +++ b/backend/examples/views/assignment.py @@ -1,7 +1,8 @@ from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import filters, generics +from rest_framework import filters, generics, status from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView, Response from examples.models import Assignment from examples.serializers import AssignmentSerializer @@ -33,3 +34,15 @@ class AssignmentDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = AssignmentSerializer lookup_url_kwarg = "assignment_id" permission_classes = [IsAuthenticated & (IsProjectAdmin | IsProjectStaffAndReadOnly)] + + +class ResetAssignment(APIView): + permission_classes = [IsAuthenticated & IsProjectAdmin] + + @property + def project(self): + return get_object_or_404(Project, pk=self.kwargs["project_id"]) + + def delete(self, *args, **kwargs): + Assignment.objects.filter(project=self.project).delete() + return Response(status=status.HTTP_204_NO_CONTENT) From 82e7289bb7ddb5a9997b2ff209a9515eb5841b6c Mon Sep 17 00:00:00 2001 From: Hironsan Date: Thu, 27 Jul 2023 13:29:50 +0900 Subject: [PATCH 07/24] Add bulk assignment API --- backend/examples/assignment/strategies.py | 18 ++++++++ backend/examples/assignment/workload.py | 20 +++++++++ backend/examples/urls.py | 8 +++- backend/examples/views/assignment.py | 52 ++++++++++++++++++++++- 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 backend/examples/assignment/workload.py diff --git a/backend/examples/assignment/strategies.py b/backend/examples/assignment/strategies.py index 0d72033c..2913eff4 100644 --- a/backend/examples/assignment/strategies.py +++ b/backend/examples/assignment/strategies.py @@ -1,5 +1,6 @@ import abc import dataclasses +import enum import random from typing import List @@ -12,6 +13,23 @@ class Assignment: example: int +class StrategyName(enum.Enum): + weighted_sequential = enum.auto() + weighted_random = enum.auto() + sampling_without_replacement = enum.auto() + + +def create_assignment_strategy(strategy_name: StrategyName, dataset_size: int, weights: List[int]) -> "BaseStrategy": + if strategy_name == StrategyName.weighted_sequential: + return WeightedSequentialStrategy(dataset_size, weights) + elif strategy_name == StrategyName.weighted_random: + return WeightedRandomStrategy(dataset_size, weights) + elif strategy_name == StrategyName.sampling_without_replacement: + return SamplingWithoutReplacementStrategy(dataset_size, weights) + else: + raise ValueError(f"Unknown strategy name: {strategy_name}") + + class BaseStrategy(abc.ABC): @abc.abstractmethod def assign(self) -> List[Assignment]: diff --git a/backend/examples/assignment/workload.py b/backend/examples/assignment/workload.py new file mode 100644 index 00000000..a21c260c --- /dev/null +++ b/backend/examples/assignment/workload.py @@ -0,0 +1,20 @@ +from typing import List + +from pydantic import BaseModel, NonNegativeInt + + +class Workload(BaseModel): + weight: NonNegativeInt + member_id: int + + +class WorkloadAllocation(BaseModel): + workloads: List[Workload] + + @property + def member_ids(self): + return [w.member_id for w in self.workloads] + + @property + def weights(self): + return [w.weight for w in self.workloads] diff --git a/backend/examples/urls.py b/backend/examples/urls.py index 82f4b398..56c6cbdb 100644 --- a/backend/examples/urls.py +++ b/backend/examples/urls.py @@ -1,6 +1,11 @@ from django.urls import path -from .views.assignment import AssignmentDetail, AssignmentList, ResetAssignment +from .views.assignment import ( + AssignmentDetail, + AssignmentList, + BulkAssignment, + ResetAssignment, +) from .views.comment import CommentDetail, CommentList from .views.example import ExampleDetail, ExampleList from .views.example_state import ExampleStateList @@ -9,6 +14,7 @@ urlpatterns = [ path(route="assignments", view=AssignmentList.as_view(), name="assignment_list"), path(route="assignments/", view=AssignmentDetail.as_view(), name="assignment_detail"), path(route="assignments/reset", view=ResetAssignment.as_view(), name="assignment_reset"), + path(route="assignments/bulk_assign", view=BulkAssignment.as_view(), name="bulk_assignment"), path(route="examples", view=ExampleList.as_view(), name="example_list"), path(route="examples/", view=ExampleDetail.as_view(), name="example_detail"), path(route="comments", view=CommentList.as_view(), name="comment_list"), diff --git a/backend/examples/views/assignment.py b/backend/examples/views/assignment.py index 3f9767a9..11fd0a7e 100644 --- a/backend/examples/views/assignment.py +++ b/backend/examples/views/assignment.py @@ -1,12 +1,15 @@ from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend +from pydantic import ValidationError from rest_framework import filters, generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView, Response +from examples.assignment.strategies import StrategyName, create_assignment_strategy +from examples.assignment.workload import WorkloadAllocation from examples.models import Assignment from examples.serializers import AssignmentSerializer -from projects.models import Project +from projects.models import Member, Project from projects.permissions import IsProjectAdmin, IsProjectStaffAndReadOnly @@ -46,3 +49,50 @@ class ResetAssignment(APIView): def delete(self, *args, **kwargs): Assignment.objects.filter(project=self.project).delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class BulkAssignment(APIView): + serializer_class = AssignmentSerializer + permission_classes = [IsAuthenticated & IsProjectAdmin] + + def post(self, *args, **kwargs): + try: + strategy_name = StrategyName[self.request.data["strategy_name"]] + except KeyError: + return Response( + {"detail": "Invalid strategy name"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + workload_allocation = WorkloadAllocation(workloads=self.request.data["workloads"]) + except ValidationError as e: + return Response( + {"detail": e.errors()}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project = get_object_or_404(Project, pk=self.kwargs["project_id"]) + members = Member.objects.filter(project=project, pk__in=workload_allocation.member_ids) + if len(members) != len(workload_allocation.member_ids): + return Response( + {"detail": "Invalid member ids"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Sort members by workload_allocation.member_ids + members = sorted(members, key=lambda m: workload_allocation.member_ids.index(m.id)) + + dataset_size = project.examples.count() # Todo: unassigned examples + strategy = create_assignment_strategy(strategy_name, dataset_size, workload_allocation.weights) + assignments = strategy.assign() + example_ids = project.examples.values_list("pk", flat=True) + assignments = [ + Assignment( + project=project, + example=example_ids[assignment.example], + assignee=members[assignment.user].user, + ) + for assignment in assignments + ] + Assignment.objects.bulk_create(assignments) + return Response(status=status.HTTP_201_CREATED) From 500cd85a5dfba43d26a1b41b8a0ef93c2cfa1aa1 Mon Sep 17 00:00:00 2001 From: Hironsan Date: Fri, 28 Jul 2023 11:32:51 +0900 Subject: [PATCH 08/24] Add test cases for assignment API --- backend/examples/tests/test_assignment.py | 117 ++++++++++++++++++++++ backend/examples/tests/utils.py | 4 + backend/examples/views/assignment.py | 8 +- 3 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 backend/examples/tests/test_assignment.py diff --git a/backend/examples/tests/test_assignment.py b/backend/examples/tests/test_assignment.py new file mode 100644 index 00000000..2aa9e257 --- /dev/null +++ b/backend/examples/tests/test_assignment.py @@ -0,0 +1,117 @@ +from rest_framework import status +from rest_framework.reverse import reverse + +from .utils import make_assignment, make_doc +from api.tests.utils import CRUDMixin +from examples.models import Assignment +from projects.models import Member +from projects.tests.utils import prepare_project +from users.tests.utils import make_user + + +class TestAssignmentList(CRUDMixin): + def setUp(self): + self.project = prepare_project() + self.non_member = make_user() + self.example = make_doc(self.project.item) + make_assignment(self.project.item, self.example, self.project.admin) + self.data = {"example": self.example.id, "assignee": self.project.staffs[0].id} + self.url = reverse(viewname="assignment_list", args=[self.project.item.id]) + + def test_allow_project_member_to_list_assignments(self): + for member in self.project.members: + self.assert_fetch(member, status.HTTP_200_OK) + + def test_denies_non_project_member_to_list_assignments(self): + self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_list_assignments(self): + self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) + + def test_allows_project_admin_to_assign(self): + response = self.assert_create(self.project.admin, status.HTTP_201_CREATED) + self.assertEqual(response.data["example"], self.data["example"]) + self.assertEqual(response.data["assignee"], self.data["assignee"]) + + def test_denies_non_admin_to_assign(self): + for member in self.project.staffs: + self.assert_create(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_assign(self): + self.assert_create(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_assign(self): + self.assert_create(expected=status.HTTP_403_FORBIDDEN) + + +class TestAssignmentDetail(CRUDMixin): + def setUp(self): + self.project = prepare_project() + self.non_member = make_user() + example = make_doc(self.project.item) + assignment = make_assignment(self.project.item, example, self.project.admin) + self.data = {"assignee": self.project.staffs[0].id} + self.url = reverse(viewname="assignment_detail", args=[self.project.item.id, assignment.id]) + + def test_allows_project_member_to_get_assignment(self): + for member in self.project.members: + self.assert_fetch(member, status.HTTP_200_OK) + + def test_denies_non_project_member_to_get_assignment(self): + self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_get_assignment(self): + self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) + + def test_allows_project_admin_to_reassign(self): + response = self.assert_update(self.project.admin, status.HTTP_200_OK) + self.assertEqual(response.data["assignee"], self.data["assignee"]) + + def test_denies_non_admin_to_reassign(self): + for member in self.project.staffs: + self.assert_update(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_reassign(self): + self.assert_update(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_reassign(self): + self.assert_update(expected=status.HTTP_403_FORBIDDEN) + + def test_allows_project_admin_to_unassign(self): + self.assert_delete(self.project.admin, status.HTTP_204_NO_CONTENT) + + def test_denies_non_admin_to_unassign(self): + for member in self.project.staffs: + self.assert_delete(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_unassign(self): + self.assert_delete(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_unassign(self): + self.assert_delete(expected=status.HTTP_403_FORBIDDEN) + + +class TestAssignmentBulk(CRUDMixin): + def setUp(self): + self.project = prepare_project() + self.non_member = make_user() + self.example = make_doc(self.project.item) + members = Member.objects.filter(project=self.project.item) + workloads = [{"member_id": member.id, "weight": 100} for member in members] + self.data = {"strategy_name": "sampling_without_replacement", "workloads": workloads} + self.url = reverse(viewname="bulk_assignment", args=[self.project.item.id]) + + def test_denies_non_admin_to_bulk_assign(self): + for member in self.project.staffs: + self.assert_create(member, status.HTTP_403_FORBIDDEN) + + def test_denies_non_project_member_to_bulk_assign(self): + self.assert_create(self.non_member, status.HTTP_403_FORBIDDEN) + + def test_denies_unauthenticated_user_to_bulk_assign(self): + self.assert_create(expected=status.HTTP_403_FORBIDDEN) + + def test_allows_project_admin_to_bulk_assign(self): + self.assert_create(self.project.admin, status.HTTP_201_CREATED) + expected = self.project.item.examples.count() * len(self.project.members) + self.assertEqual(Assignment.objects.count(), expected) diff --git a/backend/examples/tests/utils.py b/backend/examples/tests/utils.py index 06a97bdd..9a5ab3c8 100644 --- a/backend/examples/tests/utils.py +++ b/backend/examples/tests/utils.py @@ -15,3 +15,7 @@ def make_image(project, filepath): def make_example_state(example, user): return mommy.make("ExampleState", example=example, confirmed_by=user) + + +def make_assignment(project, example, user): + return mommy.make("Assignment", project=project, example=example, assignee=user) diff --git a/backend/examples/views/assignment.py b/backend/examples/views/assignment.py index 11fd0a7e..f0b50efb 100644 --- a/backend/examples/views/assignment.py +++ b/backend/examples/views/assignment.py @@ -83,13 +83,15 @@ class BulkAssignment(APIView): members = sorted(members, key=lambda m: workload_allocation.member_ids.index(m.id)) dataset_size = project.examples.count() # Todo: unassigned examples - strategy = create_assignment_strategy(strategy_name, dataset_size, workload_allocation.weights) + strategy = create_assignment_strategy( + strategy_name, dataset_size, workload_allocation.weights + ) # Todo: raise 400 if weights are not valid assignments = strategy.assign() - example_ids = project.examples.values_list("pk", flat=True) + examples = project.examples.all() assignments = [ Assignment( project=project, - example=example_ids[assignment.example], + example=examples[assignment.example], assignee=members[assignment.user].user, ) for assignment in assignments From 4d5b1858b41020e01246c648a52b4da204bd4edb Mon Sep 17 00:00:00 2001 From: Hironsan Date: Mon, 31 Jul 2023 14:11:59 +0900 Subject: [PATCH 09/24] Extract bulk_assign function --- backend/examples/assignment/strategies.py | 9 ++++-- backend/examples/assignment/usecase.py | 30 +++++++++++++++++++ backend/examples/views/assignment.py | 35 +++++++---------------- 3 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 backend/examples/assignment/usecase.py diff --git a/backend/examples/assignment/strategies.py b/backend/examples/assignment/strategies.py index 2913eff4..93419d46 100644 --- a/backend/examples/assignment/strategies.py +++ b/backend/examples/assignment/strategies.py @@ -38,7 +38,8 @@ class BaseStrategy(abc.ABC): class WeightedSequentialStrategy(BaseStrategy): def __init__(self, dataset_size: int, weights: List[int]): - assert sum(weights) == 100 + if sum(weights) != 100: + raise ValueError("Sum of weights must be 100") self.dataset_size = dataset_size self.weights = weights @@ -59,7 +60,8 @@ class WeightedSequentialStrategy(BaseStrategy): class WeightedRandomStrategy(BaseStrategy): def __init__(self, dataset_size: int, weights: List[int]): - assert sum(weights) == 100 + if sum(weights) != 100: + raise ValueError("Sum of weights must be 100") self.dataset_size = dataset_size self.weights = weights @@ -71,7 +73,8 @@ class WeightedRandomStrategy(BaseStrategy): class SamplingWithoutReplacementStrategy(BaseStrategy): def __init__(self, dataset_size: int, weights: List[int]): - assert 0 <= sum(weights) <= 100 * len(weights) + if not (0 <= sum(weights) <= 100 * len(weights)): + raise ValueError("Sum of weights must be between 0 and 100 x number of members") self.dataset_size = dataset_size self.weights = weights diff --git a/backend/examples/assignment/usecase.py b/backend/examples/assignment/usecase.py new file mode 100644 index 00000000..8b6aa046 --- /dev/null +++ b/backend/examples/assignment/usecase.py @@ -0,0 +1,30 @@ +from django.shortcuts import get_object_or_404 + +from examples.assignment.strategies import StrategyName, create_assignment_strategy +from examples.assignment.workload import WorkloadAllocation +from examples.models import Assignment +from projects.models import Member, Project + + +def bulk_assign(project_id: int, workload_allocation: WorkloadAllocation, strategy_name: StrategyName) -> None: + project = get_object_or_404(Project, pk=project_id) + members = Member.objects.filter(project=project, pk__in=workload_allocation.member_ids) + if len(members) != len(workload_allocation.member_ids): + raise ValueError("Invalid member ids") + # Sort members by workload_allocation.member_ids + members = sorted(members, key=lambda m: workload_allocation.member_ids.index(m.id)) + + dataset_size = project.examples.count() # Todo: unassigned examples + + strategy = create_assignment_strategy(strategy_name, dataset_size, workload_allocation.weights) + assignments = strategy.assign() + examples = project.examples.all() + assignments = [ + Assignment( + project=project, + example=examples[assignment.example], + assignee=members[assignment.user].user, + ) + for assignment in assignments + ] + Assignment.objects.bulk_create(assignments) diff --git a/backend/examples/views/assignment.py b/backend/examples/views/assignment.py index f0b50efb..f4256d43 100644 --- a/backend/examples/views/assignment.py +++ b/backend/examples/views/assignment.py @@ -5,11 +5,12 @@ from rest_framework import filters, generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView, Response -from examples.assignment.strategies import StrategyName, create_assignment_strategy +from examples.assignment.strategies import StrategyName +from examples.assignment.usecase import bulk_assign from examples.assignment.workload import WorkloadAllocation from examples.models import Assignment from examples.serializers import AssignmentSerializer -from projects.models import Member, Project +from projects.models import Project from projects.permissions import IsProjectAdmin, IsProjectStaffAndReadOnly @@ -72,29 +73,15 @@ class BulkAssignment(APIView): status=status.HTTP_400_BAD_REQUEST, ) - project = get_object_or_404(Project, pk=self.kwargs["project_id"]) - members = Member.objects.filter(project=project, pk__in=workload_allocation.member_ids) - if len(members) != len(workload_allocation.member_ids): + try: + bulk_assign( + project_id=self.kwargs["project_id"], + workload_allocation=workload_allocation, + strategy_name=strategy_name, + ) + except ValueError as e: return Response( - {"detail": "Invalid member ids"}, + {"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST, ) - # Sort members by workload_allocation.member_ids - members = sorted(members, key=lambda m: workload_allocation.member_ids.index(m.id)) - - dataset_size = project.examples.count() # Todo: unassigned examples - strategy = create_assignment_strategy( - strategy_name, dataset_size, workload_allocation.weights - ) # Todo: raise 400 if weights are not valid - assignments = strategy.assign() - examples = project.examples.all() - assignments = [ - Assignment( - project=project, - example=examples[assignment.example], - assignee=members[assignment.user].user, - ) - for assignment in assignments - ] - Assignment.objects.bulk_create(assignments) return Response(status=status.HTTP_201_CREATED) From 2dfc270f7f9dd10d820880cb9046f0804b3f298b Mon Sep 17 00:00:00 2001 From: Hironsan Date: Mon, 31 Jul 2023 14:32:28 +0900 Subject: [PATCH 10/24] Change bulk_assign to assign only unassigned examples --- backend/examples/assignment/usecase.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/examples/assignment/usecase.py b/backend/examples/assignment/usecase.py index 8b6aa046..a93be052 100644 --- a/backend/examples/assignment/usecase.py +++ b/backend/examples/assignment/usecase.py @@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404 from examples.assignment.strategies import StrategyName, create_assignment_strategy from examples.assignment.workload import WorkloadAllocation -from examples.models import Assignment +from examples.models import Assignment, Example from projects.models import Member, Project @@ -14,15 +14,15 @@ def bulk_assign(project_id: int, workload_allocation: WorkloadAllocation, strate # Sort members by workload_allocation.member_ids members = sorted(members, key=lambda m: workload_allocation.member_ids.index(m.id)) - dataset_size = project.examples.count() # Todo: unassigned examples + unassigned_examples = Example.objects.filter(project=project, assignments__isnull=True) + dataset_size = unassigned_examples.count() strategy = create_assignment_strategy(strategy_name, dataset_size, workload_allocation.weights) assignments = strategy.assign() - examples = project.examples.all() assignments = [ Assignment( project=project, - example=examples[assignment.example], + example=unassigned_examples[assignment.example], assignee=members[assignment.user].user, ) for assignment in assignments From 1f168b78af78ff6cdbdc254f5bf1b92ac888526e Mon Sep 17 00:00:00 2001 From: Hironsan Date: Mon, 31 Jul 2023 16:23:13 +0900 Subject: [PATCH 11/24] Add test cases for bulk_assign --- backend/examples/assignment/usecase.py | 15 +++++------ backend/examples/assignment/workload.py | 4 +-- backend/examples/tests/test_usecase.py | 33 +++++++++++++++++++++++++ backend/examples/views/assignment.py | 3 ++- 4 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 backend/examples/tests/test_usecase.py diff --git a/backend/examples/assignment/usecase.py b/backend/examples/assignment/usecase.py index a93be052..00408e48 100644 --- a/backend/examples/assignment/usecase.py +++ b/backend/examples/assignment/usecase.py @@ -1,23 +1,24 @@ +from typing import List + from django.shortcuts import get_object_or_404 from examples.assignment.strategies import StrategyName, create_assignment_strategy -from examples.assignment.workload import WorkloadAllocation from examples.models import Assignment, Example from projects.models import Member, Project -def bulk_assign(project_id: int, workload_allocation: WorkloadAllocation, strategy_name: StrategyName) -> None: +def bulk_assign(project_id: int, strategy_name: StrategyName, member_ids: List[int], weights: List[int]) -> None: project = get_object_or_404(Project, pk=project_id) - members = Member.objects.filter(project=project, pk__in=workload_allocation.member_ids) - if len(members) != len(workload_allocation.member_ids): + members = Member.objects.filter(project=project, pk__in=member_ids) + if len(members) != len(member_ids): raise ValueError("Invalid member ids") - # Sort members by workload_allocation.member_ids - members = sorted(members, key=lambda m: workload_allocation.member_ids.index(m.id)) + # Sort members by member_ids + members = sorted(members, key=lambda m: member_ids.index(m.id)) unassigned_examples = Example.objects.filter(project=project, assignments__isnull=True) dataset_size = unassigned_examples.count() - strategy = create_assignment_strategy(strategy_name, dataset_size, workload_allocation.weights) + strategy = create_assignment_strategy(strategy_name, dataset_size, weights) assignments = strategy.assign() assignments = [ Assignment( diff --git a/backend/examples/assignment/workload.py b/backend/examples/assignment/workload.py index a21c260c..26eb6a04 100644 --- a/backend/examples/assignment/workload.py +++ b/backend/examples/assignment/workload.py @@ -12,9 +12,9 @@ class WorkloadAllocation(BaseModel): workloads: List[Workload] @property - def member_ids(self): + def member_ids(self) -> List[int]: return [w.member_id for w in self.workloads] @property - def weights(self): + def weights(self) -> List[int]: return [w.weight for w in self.workloads] diff --git a/backend/examples/tests/test_usecase.py b/backend/examples/tests/test_usecase.py new file mode 100644 index 00000000..3bd3e3b6 --- /dev/null +++ b/backend/examples/tests/test_usecase.py @@ -0,0 +1,33 @@ +from django.test import TestCase +from model_mommy import mommy + +from examples.assignment.usecase import StrategyName, bulk_assign +from projects.models import Member, ProjectType +from projects.tests.utils import prepare_project + + +class TestBulkAssignment(TestCase): + def setUp(self): + self.project = prepare_project(ProjectType.SEQUENCE_LABELING) + self.member_ids = list(Member.objects.values_list("id", flat=True)) + self.example = mommy.make("Example", project=self.project.item) + + def test_raise_error_if_weights_is_invalid(self): + with self.assertRaises(ValueError): + bulk_assign( + self.project.item.id, StrategyName.weighted_sequential, self.member_ids, [0] * len(self.member_ids) + ) + + def test_raise_error_if_passing_wrong_member_ids(self): + with self.assertRaises(ValueError): + bulk_assign( + self.project.item.id, + StrategyName.weighted_sequential, + self.member_ids + [100], + [0] * len(self.member_ids), + ) + + def test_assign_examples(self): + bulk_assign(self.project.item.id, StrategyName.weighted_sequential, self.member_ids, [100, 0, 0]) + self.assertEqual(self.example.assignments.count(), 1) + self.assertEqual(self.example.assignments.first().assignee, self.project.admin) diff --git a/backend/examples/views/assignment.py b/backend/examples/views/assignment.py index f4256d43..08c10873 100644 --- a/backend/examples/views/assignment.py +++ b/backend/examples/views/assignment.py @@ -76,8 +76,9 @@ class BulkAssignment(APIView): try: bulk_assign( project_id=self.kwargs["project_id"], - workload_allocation=workload_allocation, strategy_name=strategy_name, + member_ids=workload_allocation.member_ids, + weights=workload_allocation.weights, ) except ValueError as e: return Response( From de9ebe6fb6b92618740b0d9a9bba714dde2d8cb1 Mon Sep 17 00:00:00 2001 From: Hironsan Date: Tue, 1 Aug 2023 15:24:04 +0900 Subject: [PATCH 12/24] Make ExampleList return only assigned examples --- .../{test_document.py => test_example.py} | 102 +++++++----------- backend/examples/views/example.py | 12 +-- 2 files changed, 42 insertions(+), 72 deletions(-) rename backend/examples/tests/{test_document.py => test_example.py} (59%) diff --git a/backend/examples/tests/test_document.py b/backend/examples/tests/test_example.py similarity index 59% rename from backend/examples/tests/test_document.py rename to backend/examples/tests/test_example.py index 639cb686..b39eed4e 100644 --- a/backend/examples/tests/test_document.py +++ b/backend/examples/tests/test_example.py @@ -1,12 +1,11 @@ -from django.conf import settings from django.utils.http import urlencode from rest_framework import status from rest_framework.reverse import reverse -from .utils import make_doc, make_example_state +from .utils import make_assignment, make_doc, make_example_state from api.tests.utils import CRUDMixin from projects.models import ProjectType -from projects.tests.utils import assign_user_to_role, prepare_project +from projects.tests.utils import prepare_project from users.tests.utils import make_user @@ -15,10 +14,12 @@ class TestExampleListAPI(CRUDMixin): self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION) self.non_member = make_user() self.example = make_doc(self.project.item) + for member in self.project.members: + make_assignment(self.project.item, self.example, member) self.data = {"text": "example"} self.url = reverse(viewname="example_list", args=[self.project.item.id]) - def test_allows_project_member_to_list_docs(self): + def test_allows_project_member_to_list_examples(self): for member in self.project.members: response = self.assert_fetch(member, status.HTTP_200_OK) self.assertEqual(response.data["count"], 1) @@ -26,33 +27,24 @@ class TestExampleListAPI(CRUDMixin): for item in response.data["results"]: self.assertIn("text", item) - def test_denies_non_project_member_to_list_docs(self): + def test_denies_non_project_member_to_list_examples(self): self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) - def test_denies_unauthenticated_user_to_list_docs(self): + def test_denies_unauthenticated_user_to_list_examples(self): self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) - def test_allows_project_admin_to_create_doc(self): + def test_allows_project_admin_to_create_example(self): response = self.assert_create(self.project.admin, status.HTTP_201_CREATED) self.assertEqual(response.data["text"], self.data["text"]) - def test_denies_project_staff_to_create_doc(self): + def test_denies_non_admin_to_create_example(self): for member in self.project.staffs: self.assert_create(member, status.HTTP_403_FORBIDDEN) - def test_denies_unauthenticated_user_to_create_doc(self): + def test_denies_unauthenticated_user_to_create_example(self): self.assert_create(expected=status.HTTP_403_FORBIDDEN) - def test_is_confirmed(self): - make_example_state(self.example, self.project.admin) - response = self.assert_fetch(self.project.admin, status.HTTP_200_OK) - self.assertTrue(response.data["results"][0]["is_confirmed"]) - - def test_is_not_confirmed(self): - response = self.assert_fetch(self.project.admin, status.HTTP_200_OK) - self.assertFalse(response.data["results"][0]["is_confirmed"]) - - def test_does_not_share_another_user_confirmed(self): + def test_example_is_not_approved_if_another_user_approve_it(self): make_example_state(self.example, self.project.admin) response = self.assert_fetch(self.project.annotator, status.HTTP_200_OK) self.assertFalse(response.data["results"][0]["is_confirmed"]) @@ -60,23 +52,13 @@ class TestExampleListAPI(CRUDMixin): class TestExampleListCollaborative(CRUDMixin): def setUp(self): - self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION) + self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION, collaborative_annotation=True) self.example = make_doc(self.project.item) + for member in self.project.members: + make_assignment(self.project.item, self.example, member) self.url = reverse(viewname="example_list", args=[self.project.item.id]) - def test_shares_confirmed_in_same_role(self): - annotator1 = make_user() - assign_user_to_role(annotator1, self.project.item, settings.ROLE_ANNOTATOR) - annotator2 = make_user() - assign_user_to_role(annotator2, self.project.item, settings.ROLE_ANNOTATOR) - - make_example_state(self.example, annotator1) - response = self.assert_fetch(annotator1, status.HTTP_200_OK) - self.assertTrue(response.data["results"][0]["is_confirmed"]) - response = self.assert_fetch(annotator2, status.HTTP_200_OK) - self.assertTrue(response.data["results"][0]["is_confirmed"]) - - def test_does_not_share_confirmed_in_other_role(self): + def test_example_is_approved_if_someone_approve_it(self): admin = self.project.admin approver = self.project.approver @@ -84,14 +66,20 @@ class TestExampleListCollaborative(CRUDMixin): response = self.assert_fetch(admin, status.HTTP_200_OK) self.assertTrue(response.data["results"][0]["is_confirmed"]) response = self.assert_fetch(approver, status.HTTP_200_OK) - self.assertFalse(response.data["results"][0]["is_confirmed"]) + self.assertTrue(response.data["results"][0]["is_confirmed"]) class TestExampleListFilter(CRUDMixin): def setUp(self): self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION) - self.example = make_doc(self.project.item) - make_example_state(self.example, self.project.admin) + example1 = make_doc(self.project.item) + example2 = make_doc(self.project.item) + example3 = make_doc(self.project.item) + for member in self.project.members: + make_assignment(self.project.item, example1, member) + make_assignment(self.project.item, example2, member) + make_assignment(self.project.item, example3, member) + make_example_state(example1, self.project.admin) def reverse(self, query_kwargs=None): base_url = reverse(viewname="example_list", args=[self.project.item.id]) @@ -102,67 +90,59 @@ class TestExampleListFilter(CRUDMixin): response = self.assert_fetch(user, status.HTTP_200_OK) self.assertEqual(response.data["count"], expected) - def test_returns_example_if_confirmed_is_true(self): + def test_returns_only_approved_examples(self): user = self.project.admin self.assert_filter(data={"confirmed": "True"}, user=user, expected=1) - def test_does_not_return_example_if_confirmed_is_false(self): + def test_returns_only_non_approved_examples(self): user = self.project.admin - self.assert_filter(data={"confirmed": "False"}, user=user, expected=0) + self.assert_filter(data={"confirmed": "False"}, user=user, expected=2) - def test_returns_example_if_confirmed_is_empty(self): + def test_returns_all_examples(self): user = self.project.admin - self.assert_filter(data={"confirmed": ""}, user=user, expected=1) + self.assert_filter(data={"confirmed": ""}, user=user, expected=3) - def test_does_not_return_example_if_user_is_different(self): + def test_does_not_return_approved_example_to_another_user(self): user = self.project.approver self.assert_filter(data={"confirmed": "True"}, user=user, expected=0) - def test_returns_example_if_user_is_different(self): - user = self.project.approver - self.assert_filter(data={"confirmed": "False"}, user=user, expected=1) - - def test_returns_example_if_user_is_different_and_confirmed_is_empty(self): - user = self.project.approver - self.assert_filter(data={"confirmed": ""}, user=user, expected=1) - class TestExampleDetail(CRUDMixin): def setUp(self): self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION) self.non_member = make_user() - doc = make_doc(self.project.item) + example = make_doc(self.project.item) self.data = {"text": "example"} - self.url = reverse(viewname="example_detail", args=[self.project.item.id, doc.id]) + self.url = reverse(viewname="example_detail", args=[self.project.item.id, example.id]) - def test_allows_project_member_to_get_doc(self): + def test_allows_project_member_to_get_example(self): for member in self.project.members: response = self.assert_fetch(member, status.HTTP_200_OK) self.assertIn("text", response.data) - def test_denies_non_project_member_to_get_doc(self): + def test_denies_non_project_member_to_get_example(self): self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) - def test_denies_unauthenticated_user_to_get_doc(self): + def test_denies_unauthenticated_user_to_get_example(self): self.assert_fetch(expected=status.HTTP_403_FORBIDDEN) - def test_allows_project_admin_to_update_doc(self): + def test_allows_project_admin_to_update_example(self): response = self.assert_update(self.project.admin, status.HTTP_200_OK) self.assertEqual(response.data["text"], self.data["text"]) - def test_denies_project_staff_to_update_doc(self): + def test_denies_non_admin_to_update_example(self): for member in self.project.staffs: self.assert_update(member, status.HTTP_403_FORBIDDEN) - def test_denies_non_project_member_to_update_doc(self): + def test_denies_non_project_member_to_update_example(self): self.assert_update(self.non_member, status.HTTP_403_FORBIDDEN) - def test_allows_project_admin_to_delete_doc(self): + def test_allows_project_admin_to_delete_example(self): self.assert_delete(self.project.admin, status.HTTP_204_NO_CONTENT) - def test_denies_project_staff_to_delete_doc(self): + def test_denies_non_admin_to_delete_example(self): for member in self.project.staffs: self.assert_delete(member, status.HTTP_403_FORBIDDEN) - def test_denies_non_project_member_to_delete_doc(self): + def test_denies_non_project_member_to_delete_example(self): self.assert_delete(self.non_member, status.HTTP_403_FORBIDDEN) diff --git a/backend/examples/views/example.py b/backend/examples/views/example.py index c045cac5..43b0963a 100644 --- a/backend/examples/views/example.py +++ b/backend/examples/views/example.py @@ -1,6 +1,3 @@ -import random - -from django.db.models import F from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, generics, status @@ -28,14 +25,7 @@ class ExampleList(generics.ListCreateAPIView): return get_object_or_404(Project, pk=self.kwargs["project_id"]) def get_queryset(self): - queryset = self.model.objects.filter(project=self.project) - if self.project.random_order: - # Todo: fix the algorithm. - random.seed(self.request.user.id) - value = random.randrange(2, 20) - queryset = queryset.annotate(sort_id=F("id") % value).order_by("sort_id", "id") - else: - queryset = queryset.order_by("created_at") + queryset = self.model.objects.filter(project=self.project, assignments__assignee=self.request.user) return queryset def perform_create(self, serializer): From 7bf14056836389ff90f73d7c81ccbf5eecc25cc1 Mon Sep 17 00:00:00 2001 From: Hironsan Date: Thu, 3 Aug 2023 16:29:51 +0900 Subject: [PATCH 13/24] Return all examples to project admin --- backend/examples/views/example.py | 8 +++++--- backend/projects/models.py | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/examples/views/example.py b/backend/examples/views/example.py index 43b0963a..38fdfa04 100644 --- a/backend/examples/views/example.py +++ b/backend/examples/views/example.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from examples.filters import ExampleFilter from examples.models import Example from examples.serializers import ExampleSerializer -from projects.models import Project +from projects.models import Member, Project from projects.permissions import IsProjectAdmin, IsProjectStaffAndReadOnly @@ -25,8 +25,10 @@ class ExampleList(generics.ListCreateAPIView): return get_object_or_404(Project, pk=self.kwargs["project_id"]) def get_queryset(self): - queryset = self.model.objects.filter(project=self.project, assignments__assignee=self.request.user) - return queryset + member = get_object_or_404(Member, project=self.project, user=self.request.user) + if member.is_admin: + return self.model.objects.filter(project=self.project) + return self.model.objects.filter(project=self.project, assignments__assignee=self.request.user) def perform_create(self, serializer): serializer.save(project=self.project) diff --git a/backend/projects/models.py b/backend/projects/models.py index 08022190..bd5a2f26 100644 --- a/backend/projects/models.py +++ b/backend/projects/models.py @@ -210,6 +210,9 @@ class Member(models.Model): message = "This user is already assigned to a role in this project." raise ValidationError(message) + def is_admin(self): + return self.role.name == settings.ROLE_PROJECT_ADMIN + @property def username(self): return self.user.username From 4d7803694a32a28672dc5d71eab7c3c47db727f0 Mon Sep 17 00:00:00 2001 From: Hironsan Date: Fri, 4 Aug 2023 16:41:40 +0900 Subject: [PATCH 14/24] Fix example list view --- backend/examples/views/example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/examples/views/example.py b/backend/examples/views/example.py index 38fdfa04..fa889b26 100644 --- a/backend/examples/views/example.py +++ b/backend/examples/views/example.py @@ -26,7 +26,7 @@ class ExampleList(generics.ListCreateAPIView): def get_queryset(self): member = get_object_or_404(Member, project=self.project, user=self.request.user) - if member.is_admin: + if member.is_admin(): return self.model.objects.filter(project=self.project) return self.model.objects.filter(project=self.project, assignments__assignee=self.request.user) From 927ede4e565f2b60915b0d265dc147c321446676 Mon Sep 17 00:00:00 2001 From: Hironsan Date: Mon, 7 Aug 2023 08:14:47 +0900 Subject: [PATCH 15/24] Add assignment UI --- backend/examples/serializers.py | 28 +++++-- frontend/components/example/AudioList.vue | 74 +++++++++++++++---- frontend/components/example/DocumentList.vue | 74 +++++++++++++++---- frontend/components/example/ImageList.vue | 74 +++++++++++++++---- frontend/domain/models/example/example.ts | 9 ++- frontend/pages/projects/_id/dataset/index.vue | 29 ++++++++ frontend/plugins/repositories.ts | 4 +- .../example/apiAssignmentRepository.ts | 18 +++++ .../example/apiDocumentRepository.ts | 3 +- .../application/example/exampleData.ts | 4 +- 10 files changed, 266 insertions(+), 51 deletions(-) create mode 100644 frontend/repositories/example/apiAssignmentRepository.ts diff --git a/backend/examples/serializers.py b/backend/examples/serializers.py index acdbcfef..4ea517fe 100644 --- a/backend/examples/serializers.py +++ b/backend/examples/serializers.py @@ -17,9 +17,17 @@ class CommentSerializer(serializers.ModelSerializer): read_only_fields = ("user", "example") +class AssignmentSerializer(serializers.ModelSerializer): + class Meta: + model = Assignment + fields = ("id", "assignee", "example", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") + + class ExampleSerializer(serializers.ModelSerializer): annotation_approver = serializers.SerializerMethodField() is_confirmed = serializers.SerializerMethodField() + assignments = serializers.SerializerMethodField() @classmethod def get_annotation_approver(cls, instance): @@ -34,6 +42,16 @@ class ExampleSerializer(serializers.ModelSerializer): states = instance.states.filter(confirmed_by_id=user.id) return states.count() > 0 + def get_assignments(self, instance): + return [ + { + "id": assignment.id, + "assignee": assignment.assignee.username, + "assignee_id": assignment.assignee.id, + } + for assignment in instance.assignments.all() + ] + class Meta: model = Example fields = [ @@ -46,15 +64,9 @@ class ExampleSerializer(serializers.ModelSerializer): "is_confirmed", "upload_name", "score", + "assignments", ] - read_only_fields = ["filename", "is_confirmed", "upload_name"] - - -class AssignmentSerializer(serializers.ModelSerializer): - class Meta: - model = Assignment - fields = ("id", "assignee", "example", "created_at", "updated_at") - read_only_fields = ("id", "created_at", "updated_at") + read_only_fields = ["filename", "is_confirmed", "upload_name", "assignments"] class ExampleStateSerializer(serializers.ModelSerializer): diff --git a/frontend/components/example/AudioList.vue b/frontend/components/example/AudioList.vue index 887916ab..988a2858 100644 --- a/frontend/components/example/AudioList.vue +++ b/frontend/components/example/AudioList.vue @@ -43,8 +43,23 @@ -