mirror of https://github.com/doccano/doccano.git
Browse Source
Merge pull request #1655 from doccano/enhancement/separateLabelType
Merge pull request #1655 from doccano/enhancement/separateLabelType
[Enhancement] Separate label type apppull/1656/head
Hiroki Nakayama
2 years ago
committed by
GitHub
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 623 additions and 355 deletions
Split View
Diff Options
-
21backend/api/admin.py
-
5backend/api/migrations/0018_alter_label_background_color.py
-
7backend/api/migrations/0020_auto_20211221_1415.py
-
34backend/api/migrations/0032_auto_20220127_0654.py
-
41backend/api/migrations/0033_auto_20220127_0654.py
-
96backend/api/models.py
-
101backend/api/serializers.py
-
64backend/api/tests/test_models.py
-
48backend/api/urls.py
-
1backend/app/settings.py
-
1backend/app/urls.py
-
3backend/auto_labeling/pipeline/labels.py
-
17backend/data_import/pipeline/labels.py
-
3backend/data_import/pipeline/writers.py
-
3backend/data_import/tests/test_tasks.py
-
0backend/label_types/__init__.py
-
21backend/label_types/admin.py
-
6backend/label_types/apps.py
-
0backend/label_types/exceptions.py
-
112backend/label_types/migrations/0001_initial.py
-
19backend/label_types/migrations/0002_rename_relationtypes_relationtype.py
-
0backend/label_types/migrations/__init__.py
-
100backend/label_types/models.py
-
88backend/label_types/serializers.py
-
0backend/label_types/tests/__init__.py
-
65backend/label_types/tests/test_models.py
-
4backend/label_types/tests/test_views.py
-
54backend/label_types/urls.py
-
15backend/label_types/views.py
-
35backend/labels/migrations/0003_auto_20220127_0654.py
-
5backend/labels/models.py
-
3backend/labels/serializers.py
-
3backend/labels/tests/test_span.py
-
3backend/metrics/views.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=[] |
|||
) |
|||
] |
@ -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' |
|||
) |
|||
] |
|||
) |
|||
] |
@ -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) |
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class LabelTypesConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'label_types' |
@ -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=[] |
|||
) |
|||
] |
@ -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,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') |
@ -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,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) |
@ -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' |
|||
), |
|||
] |
@ -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'), |
|||
), |
|||
] |
|||
) |
|||
] |
Write
Preview
Loading…
Cancel
Save