From 89e21140f67365d8cc868089a995a75b33806742 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 13 Apr 2021 18:52:08 +0200 Subject: [PATCH 1/3] Project tags backend --- app/api/admin.py | 10 +++++++++- app/api/migrations/0009_tag.py | 22 ++++++++++++++++++++++ app/api/models.py | 8 ++++++++ app/api/serializers.py | 15 ++++++++++++--- app/api/urls.py | 6 +++++- app/api/views/__init__.py | 3 ++- app/api/views/tag.py | 24 ++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 app/api/migrations/0009_tag.py create mode 100644 app/api/views/tag.py diff --git a/app/api/admin.py b/app/api/admin.py index 5c6c532d..53daf51c 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from .models import (Comment, Document, DocumentAnnotation, Label, Project, Role, RoleMapping, Seq2seqAnnotation, Seq2seqProject, - SequenceAnnotation, SequenceLabelingProject, + SequenceAnnotation, SequenceLabelingProject, Tag, TextClassificationProject) @@ -53,6 +53,13 @@ class RoleMappingAdmin(admin.ModelAdmin): ordering = ('user',) search_fields = ('user__username',) + +class TagAdmin(admin.ModelAdmin): + list_display = ('project', 'text', ) + ordering = ('project', 'text', ) + search_fields = ('text',) + + class CommentAdmin(admin.ModelAdmin): list_display = ('user', 'document', 'text', 'created_at', ) ordering = ('user', 'created_at', ) @@ -71,3 +78,4 @@ admin.site.register(Seq2seqProject, ProjectAdmin) admin.site.register(Role, RoleAdmin) admin.site.register(RoleMapping, RoleMappingAdmin) admin.site.register(Comment, CommentAdmin) +admin.site.register(Tag, TagAdmin) diff --git a/app/api/migrations/0009_tag.py b/app/api/migrations/0009_tag.py new file mode 100644 index 00000000..bc41eeed --- /dev/null +++ b/app/api/migrations/0009_tag.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2 on 2021-04-13 16:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0008_auto_20210302_1013'), + ] + + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='api.project')), + ], + ), + ] diff --git a/app/api/models.py b/app/api/models.py index cb367151..5f004014 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -230,6 +230,14 @@ class Comment(models.Model): ordering = ('-created_at', ) +class Tag(models.Model): + text = models.TextField() + project = models.ForeignKey(Project, related_name='tags', on_delete=models.CASCADE) + + def __str__(self): + return self.text + + class Annotation(models.Model): objects = AnnotationManager() diff --git a/app/api/serializers.py b/app/api/serializers.py index 4268f4c0..4ef09792 100644 --- a/app/api/serializers.py +++ b/app/api/serializers.py @@ -10,7 +10,7 @@ from .models import (AutoLabelingConfig, Comment, Document, DocumentAnnotation, Label, Project, Role, RoleMapping, Seq2seqAnnotation, Seq2seqProject, SequenceAnnotation, SequenceLabelingProject, Speech2textAnnotation, - Speech2textProject, TextClassificationProject) + Speech2textProject, Tag, TextClassificationProject) class UserSerializer(serializers.ModelSerializer): @@ -69,6 +69,14 @@ class CommentSerializer(serializers.ModelSerializer): read_only_fields = ('user', 'document') +class TagSerializer(serializers.ModelSerializer): + + class Meta: + model = Tag + fields = ('id', 'project', 'text', ) + read_only_fields = ('id', 'project') + + class DocumentSerializer(serializers.ModelSerializer): annotations = serializers.SerializerMethodField() annotation_approver = serializers.SerializerMethodField() @@ -103,6 +111,7 @@ class ApproverSerializer(DocumentSerializer): class ProjectSerializer(serializers.ModelSerializer): current_users_role = serializers.SerializerMethodField() + tags = TagSerializer(many=True, required=False) def get_current_users_role(self, instance): role_abstractor = { @@ -122,8 +131,8 @@ class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project fields = ('id', 'name', 'description', 'guideline', 'users', 'current_users_role', 'project_type', - 'updated_at', 'randomize_document_order', 'collaborative_annotation', 'single_class_classification') - read_only_fields = ('updated_at', 'users', 'current_users_role') + 'updated_at', 'randomize_document_order', 'collaborative_annotation', 'single_class_classification', 'tags') + read_only_fields = ('updated_at', 'users', 'current_users_role', 'tags') class TextClassificationProjectSerializer(ProjectSerializer): diff --git a/app/api/urls.py b/app/api/urls.py index 06edc4ea..2d75ed42 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -48,6 +48,11 @@ urlpatterns_project = [ view=views.AnnotationDetail.as_view(), name='annotation_detail' ), + path( + route='tags', + view=views.TagList.as_view(), + name='tag_list' + ), path( route='docs//comments', view=views.CommentListDoc.as_view(), @@ -113,7 +118,6 @@ urlpatterns_project = [ view=views.AutoLabelingAnnotation.as_view(), name='auto_labeling_annotation' ), - path( route='auto-labeling-template-testing', view=views.AutoLabelingTemplateTest.as_view(), diff --git a/app/api/views/__init__.py b/app/api/views/__init__.py index f33ef2a9..0571b864 100644 --- a/app/api/views/__init__.py +++ b/app/api/views/__init__.py @@ -8,4 +8,5 @@ from .label import * from .project import * from .role import * from .statistics import * -from .user import * +from .tag import * +from .user import * \ No newline at end of file diff --git a/app/api/views/tag.py b/app/api/views/tag.py new file mode 100644 index 00000000..eb86da4e --- /dev/null +++ b/app/api/views/tag.py @@ -0,0 +1,24 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.response import Response + +from ..models import Project, Tag +from ..serializers import TagSerializer + + +class TagList(generics.ListCreateAPIView): + serializer_class = TagSerializer + pagination_class = None + + def get_queryset(self): + project = get_object_or_404(Project, pk=self.kwargs['project_id']) + return project.tags + + def perform_create(self, serializer): + project = get_object_or_404(Project, pk=self.kwargs['project_id']) + serializer.save(project=project) + + def delete(self, request, *args, **kwargs): + delete_id = request.data['id'] + Tag.objects.get(id=delete_id).delete() + return Response(status=status.HTTP_204_NO_CONTENT) From 84ed7953e5821bf63b39c602b8b1f96e0cfdd403 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 13 Apr 2021 18:52:44 +0200 Subject: [PATCH 2/3] Project tags tests --- app/api/tests/test_api.py | 55 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py index ade647ff..91176fb6 100644 --- a/app/api/tests/test_api.py +++ b/app/api/tests/test_api.py @@ -9,7 +9,7 @@ from rest_framework.test import APITestCase from ..exceptions import FileParseException from ..models import (DOCUMENT_CLASSIFICATION, SEQ2SEQ, SEQUENCE_LABELING, - SPEECH2TEXT, Comment, Document, Role, RoleMapping, + SPEECH2TEXT, Document, Role, RoleMapping, SequenceAnnotation, User) from ..utils import (CoNLLParser, CSVParser, FastTextParser, JSONParser, PlainTextParser) @@ -831,6 +831,59 @@ class TestCommentListAPI(APITestCase): remove_all_role_mappings() +class TestTagAPI(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.main_project_member_name = 'project_member_name' + cls.main_project_member_pass = 'project_member_pass' + cls.super_user_name = 'super_user_name' + cls.super_user_pass = 'super_user_pass' + create_default_roles() + main_project_member = User.objects.create_user(username=cls.main_project_member_name, + password=cls.main_project_member_pass) + super_user = User.objects.create_superuser(username=cls.super_user_name, + password=cls.super_user_pass, + email='fizz@buzz.com') + cls.main_project = mommy.make('TextClassificationProject', users=[main_project_member, super_user]) + assign_user_to_role(project_member=main_project_member, project=cls.main_project, + role_name=settings.ROLE_ANNOTATOR) + assign_user_to_role(project_member=super_user, project=cls.main_project, + role_name=settings.ROLE_ANNOTATOR) + cls.tag = mommy.make('Tag', project=cls.main_project, text='Tag 1') + cls.url = reverse(viewname='tag_list', args=[cls.main_project.id]) + cls.project_url = reverse(viewname='project_list') + cls.delete_url = reverse(viewname='tag_list', args=[cls.main_project.id]) + + def test_create_tag(self): + self.client.login(username=self.super_user_name, + password=self.super_user_pass) + response = self.client.post(self.url, data={'text': 'Tag 2'}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response = self.client.get(self.project_url, format='json') + self.assertTrue(response.data[0]['tags'][1]['text'] == 'Tag 2' ,'Content of tags differs.') + + def test_tag_list(self): + self.client.login(username=self.main_project_member_name, + password=self.main_project_member_pass) + response = self.client.get(self.project_url, format='json') + self.assertTrue(len(response.data[0]['tags']) == 1 ,'Number of tags differs expected amount.') + self.assertTrue(response.data[0]['tags'][0]['text'] == 'Tag 1' ,'Content of tags differs.') + + def test_delete_tag(self): + self.client.login(username=self.super_user_name, + password=self.super_user_pass) + response = self.client.delete(self.delete_url, data={'id': self.tag.id}) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + response = self.client.get(self.project_url, format='json') + self.assertTrue(len(response.data[0]['tags']) == 0 ,'Number of tags differs expected amount.') + + + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + + class TestAnnotationListAPI(APITestCase, TestUtilsMixin): @classmethod From 5755804dc5e446963ee1c070f66e8bac4a6282b1 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 13 Apr 2021 18:53:26 +0200 Subject: [PATCH 3/3] Project tags frontend --- frontend/components/project/FormUpdate.vue | 40 ++++++++++++++++ frontend/components/project/ProjectList.vue | 10 +++- frontend/domain/models/project/project.ts | 13 ++++-- frontend/domain/models/tag/tag.ts | 46 +++++++++++++++++++ frontend/domain/models/tag/tagRepository.ts | 9 ++++ frontend/plugins/services.ts | 10 +++- frontend/repositories/tag/apiTagRepository.ts | 34 ++++++++++++++ .../application/project/projectData.ts | 4 +- .../application/tag/tagApplicationService.ts | 23 ++++++++++ frontend/services/application/tag/tagData.ts | 13 ++++++ 10 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 frontend/domain/models/tag/tag.ts create mode 100644 frontend/domain/models/tag/tagRepository.ts create mode 100644 frontend/repositories/tag/apiTagRepository.ts create mode 100644 frontend/services/application/tag/tagApplicationService.ts create mode 100644 frontend/services/application/tag/tagData.ts diff --git a/frontend/components/project/FormUpdate.vue b/frontend/components/project/FormUpdate.vue index 42e27d81..f7cb8c91 100644 --- a/frontend/components/project/FormUpdate.vue +++ b/frontend/components/project/FormUpdate.vue @@ -99,6 +99,28 @@ + + +

Tags

+ {{tag.text}} + + + +
+
tag.id !== id) } } } diff --git a/frontend/components/project/ProjectList.vue b/frontend/components/project/ProjectList.vue index 0aebf710..b92418b8 100644 --- a/frontend/components/project/ProjectList.vue +++ b/frontend/components/project/ProjectList.vue @@ -35,6 +35,13 @@ + @@ -76,8 +83,9 @@ export default Vue.extend({ { text: this.$t('generic.description'), value: 'description' }, { text: this.$t('generic.type'), value: 'projectType' }, { text: 'Updated', value: 'updatedAt' }, + { text: 'Tags', value: 'tags'} ] } - } + } }) diff --git a/frontend/domain/models/project/project.ts b/frontend/domain/models/project/project.ts index b66565d2..94f62239 100644 --- a/frontend/domain/models/project/project.ts +++ b/frontend/domain/models/project/project.ts @@ -22,6 +22,7 @@ export class ProjectReadItem { public collaborative_annotation: boolean, public single_class_classification: boolean, public resourcetype: string, + public tags: Object[], ) {} static valueOf( @@ -37,7 +38,8 @@ export class ProjectReadItem { randomize_document_order, collaborative_annotation, single_class_classification, - resourcetype + resourcetype, + tags }: { id: number, @@ -51,7 +53,8 @@ export class ProjectReadItem { randomize_document_order: boolean, collaborative_annotation: boolean, single_class_classification: boolean, - resourcetype: string + resourcetype: string, + tags: Object[] } ): ProjectReadItem { return new ProjectReadItem( @@ -66,7 +69,8 @@ export class ProjectReadItem { randomize_document_order, collaborative_annotation, single_class_classification, - resourcetype + resourcetype, + tags ) } @@ -118,7 +122,8 @@ export class ProjectReadItem { randomize_document_order: this.randomize_document_order, collaborative_annotation: this.collaborative_annotation, single_class_classification: this.single_class_classification, - resourcetype: this.resourcetype + resourcetype: this.resourcetype, + tags: this.tags } } } diff --git a/frontend/domain/models/tag/tag.ts b/frontend/domain/models/tag/tag.ts new file mode 100644 index 00000000..fe3b67c5 --- /dev/null +++ b/frontend/domain/models/tag/tag.ts @@ -0,0 +1,46 @@ +export class TagItemList { + constructor(public tagItems: TagItem[]) {} + + static valueOf(items: TagItem[]): TagItemList { + return new TagItemList(items) + } + + add(item: TagItem) { + this.tagItems.push(item) + } + + delete(item: TagItem) { + this.tagItems = this.tagItems.filter(tag => tag.id !== item.id) + } + + ids(): Number[]{ + return this.tagItems.map(item => item.id) + } + + toArray(): Object[] { + return this.tagItems.map(item => item.toObject()) + } +} + +export class TagItem { + constructor( + public id: number, + public text: string, + public project: string + ) {} + + static valueOf( + { id, text, project }: + { id: number, text: string, project: string } + ): TagItem { + return new TagItem(id, text, project) + } + + toObject(): Object { + return { + id: this.id, + text: this.text, + project: this.project + } + } +} diff --git a/frontend/domain/models/tag/tagRepository.ts b/frontend/domain/models/tag/tagRepository.ts new file mode 100644 index 00000000..24ad63ea --- /dev/null +++ b/frontend/domain/models/tag/tagRepository.ts @@ -0,0 +1,9 @@ +import { TagItem } from '~/domain/models/tag/tag' + +export interface TagRepository { + list(projectId: string): Promise + + create(projectId: string, item: string): Promise + + delete(projectId: string, tagId: number): Promise +} diff --git a/frontend/plugins/services.ts b/frontend/plugins/services.ts index 411b53e2..4bd46236 100644 --- a/frontend/plugins/services.ts +++ b/frontend/plugins/services.ts @@ -29,6 +29,8 @@ import { TemplateApplicationService } from '~/services/application/autoLabeling/ import { APITextClassificationRepository } from '~/repositories/tasks/textClassification/apiTextClassification' import { TextClassificationApplicationService } from '~/services/application/tasks/textClassification/textClassificationApplicationService' import { AuthApplicationService } from '~/services/application/auth/authApplicationService' +import { APITagRepository } from '~/repositories/tag/apiTagRepository' +import { TagApplicationService } from '~/services/application/tag/tagApplicationService' export interface Services { label: LabelApplicationService, @@ -46,6 +48,7 @@ export interface Services { config: ConfigApplicationService, template: TemplateApplicationService, auth: AuthApplicationService + tag: TagApplicationService } declare module 'vue/types/vue' { @@ -68,6 +71,7 @@ const plugin: Plugin = (context, inject) => { const seq2seqRepository = new APISeq2seqRepository() const optionRepository = new LocalStorageOptionRepository() const configRepository = new APIConfigRepository() + const tagRepository = new APITagRepository() const templateRepository = new APITemplateRepository() const authRepository = new APIAuthRepository() @@ -84,9 +88,10 @@ const plugin: Plugin = (context, inject) => { const seq2seq = new Seq2seqApplicationService(seq2seqRepository) const option = new OptionApplicationService(optionRepository) const config = new ConfigApplicationService(configRepository) + const tag = new TagApplicationService(tagRepository) const template = new TemplateApplicationService(templateRepository) const auth = new AuthApplicationService(authRepository) - + const services: Services = { label, member, @@ -102,7 +107,8 @@ const plugin: Plugin = (context, inject) => { option, config, template, - auth + auth, + tag } inject('services', services) } diff --git a/frontend/repositories/tag/apiTagRepository.ts b/frontend/repositories/tag/apiTagRepository.ts new file mode 100644 index 00000000..274ca239 --- /dev/null +++ b/frontend/repositories/tag/apiTagRepository.ts @@ -0,0 +1,34 @@ +import ApiService from '@/services/api.service' +import { TagRepository } from '~/domain/models/tag/tagRepository' +import { TagItem } from '~/domain/models/tag/tag' + +export interface TagItemResponse { + id: number, + text: string, + project: string +} + +export class APITagRepository implements TagRepository { + constructor( + private readonly request = ApiService + ) {} + + async list(projectId: string): Promise { + const url = `/projects/${projectId}/tags` + const response = await this.request.get(url) + const responseItems: TagItemResponse[] = response.data + return responseItems.map(item => TagItem.valueOf(item)) + } + + async create(projectId: string, item: string): Promise { + const url = `/projects/${projectId}/tags` + const response = await this.request.post(url, { text: item }) + const responseItem: TagItemResponse = response.data + return TagItem.valueOf(responseItem) + } + + async delete(projectId: string, tagId: number): Promise { + const url = `/projects/${projectId}/tags` + await this.request.delete(url, { id: tagId }) + } +} diff --git a/frontend/services/application/project/projectData.ts b/frontend/services/application/project/projectData.ts index caa28581..2a85640f 100644 --- a/frontend/services/application/project/projectData.ts +++ b/frontend/services/application/project/projectData.ts @@ -33,6 +33,7 @@ export class ProjectDTO { uploadFormats: FormatDTO[] permitApprove: Boolean filterOption: String + tags: Object[] constructor(item: ProjectReadItem) { this.id = item.id @@ -50,7 +51,8 @@ export class ProjectDTO { this.uploadFormats = item.uploadFormats.map(f => new FormatDTO(f)) this.permitApprove = item.permitApprove this.filterOption = item.filterOption + this.tags = item.tags } } -export type ProjectWriteDTO = Pick +export type ProjectWriteDTO = Pick diff --git a/frontend/services/application/tag/tagApplicationService.ts b/frontend/services/application/tag/tagApplicationService.ts new file mode 100644 index 00000000..87f7f25e --- /dev/null +++ b/frontend/services/application/tag/tagApplicationService.ts @@ -0,0 +1,23 @@ +import { TagDTO } from './tagData' +import { TagRepository } from '~/domain/models/tag/tagRepository' +import { TagItem } from '~/domain/models/tag/tag' + + +export class TagApplicationService { + constructor( + private readonly repository: TagRepository + ) {} + + public async list(id: string): Promise { + const items = await this.repository.list(id) + return items.map(item => new TagDTO(item)) + } + + public create(projectId: string, text: string): void { + this.repository.create(projectId, text) + } + + public delete(projectId: string, id: number): Promise { + return this.repository.delete(projectId, id) + } +} diff --git a/frontend/services/application/tag/tagData.ts b/frontend/services/application/tag/tagData.ts new file mode 100644 index 00000000..c90b983b --- /dev/null +++ b/frontend/services/application/tag/tagData.ts @@ -0,0 +1,13 @@ +import { TagItem } from '~/domain/models/tag/tag' + +export class TagDTO { + id: number + text: string + project: string + + constructor(item: TagItem) { + this.id = item.id + this.text = item.text + this.project = item.project + } +}