diff --git a/backend/examples/serializers.py b/backend/examples/serializers.py
index acdbcfef..4ea517fe 100644
--- a/backend/examples/serializers.py
+++ b/backend/examples/serializers.py
@@ -17,9 +17,17 @@ class CommentSerializer(serializers.ModelSerializer):
read_only_fields = ("user", "example")
+class AssignmentSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Assignment
+ fields = ("id", "assignee", "example", "created_at", "updated_at")
+ read_only_fields = ("id", "created_at", "updated_at")
+
+
class ExampleSerializer(serializers.ModelSerializer):
annotation_approver = serializers.SerializerMethodField()
is_confirmed = serializers.SerializerMethodField()
+ assignments = serializers.SerializerMethodField()
@classmethod
def get_annotation_approver(cls, instance):
@@ -34,6 +42,16 @@ class ExampleSerializer(serializers.ModelSerializer):
states = instance.states.filter(confirmed_by_id=user.id)
return states.count() > 0
+ def get_assignments(self, instance):
+ return [
+ {
+ "id": assignment.id,
+ "assignee": assignment.assignee.username,
+ "assignee_id": assignment.assignee.id,
+ }
+ for assignment in instance.assignments.all()
+ ]
+
class Meta:
model = Example
fields = [
@@ -46,15 +64,9 @@ class ExampleSerializer(serializers.ModelSerializer):
"is_confirmed",
"upload_name",
"score",
+ "assignments",
]
- read_only_fields = ["filename", "is_confirmed", "upload_name"]
-
-
-class AssignmentSerializer(serializers.ModelSerializer):
- class Meta:
- model = Assignment
- fields = ("id", "assignee", "example", "created_at", "updated_at")
- read_only_fields = ("id", "created_at", "updated_at")
+ read_only_fields = ["filename", "is_confirmed", "upload_name", "assignments"]
class ExampleStateSerializer(serializers.ModelSerializer):
diff --git a/frontend/components/example/AudioList.vue b/frontend/components/example/AudioList.vue
index 887916ab..988a2858 100644
--- a/frontend/components/example/AudioList.vue
+++ b/frontend/components/example/AudioList.vue
@@ -43,8 +43,23 @@
{{ JSON.stringify(item.meta, null, 4) }}
-
- {{ item.commentCount }}
+
+
@@ -60,6 +75,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 +98,15 @@ export default Vue.extend({
type: Number,
default: 0,
required: true
+ },
+ members: {
+ type: Array as PropType,
+ default: () => [],
+ required: true
+ },
+ isAdmin: {
+ type: Boolean,
+ default: false
}
},
@@ -95,12 +120,7 @@ export default Vue.extend({
computed: {
headers() {
- return [
- {
- text: 'ID',
- value: 'id',
- sortable: false
- },
+ const headers = [
{
text: 'Status',
value: 'isConfirmed',
@@ -121,17 +141,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 +189,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)
+ }
+ }
+ }
}
}
})
diff --git a/frontend/components/example/DocumentList.vue b/frontend/components/example/DocumentList.vue
index 1e61bcaa..61a667b3 100644
--- a/frontend/components/example/DocumentList.vue
+++ b/frontend/components/example/DocumentList.vue
@@ -41,8 +41,23 @@
{{ JSON.stringify(item.meta, null, 4) }}
-
- {{ item.commentCount }}
+
+
,
+ default: () => [],
+ required: true
+ },
+ isAdmin: {
+ type: Boolean,
+ default: false
}
},
@@ -96,12 +121,7 @@ export default Vue.extend({
computed: {
headers() {
- return [
- {
- text: 'ID',
- value: 'id',
- sortable: false
- },
+ const headers = [
{
text: 'Status',
value: 'isConfirmed',
@@ -117,17 +137,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 +185,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)
+ }
+ }
+ }
}
}
})
diff --git a/frontend/components/example/ImageList.vue b/frontend/components/example/ImageList.vue
index c3809a54..c3f2c462 100644
--- a/frontend/components/example/ImageList.vue
+++ b/frontend/components/example/ImageList.vue
@@ -47,8 +47,23 @@
{{ JSON.stringify(item.meta, null, 4) }}
-
- {{ item.commentCount }}
+
+
@@ -64,6 +79,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 +102,15 @@ export default Vue.extend({
type: Number,
default: 0,
required: true
+ },
+ members: {
+ type: Array as PropType,
+ default: () => [],
+ required: true
+ },
+ isAdmin: {
+ type: Boolean,
+ default: false
}
},
@@ -99,12 +124,7 @@ export default Vue.extend({
computed: {
headers() {
- return [
- {
- text: 'ID',
- value: 'id',
- sortable: false
- },
+ const headers = [
{
text: 'Status',
value: 'isConfirmed',
@@ -125,17 +145,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 +193,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)
+ }
+ }
+ }
}
}
})
diff --git a/frontend/domain/models/example/example.ts b/frontend/domain/models/example/example.ts
index 23d4a168..fa95b0fb 100644
--- a/frontend/domain/models/example/example.ts
+++ b/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() {
diff --git a/frontend/pages/projects/_id/dataset/index.vue b/frontend/pages/projects/_id/dataset/index.vue
index 8173487e..ee2ebc61 100644
--- a/frontend/pages/projects/_id/dataset/index.vue
+++ b/frontend/pages/projects/_id/dataset/index.vue
@@ -38,29 +38,41 @@
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"
/>
@@ -78,6 +90,7 @@ 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: {
@@ -103,6 +116,8 @@ export default Vue.extend({
dialogDeleteAll: false,
item: {} as ExampleListDTO,
selected: [] as ExampleDTO[],
+ members: [] as MemberItem[],
+ user: {} as MemberItem,
isLoading: false,
isProjectAdmin: false
}
@@ -111,6 +126,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 +194,16 @@ 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)
}
}
})
diff --git a/frontend/plugins/repositories.ts b/frontend/plugins/repositories.ts
index 7fd45e4a..cc196735 100644
--- a/frontend/plugins/repositories.ts
+++ b/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(),
diff --git a/frontend/repositories/example/apiAssignmentRepository.ts b/frontend/repositories/example/apiAssignmentRepository.ts
new file mode 100644
index 00000000..08bc9d82
--- /dev/null
+++ b/frontend/repositories/example/apiAssignmentRepository.ts
@@ -0,0 +1,18 @@
+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 {
+ 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 {
+ const url = `/projects/${projectId}/assignments/${assignmentId}`
+ await this.request.delete(url)
+ }
+}
diff --git a/frontend/repositories/example/apiDocumentRepository.ts b/frontend/repositories/example/apiDocumentRepository.ts
index 3ee62d2b..cede2b66 100644
--- a/frontend/repositories/example/apiDocumentRepository.ts
+++ b/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
)
}
diff --git a/frontend/services/application/example/exampleData.ts b/frontend/services/application/example/exampleData.ts
index c6aac2e5..c67b320d 100644
--- a/frontend/services/application/example/exampleData.ts
+++ b/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
}
}