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 new file mode 100644 index 00000000..f1eb85d5 --- /dev/null +++ b/backend/examples/assignment/strategies.py @@ -0,0 +1,82 @@ +import abc +import dataclasses +import enum +import random +from typing import List + +import numpy as np + + +@dataclasses.dataclass +class Assignment: + user: int + 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]: + ... + + +class WeightedSequentialStrategy(BaseStrategy): + def __init__(self, dataset_size: int, weights: List[int]): + if sum(weights) != 100: + raise ValueError("Sum of weights must be 100") + self.dataset_size = dataset_size + self.weights = weights + + def assign(self) -> List[Assignment]: + assignments = [] + cumsum = np.cumsum([0] + self.weights) + ratio = np.round(cumsum / 100 * self.dataset_size).astype(int) + for user, (start, end) in enumerate(zip(ratio, ratio[1:])): # Todo: use itertools.pairwise + assignments.extend([Assignment(user=user, example=example) for example in range(start, end)]) + return assignments + + +class WeightedRandomStrategy(BaseStrategy): + def __init__(self, dataset_size: int, weights: List[int]): + if sum(weights) != 100: + raise ValueError("Sum of weights must be 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)] + + +class SamplingWithoutReplacementStrategy(BaseStrategy): + def __init__(self, dataset_size: int, weights: List[int]): + 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 + + 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 diff --git a/backend/examples/assignment/usecase.py b/backend/examples/assignment/usecase.py new file mode 100644 index 00000000..f16949ea --- /dev/null +++ b/backend/examples/assignment/usecase.py @@ -0,0 +1,33 @@ +from typing import List + +from django.shortcuts import get_object_or_404 + +from examples.assignment.strategies import StrategyName, create_assignment_strategy +from examples.models import Assignment, Example +from projects.models import Member, Project + + +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=member_ids) + if len(members) != len(member_ids): + raise ValueError("Invalid member ids") + # Sort members by member_ids + members = sorted(members, key=lambda m: member_ids.index(m.id)) + index_to_user = {i: member.user for i, member in enumerate(members)} + + unassigned_examples = Example.objects.filter(project=project, assignments__isnull=True) + index_to_example = {i: example for i, example in enumerate(unassigned_examples)} + dataset_size = unassigned_examples.count() + + strategy = create_assignment_strategy(strategy_name, dataset_size, weights) + assignments = strategy.assign() + assignments = [ + Assignment( + project=project, + example=index_to_example[assignment.example], + assignee=index_to_user[assignment.user], + ) + for assignment in assignments + ] + Assignment.objects.bulk_create(assignments) diff --git a/backend/examples/assignment/workload.py b/backend/examples/assignment/workload.py new file mode 100644 index 00000000..26eb6a04 --- /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) -> List[int]: + return [w.member_id for w in self.workloads] + + @property + def weights(self) -> List[int]: + return [w.weight for w in self.workloads] 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") diff --git a/backend/examples/serializers.py b/backend/examples/serializers.py index eb6bf615..4ea517fe 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): @@ -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,8 +64,9 @@ class ExampleSerializer(serializers.ModelSerializer): "is_confirmed", "upload_name", "score", + "assignments", ] - read_only_fields = ["filename", "is_confirmed", "upload_name"] + read_only_fields = ["filename", "is_confirmed", "upload_name", "assignments"] class ExampleStateSerializer(serializers.ModelSerializer): 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/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/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/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/urls.py b/backend/examples/urls.py index 89d50561..56c6cbdb 100644 --- a/backend/examples/urls.py +++ b/backend/examples/urls.py @@ -1,10 +1,20 @@ from django.urls import path +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 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 new file mode 100644 index 00000000..08c10873 --- /dev/null +++ b/backend/examples/views/assignment.py @@ -0,0 +1,88 @@ +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 +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 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)] + + +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) + + +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, + ) + + try: + bulk_assign( + project_id=self.kwargs["project_id"], + strategy_name=strategy_name, + member_ids=workload_allocation.member_ids, + weights=workload_allocation.weights, + ) + except ValueError as e: + return Response( + {"detail": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(status=status.HTTP_201_CREATED) diff --git a/backend/examples/views/example.py b/backend/examples/views/example.py index c045cac5..f5fe041a 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 @@ -10,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 @@ -28,14 +25,13 @@ 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) + 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) + + queryset = self.model.objects.filter(project=self.project, assignments__assignee=self.request.user) 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 = queryset.order_by("assignments__id") return queryset def perform_create(self, serializer): 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 diff --git a/frontend/components/example/ActionMenu.vue b/frontend/components/example/ActionMenu.vue index 33b88cc5..1b0465c3 100644 --- a/frontend/components/example/ActionMenu.vue +++ b/frontend/components/example/ActionMenu.vue @@ -5,12 +5,14 @@ @create="$emit('create')" @upload="$emit('upload')" @download="$emit('download')" + @assign="$emit('assign')" + @reset="$emit('reset')" /> diff --git a/frontend/components/example/FormResetAssignment.vue b/frontend/components/example/FormResetAssignment.vue new file mode 100644 index 00000000..e7c43ee3 --- /dev/null +++ b/frontend/components/example/FormResetAssignment.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/components/example/ImageList.vue b/frontend/components/example/ImageList.vue index c3809a54..078290e8 100644 --- a/frontend/components/example/ImageList.vue +++ b/frontend/components/example/ImageList.vue @@ -47,8 +47,30 @@ -