From badcd19b2440aeefdee47dd5d5751e190e02abcb Mon Sep 17 00:00:00 2001 From: Hiroki Nakayama Date: Tue, 26 Mar 2019 14:23:32 +0900 Subject: [PATCH 1/3] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c2b03b03..ec3dba48 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ Run the following commands in a new shell: ```bash cd server npm install -npm start +npm run build +# npm start # for developers ``` Next we need to make migration. Run the following command: From 4bfa60185dd40b477b1941e590e530250d13a8e1 Mon Sep 17 00:00:00 2001 From: sudodoki Date: Tue, 26 Mar 2019 10:17:18 +0200 Subject: [PATCH 2/3] Fix issue with text import yielding internal server error on StopIteration --- app/server/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/utils.py b/app/server/utils.py index 38055ef4..439cc870 100644 --- a/app/server/utils.py +++ b/app/server/utils.py @@ -344,7 +344,7 @@ class PlainTextParser(FileParser): while True: batch = list(itertools.islice(file, IMPORT_BATCH_SIZE)) if not batch: - raise StopIteration + break yield [{'text': line.strip()} for line in batch] From 1d2dfdaa3d8c1898d364e8af6585d95874a4afe7 Mon Sep 17 00:00:00 2001 From: Hironsan Date: Fri, 29 Mar 2019 13:37:39 +0900 Subject: [PATCH 3/3] Fix #140 --- app/server/migrations/0001_initial.py | 71 ++++- .../migrations/0002_document_metadata.py | 18 -- app/server/migrations/0003_shortcut.py | 18 -- .../migrations/0004_auto_20190306_0626.py | 123 -------- .../migrations/0005_auto_20190306_0853.py | 18 -- app/server/models.py | 40 ++- app/server/serializers.py | 25 +- app/server/static/js/label.js | 132 +++++--- app/server/static/js/mixin.js | 8 + app/server/templates/admin/label.html | 291 +++++++++++++----- .../annotation/document_classification.html | 4 +- .../annotation/sequence_labeling.html | 4 +- app/server/tests/test_api.py | 11 + app/server/tests/test_models.py | 51 ++- 14 files changed, 471 insertions(+), 343 deletions(-) delete mode 100644 app/server/migrations/0002_document_metadata.py delete mode 100644 app/server/migrations/0003_shortcut.py delete mode 100644 app/server/migrations/0004_auto_20190306_0626.py delete mode 100644 app/server/migrations/0005_auto_20190306_0853.py diff --git a/app/server/migrations/0001_initial.py b/app/server/migrations/0001_initial.py index 8678ecff..afa0a62d 100644 --- a/app/server/migrations/0001_initial.py +++ b/app/server/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.5 on 2018-08-09 02:09 +# Generated by Django 2.1.7 on 2019-03-31 12:43 from django.conf import settings from django.db import migrations, models @@ -10,6 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -19,6 +20,9 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('text', models.TextField()), + ('meta', models.TextField(default='{}')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( @@ -27,6 +31,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('prob', models.FloatField(default=0.0)), ('manual', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='doc_annotations', to='server.Document')), ], ), @@ -35,9 +41,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('text', models.CharField(max_length=100)), - ('shortcut', models.CharField(choices=[('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=10)), + ('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=[('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='#209cee', max_length=7)), ('text_color', models.CharField(default='#ffffff', max_length=7)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( @@ -45,13 +54,16 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100)), - ('description', models.CharField(max_length=500)), - ('guideline', models.TextField()), + ('description', models.TextField(default='')), + ('guideline', models.TextField(default='')), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('project_type', models.CharField(choices=[('DocumentClassification', 'document classification'), ('SequenceLabeling', 'sequence labeling'), ('Seq2seq', 'sequence to sequence')], max_length=30)), - ('users', models.ManyToManyField(related_name='projects', to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, ), migrations.CreateModel( name='Seq2seqAnnotation', @@ -59,6 +71,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('prob', models.FloatField(default=0.0)), ('manual', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ('text', models.TextField()), ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seq2seq_annotations', to='server.Document')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), @@ -70,6 +84,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('prob', models.FloatField(default=0.0)), ('manual', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ('start_offset', models.IntegerField()), ('end_offset', models.IntegerField()), ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seq_annotations', to='server.Document')), @@ -77,6 +93,49 @@ class Migration(migrations.Migration): ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), + migrations.CreateModel( + name='Seq2seqProject', + fields=[ + ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='server.Project')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('server.project',), + ), + migrations.CreateModel( + name='SequenceLabelingProject', + fields=[ + ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='server.Project')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('server.project',), + ), + migrations.CreateModel( + name='TextClassificationProject', + fields=[ + ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='server.Project')), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('server.project',), + ), + migrations.AddField( + model_name='project', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_server.project_set+', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='project', + name='users', + field=models.ManyToManyField(related_name='projects', to=settings.AUTH_USER_MODEL), + ), migrations.AddField( model_name='label', name='project', @@ -107,7 +166,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='label', - unique_together={('project', 'text'), ('project', 'shortcut')}, + unique_together={('project', 'prefix_key', 'suffix_key'), ('project', 'text')}, ), migrations.AlterUniqueTogether( name='documentannotation', diff --git a/app/server/migrations/0002_document_metadata.py b/app/server/migrations/0002_document_metadata.py deleted file mode 100644 index 657b19ba..00000000 --- a/app/server/migrations/0002_document_metadata.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.2 on 2018-12-26 10:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('server', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='document', - name='metadata', - field=models.TextField(default='{}'), - ), - ] diff --git a/app/server/migrations/0003_shortcut.py b/app/server/migrations/0003_shortcut.py deleted file mode 100644 index b5997e8b..00000000 --- a/app/server/migrations/0003_shortcut.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-06 02:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('server', '0002_document_metadata'), - ] - - operations = [ - migrations.AlterField( - model_name='label', - name='shortcut', - field=models.CharField(blank=True, choices=[('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'), ('ctrl a', 'ctrl a'), ('ctrl b', 'ctrl b'), ('ctrl c', 'ctrl c'), ('ctrl d', 'ctrl d'), ('ctrl e', 'ctrl e'), ('ctrl f', 'ctrl f'), ('ctrl g', 'ctrl g'), ('ctrl h', 'ctrl h'), ('ctrl i', 'ctrl i'), ('ctrl j', 'ctrl j'), ('ctrl k', 'ctrl k'), ('ctrl l', 'ctrl l'), ('ctrl m', 'ctrl m'), ('ctrl n', 'ctrl n'), ('ctrl o', 'ctrl o'), ('ctrl p', 'ctrl p'), ('ctrl q', 'ctrl q'), ('ctrl r', 'ctrl r'), ('ctrl s', 'ctrl s'), ('ctrl t', 'ctrl t'), ('ctrl u', 'ctrl u'), ('ctrl v', 'ctrl v'), ('ctrl w', 'ctrl w'), ('ctrl x', 'ctrl x'), ('ctrl y', 'ctrl y'), ('ctrl z', 'ctrl z'), ('shift a', 'shift a'), ('shift b', 'shift b'), ('shift c', 'shift c'), ('shift d', 'shift d'), ('shift e', 'shift e'), ('shift f', 'shift f'), ('shift g', 'shift g'), ('shift h', 'shift h'), ('shift i', 'shift i'), ('shift j', 'shift j'), ('shift k', 'shift k'), ('shift l', 'shift l'), ('shift m', 'shift m'), ('shift n', 'shift n'), ('shift o', 'shift o'), ('shift p', 'shift p'), ('shift q', 'shift q'), ('shift r', 'shift r'), ('shift s', 'shift s'), ('shift t', 'shift t'), ('shift u', 'shift u'), ('shift v', 'shift v'), ('shift w', 'shift w'), ('shift x', 'shift x'), ('shift y', 'shift y'), ('shift z', 'shift z'), ('ctrl shift a', 'ctrl shift a'), ('ctrl shift b', 'ctrl shift b'), ('ctrl shift c', 'ctrl shift c'), ('ctrl shift d', 'ctrl shift d'), ('ctrl shift e', 'ctrl shift e'), ('ctrl shift f', 'ctrl shift f'), ('ctrl shift g', 'ctrl shift g'), ('ctrl shift h', 'ctrl shift h'), ('ctrl shift i', 'ctrl shift i'), ('ctrl shift j', 'ctrl shift j'), ('ctrl shift k', 'ctrl shift k'), ('ctrl shift l', 'ctrl shift l'), ('ctrl shift m', 'ctrl shift m'), ('ctrl shift n', 'ctrl shift n'), ('ctrl shift o', 'ctrl shift o'), ('ctrl shift p', 'ctrl shift p'), ('ctrl shift q', 'ctrl shift q'), ('ctrl shift r', 'ctrl shift r'), ('ctrl shift s', 'ctrl shift s'), ('ctrl shift t', 'ctrl shift t'), ('ctrl shift u', 'ctrl shift u'), ('ctrl shift v', 'ctrl shift v'), ('ctrl shift w', 'ctrl shift w'), ('ctrl shift x', 'ctrl shift x'), ('ctrl shift y', 'ctrl shift y'), ('ctrl shift z', 'ctrl shift z'), ('', '')], max_length=15, null=True), - ), - ] diff --git a/app/server/migrations/0004_auto_20190306_0626.py b/app/server/migrations/0004_auto_20190306_0626.py deleted file mode 100644 index c11e7b35..00000000 --- a/app/server/migrations/0004_auto_20190306_0626.py +++ /dev/null @@ -1,123 +0,0 @@ -# Generated by Django 2.1.5 on 2019-03-06 06:26 - -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('server', '0003_shortcut'), - ] - - operations = [ - migrations.CreateModel( - name='Seq2seqProject', - fields=[ - ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='server.Project')), - ], - options={ - 'abstract': False, - 'base_manager_name': 'objects', - }, - bases=('server.project',), - ), - migrations.CreateModel( - name='SequenceLabelingProject', - fields=[ - ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='server.Project')), - ], - options={ - 'abstract': False, - 'base_manager_name': 'objects', - }, - bases=('server.project',), - ), - migrations.CreateModel( - name='TextClassificationProject', - fields=[ - ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='server.Project')), - ], - options={ - 'abstract': False, - 'base_manager_name': 'objects', - }, - bases=('server.project',), - ), - migrations.AlterModelOptions( - name='project', - options={'base_manager_name': 'objects'}, - ), - migrations.AddField( - model_name='document', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='document', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='documentannotation', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='documentannotation', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='label', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='label', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='project', - name='polymorphic_ctype', - field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_server.project_set+', to='contenttypes.ContentType'), - ), - migrations.AddField( - model_name='seq2seqannotation', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='seq2seqannotation', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='sequenceannotation', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='sequenceannotation', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='project', - name='description', - field=models.TextField(default=''), - ), - migrations.AlterField( - model_name='project', - name='guideline', - field=models.TextField(default=''), - ), - ] diff --git a/app/server/migrations/0005_auto_20190306_0853.py b/app/server/migrations/0005_auto_20190306_0853.py deleted file mode 100644 index 8400d135..00000000 --- a/app/server/migrations/0005_auto_20190306_0853.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.7 on 2019-03-06 08:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('server', '0004_auto_20190306_0626'), - ] - - operations = [ - migrations.RenameField( - model_name='document', - old_name='metadata', - new_name='meta', - ), - ] diff --git a/app/server/models.py b/app/server/models.py index 441b05cb..ce57dc77 100644 --- a/app/server/models.py +++ b/app/server/models.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse from django.contrib.auth.models import User from django.contrib.staticfiles.storage import staticfiles_storage -from rest_framework.exceptions import ValidationError +from django.core.exceptions import ValidationError from polymorphic.models import PolymorphicModel DOCUMENT_CLASSIFICATION = 'DocumentClassification' @@ -17,16 +17,6 @@ PROJECT_CHOICES = ( ) -def get_key_choices(): - selectKey, shortKey = [c for c in string.ascii_lowercase], [c for c in string.ascii_lowercase] - checkKey = 'ctrl shift' - shortKey += [ck + ' ' + sk for ck in checkKey.split() for sk in selectKey] - shortKey += [checkKey + ' ' + sk for sk in selectKey] - shortKey += [''] - KEY_CHOICES = ((u, c) for u, c in zip(shortKey, shortKey)) - return KEY_CHOICES - - class Project(PolymorphicModel): name = models.CharField(max_length=100) description = models.TextField(default='') @@ -147,11 +137,18 @@ class Seq2seqProject(Project): class Label(models.Model): - KEY_CHOICES = get_key_choices() - COLOR_CHOICES = () + PREFIX_KEYS = ( + ('ctrl', 'ctrl'), + ('shift', 'shift'), + ('ctrl shift', 'ctrl shift') + ) + SUFFIX_KEYS = ( + (c, c) for c in string.ascii_lowercase + ) text = models.CharField(max_length=100) - shortcut = models.CharField(max_length=15, blank=True, null=True, choices=KEY_CHOICES) + prefix_key = models.CharField(max_length=10, blank=True, null=True, choices=PREFIX_KEYS) + suffix_key = models.CharField(max_length=1, blank=True, null=True, choices=SUFFIX_KEYS) project = models.ForeignKey(Project, related_name='labels', on_delete=models.CASCADE) background_color = models.CharField(max_length=7, default='#209cee') text_color = models.CharField(max_length=7, default='#ffffff') @@ -161,10 +158,23 @@ class Label(models.Model): def __str__(self): return self.text + def clean(self): + # Don't allow shortcut key not to have a suffix key. + if self.prefix_key and not self.suffix_key: + raise ValidationError('Shortcut key may not have a suffix key.') + super().clean() + + def validate_unique(self, exclude=None): + # Don't allow to save same shortcut key when prefix_key is null. + if Label.objects.exclude(id=self.id).filter(suffix_key=self.suffix_key, + prefix_key__isnull=True).exists(): + raise ValidationError('Duplicate key.') + super().validate_unique(exclude) + class Meta: unique_together = ( ('project', 'text'), - ('project', 'shortcut') + ('project', 'prefix_key', 'suffix_key') ) diff --git a/app/server/serializers.py b/app/server/serializers.py index 889286da..cfc4df12 100644 --- a/app/server/serializers.py +++ b/app/server/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers from rest_polymorphic.serializers import PolymorphicSerializer +from rest_framework.exceptions import ValidationError + from .models import Label, Project, Document from .models import TextClassificationProject, SequenceLabelingProject, Seq2seqProject @@ -8,9 +10,30 @@ from .models import DocumentAnnotation, SequenceAnnotation, Seq2seqAnnotation class LabelSerializer(serializers.ModelSerializer): + def validate(self, attrs): + if 'prefix_key' not in attrs and 'suffix_key' not in attrs: + return super().validate(attrs) + + prefix_key = attrs['prefix_key'] + suffix_key = attrs['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. + if Label.objects.filter(suffix_key=suffix_key, + prefix_key__isnull=True).exists(): + raise ValidationError('Duplicate key.') + return super().validate(attrs) + class Meta: model = Label - fields = ('id', 'text', 'shortcut', 'background_color', 'text_color') + fields = ('id', 'text', 'prefix_key', 'suffix_key', 'background_color', 'text_color') class DocumentSerializer(serializers.ModelSerializer): diff --git a/app/server/static/js/label.js b/app/server/static/js/label.js index d08b11b0..95083fb2 100644 --- a/app/server/static/js/label.js +++ b/app/server/static/js/label.js @@ -10,62 +10,56 @@ const vm = new Vue({ delimiters: ['[[', ']]'], data: { labels: [], - labelText: '', - selectedKey: '', - checkedKey: [], - shortcutKey: '', - backgroundColor: '#209cee', - textColor: '#ffffff', + newLabel: null, + editedLabel: null, messages: [], }, - computed: { - /** - * combineKeys: Combine selectedKey and checkedKey to get shortcutKey - * saveKeys: Save null to database if shortcutKey is empty string - */ - combineKeys: function () { - this.shortcutKey = ''; + methods: { + generateColor() { + const color = (Math.random() * 0xFFFFFF | 0).toString(16); + const randomColor = "#" + ("000000" + color).slice(-6); + return randomColor; + }, - // If checkedKey exits, add it to shortcutKey - if (this.checkedKey.length > 0) { - this.checkedKey.sort(); - this.shortcutKey = this.checkedKey.join(' '); + blackOrWhite(hexcolor) { + const r = parseInt(hexcolor.substr(1, 2), 16); + const g = parseInt(hexcolor.substr(3, 2), 16); + const b = parseInt(hexcolor.substr(5, 2), 16); + return ((((r * 299) + (g * 587) + (b * 114)) / 1000) < 128) ? '#ffffff' : '#000000'; + }, - // If selectedKey exist, add it to shortcutKey - if (this.selectedKey.length !== 0) { - this.shortcutKey = this.shortcutKey + ' ' + this.selectedKey; - } - } + setColor(label) { + const bgColor = this.generateColor(); + const textColor = this.blackOrWhite(bgColor); + label.background_color = bgColor; + label.text_color = textColor; + }, - // If only selectedKey exist, assign to shortcutKey - if (this.shortcutKey.length === 0 && this.selectedKey.length !== 0) { - this.shortcutKey = this.selectedKey; + shortcutKey(label) { + let shortcut = label.suffix_key; + if (label.prefix_key) { + shortcut = `${label.prefix_key} ${shortcut}`; } - return this.shortcutKey; + return shortcut; }, - saveKeys: function () { - this.shortcutKey = this.combineKeys; - if (this.shortcutKey === '') { - return null; - } - return this.shortcutKey; + sortLabels() { + return this.labels.sort((a, b) => ((a.text < b.text) ? -1 : 1)); }, - }, - methods: { addLabel() { - const payload = { - text: this.labelText, - shortcut: this.saveKeys, - background_color: this.backgroundColor, - text_color: this.textColor, - }; - HTTP.post('labels', payload).then((response) => { - this.reset(); - this.labels.push(response.data); - }); + HTTP.post('labels', this.newLabel) + .then((response) => { + this.cancelCreate(); + this.labels.push(response.data); + this.sortLabels(); + this.messages = []; + }) + .catch((error) => { + console.log(error); + this.messages.push('You cannot use same label name or shortcut key.'); + }); }, removeLabel(label) { @@ -76,18 +70,54 @@ const vm = new Vue({ }); }, - reset() { - this.labelText = ''; - this.selectedKey = ''; - this.checkedKey = []; - this.shortcutKey = ''; - this.backgroundColor = '#209cee'; - this.textColor = '#ffffff'; + createLabel() { + this.newLabel = { + text: '', + prefix_key: null, + suffix_key: null, + background_color: '#209cee', + text_color: '#ffffff', + }; + }, + + cancelCreate() { + this.newLabel = null; + }, + + editLabel(label) { + this.beforeEditCache = Object.assign({}, label); + this.editedLabel = label; + }, + + doneEdit(label) { + if (!this.editedLabel) { + return; + } + this.editedLabel = null; + label.text = label.text.trim(); + if (!label.text) { + this.removeLabel(label); + } + HTTP.patch(`labels/${label.id}`, label) + .then((response) => { + this.sortLabels(); + this.messages = []; + }) + .catch((error) => { + console.log(error); + this.messages.push('You cannot use same label name or shortcut key.'); + }); + }, + + cancelEdit(label) { + this.editedLabel = null; + Object.assign(label, this.beforeEditCache); }, }, created() { HTTP.get('labels').then((response) => { this.labels = response.data; + this.sortLabels(); }); }, }); diff --git a/app/server/static/js/mixin.js b/app/server/static/js/mixin.js index 8a853ee7..739527e6 100644 --- a/app/server/static/js/mixin.js +++ b/app/server/static/js/mixin.js @@ -125,6 +125,14 @@ const annotationMixin = { shortcut = shortcut.split(' '); return shortcut; }, + + shortcutKey(label) { + let shortcut = label.suffix_key; + if (label.prefix_key) { + shortcut = `${label.prefix_key} ${shortcut}`; + } + return shortcut; + }, }, watch: { diff --git a/app/server/templates/admin/label.html b/app/server/templates/admin/label.html index 51d98a35..4df0876a 100644 --- a/app/server/templates/admin/label.html +++ b/app/server/templates/admin/label.html @@ -3,10 +3,114 @@ {% load render_bundle from webpack_loader %} {% block content-area %} +
+ +
+
+
+ +
+
+ + [[ newLabel.text ]] + + + + [[ shortcutKey(newLabel) | simpleShortcut ]] + + +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+

+ + + +

+
+
+ +
+
+
+
+
+ +
+
+ +
+ +
+ +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+

- Label editor + [[ labels.length ]] labels

@@ -16,98 +120,129 @@
-
-
-
-
- - - [[ label.text ]] - - [[ label.shortcut | simpleShortcut ]] -
-
-
-
- -
-
- - [[ labelText ]] - - [[ combineKeys | simpleShortcut ]] +
+
+
+
+

+

+ + [[ label.text ]] + + + + [[ shortcutKey(label) | simpleShortcut ]] + + +
+

-
-
-
- -

required

-
-
- +
-
-
- -

optional

-
-
-
- +
+
+
+ +
+ +
-
- - -
-
- - -
-
-
-
- -

optional

-
-
- +
+
+ +
+

+ + + +

+
+
+ +
+
+
+
-
-
-
- -

optional

-
-
- +
+
+ +
+ +
+ +
+
+
-
-
-
- -
-
- +
+
+ + +
-
-{% endblock %} -{% block footer %} -{% render_bundle 'label' 'js' %} -{% endblock %} \ No newline at end of file + {% endblock %} + {% block footer %} + {% render_bundle 'label' 'js' %} + {% endblock %} \ No newline at end of file diff --git a/app/server/templates/annotation/document_classification.html b/app/server/templates/annotation/document_classification.html index 64806c8f..e3d093a4 100644 --- a/app/server/templates/annotation/document_classification.html +++ b/app/server/templates/annotation/document_classification.html @@ -9,10 +9,10 @@
+ v-shortkey.once=" replaceNull(shortcutKey(label)) " @shortkey="addLabel(label)"> [[ label.text ]] - [[ label.shortcut | simpleShortcut ]] + [[ shortcutKey(label) | simpleShortcut ]]
diff --git a/app/server/templates/annotation/sequence_labeling.html b/app/server/templates/annotation/sequence_labeling.html index 83c390de..07fb8228 100644 --- a/app/server/templates/annotation/sequence_labeling.html +++ b/app/server/templates/annotation/sequence_labeling.html @@ -9,10 +9,10 @@
+ v-shortkey.once=" replaceNull(shortcutKey(label)) " @shortkey="annotate(label.id)"> [[ label.text ]] - [[ label.shortcut | simpleShortcut ]] + [[ shortcutKey(label) | simpleShortcut ]]
diff --git a/app/server/tests/test_api.py b/app/server/tests/test_api.py index 043b2e00..6cb0a28b 100644 --- a/app/server/tests/test_api.py +++ b/app/server/tests/test_api.py @@ -183,6 +183,17 @@ class TestLabelListAPI(APITestCase): response = self.client.post(self.url, format='json', data=self.data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_can_create_multiple_labels_without_shortcut_key(self): + self.client.login(username=self.super_user_name, + password=self.super_user_pass) + labels = [ + {'text': 'Ruby', 'prefix_key': None, 'suffix_key': None}, + {'text': 'PHP', 'prefix_key': None, 'suffix_key': None} + ] + for label in labels: + response = self.client.post(self.url, format='json', data=label) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_disallows_project_member_to_create_label(self): self.client.login(username=self.project_member_name, password=self.project_member_pass) diff --git a/app/server/tests/test_models.py b/app/server/tests/test_models.py index 24b44a2b..79fb9fa6 100644 --- a/app/server/tests/test_models.py +++ b/app/server/tests/test_models.py @@ -1,5 +1,5 @@ from django.test import TestCase -from rest_framework.exceptions import ValidationError +from django.core.exceptions import ValidationError from django.db.utils import IntegrityError from model_mommy import mommy @@ -80,22 +80,51 @@ class TestSeq2seqProject(TestCase): class TestLabel(TestCase): - def test_shortcut_uniqueness(self): - label = mommy.make('server.Label', shortcut='a') - mommy.make('server.Label', shortcut=label.shortcut) - with self.assertRaises(IntegrityError): - Label(project=label.project, shortcut=label.shortcut).save() - - def test_create_none_shortcut(self): - label = mommy.make('server.Label', shortcut=None) - self.assertEqual(label.shortcut, None) - def test_text_uniqueness(self): label = mommy.make('server.Label') mommy.make('server.Label', text=label.text) with self.assertRaises(IntegrityError): Label(project=label.project, text=label.text).save() + def test_keys_uniqueness(self): + label = mommy.make('server.Label', prefix_key='ctrl', suffix_key='a') + with self.assertRaises(IntegrityError): + Label(project=label.project, + text='example', + prefix_key=label.prefix_key, + suffix_key=label.suffix_key).save() + + def test_suffix_key_uniqueness(self): + label = mommy.make('server.Label', prefix_key=None, suffix_key='a') + with self.assertRaises(ValidationError): + Label(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('server.Project') + label = Label(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('server.Project') + label = Label(project=project, + text='example', + suffix_key='a') + label.full_clean() + + def test_can_add_label_suffix_key_with_prefix_key(self): + project = mommy.make('server.Project') + label = Label(project=project, + text='example', + prefix_key='ctrl', + suffix_key='a') + label.full_clean() + class TestDocumentAnnotation(TestCase):