Browse Source

Merge pull request #1655 from doccano/enhancement/separateLabelType

[Enhancement] Separate label type app
pull/1656/head
Hiroki Nakayama 2 years ago
committed by GitHub
parent
commit
da608cf30f
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 623 additions and 355 deletions
  1. 21
      backend/api/admin.py
  2. 5
      backend/api/migrations/0018_alter_label_background_color.py
  3. 7
      backend/api/migrations/0020_auto_20211221_1415.py
  4. 34
      backend/api/migrations/0032_auto_20220127_0654.py
  5. 41
      backend/api/migrations/0033_auto_20220127_0654.py
  6. 96
      backend/api/models.py
  7. 101
      backend/api/serializers.py
  8. 64
      backend/api/tests/test_models.py
  9. 48
      backend/api/urls.py
  10. 1
      backend/app/settings.py
  11. 1
      backend/app/urls.py
  12. 3
      backend/auto_labeling/pipeline/labels.py
  13. 17
      backend/data_import/pipeline/labels.py
  14. 3
      backend/data_import/pipeline/writers.py
  15. 3
      backend/data_import/tests/test_tasks.py
  16. 0
      backend/label_types/__init__.py
  17. 21
      backend/label_types/admin.py
  18. 6
      backend/label_types/apps.py
  19. 0
      backend/label_types/exceptions.py
  20. 112
      backend/label_types/migrations/0001_initial.py
  21. 19
      backend/label_types/migrations/0002_rename_relationtypes_relationtype.py
  22. 0
      backend/label_types/migrations/__init__.py
  23. 100
      backend/label_types/models.py
  24. 88
      backend/label_types/serializers.py
  25. 0
      backend/label_types/tests/__init__.py
  26. 65
      backend/label_types/tests/test_models.py
  27. 4
      backend/label_types/tests/test_views.py
  28. 54
      backend/label_types/urls.py
  29. 15
      backend/label_types/views.py
  30. 35
      backend/labels/migrations/0003_auto_20220127_0654.py
  31. 5
      backend/labels/models.py
  32. 3
      backend/labels/serializers.py
  33. 3
      backend/labels/tests/test_span.py
  34. 3
      backend/metrics/views.py

21
backend/api/admin.py

@ -1,22 +1,7 @@
from django.contrib import admin from django.contrib import admin
from .models import (CategoryType, Comment, Example, Project, Seq2seqProject,
SequenceLabelingProject, SpanType, Tag,
TextClassificationProject)
class LabelAdmin(admin.ModelAdmin):
list_display = ('text', 'project', 'text_color', 'background_color')
ordering = ('project',)
search_fields = ('text',)
class CategoryTypeAdmin(LabelAdmin):
pass
class SpanTypeAdmin(LabelAdmin):
pass
from .models import (Comment, Example, Project, Seq2seqProject,
SequenceLabelingProject, Tag, TextClassificationProject)
class ExampleAdmin(admin.ModelAdmin): class ExampleAdmin(admin.ModelAdmin):
@ -43,8 +28,6 @@ class CommentAdmin(admin.ModelAdmin):
search_fields = ('user',) search_fields = ('user',)
admin.site.register(CategoryType, CategoryTypeAdmin)
admin.site.register(SpanType, SpanTypeAdmin)
admin.site.register(Example, ExampleAdmin) admin.site.register(Example, ExampleAdmin)
admin.site.register(Project, ProjectAdmin) admin.site.register(Project, ProjectAdmin)
admin.site.register(TextClassificationProject, ProjectAdmin) admin.site.register(TextClassificationProject, ProjectAdmin)

5
backend/api/migrations/0018_alter_label_background_color.py

@ -1,8 +1,9 @@
# Generated by Django 3.2.8 on 2021-11-17 05:56 # Generated by Django 3.2.8 on 2021-11-17 05:56
import api.models
from django.db import migrations, models from django.db import migrations, models
from label_types.models import generate_random_hex_color
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -14,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='label', model_name='label',
name='background_color', name='background_color',
field=models.CharField(default=api.models.generate_random_hex_color, max_length=7),
field=models.CharField(default=generate_random_hex_color, max_length=7),
), ),
] ]

7
backend/api/migrations/0020_auto_20211221_1415.py

@ -1,9 +1,10 @@
# Generated by Django 3.2.8 on 2021-12-21 14:15 # Generated by Django 3.2.8 on 2021-12-21 14:15
import api.models
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from label_types.models import generate_random_hex_color
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -19,7 +20,7 @@ class Migration(migrations.Migration):
('text', models.CharField(db_index=True, max_length=100)), ('text', models.CharField(db_index=True, max_length=100)),
('prefix_key', models.CharField(blank=True, choices=[('ctrl', 'ctrl'), ('shift', 'shift'), ('ctrl shift', 'ctrl shift')], max_length=10, null=True)), ('prefix_key', models.CharField(blank=True, choices=[('ctrl', 'ctrl'), ('shift', 'shift'), ('ctrl shift', 'ctrl shift')], max_length=10, null=True)),
('suffix_key', models.CharField(blank=True, choices=[('0', '0'), ('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('a', 'a'), ('b', 'b'), ('c', 'c'), ('d', 'd'), ('e', 'e'), ('f', 'f'), ('g', 'g'), ('h', 'h'), ('i', 'i'), ('j', 'j'), ('k', 'k'), ('l', 'l'), ('m', 'm'), ('n', 'n'), ('o', 'o'), ('p', 'p'), ('q', 'q'), ('r', 'r'), ('s', 's'), ('t', 't'), ('u', 'u'), ('v', 'v'), ('w', 'w'), ('x', 'x'), ('y', 'y'), ('z', 'z')], max_length=1, null=True)), ('suffix_key', models.CharField(blank=True, choices=[('0', '0'), ('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('a', 'a'), ('b', 'b'), ('c', 'c'), ('d', 'd'), ('e', 'e'), ('f', 'f'), ('g', 'g'), ('h', 'h'), ('i', 'i'), ('j', 'j'), ('k', 'k'), ('l', 'l'), ('m', 'm'), ('n', 'n'), ('o', 'o'), ('p', 'p'), ('q', 'q'), ('r', 'r'), ('s', 's'), ('t', 't'), ('u', 'u'), ('v', 'v'), ('w', 'w'), ('x', 'x'), ('y', 'y'), ('z', 'z')], max_length=1, null=True)),
('background_color', models.CharField(default=api.models.generate_random_hex_color, max_length=7)),
('background_color', models.CharField(default=generate_random_hex_color, max_length=7)),
('text_color', models.CharField(default='#ffffff', max_length=7)), ('text_color', models.CharField(default='#ffffff', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
@ -36,7 +37,7 @@ class Migration(migrations.Migration):
('text', models.CharField(db_index=True, max_length=100)), ('text', models.CharField(db_index=True, max_length=100)),
('prefix_key', models.CharField(blank=True, choices=[('ctrl', 'ctrl'), ('shift', 'shift'), ('ctrl shift', 'ctrl shift')], max_length=10, null=True)), ('prefix_key', models.CharField(blank=True, choices=[('ctrl', 'ctrl'), ('shift', 'shift'), ('ctrl shift', 'ctrl shift')], max_length=10, null=True)),
('suffix_key', models.CharField(blank=True, choices=[('0', '0'), ('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('a', 'a'), ('b', 'b'), ('c', 'c'), ('d', 'd'), ('e', 'e'), ('f', 'f'), ('g', 'g'), ('h', 'h'), ('i', 'i'), ('j', 'j'), ('k', 'k'), ('l', 'l'), ('m', 'm'), ('n', 'n'), ('o', 'o'), ('p', 'p'), ('q', 'q'), ('r', 'r'), ('s', 's'), ('t', 't'), ('u', 'u'), ('v', 'v'), ('w', 'w'), ('x', 'x'), ('y', 'y'), ('z', 'z')], max_length=1, null=True)), ('suffix_key', models.CharField(blank=True, choices=[('0', '0'), ('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('a', 'a'), ('b', 'b'), ('c', 'c'), ('d', 'd'), ('e', 'e'), ('f', 'f'), ('g', 'g'), ('h', 'h'), ('i', 'i'), ('j', 'j'), ('k', 'k'), ('l', 'l'), ('m', 'm'), ('n', 'n'), ('o', 'o'), ('p', 'p'), ('q', 'q'), ('r', 'r'), ('s', 's'), ('t', 't'), ('u', 'u'), ('v', 'v'), ('w', 'w'), ('x', 'x'), ('y', 'y'), ('z', 'z')], max_length=1, null=True)),
('background_color', models.CharField(default=api.models.generate_random_hex_color, max_length=7)),
('background_color', models.CharField(default=generate_random_hex_color, max_length=7)),
('text_color', models.CharField(default='#ffffff', max_length=7)), ('text_color', models.CharField(default='#ffffff', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),

34
backend/api/migrations/0032_auto_20220127_0654.py

@ -0,0 +1,34 @@
# Generated by Django 3.2.11 on 2022-01-27 06:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('api', '0031_auto_20220127_0032'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name='categorytype',
name='project',
),
migrations.AlterUniqueTogether(
name='relationtypes',
unique_together=None,
),
migrations.RemoveField(
model_name='relationtypes',
name='project',
),
migrations.RemoveField(
model_name='spantype',
name='project',
),
],
database_operations=[]
)
]

41
backend/api/migrations/0033_auto_20220127_0654.py

@ -0,0 +1,41 @@
# Generated by Django 3.2.11 on 2022-01-27 06:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('labels', '0003_auto_20220127_0654'),
('api', '0032_auto_20220127_0654'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.DeleteModel(
name='CategoryType',
),
migrations.DeleteModel(
name='RelationTypes',
),
migrations.DeleteModel(
name='SpanType',
),
],
database_operations=[
migrations.AlterModelTable(
name='CategoryType',
table='label_types_categorytype'
),
migrations.AlterModelTable(
name='RelationTypes',
table='label_types_relationtypes'
),
migrations.AlterModelTable(
name='SpanType',
table='label_types_spanType'
)
]
)
]

96
backend/api/models.py

@ -1,10 +1,7 @@
import abc import abc
import random
import string
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 polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
@ -151,87 +148,6 @@ class ImageClassificationProject(Project):
return True return True
def generate_random_hex_color():
return f'#{random.randint(0, 0xFFFFFF):06x}'
class Label(models.Model):
text = models.CharField(max_length=100, db_index=True)
prefix_key = models.CharField(
max_length=10,
blank=True,
null=True,
choices=(
('ctrl', 'ctrl'),
('shift', 'shift'),
('ctrl shift', 'ctrl shift')
)
)
suffix_key = models.CharField(
max_length=1,
blank=True,
null=True,
choices=tuple(
(c, c) for c in string.digits + string.ascii_lowercase
)
)
project = models.ForeignKey(
to=Project,
on_delete=models.CASCADE,
# related_name='labels'
)
background_color = models.CharField(max_length=7, default=generate_random_hex_color)
text_color = models.CharField(max_length=7, default='#ffffff')
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.text
@property
def labels(self):
raise NotImplementedError()
def clean(self):
# Don't allow shortcut key not to have a suffix key.
if self.prefix_key and not self.suffix_key:
message = 'Shortcut key may not have a suffix key.'
raise ValidationError(message)
# each shortcut (prefix key + suffix key) can only be assigned to one label
if self.suffix_key or self.prefix_key:
other_labels = self.labels.exclude(id=self.id)
if other_labels.filter(suffix_key=self.suffix_key, prefix_key=self.prefix_key).exists():
message = 'A label with the shortcut already exists in the project.'
raise ValidationError(message)
super().clean()
class Meta:
abstract = True
constraints = [
models.UniqueConstraint(
fields=['project', 'text'],
name='%(app_label)s_%(class)s_is_unique'
)
]
ordering = ['created_at']
class CategoryType(Label):
@property
def labels(self):
return CategoryType.objects.filter(project=self.project)
class SpanType(Label):
@property
def labels(self):
return SpanType.objects.filter(project=self.project)
class Example(models.Model): class Example(models.Model):
objects = ExampleManager() objects = ExampleManager()
@ -318,15 +234,3 @@ class Tag(models.Model):
def __str__(self): def __str__(self):
return self.text return self.text
class RelationTypes(models.Model):
color = models.TextField()
name = models.TextField()
project = models.ForeignKey(Project, related_name='relation_types', on_delete=models.CASCADE)
def __str__(self):
return self.name
class Meta:
unique_together = ('color', 'name')

101
backend/api/serializers.py

@ -1,87 +1,11 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_polymorphic.serializers import PolymorphicSerializer from rest_polymorphic.serializers import PolymorphicSerializer
from .models import (CategoryType, Comment, Example, ExampleState,
from .models import (Comment, Example, ExampleState,
ImageClassificationProject, ImageClassificationProject,
IntentDetectionAndSlotFillingProject, Label, Project,
RelationTypes, Seq2seqProject, SequenceLabelingProject,
SpanType, Speech2textProject, Tag,
TextClassificationProject)
class LabelSerializer(serializers.ModelSerializer):
def validate(self, attrs):
prefix_key = attrs.get('prefix_key')
suffix_key = attrs.get('suffix_key')
# In the case of user don't set any shortcut key.
if prefix_key is None and suffix_key is None:
return super().validate(attrs)
# Don't allow shortcut key not to have a suffix key.
if prefix_key and not suffix_key:
raise ValidationError('Shortcut key may not have a suffix key.')
# Don't allow to save same shortcut key when prefix_key is null.
try:
context = self.context['request'].parser_context
project_id = context['kwargs']['project_id']
label_id = context['kwargs'].get('label_id')
except (AttributeError, KeyError):
pass # unit tests don't always have the correct context set up
else:
conflicting_labels = self.Meta.model.objects.filter(
suffix_key=suffix_key,
prefix_key=prefix_key,
project=project_id,
)
if label_id is not None:
conflicting_labels = conflicting_labels.exclude(id=label_id)
if conflicting_labels.exists():
raise ValidationError('Duplicate shortcut key.')
return super().validate(attrs)
class Meta:
model = Label
fields = (
'id',
'text',
'prefix_key',
'suffix_key',
'background_color',
'text_color',
)
class CategoryTypeSerializer(LabelSerializer):
class Meta:
model = CategoryType
fields = (
'id',
'text',
'prefix_key',
'suffix_key',
'background_color',
'text_color',
)
class SpanTypeSerializer(LabelSerializer):
class Meta:
model = SpanType
fields = (
'id',
'text',
'prefix_key',
'suffix_key',
'background_color',
'text_color',
)
IntentDetectionAndSlotFillingProject, Project,
Seq2seqProject, SequenceLabelingProject,
Speech2textProject, Tag, TextClassificationProject)
class CommentSerializer(serializers.ModelSerializer): class CommentSerializer(serializers.ModelSerializer):
@ -139,13 +63,6 @@ class ExampleStateSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'example', 'confirmed_by') read_only_fields = ('id', 'example', 'confirmed_by')
class ApproverSerializer(ExampleSerializer):
class Meta:
model = Example
fields = ('id', 'annotation_approver')
class ProjectSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer):
tags = TagSerializer(many=True, required=False) tags = TagSerializer(many=True, required=False)
@ -224,13 +141,3 @@ class ProjectPolymorphicSerializer(PolymorphicSerializer):
cls.Meta.model: cls for cls in ProjectSerializer.__subclasses__() cls.Meta.model: cls for cls in ProjectSerializer.__subclasses__()
} }
} }
class RelationTypesSerializer(serializers.ModelSerializer):
def validate(self, attrs):
return super().validate(attrs)
class Meta:
model = RelationTypes
fields = ('id', 'color', 'name')

64
backend/api/tests/test_models.py

@ -1,73 +1,11 @@
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase from django.test import TestCase
from model_mommy import mommy from model_mommy import mommy
from api.models import (IMAGE_CLASSIFICATION, SEQUENCE_LABELING, CategoryType,
ExampleState, generate_random_hex_color)
from api.models import IMAGE_CLASSIFICATION, SEQUENCE_LABELING, ExampleState
from .api.utils import prepare_project from .api.utils import prepare_project
class TestLabel(TestCase):
def test_deny_creating_same_text(self):
label = mommy.make('CategoryType')
with self.assertRaises(IntegrityError):
mommy.make('CategoryType', project=label.project, text=label.text)
def test_keys_uniqueness(self):
label = mommy.make('CategoryType', prefix_key='ctrl', suffix_key='a')
with self.assertRaises(ValidationError):
CategoryType(project=label.project,
text='example',
prefix_key=label.prefix_key,
suffix_key=label.suffix_key).full_clean()
def test_suffix_key_uniqueness(self):
label = mommy.make('CategoryType', prefix_key=None, suffix_key='a')
with self.assertRaises(ValidationError):
CategoryType(project=label.project,
text='example',
prefix_key=label.prefix_key,
suffix_key=label.suffix_key).full_clean()
def test_cannot_add_label_only_prefix_key(self):
project = mommy.make('Project')
label = CategoryType(project=project,
text='example',
prefix_key='ctrl')
with self.assertRaises(ValidationError):
label.clean()
def test_can_add_label_only_suffix_key(self):
project = mommy.make('Project')
label = CategoryType(project=project, text='example', suffix_key='a')
try:
label.full_clean()
except ValidationError:
self.fail(msg=ValidationError)
def test_can_add_label_suffix_key_with_prefix_key(self):
project = mommy.make('Project')
label = CategoryType(project=project,
text='example',
prefix_key='ctrl',
suffix_key='a')
try:
label.full_clean()
except ValidationError:
self.fail(msg=ValidationError)
class TestGeneratedColor(TestCase):
def test_length(self):
for i in range(100):
color = generate_random_hex_color()
self.assertEqual(len(color), 7)
class TestExampleState(TestCase): class TestExampleState(TestCase):
def setUp(self): def setUp(self):

48
backend/api/urls.py

@ -1,39 +1,8 @@
from django.urls import include, path from django.urls import include, path
from .views import (comment, example, example_state, health, label, project,
tag, task)
from .views import comment, example, example_state, health, project, tag, task
urlpatterns_project = [ urlpatterns_project = [
path(
route='category-types',
view=label.CategoryTypeList.as_view(),
name='category_types'
),
path(
route='category-types/<int:label_id>',
view=label.CategoryTypeDetail.as_view(),
name='category_type'
),
path(
route='span-types',
view=label.SpanTypeList.as_view(),
name='span_types'
),
path(
route='span-types/<int:label_id>',
view=label.SpanTypeDetail.as_view(),
name='span_type'
),
path(
route='category-type-upload',
view=label.CategoryTypeUploadAPI.as_view(),
name='category_type_upload'
),
path(
route='span-type-upload',
view=label.SpanTypeUploadAPI.as_view(),
name='span_type_upload'
),
path( path(
route='examples', route='examples',
view=example.ExampleList.as_view(), view=example.ExampleList.as_view(),
@ -44,21 +13,6 @@ urlpatterns_project = [
view=example.ExampleDetail.as_view(), view=example.ExampleDetail.as_view(),
name='example_detail' name='example_detail'
), ),
path(
route='relation_types',
view=label.RelationTypeList.as_view(),
name='relation_types_list'
),
path(
route='relation_type-upload',
view=label.RelationTypeUploadAPI.as_view(),
name='relation_type-upload'
),
path(
route='relation_types/<int:relation_type_id>',
view=label.RelationTypeDetail.as_view(),
name='relation_type_detail'
),
path( path(
route='tags', route='tags',
view=tag.TagList.as_view(), view=tag.TagList.as_view(),

1
backend/app/settings.py

@ -60,6 +60,7 @@ INSTALLED_APPS = [
'data_export.apps.DataExportConfig', 'data_export.apps.DataExportConfig',
'auto_labeling.apps.AutoLabelingConfig', 'auto_labeling.apps.AutoLabelingConfig',
'labels.apps.LabelsConfig', 'labels.apps.LabelsConfig',
'label_types.apps.LabelTypesConfig',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'django_filters', 'django_filters',

1
backend/app/urls.py

@ -49,6 +49,7 @@ urlpatterns += [
path('v1/projects/<int:project_id>/metrics/', include('metrics.urls')), path('v1/projects/<int:project_id>/metrics/', include('metrics.urls')),
path('v1/projects/<int:project_id>/', include('auto_labeling.urls')), path('v1/projects/<int:project_id>/', include('auto_labeling.urls')),
path('v1/projects/<int:project_id>/', include('labels.urls')), path('v1/projects/<int:project_id>/', include('labels.urls')),
path('v1/projects/<int:project_id>/', include('label_types.urls')),
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
re_path('', TemplateView.as_view(template_name='index.html')), re_path('', TemplateView.as_view(template_name='index.html')),
] ]

3
backend/auto_labeling/pipeline/labels.py

@ -4,7 +4,8 @@ from typing import List
from auto_labeling_pipeline.labels import Labels from auto_labeling_pipeline.labels import Labels
from django.contrib.auth.models import User from django.contrib.auth.models import User
from api.models import Project, Example, CategoryType, SpanType
from api.models import Project, Example
from label_types.models import CategoryType, SpanType
from labels.models import Label, Category, Span, TextLabel from labels.models import Label, Category, Span, TextLabel

17
backend/data_import/pipeline/labels.py

@ -3,9 +3,8 @@ from typing import Any, Dict, Optional, Union
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from api.models import CategoryType
from api.models import Label as LabelModel
from api.models import Project, SpanType
from api.models import Project
from label_types.models import LabelType, CategoryType, SpanType
from labels.models import Category, Span, TextLabel as TL from labels.models import Category, Span, TextLabel as TL
@ -25,7 +24,7 @@ class Label(BaseModel, abc.ABC):
raise NotImplementedError() raise NotImplementedError()
@abc.abstractmethod @abc.abstractmethod
def create(self, project: Project) -> Optional[LabelModel]:
def create(self, project: Project) -> Optional[LabelType]:
raise NotImplementedError() raise NotImplementedError()
@abc.abstractmethod @abc.abstractmethod
@ -62,10 +61,10 @@ class CategoryLabel(Label):
else: else:
raise TypeError(f'{obj} is not str.') raise TypeError(f'{obj} is not str.')
def create(self, project: Project) -> Optional[LabelModel]:
def create(self, project: Project) -> Optional[LabelType]:
return CategoryType(text=self.label, project=project) return CategoryType(text=self.label, project=project)
def create_annotation(self, user, example, mapping: Dict[str, LabelModel]):
def create_annotation(self, user, example, mapping: Dict[str, LabelType]):
return Category( return Category(
user=user, user=user,
example=example, example=example,
@ -96,10 +95,10 @@ class SpanLabel(Label):
else: else:
raise TypeError(f'{obj} is invalid type.') raise TypeError(f'{obj} is invalid type.')
def create(self, project: Project) -> Optional[LabelModel]:
def create(self, project: Project) -> Optional[LabelType]:
return SpanType(text=self.label, project=project) return SpanType(text=self.label, project=project)
def create_annotation(self, user, example, mapping: Dict[str, LabelModel]):
def create_annotation(self, user, example, mapping: Dict[str, LabelType]):
return Span( return Span(
user=user, user=user,
example=example, example=example,
@ -126,7 +125,7 @@ class TextLabel(Label):
else: else:
raise TypeError(f'{obj} is not str or empty.') raise TypeError(f'{obj} is not str or empty.')
def create(self, project: Project) -> Optional[LabelModel]:
def create(self, project: Project) -> Optional[LabelType]:
return None return None
def create_annotation(self, user, example, mapping): def create_annotation(self, user, example, mapping):

3
backend/data_import/pipeline/writers.py

@ -5,7 +5,8 @@ from typing import Any, Dict, List
from django.conf import settings from django.conf import settings
from api.models import CategoryType, Example, Project, SpanType
from api.models import Example, Project
from label_types.models import CategoryType, SpanType
from .exceptions import FileParseException from .exceptions import FileParseException
from .readers import BaseReader from .readers import BaseReader

3
backend/data_import/tests/test_tasks.py

@ -5,7 +5,8 @@ from django.test import TestCase
from data_import.celery_tasks import import_dataset from data_import.celery_tasks import import_dataset
from api.models import (DOCUMENT_CLASSIFICATION, from api.models import (DOCUMENT_CLASSIFICATION,
INTENT_DETECTION_AND_SLOT_FILLING, SEQ2SEQ, INTENT_DETECTION_AND_SLOT_FILLING, SEQ2SEQ,
SEQUENCE_LABELING, CategoryType, Example, SpanType)
SEQUENCE_LABELING, Example)
from label_types.models import CategoryType, SpanType
from labels.models import Category, Span from labels.models import Category, Span
from api.tests.api.utils import prepare_project from api.tests.api.utils import prepare_project

0
backend/label_types/__init__.py

21
backend/label_types/admin.py

@ -0,0 +1,21 @@
from django.contrib import admin
from .models import CategoryType, SpanType
class LabelAdmin(admin.ModelAdmin):
list_display = ('text', 'project', 'text_color', 'background_color')
ordering = ('project',)
search_fields = ('text',)
class CategoryTypeAdmin(LabelAdmin):
pass
class SpanTypeAdmin(LabelAdmin):
pass
admin.site.register(CategoryType, CategoryTypeAdmin)
admin.site.register(SpanType, SpanTypeAdmin)

6
backend/label_types/apps.py

@ -0,0 +1,6 @@
from django.apps import AppConfig
class LabelTypesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'label_types'

backend/api/exceptions.py → backend/label_types/exceptions.py

112
backend/label_types/migrations/0001_initial.py

@ -0,0 +1,112 @@
# Generated by Django 3.2.11 on 2022-01-27 06:54
from django.db import migrations, models
import django.db.models.deletion
import label_types.models
class Migration(migrations.Migration):
initial = True
dependencies = [
('api', '0032_auto_20220127_0654'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='SpanType',
fields=[
('id',
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(db_index=True, max_length=100)),
('prefix_key', models.CharField(blank=True, choices=[('ctrl', 'ctrl'), ('shift', 'shift'),
('ctrl shift', 'ctrl shift')],
max_length=10, null=True)),
('suffix_key', models.CharField(blank=True,
choices=[('0', '0'), ('1', '1'), ('2', '2'), ('3', '3'),
('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'),
('8', '8'), ('9', '9'), ('a', 'a'), ('b', 'b'),
('c', 'c'), ('d', 'd'), ('e', 'e'), ('f', 'f'),
('g', 'g'), ('h', 'h'), ('i', 'i'), ('j', 'j'),
('k', 'k'), ('l', 'l'), ('m', 'm'), ('n', 'n'),
('o', 'o'), ('p', 'p'), ('q', 'q'), ('r', 'r'),
('s', 's'), ('t', 't'), ('u', 'u'), ('v', 'v'),
('w', 'w'), ('x', 'x'), ('y', 'y'), ('z', 'z')],
max_length=1, null=True)),
('background_color',
models.CharField(default=label_types.models.generate_random_hex_color, max_length=7)),
('text_color', models.CharField(default='#ffffff', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.project')),
],
options={
'ordering': ['created_at'],
'abstract': False,
},
),
migrations.CreateModel(
name='RelationTypes',
fields=[
('id',
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('color', models.TextField()),
('name', models.TextField()),
('project',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relation_types',
to='api.project')),
],
),
migrations.CreateModel(
name='CategoryType',
fields=[
('id',
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(db_index=True, max_length=100)),
('prefix_key', models.CharField(blank=True, choices=[('ctrl', 'ctrl'), ('shift', 'shift'),
('ctrl shift', 'ctrl shift')],
max_length=10, null=True)),
('suffix_key', models.CharField(blank=True,
choices=[('0', '0'), ('1', '1'), ('2', '2'), ('3', '3'),
('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'),
('8', '8'), ('9', '9'), ('a', 'a'), ('b', 'b'),
('c', 'c'), ('d', 'd'), ('e', 'e'), ('f', 'f'),
('g', 'g'), ('h', 'h'), ('i', 'i'), ('j', 'j'),
('k', 'k'), ('l', 'l'), ('m', 'm'), ('n', 'n'),
('o', 'o'), ('p', 'p'), ('q', 'q'), ('r', 'r'),
('s', 's'), ('t', 't'), ('u', 'u'), ('v', 'v'),
('w', 'w'), ('x', 'x'), ('y', 'y'), ('z', 'z')],
max_length=1, null=True)),
('background_color',
models.CharField(default=label_types.models.generate_random_hex_color, max_length=7)),
('text_color', models.CharField(default='#ffffff', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.project')),
],
options={
'ordering': ['created_at'],
'abstract': False,
},
),
migrations.AddConstraint(
model_name='spantype',
constraint=models.UniqueConstraint(fields=('project', 'text'),
name='label_types_spantype_is_unique'),
),
migrations.AlterUniqueTogether(
name='relationtypes',
unique_together={('color', 'name')},
),
migrations.AddConstraint(
model_name='categorytype',
constraint=models.UniqueConstraint(fields=('project', 'text'),
name='label_types_categorytype_is_unique'),
),
],
database_operations=[]
)
]

19
backend/label_types/migrations/0002_rename_relationtypes_relationtype.py

@ -0,0 +1,19 @@
# Generated by Django 3.2.11 on 2022-01-27 07:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('labels', '0003_auto_20220127_0654'),
('api', '0033_auto_20220127_0654'),
('label_types', '0001_initial'),
]
operations = [
migrations.RenameModel(
old_name='RelationTypes',
new_name='RelationType',
),
]

0
backend/label_types/migrations/__init__.py

100
backend/label_types/models.py

@ -0,0 +1,100 @@
import random
import string
from django.core.exceptions import ValidationError
from django.db import models
from api.models import Project
def generate_random_hex_color():
return f'#{random.randint(0, 0xFFFFFF):06x}'
class LabelType(models.Model):
text = models.CharField(max_length=100, db_index=True)
prefix_key = models.CharField(
max_length=10,
blank=True,
null=True,
choices=(
('ctrl', 'ctrl'),
('shift', 'shift'),
('ctrl shift', 'ctrl shift')
)
)
suffix_key = models.CharField(
max_length=1,
blank=True,
null=True,
choices=tuple(
(c, c) for c in string.digits + string.ascii_lowercase
)
)
project = models.ForeignKey(
to=Project,
on_delete=models.CASCADE,
# related_name='labels'
)
background_color = models.CharField(max_length=7, default=generate_random_hex_color)
text_color = models.CharField(max_length=7, default='#ffffff')
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.text
@property
def labels(self):
raise NotImplementedError()
def clean(self):
# Don't allow shortcut key not to have a suffix key.
if self.prefix_key and not self.suffix_key:
message = 'Shortcut key may not have a suffix key.'
raise ValidationError(message)
# each shortcut (prefix key + suffix key) can only be assigned to one label
if self.suffix_key or self.prefix_key:
other_labels = self.labels.exclude(id=self.id)
if other_labels.filter(suffix_key=self.suffix_key, prefix_key=self.prefix_key).exists():
message = 'A label with the shortcut already exists in the project.'
raise ValidationError(message)
super().clean()
class Meta:
abstract = True
constraints = [
models.UniqueConstraint(
fields=['project', 'text'],
name='%(app_label)s_%(class)s_is_unique'
)
]
ordering = ['created_at']
class CategoryType(LabelType):
@property
def labels(self):
return CategoryType.objects.filter(project=self.project)
class SpanType(LabelType):
@property
def labels(self):
return SpanType.objects.filter(project=self.project)
class RelationType(models.Model):
color = models.TextField()
name = models.TextField()
project = models.ForeignKey(Project, related_name='relation_types', on_delete=models.CASCADE)
def __str__(self):
return self.name
class Meta:
unique_together = ('color', 'name')

88
backend/label_types/serializers.py

@ -0,0 +1,88 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from .models import LabelType, CategoryType, SpanType, RelationType
class LabelSerializer(serializers.ModelSerializer):
def validate(self, attrs):
prefix_key = attrs.get('prefix_key')
suffix_key = attrs.get('suffix_key')
# In the case of user don't set any shortcut key.
if prefix_key is None and suffix_key is None:
return super().validate(attrs)
# Don't allow shortcut key not to have a suffix key.
if prefix_key and not suffix_key:
raise ValidationError('Shortcut key may not have a suffix key.')
# Don't allow to save same shortcut key when prefix_key is null.
try:
context = self.context['request'].parser_context
project_id = context['kwargs']['project_id']
label_id = context['kwargs'].get('label_id')
except (AttributeError, KeyError):
pass # unit tests don't always have the correct context set up
else:
conflicting_labels = self.Meta.model.objects.filter(
suffix_key=suffix_key,
prefix_key=prefix_key,
project=project_id,
)
if label_id is not None:
conflicting_labels = conflicting_labels.exclude(id=label_id)
if conflicting_labels.exists():
raise ValidationError('Duplicate shortcut key.')
return super().validate(attrs)
class Meta:
model = LabelType
fields = (
'id',
'text',
'prefix_key',
'suffix_key',
'background_color',
'text_color',
)
class CategoryTypeSerializer(LabelSerializer):
class Meta:
model = CategoryType
fields = (
'id',
'text',
'prefix_key',
'suffix_key',
'background_color',
'text_color',
)
class SpanTypeSerializer(LabelSerializer):
class Meta:
model = SpanType
fields = (
'id',
'text',
'prefix_key',
'suffix_key',
'background_color',
'text_color',
)
class RelationTypesSerializer(serializers.ModelSerializer):
def validate(self, attrs):
return super().validate(attrs)
class Meta:
model = RelationType
fields = ('id', 'color', 'name')

0
backend/label_types/tests/__init__.py

65
backend/label_types/tests/test_models.py

@ -0,0 +1,65 @@
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.test import TestCase
from model_mommy import mommy
from label_types.models import CategoryType, generate_random_hex_color
class TestLabel(TestCase):
def test_deny_creating_same_text(self):
label = mommy.make('CategoryType')
with self.assertRaises(IntegrityError):
mommy.make('CategoryType', project=label.project, text=label.text)
def test_keys_uniqueness(self):
label = mommy.make('CategoryType', prefix_key='ctrl', suffix_key='a')
with self.assertRaises(ValidationError):
CategoryType(project=label.project,
text='example',
prefix_key=label.prefix_key,
suffix_key=label.suffix_key).full_clean()
def test_suffix_key_uniqueness(self):
label = mommy.make('CategoryType', prefix_key=None, suffix_key='a')
with self.assertRaises(ValidationError):
CategoryType(project=label.project,
text='example',
prefix_key=label.prefix_key,
suffix_key=label.suffix_key).full_clean()
def test_cannot_add_label_only_prefix_key(self):
project = mommy.make('Project')
label = CategoryType(project=project,
text='example',
prefix_key='ctrl')
with self.assertRaises(ValidationError):
label.clean()
def test_can_add_label_only_suffix_key(self):
project = mommy.make('Project')
label = CategoryType(project=project, text='example', suffix_key='a')
try:
label.full_clean()
except ValidationError:
self.fail(msg=ValidationError)
def test_can_add_label_suffix_key_with_prefix_key(self):
project = mommy.make('Project')
label = CategoryType(project=project,
text='example',
prefix_key='ctrl',
suffix_key='a')
try:
label.full_clean()
except ValidationError:
self.fail(msg=ValidationError)
class TestGeneratedColor(TestCase):
def test_length(self):
for i in range(100):
color = generate_random_hex_color()
self.assertEqual(len(color), 7)

backend/api/tests/api/test_label.py → backend/label_types/tests/test_views.py

@ -6,9 +6,7 @@ from rest_framework.reverse import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from api.models import DOCUMENT_CLASSIFICATION from api.models import DOCUMENT_CLASSIFICATION
from .utils import (DATA_DIR, CRUDMixin, make_label, make_project, make_user,
prepare_project)
from api.tests.api.utils import (DATA_DIR, CRUDMixin, make_label, make_project, make_user, prepare_project)
class TestLabelList(CRUDMixin): class TestLabelList(CRUDMixin):

54
backend/label_types/urls.py

@ -0,0 +1,54 @@
from django.urls import path
from .views import CategoryTypeList, CategoryTypeDetail, CategoryTypeUploadAPI
from .views import SpanTypeList, SpanTypeDetail, SpanTypeUploadAPI
from .views import RelationTypeList, RelationTypeDetail, RelationTypeUploadAPI
urlpatterns = [
path(
route='category-types',
view=CategoryTypeList.as_view(),
name='category_types'
),
path(
route='category-types/<int:label_id>',
view=CategoryTypeDetail.as_view(),
name='category_type'
),
path(
route='span-types',
view=SpanTypeList.as_view(),
name='span_types'
),
path(
route='span-types/<int:label_id>',
view=SpanTypeDetail.as_view(),
name='span_type'
),
path(
route='category-type-upload',
view=CategoryTypeUploadAPI.as_view(),
name='category_type_upload'
),
path(
route='span-type-upload',
view=SpanTypeUploadAPI.as_view(),
name='span_type_upload'
),
path(
route='relation_types',
view=RelationTypeList.as_view(),
name='relation_types_list'
),
path(
route='relation_type-upload',
view=RelationTypeUploadAPI.as_view(),
name='relation_type-upload'
),
path(
route='relation_types/<int:relation_type_id>',
view=RelationTypeDetail.as_view(),
name='relation_type_detail'
),
]

backend/api/views/label.py → backend/label_types/views.py

@ -11,11 +11,10 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from members.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin from members.permissions import IsInProjectReadOnlyOrAdmin, IsProjectAdmin
from ..exceptions import LabelValidationError
from ..models import CategoryType, Label, RelationTypes, SpanType
from ..serializers import (CategoryTypeSerializer, LabelSerializer,
RelationTypesSerializer, SpanTypeSerializer)
from .models import LabelType, CategoryType, SpanType, RelationType
from .exceptions import LabelValidationError
from .serializers import (CategoryTypeSerializer, LabelSerializer,
RelationTypesSerializer, SpanTypeSerializer)
def camel_to_snake(name): def camel_to_snake(name):
@ -28,7 +27,7 @@ def camel_to_snake_dict(d):
class LabelList(generics.ListCreateAPIView): class LabelList(generics.ListCreateAPIView):
model = Label
model = LabelType
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
serializer_class = LabelSerializer serializer_class = LabelSerializer
pagination_class = None pagination_class = None
@ -71,12 +70,12 @@ class SpanTypeDetail(generics.RetrieveUpdateDestroyAPIView):
class RelationTypeList(LabelList): class RelationTypeList(LabelList):
model = RelationTypes
model = RelationType
serializer_class = RelationTypesSerializer serializer_class = RelationTypesSerializer
class RelationTypeDetail(generics.RetrieveUpdateDestroyAPIView): class RelationTypeDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = RelationTypes.objects.all()
queryset = RelationType.objects.all()
serializer_class = RelationTypesSerializer serializer_class = RelationTypesSerializer
lookup_url_kwarg = 'relation_type_id' lookup_url_kwarg = 'relation_type_id'
permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin] permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin]

35
backend/labels/migrations/0003_auto_20220127_0654.py

@ -0,0 +1,35 @@
# Generated by Django 3.2.11 on 2022-01-27 06:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('label_types', '0001_initial'),
('labels', '0002_rename_annotationrelations_relation'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AlterField(
model_name='category',
name='label',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='label_types.categorytype'),
),
migrations.AlterField(
model_name='relation',
name='type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='annotation_relations', to='label_types.relationtypes'),
),
migrations.AlterField(
model_name='span',
name='label',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='label_types.spantype'),
),
]
)
]

5
backend/labels/models.py

@ -3,7 +3,8 @@ from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from .managers import LabelManager, CategoryManager, SpanManager, TextLabelManager from .managers import LabelManager, CategoryManager, SpanManager, TextLabelManager
from api.models import Example, CategoryType, SpanType, RelationTypes, Project
from api.models import Example, Project
from label_types.models import CategoryType, SpanType, RelationType
class Label(models.Model): class Label(models.Model):
@ -107,7 +108,7 @@ class TextLabel(Label):
class Relation(models.Model): class Relation(models.Model):
annotation_id_1 = models.IntegerField() annotation_id_1 = models.IntegerField()
annotation_id_2 = models.IntegerField() annotation_id_2 = models.IntegerField()
type = models.ForeignKey(RelationTypes, related_name='annotation_relations', on_delete=models.CASCADE)
type = models.ForeignKey(RelationType, related_name='annotation_relations', on_delete=models.CASCADE)
timestamp = models.DateTimeField() timestamp = models.DateTimeField()
user = models.ForeignKey(User, related_name='annotation_relations', on_delete=models.CASCADE) user = models.ForeignKey(User, related_name='annotation_relations', on_delete=models.CASCADE)
project = models.ForeignKey(Project, related_name='annotation_relations', on_delete=models.CASCADE) project = models.ForeignKey(Project, related_name='annotation_relations', on_delete=models.CASCADE)

3
backend/labels/serializers.py

@ -1,6 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from api.models import CategoryType, Example, SpanType
from api.models import Example
from label_types.models import CategoryType, SpanType
from .models import Category, Span, TextLabel, Relation from .models import Category, Span, TextLabel, Relation

3
backend/labels/tests/test_span.py

@ -5,7 +5,8 @@ from django.db import IntegrityError
from django.test import TestCase from django.test import TestCase
from model_mommy import mommy from model_mommy import mommy
from api.models import SEQUENCE_LABELING, SpanType
from api.models import SEQUENCE_LABELING
from label_types.models import SpanType
from labels.models import Span from labels.models import Span
from api.tests.api.utils import prepare_project from api.tests.api.utils import prepare_project

3
backend/metrics/views.py

@ -5,7 +5,8 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from api.models import Example, ExampleState, CategoryType, SpanType, Label as LabelType
from api.models import Example, ExampleState
from label_types.models import LabelType, CategoryType, SpanType
from labels.models import Label, Category, Span from labels.models import Label, Category, Span
from members.models import Member from members.models import Member
from members.permissions import IsInProjectReadOnlyOrAdmin from members.permissions import IsInProjectReadOnlyOrAdmin

Loading…
Cancel
Save