mirror of https://github.com/doccano/doccano.git
Browse Source
Merge pull request #2261 from doccano/enhancement/assignment
Merge pull request #2261 from doccano/enhancement/assignment
[Enhancement] Assign examples to memberspull/2276/head
Hiroki Nakayama
1 year ago
committed by
GitHub
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1001 additions and 119 deletions
Unified View
Diff Options
-
0backend/examples/assignment/__init__.py
-
82backend/examples/assignment/strategies.py
-
33backend/examples/assignment/usecase.py
-
20backend/examples/assignment/workload.py
-
45backend/examples/migrations/0008_assignment.py
-
24backend/examples/models.py
-
23backend/examples/serializers.py
-
117backend/examples/tests/test_assignment.py
-
102backend/examples/tests/test_example.py
-
33backend/examples/tests/test_usecase.py
-
4backend/examples/tests/utils.py
-
10backend/examples/urls.py
-
88backend/examples/views/assignment.py
-
18backend/examples/views/example.py
-
3backend/projects/models.py
-
14frontend/components/example/ActionMenu.vue
-
81frontend/components/example/AudioList.vue
-
81frontend/components/example/DocumentList.vue
-
138frontend/components/example/FormAssignment.vue
-
19frontend/components/example/FormResetAssignment.vue
-
81frontend/components/example/ImageList.vue
-
9frontend/domain/models/example/example.ts
-
56frontend/pages/projects/_id/dataset/index.vue
-
4frontend/plugins/repositories.ts
-
28frontend/repositories/example/apiAssignmentRepository.ts
-
3frontend/repositories/example/apiDocumentRepository.ts
-
4frontend/services/application/example/exampleData.ts
@ -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 |
@ -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) |
@ -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] |
@ -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")}, |
||||
|
}, |
||||
|
), |
||||
|
] |
@ -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) |
@ -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) |
@ -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) |
@ -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> |
@ -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> |
@ -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) |
||||
|
} |
||||
|
} |
Write
Preview
Loading…
Cancel
Save