Browse Source

Add assignment UI

pull/2261/head
Hironsan 1 year ago
parent
commit
927ede4e56
10 changed files with 266 additions and 51 deletions
  1. 28
      backend/examples/serializers.py
  2. 74
      frontend/components/example/AudioList.vue
  3. 74
      frontend/components/example/DocumentList.vue
  4. 74
      frontend/components/example/ImageList.vue
  5. 9
      frontend/domain/models/example/example.ts
  6. 29
      frontend/pages/projects/_id/dataset/index.vue
  7. 4
      frontend/plugins/repositories.ts
  8. 18
      frontend/repositories/example/apiAssignmentRepository.ts
  9. 3
      frontend/repositories/example/apiDocumentRepository.ts
  10. 4
      frontend/services/application/example/exampleData.ts

28
backend/examples/serializers.py

@ -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,15 +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"]
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): class ExampleStateSerializer(serializers.ModelSerializer):

74
frontend/components/example/AudioList.vue

@ -43,8 +43,23 @@
<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> </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 +75,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 +98,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 +120,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 +141,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 +189,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)
}
}
}
} }
} }
}) })

74
frontend/components/example/DocumentList.vue

@ -41,8 +41,23 @@
<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> </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 +76,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 +99,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 +121,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 +137,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 +185,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)
}
}
}
} }
} }
}) })

74
frontend/components/example/ImageList.vue

@ -47,8 +47,23 @@
<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> </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 +79,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 +102,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 +124,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 +145,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 +193,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() {

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

@ -38,29 +38,41 @@
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>
@ -78,6 +90,7 @@ 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: {
@ -103,6 +116,8 @@ export default Vue.extend({
dialogDeleteAll: false, dialogDeleteAll: 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 +126,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 +194,16 @@ 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)
} }
} }
}) })

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(),

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

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