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 import uuid
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django_drf_filepond.models import DrfFilePondStoredStorage from django_drf_filepond.models import DrfFilePondStoredStorage
@ -37,6 +38,29 @@ class Example(models.Model):
ordering = ["created_at"] 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): class ExampleState(models.Model):
objects = ExampleStateManager() objects = ExampleStateManager()
example = models.ForeignKey(to=Example, on_delete=models.CASCADE, related_name="states") 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 rest_framework import serializers
from .models import Comment, Example, ExampleState
from .models import Assignment, Comment, Example, ExampleState
class CommentSerializer(serializers.ModelSerializer): class CommentSerializer(serializers.ModelSerializer):
@ -17,9 +17,17 @@ class CommentSerializer(serializers.ModelSerializer):
read_only_fields = ("user", "example") 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): class ExampleSerializer(serializers.ModelSerializer):
annotation_approver = serializers.SerializerMethodField() annotation_approver = serializers.SerializerMethodField()
is_confirmed = serializers.SerializerMethodField() is_confirmed = serializers.SerializerMethodField()
assignments = serializers.SerializerMethodField()
@classmethod @classmethod
def get_annotation_approver(cls, instance): def get_annotation_approver(cls, instance):
@ -34,6 +42,16 @@ class ExampleSerializer(serializers.ModelSerializer):
states = instance.states.filter(confirmed_by_id=user.id) states = instance.states.filter(confirmed_by_id=user.id)
return states.count() > 0 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: class Meta:
model = Example model = Example
fields = [ fields = [
@ -46,8 +64,9 @@ class ExampleSerializer(serializers.ModelSerializer):
"is_confirmed", "is_confirmed",
"upload_name", "upload_name",
"score", "score",
"assignments",
] ]
read_only_fields = ["filename", "is_confirmed", "upload_name"]
read_only_fields = ["filename", "is_confirmed", "upload_name", "assignments"]
class ExampleStateSerializer(serializers.ModelSerializer): 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 django.utils.http import urlencode
from rest_framework import status from rest_framework import status
from rest_framework.reverse import reverse 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 api.tests.utils import CRUDMixin
from projects.models import ProjectType 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 from users.tests.utils import make_user
@ -15,10 +14,12 @@ class TestExampleListAPI(CRUDMixin):
self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION) self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION)
self.non_member = make_user() self.non_member = make_user()
self.example = make_doc(self.project.item) 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.data = {"text": "example"}
self.url = reverse(viewname="example_list", args=[self.project.item.id]) 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: for member in self.project.members:
response = self.assert_fetch(member, status.HTTP_200_OK) response = self.assert_fetch(member, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1) self.assertEqual(response.data["count"], 1)
@ -26,33 +27,24 @@ class TestExampleListAPI(CRUDMixin):
for item in response.data["results"]: for item in response.data["results"]:
self.assertIn("text", item) 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) 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) 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) response = self.assert_create(self.project.admin, status.HTTP_201_CREATED)
self.assertEqual(response.data["text"], self.data["text"]) 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: for member in self.project.staffs:
self.assert_create(member, status.HTTP_403_FORBIDDEN) 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) 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) make_example_state(self.example, self.project.admin)
response = self.assert_fetch(self.project.annotator, status.HTTP_200_OK) response = self.assert_fetch(self.project.annotator, status.HTTP_200_OK)
self.assertFalse(response.data["results"][0]["is_confirmed"]) self.assertFalse(response.data["results"][0]["is_confirmed"])
@ -60,23 +52,13 @@ class TestExampleListAPI(CRUDMixin):
class TestExampleListCollaborative(CRUDMixin): class TestExampleListCollaborative(CRUDMixin):
def setUp(self): 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) 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]) 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 admin = self.project.admin
approver = self.project.approver approver = self.project.approver
@ -84,14 +66,20 @@ class TestExampleListCollaborative(CRUDMixin):
response = self.assert_fetch(admin, status.HTTP_200_OK) response = self.assert_fetch(admin, status.HTTP_200_OK)
self.assertTrue(response.data["results"][0]["is_confirmed"]) self.assertTrue(response.data["results"][0]["is_confirmed"])
response = self.assert_fetch(approver, status.HTTP_200_OK) 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): class TestExampleListFilter(CRUDMixin):
def setUp(self): def setUp(self):
self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION) 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): def reverse(self, query_kwargs=None):
base_url = reverse(viewname="example_list", args=[self.project.item.id]) 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) response = self.assert_fetch(user, status.HTTP_200_OK)
self.assertEqual(response.data["count"], expected) 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 user = self.project.admin
self.assert_filter(data={"confirmed": "True"}, user=user, expected=1) 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 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 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 user = self.project.approver
self.assert_filter(data={"confirmed": "True"}, user=user, expected=0) 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): class TestExampleDetail(CRUDMixin):
def setUp(self): def setUp(self):
self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION) self.project = prepare_project(task=ProjectType.DOCUMENT_CLASSIFICATION)
self.non_member = make_user() self.non_member = make_user()
doc = make_doc(self.project.item)
example = make_doc(self.project.item)
self.data = {"text": "example"} 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: for member in self.project.members:
response = self.assert_fetch(member, status.HTTP_200_OK) response = self.assert_fetch(member, status.HTTP_200_OK)
self.assertIn("text", response.data) 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) 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) 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) response = self.assert_update(self.project.admin, status.HTTP_200_OK)
self.assertEqual(response.data["text"], self.data["text"]) 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: for member in self.project.staffs:
self.assert_update(member, status.HTTP_403_FORBIDDEN) 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) 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) 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: for member in self.project.staffs:
self.assert_delete(member, status.HTTP_403_FORBIDDEN) 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) 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): def make_example_state(example, user):
return mommy.make("ExampleState", example=example, confirmed_by=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 django.urls import path
from .views.assignment import (
AssignmentDetail,
AssignmentList,
BulkAssignment,
ResetAssignment,
)
from .views.comment import CommentDetail, CommentList from .views.comment import CommentDetail, CommentList
from .views.example import ExampleDetail, ExampleList from .views.example import ExampleDetail, ExampleList
from .views.example_state import ExampleStateList from .views.example_state import ExampleStateList
urlpatterns = [ 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", view=ExampleList.as_view(), name="example_list"),
path(route="examples/<int:example_id>", view=ExampleDetail.as_view(), name="example_detail"), path(route="examples/<int:example_id>", view=ExampleDetail.as_view(), name="example_detail"),
path(route="comments", view=CommentList.as_view(), name="comment_list"), 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.shortcuts import get_object_or_404
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics, status from rest_framework import filters, generics, status
@ -10,7 +7,7 @@ from rest_framework.response import Response
from examples.filters import ExampleFilter from examples.filters import ExampleFilter
from examples.models import Example from examples.models import Example
from examples.serializers import ExampleSerializer from examples.serializers import ExampleSerializer
from projects.models import Project
from projects.models import Member, Project
from projects.permissions import IsProjectAdmin, IsProjectStaffAndReadOnly 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"]) return get_object_or_404(Project, pk=self.kwargs["project_id"])
def get_queryset(self): 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: 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 return queryset
def perform_create(self, serializer): 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." message = "This user is already assigned to a role in this project."
raise ValidationError(message) raise ValidationError(message)
def is_admin(self):
return self.role.name == settings.ROLE_PROJECT_ADMIN
@property @property
def username(self): def username(self):
return self.user.username return self.user.username

14
frontend/components/example/ActionMenu.vue

@ -5,12 +5,14 @@
@create="$emit('create')" @create="$emit('create')"
@upload="$emit('upload')" @upload="$emit('upload')"
@download="$emit('download')" @download="$emit('download')"
@assign="$emit('assign')"
@reset="$emit('reset')"
/> />
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue' 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' import ActionMenu from '~/components/utils/ActionMenu.vue'
export default Vue.extend({ export default Vue.extend({
@ -30,6 +32,16 @@ export default Vue.extend({
title: this.$t('dataset.exportDataset'), title: this.$t('dataset.exportDataset'),
icon: mdiDownload, icon: mdiDownload,
event: 'download' 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 }"> <template #[`item.meta`]="{ item }">
{{ JSON.stringify(item.meta, null, 4) }} {{ JSON.stringify(item.meta, null, 4) }}
</template> </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>
<template #[`item.action`]="{ item }"> <template #[`item.action`]="{ item }">
<v-btn small color="primary text-capitalize" @click="toLabeling(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 Vue from 'vue'
import { DataOptions } from 'vuetify/types' import { DataOptions } from 'vuetify/types'
import { ExampleDTO } from '~/services/application/example/exampleData' import { ExampleDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({ export default Vue.extend({
props: { props: {
@ -82,6 +105,15 @@ export default Vue.extend({
type: Number, type: Number,
default: 0, default: 0,
required: true 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: { computed: {
headers() { headers() {
return [
{
text: 'ID',
value: 'id',
sortable: false
},
const headers = [
{ {
text: 'Status', text: 'Status',
value: 'isConfirmed', value: 'isConfirmed',
@ -121,17 +148,20 @@ export default Vue.extend({
value: 'meta', value: 'meta',
sortable: false sortable: false
}, },
{
text: this.$t('comments.comments'),
value: 'commentCount',
sortable: false
},
{ {
text: this.$t('dataset.action'), text: this.$t('dataset.action'),
value: 'action', value: 'action',
sortable: false 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 offset = (this.options.page - 1) * this.options.itemsPerPage
const page = (offset + index + 1).toString() const page = (offset + index + 1).toString()
this.$emit('click:labeling', { page, q: this.search }) 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 }"> <template #[`item.meta`]="{ item }">
{{ JSON.stringify(item.meta, null, 4) }} {{ JSON.stringify(item.meta, null, 4) }}
</template> </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>
<template #[`item.action`]="{ item }"> <template #[`item.action`]="{ item }">
<v-btn class="me-1" small color="primary text-capitalize" @click="$emit('edit', 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 Vue from 'vue'
import { DataOptions } from 'vuetify/types' import { DataOptions } from 'vuetify/types'
import { ExampleDTO } from '~/services/application/example/exampleData' import { ExampleDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({ export default Vue.extend({
props: { props: {
@ -83,6 +106,15 @@ export default Vue.extend({
type: Number, type: Number,
default: 0, default: 0,
required: true 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: { computed: {
headers() { headers() {
return [
{
text: 'ID',
value: 'id',
sortable: false
},
const headers = [
{ {
text: 'Status', text: 'Status',
value: 'isConfirmed', value: 'isConfirmed',
@ -117,17 +144,20 @@ export default Vue.extend({
value: 'meta', value: 'meta',
sortable: false sortable: false
}, },
{
text: this.$t('comments.comments'),
value: 'commentCount',
sortable: false
},
{ {
text: this.$t('dataset.action'), text: this.$t('dataset.action'),
value: 'action', value: 'action',
sortable: false 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 offset = (this.options.page - 1) * this.options.itemsPerPage
const page = (offset + index + 1).toString() const page = (offset + index + 1).toString()
this.$emit('click:labeling', { page, q: this.search }) 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 }"> <template #[`item.meta`]="{ item }">
{{ JSON.stringify(item.meta, null, 4) }} {{ JSON.stringify(item.meta, null, 4) }}
</template> </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>
<template #[`item.action`]="{ item }"> <template #[`item.action`]="{ item }">
<v-btn small color="primary text-capitalize" @click="toLabeling(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 Vue from 'vue'
import { DataOptions } from 'vuetify/types' import { DataOptions } from 'vuetify/types'
import { ExampleDTO } from '~/services/application/example/exampleData' import { ExampleDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({ export default Vue.extend({
props: { props: {
@ -86,6 +109,15 @@ export default Vue.extend({
type: Number, type: Number,
default: 0, default: 0,
required: true 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: { computed: {
headers() { headers() {
return [
{
text: 'ID',
value: 'id',
sortable: false
},
const headers = [
{ {
text: 'Status', text: 'Status',
value: 'isConfirmed', value: 'isConfirmed',
@ -125,17 +152,20 @@ export default Vue.extend({
value: 'meta', value: 'meta',
sortable: false sortable: false
}, },
{
text: this.$t('comments.comments'),
value: 'commentCount',
sortable: false
},
{ {
text: this.$t('dataset.action'), text: this.$t('dataset.action'),
value: 'action', value: 'action',
sortable: false 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 offset = (this.options.page - 1) * this.options.itemsPerPage
const page = (offset + index + 1).toString() const page = (offset + index + 1).toString()
this.$emit('click:labeling', { page, q: this.search }) 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 { export class ExampleItem {
constructor( constructor(
readonly id: number, readonly id: number,
@ -7,7 +13,8 @@ export class ExampleItem {
readonly commentCount: number, readonly commentCount: number,
readonly fileUrl: string, readonly fileUrl: string,
readonly isConfirmed: boolean, readonly isConfirmed: boolean,
readonly filename: string
readonly filename: string,
readonly assignments: Assignment[]
) {} ) {}
get url() { get url() {

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

@ -4,6 +4,8 @@
<action-menu <action-menu
@upload="$router.push('dataset/import')" @upload="$router.push('dataset/import')"
@download="$router.push('dataset/export')" @download="$router.push('dataset/export')"
@assign="dialogAssignment = true"
@reset="dialogReset = true"
/> />
<v-btn <v-btn
class="text-capitalize ms-2" class="text-capitalize ms-2"
@ -33,34 +35,52 @@
<v-dialog v-model="dialogDeleteAll"> <v-dialog v-model="dialogDeleteAll">
<form-delete-bulk @cancel="dialogDeleteAll = false" @remove="removeAll" /> <form-delete-bulk @cancel="dialogDeleteAll = false" @remove="removeAll" />
</v-dialog> </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> </v-card-title>
<image-list <image-list
v-if="project.isImageProject" v-if="project.isImageProject"
v-model="selected" v-model="selected"
:items="item.items" :items="item.items"
:is-admin="user.isProjectAdmin"
:is-loading="isLoading" :is-loading="isLoading"
:members="members"
:total="item.count" :total="item.count"
@update:query="updateQuery" @update:query="updateQuery"
@click:labeling="movePage" @click:labeling="movePage"
@assign="assign"
@unassign="unassign"
/> />
<audio-list <audio-list
v-else-if="project.isAudioProject" v-else-if="project.isAudioProject"
v-model="selected" v-model="selected"
:items="item.items" :items="item.items"
:is-admin="user.isProjectAdmin"
:is-loading="isLoading" :is-loading="isLoading"
:members="members"
:total="item.count" :total="item.count"
@update:query="updateQuery" @update:query="updateQuery"
@click:labeling="movePage" @click:labeling="movePage"
@assign="assign"
@unassign="unassign"
/> />
<document-list <document-list
v-else v-else
v-model="selected" v-model="selected"
:items="item.items" :items="item.items"
:is-admin="user.isProjectAdmin"
:is-loading="isLoading" :is-loading="isLoading"
:members="members"
:total="item.count" :total="item.count"
@update:query="updateQuery" @update:query="updateQuery"
@click:labeling="movePage" @click:labeling="movePage"
@edit="editItem" @edit="editItem"
@assign="assign"
@unassign="unassign"
/> />
</v-card> </v-card>
</template> </template>
@ -71,13 +91,16 @@ import { mapGetters } from 'vuex'
import Vue from 'vue' import Vue from 'vue'
import { NuxtAppOptions } from '@nuxt/types' import { NuxtAppOptions } from '@nuxt/types'
import DocumentList from '@/components/example/DocumentList.vue' import DocumentList from '@/components/example/DocumentList.vue'
import FormAssignment from '~/components/example/FormAssignment.vue'
import FormDelete from '@/components/example/FormDelete.vue' import FormDelete from '@/components/example/FormDelete.vue'
import FormDeleteBulk from '@/components/example/FormDeleteBulk.vue' import FormDeleteBulk from '@/components/example/FormDeleteBulk.vue'
import FormResetAssignment from '~/components/example/FormResetAssignment.vue'
import ActionMenu from '~/components/example/ActionMenu.vue' import ActionMenu from '~/components/example/ActionMenu.vue'
import AudioList from '~/components/example/AudioList.vue' import AudioList from '~/components/example/AudioList.vue'
import ImageList from '~/components/example/ImageList.vue' import ImageList from '~/components/example/ImageList.vue'
import { getLinkToAnnotationPage } from '~/presenter/linkToAnnotationPage' import { getLinkToAnnotationPage } from '~/presenter/linkToAnnotationPage'
import { ExampleDTO, ExampleListDTO } from '~/services/application/example/exampleData' import { ExampleDTO, ExampleListDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -85,8 +108,10 @@ export default Vue.extend({
AudioList, AudioList,
DocumentList, DocumentList,
ImageList, ImageList,
FormAssignment,
FormDelete, FormDelete,
FormDeleteBulk
FormDeleteBulk,
FormResetAssignment
}, },
layout: 'project', layout: 'project',
@ -101,8 +126,12 @@ export default Vue.extend({
return { return {
dialogDelete: false, dialogDelete: false,
dialogDeleteAll: false, dialogDeleteAll: false,
dialogAssignment: false,
dialogReset: false,
item: {} as ExampleListDTO, item: {} as ExampleListDTO,
selected: [] as ExampleDTO[], selected: [] as ExampleDTO[],
members: [] as MemberItem[],
user: {} as MemberItem,
isLoading: false, isLoading: false,
isProjectAdmin: false isProjectAdmin: false
} }
@ -111,6 +140,10 @@ export default Vue.extend({
async fetch() { async fetch() {
this.isLoading = true this.isLoading = true
this.item = await this.$services.example.list(this.projectId, this.$route.query) 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 this.isLoading = false
}, },
@ -175,6 +208,27 @@ export default Vue.extend({
editItem(item: ExampleDTO) { editItem(item: ExampleDTO) {
this.$router.push(`dataset/${item.id}/edit`) 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 { Plugin } from '@nuxt/types'
import { APIAssignmentRepository } from '@/repositories/example/apiAssignmentRepository'
import { APIAuthRepository } from '@/repositories/auth/apiAuthRepository' import { APIAuthRepository } from '@/repositories/auth/apiAuthRepository'
import { APIConfigRepository } from '@/repositories/autoLabeling/config/apiConfigRepository' import { APIConfigRepository } from '@/repositories/autoLabeling/config/apiConfigRepository'
import { APITemplateRepository } from '@/repositories/autoLabeling/template/apiTemplateRepository' import { APITemplateRepository } from '@/repositories/autoLabeling/template/apiTemplateRepository'
@ -23,7 +24,6 @@ import { APICatalogRepository } from '@/repositories/upload/apiCatalogRepository
import { APIParseRepository } from '@/repositories/upload/apiParseRepository' import { APIParseRepository } from '@/repositories/upload/apiParseRepository'
import { APIUserRepository } from '@/repositories/user/apiUserRepository' import { APIUserRepository } from '@/repositories/user/apiUserRepository'
import { APISegmentationRepository } from '~/repositories/tasks/apiSegmentationRepository' import { APISegmentationRepository } from '~/repositories/tasks/apiSegmentationRepository'
export interface Repositories { export interface Repositories {
// User // User
auth: APIAuthRepository auth: APIAuthRepository
@ -41,6 +41,7 @@ export interface Repositories {
taskStatus: APITaskStatusRepository taskStatus: APITaskStatusRepository
metrics: APIMetricsRepository metrics: APIMetricsRepository
option: LocalStorageOptionRepository option: LocalStorageOptionRepository
assignment: APIAssignmentRepository
// Auto Labeling // Auto Labeling
config: APIConfigRepository config: APIConfigRepository
@ -91,6 +92,7 @@ const repositories: Repositories = {
taskStatus: new APITaskStatusRepository(), taskStatus: new APITaskStatusRepository(),
metrics: new APIMetricsRepository(), metrics: new APIMetricsRepository(),
option: new LocalStorageOptionRepository(), option: new LocalStorageOptionRepository(),
assignment: new APIAssignmentRepository(),
// Auto Labeling // Auto Labeling
config: new APIConfigRepository(), 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.comment_count,
item.filename, item.filename,
item.is_confirmed, 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 { export class ExampleDTO {
id: number id: number
@ -11,6 +11,7 @@ export class ExampleDTO {
filename: string filename: string
url: string url: string
isConfirmed: boolean isConfirmed: boolean
assignments: Assignment[]
constructor(item: ExampleItem) { constructor(item: ExampleItem) {
this.id = item.id this.id = item.id
@ -23,6 +24,7 @@ export class ExampleDTO {
this.filename = item.filename this.filename = item.filename
this.url = item.url this.url = item.url
this.isConfirmed = item.isConfirmed this.isConfirmed = item.isConfirmed
this.assignments = item.assignments
} }
} }

Loading…
Cancel
Save