Browse Source

Merge pull request #2261 from doccano/enhancement/assignment

[Enhancement] Assign examples to members
pull/2276/head
Hiroki Nakayama 1 year ago
committed by GitHub
parent
commit
63870976cc
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1001 additions and 119 deletions
  1. 0
      backend/examples/assignment/__init__.py
  2. 82
      backend/examples/assignment/strategies.py
  3. 33
      backend/examples/assignment/usecase.py
  4. 20
      backend/examples/assignment/workload.py
  5. 45
      backend/examples/migrations/0008_assignment.py
  6. 24
      backend/examples/models.py
  7. 23
      backend/examples/serializers.py
  8. 117
      backend/examples/tests/test_assignment.py
  9. 102
      backend/examples/tests/test_example.py
  10. 33
      backend/examples/tests/test_usecase.py
  11. 4
      backend/examples/tests/utils.py
  12. 10
      backend/examples/urls.py
  13. 88
      backend/examples/views/assignment.py
  14. 18
      backend/examples/views/example.py
  15. 3
      backend/projects/models.py
  16. 14
      frontend/components/example/ActionMenu.vue
  17. 81
      frontend/components/example/AudioList.vue
  18. 81
      frontend/components/example/DocumentList.vue
  19. 138
      frontend/components/example/FormAssignment.vue
  20. 19
      frontend/components/example/FormResetAssignment.vue
  21. 81
      frontend/components/example/ImageList.vue
  22. 9
      frontend/domain/models/example/example.ts
  23. 56
      frontend/pages/projects/_id/dataset/index.vue
  24. 4
      frontend/plugins/repositories.ts
  25. 28
      frontend/repositories/example/apiAssignmentRepository.ts
  26. 3
      frontend/repositories/example/apiDocumentRepository.ts
  27. 4
      frontend/services/application/example/exampleData.ts

0
backend/examples/assignment/__init__.py

82
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

33
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)

20
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]

45
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")},
},
),
]

24
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")

23
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):

117
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)

backend/examples/tests/test_document.py → 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)

33
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)

4
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)

10
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/<uuid:assignment_id>", 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/<int:example_id>", view=ExampleDetail.as_view(), name="example_detail"),
path(route="comments", view=CommentList.as_view(), name="comment_list"),

88
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)

18
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):

3
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

14
frontend/components/example/ActionMenu.vue

@ -5,12 +5,14 @@
@create="$emit('create')"
@upload="$emit('upload')"
@download="$emit('download')"
@assign="$emit('assign')"
@reset="$emit('reset')"
/>
</template>
<script lang="ts">
import Vue from 'vue'
import { mdiUpload, mdiDownload } from '@mdi/js'
import { mdiAccountCheck, mdiUpload, mdiDownload, mdiUpdate } from '@mdi/js'
import ActionMenu from '~/components/utils/ActionMenu.vue'
export default Vue.extend({
@ -30,6 +32,16 @@ export default Vue.extend({
title: this.$t('dataset.exportDataset'),
icon: mdiDownload,
event: 'download'
},
{
title: 'Assign to member',
icon: mdiAccountCheck,
event: 'assign'
},
{
title: 'Reset Assignment',
icon: mdiUpdate,
event: 'reset'
}
]
}

81
frontend/components/example/AudioList.vue

@ -43,8 +43,30 @@
<template #[`item.meta`]="{ item }">
{{ JSON.stringify(item.meta, null, 4) }}
</template>
<template #[`item.commentCount`]="{ item }">
<span> {{ item.commentCount }} </span>
<template #[`item.assignee`]="{ item }">
<v-combobox
:value="toSelected(item)"
:items="members"
item-text="username"
no-data-text="No one"
multiple
chips
dense
flat
hide-selected
hide-details
small-chips
solo
style="width: 200px"
@change="onAssignOrUnassign(item, $event)"
>
<template #selection="{ attrs, item, parent, selected }">
<v-chip v-bind="attrs" :input-value="selected" small class="mt-1 mb-1">
<span class="pr-1">{{ item.username }}</span>
<v-icon small @click="parent.selectItem(item)"> $delete </v-icon>
</v-chip>
</template>
</v-combobox>
</template>
<template #[`item.action`]="{ item }">
<v-btn small color="primary text-capitalize" @click="toLabeling(item)">
@ -60,6 +82,7 @@ import type { PropType } from 'vue'
import Vue from 'vue'
import { DataOptions } from 'vuetify/types'
import { ExampleDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({
props: {
@ -82,6 +105,15 @@ export default Vue.extend({
type: Number,
default: 0,
required: true
},
members: {
type: Array as PropType<MemberItem[]>,
default: () => [],
required: true
},
isAdmin: {
type: Boolean,
default: false
}
},
@ -95,12 +127,7 @@ export default Vue.extend({
computed: {
headers() {
return [
{
text: 'ID',
value: 'id',
sortable: false
},
const headers = [
{
text: 'Status',
value: 'isConfirmed',
@ -121,17 +148,20 @@ export default Vue.extend({
value: 'meta',
sortable: false
},
{
text: this.$t('comments.comments'),
value: 'commentCount',
sortable: false
},
{
text: this.$t('dataset.action'),
value: 'action',
sortable: false
}
]
if (this.isAdmin) {
headers.splice(4, 0, {
text: 'Assignee',
value: 'assignee',
sortable: false
})
}
return headers
}
},
@ -166,6 +196,31 @@ export default Vue.extend({
const offset = (this.options.page - 1) * this.options.itemsPerPage
const page = (offset + index + 1).toString()
this.$emit('click:labeling', { page, q: this.search })
},
toSelected(item: ExampleDTO) {
const assigneeIds = item.assignments.map((assignment) => assignment.assignee_id)
return this.members.filter((member) => assigneeIds.includes(member.user))
},
onAssignOrUnassign(item: ExampleDTO, newAssignees: MemberItem[]) {
const newAssigneeIds = newAssignees.map((assignee) => assignee.user)
const oldAssigneeIds = item.assignments.map((assignment) => assignment.assignee_id)
if (oldAssigneeIds.length > newAssigneeIds.length) {
// unassign
for (const assignment of item.assignments) {
if (!newAssigneeIds.includes(assignment.assignee_id)) {
this.$emit('unassign', assignment.id)
}
}
} else {
// assign
for (const newAssigneeId of newAssigneeIds) {
if (!oldAssigneeIds.includes(newAssigneeId)) {
this.$emit('assign', item.id, newAssigneeId)
}
}
}
}
}
})

81
frontend/components/example/DocumentList.vue

@ -41,8 +41,30 @@
<template #[`item.meta`]="{ item }">
{{ JSON.stringify(item.meta, null, 4) }}
</template>
<template #[`item.commentCount`]="{ item }">
<span> {{ item.commentCount }} </span>
<template #[`item.assignee`]="{ item }">
<v-combobox
:value="toSelected(item)"
:items="members"
item-text="username"
no-data-text="No one"
multiple
chips
dense
flat
hide-selected
hide-details
small-chips
solo
style="width: 200px"
@change="onAssignOrUnassign(item, $event)"
>
<template #selection="{ attrs, item, parent, selected }">
<v-chip v-bind="attrs" :input-value="selected" small class="mt-1 mb-1">
<span class="pr-1">{{ item.username }}</span>
<v-icon small @click="parent.selectItem(item)"> $delete </v-icon>
</v-chip>
</template>
</v-combobox>
</template>
<template #[`item.action`]="{ item }">
<v-btn class="me-1" small color="primary text-capitalize" @click="$emit('edit', item)"
@ -61,6 +83,7 @@ import type { PropType } from 'vue'
import Vue from 'vue'
import { DataOptions } from 'vuetify/types'
import { ExampleDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({
props: {
@ -83,6 +106,15 @@ export default Vue.extend({
type: Number,
default: 0,
required: true
},
members: {
type: Array as PropType<MemberItem[]>,
default: () => [],
required: true
},
isAdmin: {
type: Boolean,
default: false
}
},
@ -96,12 +128,7 @@ export default Vue.extend({
computed: {
headers() {
return [
{
text: 'ID',
value: 'id',
sortable: false
},
const headers = [
{
text: 'Status',
value: 'isConfirmed',
@ -117,17 +144,20 @@ export default Vue.extend({
value: 'meta',
sortable: false
},
{
text: this.$t('comments.comments'),
value: 'commentCount',
sortable: false
},
{
text: this.$t('dataset.action'),
value: 'action',
sortable: false
}
]
if (this.isAdmin) {
headers.splice(3, 0, {
text: 'Assignee',
value: 'assignee',
sortable: false
})
}
return headers
}
},
@ -162,6 +192,31 @@ export default Vue.extend({
const offset = (this.options.page - 1) * this.options.itemsPerPage
const page = (offset + index + 1).toString()
this.$emit('click:labeling', { page, q: this.search })
},
toSelected(item: ExampleDTO) {
const assigneeIds = item.assignments.map((assignment) => assignment.assignee_id)
return this.members.filter((member) => assigneeIds.includes(member.user))
},
onAssignOrUnassign(item: ExampleDTO, newAssignees: MemberItem[]) {
const newAssigneeIds = newAssignees.map((assignee) => assignee.user)
const oldAssigneeIds = item.assignments.map((assignment) => assignment.assignee_id)
if (oldAssigneeIds.length > newAssigneeIds.length) {
// unassign
for (const assignment of item.assignments) {
if (!newAssigneeIds.includes(assignment.assignee_id)) {
this.$emit('unassign', assignment.id)
}
}
} else {
// assign
for (const newAssigneeId of newAssigneeIds) {
if (!oldAssigneeIds.includes(newAssigneeId)) {
this.$emit('assign', item.id, newAssigneeId)
}
}
}
}
}
})

138
frontend/components/example/FormAssignment.vue

@ -0,0 +1,138 @@
<template>
<v-card>
<v-toolbar color="primary white--text" flat>
<v-toolbar-title>Assign examples to members</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-container fluid>
<v-row>
<v-card-title class="pb-0 pl-3">Select assignment strategy</v-card-title>
<v-col cols="12">
<v-select
v-model="selectedStrategy"
:items="strategies"
item-text="displayName"
item-value="value"
outlined
dense
hide-details
></v-select>
{{ strategies.find((strategy) => strategy.value === selectedStrategy)?.description }}
The project managers have access to all examples, regardless of whether they are
assigned or not.
</v-col>
</v-row>
<v-row>
<v-card-title class="pb-0 pl-3">Allocate weights</v-card-title>
<v-col v-for="(member, i) in members" :key="member.id" cols="12" class="pt-0 pb-0">
<v-subheader class="pl-0">{{ member.username }}</v-subheader>
<v-slider v-model="workloadAllocation[i]" :max="100" class="align-center">
<template #append>
<v-text-field
v-model="workloadAllocation[i]"
class="mt-0 pt-0"
type="number"
style="width: 60px"
></v-text-field>
</template>
</v-slider>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn class="text-capitalize" text color="primary" data-test="cancel-button" @click="cancel">
Cancel
</v-btn>
<v-btn
class="text-none"
text
:disabled="!validateWeight || isWaiting"
data-test="delete-button"
@click="agree"
>
Assign
</v-btn>
</v-card-actions>
<v-overlay :value="isWaiting">
<v-progress-circular indeterminate size="64" />
</v-overlay>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({
data() {
return {
members: [] as MemberItem[],
workloadAllocation: [] as number[],
selectedStrategy: 'weighted_sequential',
isWaiting: false
}
},
async fetch() {
this.members = await this.$repositories.member.list(this.projectId)
this.workloadAllocation = this.members.map(() => Math.round(100 / this.members.length))
},
computed: {
projectId() {
return this.$route.params.id
},
strategies() {
return [
{
displayName: 'Weighted sequential',
value: 'weighted_sequential',
description:
'Assign examples to members in order of their workload. The total weight must equal 100.'
},
{
displayName: 'Weighted random',
value: 'weighted_random',
description:
'Assign examples to members randomly based on their workload. The total weight must equal 100.'
},
{
displayName: 'Sampling without replacement',
value: 'sampling_without_replacement',
description: 'Assign examples to members randomly without replacement.'
}
]
},
validateWeight(): boolean {
if (this.selectedStrategy === 'sampling_without_replacement') {
return true
} else {
return this.workloadAllocation.reduce((acc, cur) => acc + cur, 0) === 100
}
}
},
methods: {
async agree() {
this.isWaiting = true
const workloads = this.workloadAllocation.map((weight, i) => ({
weight,
member_id: this.members[i].id
}))
await this.$repositories.assignment.bulkAssign(this.projectId, {
strategy_name: this.selectedStrategy,
workloads
})
this.isWaiting = false
this.$emit('assigned')
},
cancel() {
this.$emit('cancel')
}
}
})
</script>

19
frontend/components/example/FormResetAssignment.vue

@ -0,0 +1,19 @@
<template>
<confirm-form
title="Reset assignment"
message="Are you sure you want to reset all the assignments?"
@ok="$emit('reset')"
@cancel="$emit('cancel')"
/>
</template>
<script lang="ts">
import Vue from 'vue'
import ConfirmForm from '@/components/utils/ConfirmForm.vue'
export default Vue.extend({
components: {
ConfirmForm
}
})
</script>

81
frontend/components/example/ImageList.vue

@ -47,8 +47,30 @@
<template #[`item.meta`]="{ item }">
{{ JSON.stringify(item.meta, null, 4) }}
</template>
<template #[`item.commentCount`]="{ item }">
<span> {{ item.commentCount }} </span>
<template #[`item.assignee`]="{ item }">
<v-combobox
:value="toSelected(item)"
:items="members"
item-text="username"
no-data-text="No one"
multiple
chips
dense
flat
hide-selected
hide-details
small-chips
solo
style="width: 200px"
@change="onAssignOrUnassign(item, $event)"
>
<template #selection="{ attrs, item, parent, selected }">
<v-chip v-bind="attrs" :input-value="selected" small class="mt-1 mb-1">
<span class="pr-1">{{ item.username }}</span>
<v-icon small @click="parent.selectItem(item)"> $delete </v-icon>
</v-chip>
</template>
</v-combobox>
</template>
<template #[`item.action`]="{ item }">
<v-btn small color="primary text-capitalize" @click="toLabeling(item)">
@ -64,6 +86,7 @@ import type { PropType } from 'vue'
import Vue from 'vue'
import { DataOptions } from 'vuetify/types'
import { ExampleDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({
props: {
@ -86,6 +109,15 @@ export default Vue.extend({
type: Number,
default: 0,
required: true
},
members: {
type: Array as PropType<MemberItem[]>,
default: () => [],
required: true
},
isAdmin: {
type: Boolean,
default: false
}
},
@ -99,12 +131,7 @@ export default Vue.extend({
computed: {
headers() {
return [
{
text: 'ID',
value: 'id',
sortable: false
},
const headers = [
{
text: 'Status',
value: 'isConfirmed',
@ -125,17 +152,20 @@ export default Vue.extend({
value: 'meta',
sortable: false
},
{
text: this.$t('comments.comments'),
value: 'commentCount',
sortable: false
},
{
text: this.$t('dataset.action'),
value: 'action',
sortable: false
}
]
if (this.isAdmin) {
headers.splice(4, 0, {
text: 'Assignee',
value: 'assignee',
sortable: false
})
}
return headers
}
},
@ -170,6 +200,31 @@ export default Vue.extend({
const offset = (this.options.page - 1) * this.options.itemsPerPage
const page = (offset + index + 1).toString()
this.$emit('click:labeling', { page, q: this.search })
},
toSelected(item: ExampleDTO) {
const assigneeIds = item.assignments.map((assignment) => assignment.assignee_id)
return this.members.filter((member) => assigneeIds.includes(member.user))
},
onAssignOrUnassign(item: ExampleDTO, newAssignees: MemberItem[]) {
const newAssigneeIds = newAssignees.map((assignee) => assignee.user)
const oldAssigneeIds = item.assignments.map((assignment) => assignment.assignee_id)
if (oldAssigneeIds.length > newAssigneeIds.length) {
// unassign
for (const assignment of item.assignments) {
if (!newAssigneeIds.includes(assignment.assignee_id)) {
this.$emit('unassign', assignment.id)
}
}
} else {
// assign
for (const newAssigneeId of newAssigneeIds) {
if (!oldAssigneeIds.includes(newAssigneeId)) {
this.$emit('assign', item.id, newAssigneeId)
}
}
}
}
}
})

9
frontend/domain/models/example/example.ts

@ -1,3 +1,9 @@
export interface Assignment {
id: string
assignee: string
assignee_id: number
}
export class ExampleItem {
constructor(
readonly id: number,
@ -7,7 +13,8 @@ export class ExampleItem {
readonly commentCount: number,
readonly fileUrl: string,
readonly isConfirmed: boolean,
readonly filename: string
readonly filename: string,
readonly assignments: Assignment[]
) {}
get url() {

56
frontend/pages/projects/_id/dataset/index.vue

@ -4,6 +4,8 @@
<action-menu
@upload="$router.push('dataset/import')"
@download="$router.push('dataset/export')"
@assign="dialogAssignment = true"
@reset="dialogReset = true"
/>
<v-btn
class="text-capitalize ms-2"
@ -33,34 +35,52 @@
<v-dialog v-model="dialogDeleteAll">
<form-delete-bulk @cancel="dialogDeleteAll = false" @remove="removeAll" />
</v-dialog>
<v-dialog v-model="dialogAssignment">
<form-assignment @assigned="assigned" @cancel="dialogAssignment = false" />
</v-dialog>
<v-dialog v-model="dialogReset">
<form-reset-assignment @cancel="dialogReset = false" @reset="resetAssignment" />
</v-dialog>
</v-card-title>
<image-list
v-if="project.isImageProject"
v-model="selected"
:items="item.items"
:is-admin="user.isProjectAdmin"
:is-loading="isLoading"
:members="members"
:total="item.count"
@update:query="updateQuery"
@click:labeling="movePage"
@assign="assign"
@unassign="unassign"
/>
<audio-list
v-else-if="project.isAudioProject"
v-model="selected"
:items="item.items"
:is-admin="user.isProjectAdmin"
:is-loading="isLoading"
:members="members"
:total="item.count"
@update:query="updateQuery"
@click:labeling="movePage"
@assign="assign"
@unassign="unassign"
/>
<document-list
v-else
v-model="selected"
:items="item.items"
:is-admin="user.isProjectAdmin"
:is-loading="isLoading"
:members="members"
:total="item.count"
@update:query="updateQuery"
@click:labeling="movePage"
@edit="editItem"
@assign="assign"
@unassign="unassign"
/>
</v-card>
</template>
@ -71,13 +91,16 @@ import { mapGetters } from 'vuex'
import Vue from 'vue'
import { NuxtAppOptions } from '@nuxt/types'
import DocumentList from '@/components/example/DocumentList.vue'
import FormAssignment from '~/components/example/FormAssignment.vue'
import FormDelete from '@/components/example/FormDelete.vue'
import FormDeleteBulk from '@/components/example/FormDeleteBulk.vue'
import FormResetAssignment from '~/components/example/FormResetAssignment.vue'
import ActionMenu from '~/components/example/ActionMenu.vue'
import AudioList from '~/components/example/AudioList.vue'
import ImageList from '~/components/example/ImageList.vue'
import { getLinkToAnnotationPage } from '~/presenter/linkToAnnotationPage'
import { ExampleDTO, ExampleListDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({
components: {
@ -85,8 +108,10 @@ export default Vue.extend({
AudioList,
DocumentList,
ImageList,
FormAssignment,
FormDelete,
FormDeleteBulk
FormDeleteBulk,
FormResetAssignment
},
layout: 'project',
@ -101,8 +126,12 @@ export default Vue.extend({
return {
dialogDelete: false,
dialogDeleteAll: false,
dialogAssignment: false,
dialogReset: false,
item: {} as ExampleListDTO,
selected: [] as ExampleDTO[],
members: [] as MemberItem[],
user: {} as MemberItem,
isLoading: false,
isProjectAdmin: false
}
@ -111,6 +140,10 @@ export default Vue.extend({
async fetch() {
this.isLoading = true
this.item = await this.$services.example.list(this.projectId, this.$route.query)
this.user = await this.$repositories.member.fetchMyRole(this.projectId)
if (this.user.isProjectAdmin) {
this.members = await this.$repositories.member.list(this.projectId)
}
this.isLoading = false
},
@ -175,6 +208,27 @@ export default Vue.extend({
editItem(item: ExampleDTO) {
this.$router.push(`dataset/${item.id}/edit`)
},
async assign(exampleId: number, userId: number) {
await this.$repositories.assignment.assign(this.projectId, exampleId, userId)
this.item = await this.$services.example.list(this.projectId, this.$route.query)
},
async unassign(assignmentId: string) {
await this.$repositories.assignment.unassign(this.projectId, assignmentId)
this.item = await this.$services.example.list(this.projectId, this.$route.query)
},
async assigned() {
this.dialogAssignment = false
this.item = await this.$services.example.list(this.projectId, this.$route.query)
},
async resetAssignment() {
this.dialogReset = false
await this.$repositories.assignment.reset(this.projectId)
this.item = await this.$services.example.list(this.projectId, this.$route.query)
}
}
})

4
frontend/plugins/repositories.ts

@ -1,4 +1,5 @@
import { Plugin } from '@nuxt/types'
import { APIAssignmentRepository } from '@/repositories/example/apiAssignmentRepository'
import { APIAuthRepository } from '@/repositories/auth/apiAuthRepository'
import { APIConfigRepository } from '@/repositories/autoLabeling/config/apiConfigRepository'
import { APITemplateRepository } from '@/repositories/autoLabeling/template/apiTemplateRepository'
@ -23,7 +24,6 @@ import { APICatalogRepository } from '@/repositories/upload/apiCatalogRepository
import { APIParseRepository } from '@/repositories/upload/apiParseRepository'
import { APIUserRepository } from '@/repositories/user/apiUserRepository'
import { APISegmentationRepository } from '~/repositories/tasks/apiSegmentationRepository'
export interface Repositories {
// User
auth: APIAuthRepository
@ -41,6 +41,7 @@ export interface Repositories {
taskStatus: APITaskStatusRepository
metrics: APIMetricsRepository
option: LocalStorageOptionRepository
assignment: APIAssignmentRepository
// Auto Labeling
config: APIConfigRepository
@ -91,6 +92,7 @@ const repositories: Repositories = {
taskStatus: new APITaskStatusRepository(),
metrics: new APIMetricsRepository(),
option: new LocalStorageOptionRepository(),
assignment: new APIAssignmentRepository(),
// Auto Labeling
config: new APIConfigRepository(),

28
frontend/repositories/example/apiAssignmentRepository.ts

@ -0,0 +1,28 @@
import ApiService from '@/services/api.service'
import { Assignment } from '@/domain/models/example/example'
export class APIAssignmentRepository {
constructor(private readonly request = ApiService) {}
async assign(projectId: string, exampleId: number, userId: number): Promise<Assignment> {
const url = `/projects/${projectId}/assignments`
const payload = { example: exampleId, assignee: userId }
const response = await this.request.post(url, payload)
return response.data
}
async unassign(projectId: string, assignmentId: string): Promise<void> {
const url = `/projects/${projectId}/assignments/${assignmentId}`
await this.request.delete(url)
}
async bulkAssign(projectId: string, workloadAllocation: Object): Promise<void> {
const url = `/projects/${projectId}/assignments/bulk_assign`
await this.request.post(url, workloadAllocation)
}
async reset(projectId: string): Promise<void> {
const url = `/projects/${projectId}/assignments/reset`
await this.request.delete(url)
}
}

3
frontend/repositories/example/apiDocumentRepository.ts

@ -11,7 +11,8 @@ function toModel(item: { [key: string]: any }): ExampleItem {
item.comment_count,
item.filename,
item.is_confirmed,
item.upload_name
item.upload_name,
item.assignments
)
}

4
frontend/services/application/example/exampleData.ts

@ -1,4 +1,4 @@
import { ExampleItem, ExampleItemList } from '~/domain/models/example/example'
import { ExampleItem, ExampleItemList, Assignment } from '~/domain/models/example/example'
export class ExampleDTO {
id: number
@ -11,6 +11,7 @@ export class ExampleDTO {
filename: string
url: string
isConfirmed: boolean
assignments: Assignment[]
constructor(item: ExampleItem) {
this.id = item.id
@ -23,6 +24,7 @@ export class ExampleDTO {
this.filename = item.filename
this.url = item.url
this.isConfirmed = item.isConfirmed
this.assignments = item.assignments
}
}

Loading…
Cancel
Save