Browse Source

Fix #140

pull/143/head
Hironsan 5 years ago
parent
commit
1d2dfdaa3d
14 changed files with 471 additions and 343 deletions
  1. 71
      app/server/migrations/0001_initial.py
  2. 18
      app/server/migrations/0002_document_metadata.py
  3. 18
      app/server/migrations/0003_shortcut.py
  4. 123
      app/server/migrations/0004_auto_20190306_0626.py
  5. 18
      app/server/migrations/0005_auto_20190306_0853.py
  6. 40
      app/server/models.py
  7. 25
      app/server/serializers.py
  8. 132
      app/server/static/js/label.js
  9. 8
      app/server/static/js/mixin.js
  10. 291
      app/server/templates/admin/label.html
  11. 4
      app/server/templates/annotation/document_classification.html
  12. 4
      app/server/templates/annotation/sequence_labeling.html
  13. 11
      app/server/tests/test_api.py
  14. 51
      app/server/tests/test_models.py

71
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',

18
app/server/migrations/0002_document_metadata.py

@ -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='{}'),
),
]

18
app/server/migrations/0003_shortcut.py

@ -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),
),
]

123
app/server/migrations/0004_auto_20190306_0626.py

@ -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=''),
),
]

18
app/server/migrations/0005_auto_20190306_0853.py

@ -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',
),
]

40
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')
)

25
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):

132
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();
});
},
});

8
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: {

291
app/server/templates/admin/label.html

@ -3,10 +3,114 @@
{% load render_bundle from webpack_loader %}
{% block content-area %}
<div class="columns is-multiline">
<div class="column is-12">
<a class="button is-primary" @click="createLabel()">
New label
</a>
</div>
<div class="column is-12" v-if="newLabel">
<div class="box">
<div class="columns is-multiline">
<div class="column is-12">
<div class="tags has-addons mb0">
<span class="tag is-medium"
v-bind:style="{ color: newLabel.text_color, 'background-color': newLabel.background_color }">
[[ newLabel.text ]]
</span>
<span class="tag is-medium">
<kbd>
[[ shortcutKey(newLabel) | simpleShortcut ]]
</kbd>
</span>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Label name</label>
<div class="control">
<input class="input" type="text" placeholder="Text input" v-model="newLabel.text">
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Shortcut</label>
<div class="field has-addons">
<p class="control">
<span class="select">
<select v-model="newLabel.prefix_key">
<option value=""></option>
<option value="ctrl">Ctrl</option>
<option value="shift">Shift</option>
<option value="ctrl shift">Ctrl + Shift</option>
</select>
</span>
</p>
<div class="control">
<div class="select">
<select v-model="newLabel.suffix_key">
<option disabled value="">key</option>
{% for ch in 'abcdefghijklmnopqrstuvwxyz' %}
<option>{{ ch }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Color</label>
<div class="field has-addons">
<div class="control">
<a class="button"
v-bind:style="{ color: newLabel.text_color, 'background-color': newLabel.background_color }"
@click="setColor(newLabel)">
<span class="icon is-small">
<i class="fas fa-sync-alt"></i>
</span>
</a>
</div>
<div class="control is-expanded">
<input class="input" type="text" placeholder="Text input" v-model="newLabel.background_color">
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">&nbsp;</label>
<div class="field is-grouped">
<p class="control">
<a class="button is-light" @click="cancelCreate()">
Cancel
</a>
</p>
<p class="control">
<a class="button is-primary" @click="addLabel()">
Create label
</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<header class="card-header">
<p class="card-header-title">
Label editor
[[ labels.length ]] labels
</p>
<a href="#" class="card-header-icon" aria-label="more options">
<span class="icon">
@ -16,98 +120,129 @@
</header>
<div class="card-content">
<div class="has-text-right">
<div class="field is-grouped is-grouped-multiline has-text-weight-bold pbrem075">
<div class="control" v-for="label in labels">
<div class="tags has-addons">
<span class="tag is-medium" v-bind:style="{ color: label.text_color, 'background-color': label.background_color }">
<button class="delete is-small tweaked-margin" @click="removeLabel(label)"></button>
[[ label.text ]]
</span>
<span class="tag is-medium"><kbd>[[ label.shortcut | simpleShortcut ]]</kbd></span>
</div>
</div>
</div>
<div class="field is-horizontal bordered-row">
<label class="label column is-3 mb0">Preview</label>
<div class="control column is-6">
<div class="tags has-addons has-text-weight-bold">
<a class="tag is-medium" v-bind:style="{ color: textColor, backgroundColor: backgroundColor }">
[[ labelText ]]
</a>
<span class="tag is-medium"><kbd>[[ combineKeys | simpleShortcut ]]</kbd></span>
<div class="mb10" v-for="label in labels">
<div class="level is-mobile mb0">
<div class="level-left">
<div class="level-item">
<p class="subtitle is-5">
<div class="tags has-addons mb0">
<span class="tag is-medium"
v-bind:style="{ color: label.text_color, 'background-color': label.background_color }">
[[ label.text ]]
</span>
<span class="tag is-medium">
<kbd>
[[ shortcutKey(label) | simpleShortcut ]]
</kbd>
</span>
</div>
</p>
</div>
</div>
</div>
<div class="field is-horizontal bordered-row">
<div class="column is-3 mb0">
<label class="label mb0">Label Name</label>
<p class="is-small has-text-grey">required</p>
</div>
<div class="control column is-6">
<input class="input" type="text" placeholder="Text input" v-model="labelText">
<div class="level-right">
<p class="level-item">
<div class="field is-grouped">
<p class="control">
<a class="button is-text" @click="editLabel(label)">
<span class="icon is-small">
<i class="fas fa-pencil-alt"></i>
</span>
<span>Edit</span>
</a>
</p>
<p class="control">
<a class="button is-text" @click="removeLabel(label)">
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
<span>Delete</span>
</a>
</p>
</div>
</p>
</div>
</div>
<div class="field is-horizontal bordered-row">
<div class="column is-3 mb0">
<label class="label mb0">Shortcut Key</label>
<p class="is-small has-text-grey">optional</p>
</div>
<div class="control column is-narrow">
<div class="select">
<select v-model="selectedKey">
<option disabled value="">Please select one</option>
{% for ch in 'abcdefghijklmnopqrstuvwxyz' %}
<option>{{ ch }}</option>
{% endfor %}
</select>
<div class="columns" v-show="label===editedLabel">
<div class="column">
<div class="field">
<label class="label">Label name</label>
<div class="control">
<input class="input" type="text" placeholder="Text input" v-model="label.text">
</div>
</div>
</div>
<div class="is-narrow">
<input class="is-checkradio" type="checkbox" id="ctrl" value="ctrl" v-model="checkedKey">
<label for="ctrl"><kbd>C: Ctrl</kbd></label>
</div>
<div class="is-narrow">
<input class="is-checkradio" type="checkbox" id="shift" value="shift" v-model="checkedKey">
<label for="shift"><kbd>S: Shift</kbd></label>
</div>
</div>
<div class="field is-horizontal bordered-row">
<div class="column is-3 mb0">
<label class="label mb0">Background Color</label>
<p class="is-small has-text-grey">optional</p>
</div>
<div class="control column is-6">
<input class="input" type="color" v-model="backgroundColor">
<div class="column">
<div class="field">
<label class="label">Shortcut</label>
<div class="field has-addons">
<p class="control">
<span class="select">
<select v-model="label.prefix_key">
<option value=""></option>
<option value="ctrl">Ctrl</option>
<option value="shift">Shift</option>
<option value="ctrl shift">Ctrl + Shift</option>
</select>
</span>
</p>
<div class="control">
<div class="select">
<select v-model="label.suffix_key">
<option disabled value="">key</option>
{% for ch in 'abcdefghijklmnopqrstuvwxyz' %}
<option>{{ ch }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal bordered-row">
<div class="column is-3 mb0">
<label class="label mb0">Text Color</label>
<p class="is-small has-text-grey">optional</p>
</div>
<div class="control column is-6">
<input class="input" type="color" v-model="textColor">
<div class="column">
<div class="field">
<label class="label">Color</label>
<div class="field has-addons">
<div class="control">
<a class="button has-text-white" v-bind:style="{ 'background-color': label.background_color }"
@click="setColor(label)">
<span class="icon is-small">
<i class="fas fa-sync-alt"></i>
</span>
</a>
</div>
<div class="control is-expanded">
<input class="input" type="text" placeholder="Text input" v-model="label.background_color">
</div>
</div>
</div>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" @click="addLabel()">Add label</button>
</div>
<div class="control">
<button class="button is-text" @click="reset()">Reset</button>
<div class="column">
<div class="field">
<label class="label">&nbsp;</label>
<div class="field is-grouped">
<p class="control">
<a class="button is-light" @click="cancelEdit(label)">
Cancel
</a>
</p>
<p class="control">
<a class="button is-primary" @click="doneEdit(label)">
Save changes
</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block footer %}
{% render_bundle 'label' 'js' %}
{% endblock %}
{% endblock %}
{% block footer %}
{% render_bundle 'label' 'js' %}
{% endblock %}

4
app/server/templates/annotation/document_classification.html

@ -9,10 +9,10 @@
<div class="control" v-for="(label, item) in labels">
<div class="tags has-addons">
<a class="tag is-medium" v-bind:style="{ color: label.text_color, backgroundColor: label.background_color }" v-on:click="addLabel(label)"
v-shortkey.once=" replaceNull(label.shortcut) " @shortkey="addLabel(label)">
v-shortkey.once=" replaceNull(shortcutKey(label)) " @shortkey="addLabel(label)">
[[ label.text ]]
</a>
<span class="tag is-medium"><kbd>[[ label.shortcut | simpleShortcut ]]</kbd></span>
<span class="tag is-medium"><kbd>[[ shortcutKey(label) | simpleShortcut ]]</kbd></span>
</div>
</div>
</div>

4
app/server/templates/annotation/sequence_labeling.html

@ -9,10 +9,10 @@
<div class="control" v-for="label in labels">
<div class="tags has-addons">
<a class="tag is-medium" v-bind:style="{ color: label.text_color, backgroundColor: label.background_color }" v-on:click="annotate(label.id)"
v-shortkey.once=" replaceNull(label.shortcut) " @shortkey="annotate(label.id)">
v-shortkey.once=" replaceNull(shortcutKey(label)) " @shortkey="annotate(label.id)">
[[ label.text ]]
</a>
<span class="tag is-medium"><kbd>[[ label.shortcut | simpleShortcut ]]</kbd></span>
<span class="tag is-medium"><kbd>[[ shortcutKey(label) | simpleShortcut ]]</kbd></span>
</div>
</div>
</div>

11
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)

51
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):

Loading…
Cancel
Save