From b4902478db96985efb74a7cbec0bcf0fab49e6a7 Mon Sep 17 00:00:00 2001 From: ghontolux <87199298+ghontolux@users.noreply.github.com> Date: Tue, 25 Jan 2022 10:28:39 +0100 Subject: [PATCH] Update/doccano 1.5.5 (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixing Data Annotation Issues When uploading datasets, the code uses a `bulk_create` to upload Examples and Labels. It then filters the data from the database based on when it was created. However, [Django doesn't enforce the list order when calling filter](https://stackoverflow.com/questions/7163640/what-is-the-default-order-of-a-list-returned-from-a-django-filter-call) unless ordering is specified. The previous behavior mismatched labels and examples. When this was shown in the UI, the data would show labels for incorrect examples (i.e. a label for message #2 would be shown on message #1). This fix enforces that the data is returned in the order it was inserted so that the data, label pair is as expected. * move later to copy files in Dockerfile.prod * fix client-side types about comment as backend returns * add annotation link in commentList page * Add admin interface for AutoLabelingConfigs. Solves #1423 Thanks to @uklft for the idea. * Sort imports * Return a Response with a status if the task is not yet ready. * Remove unneeded query Bulk create returns the created objects in the same order as they have been added. In Postgres, the query was wrong, because ordering was not guaranteed. * Remove unneed import * removing debugging statement * iss1348: fix colors when importing labels Signed-off-by: Dimid Duchovny * Updated various dependency and image versions * Python version pinning fix * update cloudformation template to modify the sample env file, now that all the config params are stored in environment variables as per commit 57286362ce7545d469e110c20b0016387ee25abe * show a check button for annotators * filter by role in the confirm API * add a property to the ExampleState model * separate confirm status for each role or user * fix flake8 * fix TestExampleStateConfirmCollaborative * fix isort * move ExampleSerializer tests to test_document.py * add tests * Sequence labelling: fix background color in dark mode * add confirmed count to statistics api response * receive confirmed count value in frontend statistics models * make progress data per role * show progress of each role * not display legend of bar-chart * Increase the allowed max length for uploaded dataset filepath * Bump django from 3.2.4 to 3.2.5 Bumps [django](https://github.com/django/django) from 3.2.4 to 3.2.5. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.4...3.2.5) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Add EntityEditor * Fix flake8 warnings * Update Dockerfiles * Add v-annotator * Update ner demo * Update sequence labeling page * Support RTL in sequence labeling * Update index.md * Update package * Add fields to SequenceLabelingProject * Update serializer in ProjectDetail * Enable to handle allowOverlapping and graphemeMode option in sequence labeling page * Enable to create project with allowOverlapping and graphemeMode option * Remove unused import * Update v-annotator to fix the problem The problem occurred when the user changes the state of RTL. Once the state changes, the entities are visually disappeared. * Show shortcut key on menu * Add explanation for nested mode * Add explanation for grapheme mode * Update shortcut on menu * Update package version * Enable to pass grapheme-mode to EntityEditor.vue * Add explanation for project creation * Support doccano init on windows * Fix cli * Add dependency, fix #1481 * Update cli, fix #1408 * Add explanation on create user, close #1410 * Update faq, close #1496 * Remove old tests * Update test config * Update components, fix #1541 * Add test for FormGuideline component * Update the name of test case * Apply linter * Update eslint config * Update docker-compose.dev.yml, fix #1536 * Change example id from auto field to uuid field * Update import method of urls * Add test cases for ingest classification data * Move test data * Rename classification.jsonl * Fix CoNLLDataset * Add test cases for ingesting sequence labeling data * Refactor test_tasks.py * Move test data * Add test cases for ingesting seq2seq data * Update test cases for ingesting data to check mapping * Improve error handling for jsonl parser * Improve error handling for json parser * Improve error handling for excel parser * Add csv test case * Add conll test case * Change doc/example id type from number to string * Update order of examples * Revert primary key change * Add migration file * Update task queue command to support windows * Create FUNDING.yml * Update README.md * Update compose files, fix #1546 * Update CsvWriter, fix #1497 * Sort exported labels, fix #1466 * Add keyboard shortcut back to accept button * Add how to use PostgreSQL * Assign label colors automatically * Add a test case for generating color function * Fix typo: injest -> ingest * Add PostgreSQL related env in docker compose mode * Update README.md * Add a validator to the text field * Enable to ingest lines without errors even if an exception occurs during parsing * Fix TextLineDataset to raise exception * Enable to delete relation if one of the entities are deleted * Update Span model * Add a migration * Refactor CoNLLDataset * Enable to return line number of exception occured * Update Cleaner to change error the message by the project type * Install mdi font * Set icons locally * Support offline font * Remove font awesome script * Add a demo image to show it in offline environment * Fix speech to text demo * Remove unused scripts * Update publish-image.yml * Enable to list all labels * Fix unique constraint * Add clean up after closing menu * Update the way of clean up selected items * Wrap by nexttick * Update Dockerfile to change the default value of DEBUG, fix #1457 * Update cleanup method * Update unique constraint of Span * Handle unique constraint exception * Add try/catch to update/delete method * Show number of deleting rows only in confirm dialog, resolve #1077 * Speed up fetching comment Co-authored-by: zanussbaum Co-authored-by: youichiro Co-authored-by: ayanamizuta Co-authored-by: Roland Szabo Co-authored-by: Dimid Duchovny Co-authored-by: rcarew@xelerance.com Co-authored-by: Dale Evans Co-authored-by: Colin Darie Co-authored-by: Yosua Michael M Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Hironsan Co-authored-by: Hiroki Nakayama Co-authored-by: Talha Oz Co-authored-by: Fynn Schmitt-Ulms Co-authored-by: Zader Zheng Co-authored-by: Gerhard Haß --- .github/workflows/publish-image.yml | 3 +- Dockerfile | 2 +- README.md | 55 ++++- .../0018_alter_label_background_color.py | 19 ++ .../api/migrations/0019_auto_20211124_0506.py | 30 +++ .../migrations/0020_merge_20211203_1558.py | 14 ++ backend/api/models.py | 45 +++- backend/api/tasks.py | 16 +- backend/api/tests/api/test_annotation.py | 12 +- backend/api/tests/api/test_comment.py | 4 +- backend/api/tests/api/utils.py | 15 +- backend/api/tests/data/seq2seq/example.csv | 2 +- .../example_overlapping.jsonl | 1 + backend/api/tests/download/test_writer.py | 10 + backend/api/tests/test_models.py | 96 +++++++- backend/api/tests/test_tasks.py | 15 +- backend/api/views/annotation.py | 7 +- backend/api/views/comment.py | 1 - backend/api/views/download/writer.py | 3 +- backend/api/views/import_dataset.py | 4 +- backend/api/views/upload/cleaners.py | 58 +++++ backend/api/views/upload/data.py | 9 +- backend/api/views/upload/dataset.py | 112 ++++++--- backend/api/views/upload/exception.py | 16 ++ backend/api/views/upload/factory.py | 14 +- docs/advanced/offline_deployment.md | 29 --- frontend/assets/6737785.png | Bin 0 -> 20778 bytes frontend/components/auth/FormLogin.vue | 9 +- frontend/components/comment/Comment.vue | 9 +- frontend/components/comment/CommentList.vue | 58 ++++- .../configAutoLabeling/form/LabelMapping.vue | 9 +- .../configAutoLabeling/form/ObjectField.vue | 7 +- frontend/components/example/ActionMenu.vue | 5 +- frontend/components/example/AudioList.vue | 4 +- frontend/components/example/DocumentList.vue | 4 +- frontend/components/example/FormDelete.vue | 4 +- frontend/components/example/ImageList.vue | 4 +- frontend/components/label/ActionMenu.vue | 7 +- frontend/components/label/FormCreate.vue | 9 +- frontend/components/label/LabelList.vue | 9 +- frontend/components/layout/LocaleMenu.vue | 15 +- .../components/layout/TheBottomBanner.vue | 7 +- .../layout/TheColorModeSwitcher.vue | 10 +- frontend/components/layout/TheHeader.vue | 13 +- frontend/components/layout/TheSideBar.vue | 25 +- frontend/components/layout/TheTopBanner.vue | 15 +- frontend/components/links/ActionMenu.vue | 3 +- frontend/components/links/FormCreate.vue | 6 +- frontend/components/links/LinksList.vue | 9 +- frontend/components/member/FormCreate.vue | 9 +- frontend/components/member/MemberList.vue | 9 +- frontend/components/project/FormCreate.vue | 12 +- frontend/components/project/FormUpdate.vue | 14 +- frontend/components/project/ProjectList.vue | 6 +- .../components/tasks/audio/AudioViewer.vue | 19 +- .../components/tasks/seq2seq/Seq2seqBox.vue | 9 +- .../tasks/sequenceLabeling/EntityEditor.vue | 62 ++++- .../tasks/toolbar/ToolbarMobile.vue | 13 +- .../toolbar/buttons/ButtonAutoLabeling.vue | 15 +- .../tasks/toolbar/buttons/ButtonClear.vue | 14 +- .../tasks/toolbar/buttons/ButtonComment.vue | 15 +- .../tasks/toolbar/buttons/ButtonFilter.vue | 10 +- .../tasks/toolbar/buttons/ButtonGuideline.vue | 14 +- .../toolbar/buttons/ButtonLabelSwitch.vue | 10 +- .../toolbar/buttons/ButtonPagination.vue | 16 +- .../tasks/toolbar/buttons/ButtonReview.vue | 14 +- frontend/components/utils/ActionMenu.vue | 11 +- frontend/domain/models/comment/comment.ts | 53 +++-- .../models/comment/commentRepository.ts | 6 +- frontend/i18n/en/projects/dataset.js | 2 +- frontend/nuxt.config.js | 34 ++- frontend/package.json | 2 + .../pages/demo/image-classification/index.vue | 2 +- .../demo/named-entity-recognition/index.vue | 5 + frontend/pages/demo/speech-to-text/index.vue | 5 +- .../pages/projects/_id/comments/index.vue | 32 ++- .../_id/image-classification/index.vue | 9 +- .../projects/_id/sequence-labeling/index.vue | 28 +-- frontend/pages/projects/_id/upload/index.vue | 2 +- .../comment/apiCommentRepository.ts | 11 +- .../comment/commentApplicationService.ts | 10 +- .../application/comment/commentData.ts | 16 +- .../tasks/annotationApplicationService.ts | 6 +- .../sequenceLabelingApplicationService.ts | 12 +- frontend/yarn.lock | 222 +++++++++++++++--- .../offline_01_1-optional_use_https.sh | 28 --- ...ne_01_2-patch_and_extract_Docker_images.sh | 40 ---- .../offline_01_3-download_APT_packages.sh | 22 -- .../offline_02_1-install_APT_packages.sh | 30 --- .../offline_02_2-import_Docker_images.sh | 16 -- .../offline_03_1-runDoccano.sh | 7 - tools/offline_deployment/offline_patcher.py | 202 ---------------- 92 files changed, 1205 insertions(+), 702 deletions(-) create mode 100644 backend/api/migrations/0018_alter_label_background_color.py create mode 100644 backend/api/migrations/0019_auto_20211124_0506.py create mode 100644 backend/api/migrations/0020_merge_20211203_1558.py create mode 100644 backend/api/tests/data/sequence_labeling/example_overlapping.jsonl create mode 100644 backend/api/views/upload/cleaners.py delete mode 100644 docs/advanced/offline_deployment.md create mode 100644 frontend/assets/6737785.png delete mode 100755 tools/offline_deployment/offline_01_1-optional_use_https.sh delete mode 100755 tools/offline_deployment/offline_01_2-patch_and_extract_Docker_images.sh delete mode 100755 tools/offline_deployment/offline_01_3-download_APT_packages.sh delete mode 100755 tools/offline_deployment/offline_02_1-install_APT_packages.sh delete mode 100755 tools/offline_deployment/offline_02_2-import_Docker_images.sh delete mode 100755 tools/offline_deployment/offline_03_1-runDoccano.sh delete mode 100644 tools/offline_deployment/offline_patcher.py diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 677880d1c7..fe54ba893b 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -5,10 +5,9 @@ on: - cron: '0 10 * * *' # everyday at 10am push: branches: - - '**' + - master tags: - 'v*.*.*' - pull_request: jobs: docker: diff --git a/Dockerfile b/Dockerfile index 6804e7a70c..a3dd4a373d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,7 @@ RUN chown -R doccano:doccano . VOLUME /data ENV DATABASE_URL="sqlite:////data/doccano.db" -ENV DEBUG="True" +ENV DEBUG="False" ENV SECRET_KEY="change-me-in-production" ENV PORT="8000" ENV WORKERS="2" diff --git a/README.md b/README.md index 48e35cd637..ee6344a652 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,49 @@ doccano task Go to . +By default, sqlite3 is used for the default database. If you want to use PostgreSQL, install the additional dependency: + +```bash +pip install 'doccano[postgresql]' +``` + +Create an .env file with variables in the following format, each on a new line: + +```bash +POSTGRES_USER=doccano +POSTGRES_PASSWORD=doccano +POSTGRES_DB=doccano +``` + +Then, pass it to docker run with the --env-file flag: + +```bash +docker run --rm -d \ + -p 5432:5432 \ + -v postgres-data:/var/lib/postgresql/data \ + --env-file .env \ + postgres:13.3-alpine +``` + +And set `DATABASE_URL` environment variable: + +```bash +# Please replace each variable. +DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=disable +``` + +Now run the command as before: + +```bash +doccano init +doccano createuser --username admin --password pass +doccano webserver --port 8000 + +# In another terminal. +# Don't forget to set DATABASE_URL +doccano task +``` + ### Docker As a one-time setup, create a Docker container as follows: @@ -107,12 +150,22 @@ _Note for Windows developers:_ Be sure to configure git to correctly handle line git clone https://github.com/doccano/doccano.git --config core.autocrlf=input ``` -Set the superuser account credentials in the `./config/env.example` file: +Then, create an `.env` file with variables in the following format(see [./config/.env.example](https://github.com/doccano/doccano/blob/master/config/.env.example)): ```plain +# platform settings ADMIN_USERNAME=admin ADMIN_PASSWORD=password ADMIN_EMAIL=admin@example.com + +# rabbit mq settings +RABBITMQ_DEFAULT_USER=doccano +RABBITMQ_DEFAULT_PASS=doccano + +# database settings +POSTGRES_USER=doccano +POSTGRES_PASSWORD=doccano +POSTGRES_DB=doccano ``` #### Production diff --git a/backend/api/migrations/0018_alter_label_background_color.py b/backend/api/migrations/0018_alter_label_background_color.py new file mode 100644 index 0000000000..ca7ec81eb6 --- /dev/null +++ b/backend/api/migrations/0018_alter_label_background_color.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-11-17 05:56 + +import api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0017_example_uuid'), + ] + + operations = [ + migrations.AlterField( + model_name='label', + name='background_color', + field=models.CharField(default=api.models.generate_random_hex_color, max_length=7), + ), + ] diff --git a/backend/api/migrations/0019_auto_20211124_0506.py b/backend/api/migrations/0019_auto_20211124_0506.py new file mode 100644 index 0000000000..d8baa1bff4 --- /dev/null +++ b/backend/api/migrations/0019_auto_20211124_0506.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.8 on 2021-11-24 05:06 + +from django.db import migrations, models +import django.db.models.expressions + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0018_alter_label_background_color'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='span', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='span', + constraint=models.CheckConstraint(check=models.Q(('start_offset__gte', 0)), name='startOffset >= 0'), + ), + migrations.AddConstraint( + model_name='span', + constraint=models.CheckConstraint(check=models.Q(('end_offset__gte', 0)), name='endOffset >= 0'), + ), + migrations.AddConstraint( + model_name='span', + constraint=models.CheckConstraint(check=models.Q(('start_offset__lt', django.db.models.expressions.F('end_offset'))), name='start < end'), + ), + ] diff --git a/backend/api/migrations/0020_merge_20211203_1558.py b/backend/api/migrations/0020_merge_20211203_1558.py new file mode 100644 index 0000000000..0887af0b8c --- /dev/null +++ b/backend/api/migrations/0020_merge_20211203_1558.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.9 on 2021-12-03 15:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0018_merge_20211110_1607'), + ('api', '0019_auto_20211124_0506'), + ] + + operations = [ + ] diff --git a/backend/api/models.py b/backend/api/models.py index c87dc6be88..b2fca80ea6 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,3 +1,4 @@ +import random import string import uuid from typing import Literal @@ -106,6 +107,10 @@ def is_task_of(self, task: Literal['text', 'image', 'speech']): return task == 'image' +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( @@ -131,7 +136,7 @@ class Label(models.Model): on_delete=models.CASCADE, related_name='labels' ) - background_color = models.CharField(max_length=7, default='#209cee') + 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) @@ -289,18 +294,36 @@ class Span(Annotation): start_offset = models.IntegerField() end_offset = models.IntegerField() - def clean(self): - if self.start_offset >= self.end_offset: - raise ValidationError('start_offset > end_offset') + def validate_unique(self, exclude=None): + allow_overlapping = getattr(self.example.project, 'allow_overlapping', False) + is_collaborative = self.example.project.collaborative_annotation + if allow_overlapping: + super().validate_unique(exclude=exclude) + return + + overlapping_span = Span.objects.exclude(id=self.id).filter(example=self.example).filter( + models.Q(start_offset__gte=self.start_offset, start_offset__lt=self.end_offset) | + models.Q(end_offset__gt=self.start_offset, end_offset__lte=self.end_offset) | + models.Q(start_offset__lte=self.start_offset, end_offset__gte=self.end_offset) + ) + if is_collaborative: + if overlapping_span.exists(): + raise ValidationError('This overlapping is not allowed in this project.') + else: + if overlapping_span.filter(user=self.user).exists(): + raise ValidationError('This overlapping is not allowed in this project.') + + def save(self, force_insert=False, force_update=False, using=None, + update_fields=None): + self.full_clean() + super().save(force_insert, force_update, using, update_fields) class Meta: - unique_together = ( - 'example', - 'user', - 'label', - 'start_offset', - 'end_offset' - ) + constraints = [ + models.CheckConstraint(check=models.Q(start_offset__gte=0), name='startOffset >= 0'), + models.CheckConstraint(check=models.Q(end_offset__gte=0), name='endOffset >= 0'), + models.CheckConstraint(check=models.Q(start_offset__lt=models.F('end_offset')), name='start < end') + ] class EntitySpan(Annotation): diff --git a/backend/api/tasks.py b/backend/api/tasks.py index 2642ef61c6..95b2966c6c 100644 --- a/backend/api/tasks.py +++ b/backend/api/tasks.py @@ -10,9 +10,9 @@ from .models import Example, Label, Project, EntitySpan from .views.download.factory import create_repository, create_writer from .views.download.service import ExportApplicationService -from .views.upload.exception import FileParseException -from .views.upload.factory import (get_data_class, get_dataset_class, - get_label_class) +from .views.upload.exception import FileParseException, FileParseExceptions +from .views.upload.factory import (create_cleaner, get_data_class, + get_dataset_class, get_label_class) from .views.upload.utils import append_field logger = get_task_logger(__name__) @@ -89,7 +89,7 @@ def create(self, examples, user, project): @shared_task -def injest_data(user_id, project_id, filenames, format: str, **kwargs): +def ingest_data(user_id, project_id, filenames, format: str, **kwargs): project = get_object_or_404(Project, pk=project_id) user = get_object_or_404(get_user_model(), pk=user_id) response = {'error': []} @@ -110,6 +110,7 @@ def injest_data(user_id, project_id, filenames, format: str, **kwargs): label_class=Label, annotation_class=project.get_annotation_class() ) + cleaner = create_cleaner(project) while True: try: example = next(it) @@ -118,6 +119,13 @@ def injest_data(user_id, project_id, filenames, format: str, **kwargs): except FileParseException as err: response['error'].append(err.dict()) continue + except FileParseExceptions as err: + response['error'].extend(list(err)) + continue + try: + example.clean(cleaner) + except FileParseException as err: + response['error'].append(err.dict()) buffer.add(example) if buffer.is_full(): diff --git a/backend/api/tests/api/test_annotation.py b/backend/api/tests/api/test_annotation.py index 27d2f4852c..242f88c569 100644 --- a/backend/api/tests/api/test_annotation.py +++ b/backend/api/tests/api/test_annotation.py @@ -1,7 +1,7 @@ from rest_framework import status from rest_framework.reverse import reverse -from ...models import DOCUMENT_CLASSIFICATION, Category +from ...models import DOCUMENT_CLASSIFICATION, SEQUENCE_LABELING, Category from .utils import (CRUDMixin, make_annotation, make_doc, make_label, make_user, prepare_project) @@ -79,11 +79,17 @@ def test_denies_unauthenticated_user_to_annotate(self): class TestAnnotationDetail(CRUDMixin): def setUp(self): - self.project = prepare_project(task=DOCUMENT_CLASSIFICATION) + self.project = prepare_project(task=SEQUENCE_LABELING) self.non_member = make_user() doc = make_doc(self.project.item) label = make_label(self.project.item) - annotation = make_annotation(task=DOCUMENT_CLASSIFICATION, doc=doc, user=self.project.users[0]) + annotation = make_annotation( + task=SEQUENCE_LABELING, + doc=doc, + user=self.project.users[0], + start_offset=0, + end_offset=1 + ) self.data = {'label': label.id} self.url = reverse(viewname='annotation_detail', args=[self.project.item.id, doc.id, annotation.id]) diff --git a/backend/api/tests/api/test_comment.py b/backend/api/tests/api/test_comment.py index 1aacb370dc..d24f690260 100644 --- a/backend/api/tests/api/test_comment.py +++ b/backend/api/tests/api/test_comment.py @@ -50,7 +50,7 @@ def setUp(self): def test_allows_project_member_to_list_comments(self): for member in self.project.users: response = self.assert_fetch(member, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) + self.assertEqual(response.data['count'], 1) def test_denies_non_project_member_to_list_comments(self): self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) @@ -70,7 +70,7 @@ def test_allows_project_member_to_delete_comments(self): for member in self.project.users: self.assert_bulk_delete(member, status.HTTP_204_NO_CONTENT) response = self.client.get(self.url) - self.assertEqual(len(response.data), 0) + self.assertEqual(response.data['count'], 0) def test_denies_non_project_member_to_delete_comments(self): self.assert_fetch(self.non_member, status.HTTP_403_FORBIDDEN) diff --git a/backend/api/tests/api/utils.py b/backend/api/tests/api/utils.py index 3a7a923179..c820506887 100644 --- a/backend/api/tests/api/utils.py +++ b/backend/api/tests/api/utils.py @@ -49,7 +49,8 @@ def make_project( task: str, users: List[str], roles: List[str] = None, - collaborative_annotation=False): + collaborative_annotation=False, + **kwargs): create_default_roles() # create users. @@ -70,7 +71,8 @@ def make_project( _model=project_model, project_type=task, users=users, - collaborative_annotation=collaborative_annotation + collaborative_annotation=collaborative_annotation, + **kwargs ) # assign roles to the users. @@ -111,7 +113,7 @@ def make_auto_labeling_config(project): return mommy.make('AutoLabelingConfig', project=project) -def make_annotation(task, doc, user): +def make_annotation(task, doc, user, **kwargs): annotation_model = { DOCUMENT_CLASSIFICATION: 'Category', SEQUENCE_LABELING: 'Span', @@ -119,10 +121,10 @@ def make_annotation(task, doc, user): SPEECH2TEXT: 'TextLabel', ENTITY_RECOGNITION: 'EntitySpan' }.get(task) - return mommy.make(annotation_model, example=doc, user=user) + return mommy.make(annotation_model, example=doc, user=user, **kwargs) -def prepare_project(task: str = 'Any', collaborative_annotation=False): +def prepare_project(task: str = 'Any', collaborative_annotation=False, **kwargs): return make_project( task=task, users=['admin', 'approver', 'annotator'], @@ -131,7 +133,8 @@ def prepare_project(task: str = 'Any', collaborative_annotation=False): settings.ROLE_ANNOTATION_APPROVER, settings.ROLE_ANNOTATOR, ], - collaborative_annotation=collaborative_annotation + collaborative_annotation=collaborative_annotation, + **kwargs ) diff --git a/backend/api/tests/data/seq2seq/example.csv b/backend/api/tests/data/seq2seq/example.csv index 41bae2ef22..a590220139 100644 --- a/backend/api/tests/data/seq2seq/example.csv +++ b/backend/api/tests/data/seq2seq/example.csv @@ -1,5 +1,5 @@ text,label +,label2 exampleA,label1 exampleB, -,label2 , diff --git a/backend/api/tests/data/sequence_labeling/example_overlapping.jsonl b/backend/api/tests/data/sequence_labeling/example_overlapping.jsonl new file mode 100644 index 0000000000..f907619514 --- /dev/null +++ b/backend/api/tests/data/sequence_labeling/example_overlapping.jsonl @@ -0,0 +1 @@ +{"text": "exampleA", "label": [[0, 1, "LOC"], [0, 1, "LOC"]], "meta": {"wikiPageID": 1}} diff --git a/backend/api/tests/download/test_writer.py b/backend/api/tests/download/test_writer.py index 2c48cb9220..720244dd2d 100644 --- a/backend/api/tests/download/test_writer.py +++ b/backend/api/tests/download/test_writer.py @@ -32,6 +32,16 @@ def test_create_line(self): } self.assertEqual(line, expected) + def test_label_order(self): + writer = CsvWriter('.') + record1 = Record(id=0, data='', label=['labelA', 'labelB'], user='', metadata={}) + record2 = Record(id=0, data='', label=['labelB', 'labelA'], user='', metadata={}) + line1 = writer.create_line(record1) + line2 = writer.create_line(record2) + expected = 'labelA#labelB' + self.assertEqual(line1['label'], expected) + self.assertEqual(line2['label'], expected) + @patch('os.remove') @patch('zipfile.ZipFile') @patch('csv.DictWriter.writerow') diff --git a/backend/api/tests/test_models.py b/backend/api/tests/test_models.py index fee0c46db5..233248fafa 100644 --- a/backend/api/tests/test_models.py +++ b/backend/api/tests/test_models.py @@ -3,7 +3,9 @@ from django.test import TestCase, override_settings from model_mommy import mommy -from ..models import Category, Label, Span, TextLabel +from ..models import (SEQUENCE_LABELING, Category, Label, Span, TextLabel, + generate_random_hex_color) +from .api.utils import prepare_project @override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') @@ -116,21 +118,83 @@ def test_uniqueness(self): Category(example=a.example, user=a.user, label=a.label).save() -class TestSequenceAnnotation(TestCase): +class TestSpan(TestCase): - def test_uniqueness(self): - a = mommy.make('Span') + def setUp(self): + self.project = prepare_project(SEQUENCE_LABELING, allow_overlapping=False) + self.example = mommy.make('Example', project=self.project.item) + self.user = self.project.users[0] + + def test_start_offset_is_not_negative(self): with self.assertRaises(IntegrityError): - Span(example=a.example, - user=a.user, - label=a.label, - start_offset=a.start_offset, - end_offset=a.end_offset).save() + mommy.make('Span', start_offset=-1, end_offset=0) - def test_position_constraint(self): + def test_end_offset_is_not_negative(self): + with self.assertRaises(IntegrityError): + mommy.make('Span', start_offset=-2, end_offset=-1) + + def test_start_offset_is_less_than_end_offset(self): + with self.assertRaises(IntegrityError): + mommy.make('Span', start_offset=0, end_offset=0) + + def test_unique_constraint(self): + mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.user) + mommy.make('Span', example=self.example, start_offset=0, end_offset=5, user=self.user) + mommy.make('Span', example=self.example, start_offset=10, end_offset=15, user=self.user) + + def test_unique_constraint_violated(self): + mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.user) + spans = [(5, 10), (5, 11), (4, 10), (6, 9), (9, 15), (0, 6)] + for start_offset, end_offset in spans: + with self.assertRaises(ValidationError): + mommy.make( + 'Span', + example=self.example, + start_offset=start_offset, + end_offset=end_offset, + user=self.user + ) + + def test_unique_constraint_if_overlapping_is_allowed(self): + project = prepare_project(SEQUENCE_LABELING, allow_overlapping=True) + example = mommy.make('Example', project=project.item) + user = project.users[0] + mommy.make('Span', example=example, start_offset=5, end_offset=10, user=user) + spans = [(5, 10), (5, 11), (4, 10), (6, 9), (9, 15), (0, 6)] + for start_offset, end_offset in spans: + mommy.make('Span', example=example, start_offset=start_offset, end_offset=end_offset, user=user) + + def test_update(self): + span = mommy.make('Span', example=self.example, start_offset=0, end_offset=5) + span.end_offset = 6 + span.save() + + +class TestSpanWithoutCollaborativeMode(TestCase): + + def setUp(self): + self.project = prepare_project(SEQUENCE_LABELING, False, allow_overlapping=False) + self.example = mommy.make('Example', project=self.project.item) + + def test_allow_users_to_create_same_spans(self): + mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.project.users[0]) + mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.project.users[1]) + + +class TestSpanWithCollaborativeMode(TestCase): + + def test_deny_users_to_create_same_spans(self): + project = prepare_project(SEQUENCE_LABELING, True, allow_overlapping=False) + example = mommy.make('Example', project=project.item) + mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[0]) with self.assertRaises(ValidationError): - mommy.make('Span', - start_offset=1, end_offset=0).clean() + mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[1]) + + def test_allow_users_to_create_same_spans_if_overlapping_is_allowed(self): + project = prepare_project(SEQUENCE_LABELING, True, allow_overlapping=True) + example = mommy.make('Example', project=project.item) + mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[0]) + mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[1]) class TestSeq2seqAnnotation(TestCase): @@ -141,3 +205,11 @@ def test_uniqueness(self): TextLabel(example=a.example, user=a.user, text=a.text).save() + + +class TestGeneratedColor(TestCase): + + def test_length(self): + for i in range(100): + color = generate_random_hex_color() + self.assertEqual(len(color), 7) diff --git a/backend/api/tests/test_tasks.py b/backend/api/tests/test_tasks.py index 135a20c5a8..f86724919f 100644 --- a/backend/api/tests/test_tasks.py +++ b/backend/api/tests/test_tasks.py @@ -4,7 +4,7 @@ from ..models import (DOCUMENT_CLASSIFICATION, SEQ2SEQ, SEQUENCE_LABELING, Category, Example, Label, Span) -from ..tasks import injest_data +from ..tasks import ingest_data from .api.utils import prepare_project @@ -20,13 +20,14 @@ def setUp(self): def ingest_data(self, filename, file_format, kwargs=None): filenames = [str(self.data_path / filename)] kwargs = kwargs or {} - return injest_data(self.user.id, self.project.item.id, filenames, file_format, **kwargs) + return ingest_data(self.user.id, self.project.item.id, filenames, file_format, **kwargs) class TestIngestClassificationData(TestIngestData): task = DOCUMENT_CLASSIFICATION def assert_examples(self, dataset): + self.assertEqual(Example.objects.count(), len(dataset)) for text, expected_labels in dataset: example = Example.objects.get(text=text) labels = set(cat.label.text for cat in example.categories.all()) @@ -106,7 +107,7 @@ def test_textfile(self): filename = 'example.txt' file_format = 'TextFile' dataset = [ - ('exampleA\nexampleB\nexampleC\n', []) + ('exampleA\nexampleB\n\nexampleC\n', []) ] self.ingest_data(filename, file_format) self.assert_examples(dataset) @@ -151,6 +152,7 @@ class TestIngestSequenceLabelingData(TestIngestData): task = SEQUENCE_LABELING def assert_examples(self, dataset): + self.assertEqual(Example.objects.count(), len(dataset)) for text, expected_labels in dataset: example = Example.objects.get(text=text) labels = [[span.start_offset, span.end_offset, span.label.text] for span in example.spans.all()] @@ -188,11 +190,18 @@ def test_wrong_conll(self): response = self.ingest_data(filename, file_format) self.assert_parse_error(response) + def test_jsonl_with_overlapping(self): + filename = 'sequence_labeling/example_overlapping.jsonl' + file_format = 'JSONL' + response = self.ingest_data(filename, file_format) + self.assertEqual(len(response['error']), 1) + class TestIngestSeq2seqData(TestIngestData): task = SEQ2SEQ def assert_examples(self, dataset): + self.assertEqual(Example.objects.count(), len(dataset)) for text, expected_labels in dataset: example = Example.objects.get(text=text) labels = set(text_label.text for text_label in example.texts.all()) diff --git a/backend/api/views/annotation.py b/backend/api/views/annotation.py index 0340d1150c..fd1ccccf52 100644 --- a/backend/api/views/annotation.py +++ b/backend/api/views/annotation.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.shortcuts import get_object_or_404 from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated @@ -34,7 +35,11 @@ def create(self, request, *args, **kwargs): if self.project.single_class_classification: self.get_queryset().delete() request.data['example'] = self.kwargs['doc_id'] - return super().create(request, args, kwargs) + try: + response = super().create(request, args, kwargs) + except ValidationError as err: + response = Response({'detail': err.messages}, status=status.HTTP_400_BAD_REQUEST) + return response def perform_create(self, serializer): serializer.save(example_id=self.kwargs['doc_id'], user=self.request.user) diff --git a/backend/api/views/comment.py b/backend/api/views/comment.py index 3e2363e353..fe2356e0cc 100644 --- a/backend/api/views/comment.py +++ b/backend/api/views/comment.py @@ -26,7 +26,6 @@ def perform_create(self, serializer): class CommentListProject(generics.ListAPIView): - pagination_class = None permission_classes = [IsAuthenticated & IsInProjectOrAdmin] serializer_class = CommentSerializer filter_backends = (DjangoFilterBackend, filters.SearchFilter) diff --git a/backend/api/views/download/writer.py b/backend/api/views/download/writer.py index 5de1264e96..a4d5293ae0 100644 --- a/backend/api/views/download/writer.py +++ b/backend/api/views/download/writer.py @@ -84,7 +84,7 @@ def create_line(self, record) -> Dict: return { 'id': record.id, 'data': record.data, - 'label': '#'.join(record.label), + 'label': '#'.join(sorted(record.label)), **record.metadata } @@ -144,6 +144,7 @@ class FastTextWriter(LineWriter): def create_line(self, record): line = [f'__label__{label}' for label in record.label] + line.sort() line.append(record.data) line = ' '.join(line) return line diff --git a/backend/api/views/import_dataset.py b/backend/api/views/import_dataset.py index 241ee5fcaf..c58da24f82 100644 --- a/backend/api/views/import_dataset.py +++ b/backend/api/views/import_dataset.py @@ -10,7 +10,7 @@ from ..models import Project from ..permissions import IsProjectAdmin -from ..tasks import injest_data +from ..tasks import ingest_data from .upload.catalog import Options @@ -41,7 +41,7 @@ def post(self, request, *args, **kwargs): for tu in tus ] filenames = [su.file.path for su in sus] - task = injest_data.delay( + task = ingest_data.delay( user_id=request.user.id, project_id=project_id, filenames=filenames, diff --git a/backend/api/views/upload/cleaners.py b/backend/api/views/upload/cleaners.py new file mode 100644 index 0000000000..d0d4fd92b8 --- /dev/null +++ b/backend/api/views/upload/cleaners.py @@ -0,0 +1,58 @@ +from typing import List + +from ...models import Project +from .label import CategoryLabel, Label, OffsetLabel + + +class Cleaner: + + def __init__(self, project: Project): + pass + + def clean(self, labels: List[Label]) -> List[Label]: + return labels + + @property + def message(self) -> str: + return '' + + +class SpanCleaner(Cleaner): + + def __init__(self, project: Project): + super().__init__(project) + self.allow_overlapping = getattr(project, 'allow_overlapping', False) + + def clean(self, labels: List[OffsetLabel]) -> List[OffsetLabel]: + if self.allow_overlapping: + return labels + + labels.sort(key=lambda label: label.start_offset) + last_offset = -1 + new_labels = [] + for label in labels: + if label.start_offset >= last_offset: + last_offset = label.end_offset + new_labels.append(label) + return new_labels + + @property + def message(self) -> str: + return 'This project cannot allow label overlapping. It\'s cleaned.' + + +class CategoryCleaner(Cleaner): + + def __init__(self, project: Project): + super().__init__(project) + self.exclusive = getattr(project, 'single_class_classification', False) + + def clean(self, labels: List[CategoryLabel]) -> List[CategoryLabel]: + if self.exclusive: + return labels[:1] + else: + return labels + + @property + def message(self) -> str: + return 'This project only one label can apply but multiple label found. It\'s cleaned.' diff --git a/backend/api/views/upload/data.py b/backend/api/views/upload/data.py index 6786590f57..57109339c7 100644 --- a/backend/api/views/upload/data.py +++ b/backend/api/views/upload/data.py @@ -1,7 +1,7 @@ import abc from typing import Dict -from pydantic import BaseModel +from pydantic import BaseModel, validator class BaseData(BaseModel, abc.ABC): @@ -19,6 +19,13 @@ def __hash__(self): class TextData(BaseData): text: str + @validator('text') + def text_is_not_empty(cls, value: str): + if value: + return value + else: + raise ValueError('is not empty.') + class FileData(BaseData): pass diff --git a/backend/api/views/upload/dataset.py b/backend/api/views/upload/dataset.py index 0a4d7bb1d0..76c650c68b 100644 --- a/backend/api/views/upload/dataset.py +++ b/backend/api/views/upload/dataset.py @@ -5,14 +5,15 @@ from typing import Dict, Iterator, List, Optional, Type import chardet -import pydantic.error_wrappers import pyexcel import pyexcel.exceptions from chardet.universaldetector import UniversalDetector +from pydantic import ValidationError from seqeval.scheme import BILOU, IOB2, IOBES, IOE2, Tokens +from .cleaners import Cleaner from .data import BaseData -from .exception import FileParseException +from .exception import FileParseException, FileParseExceptions from .label import Label from .labels import Labels @@ -21,15 +22,28 @@ class Record: def __init__(self, data: Type[BaseData], - label: List[Label] = None): + label: List[Label] = None, + line_num: int = -1): if label is None: label = [] self._data = data self._label = label + self._line_num = line_num def __str__(self): return f'{self._data}\t{self._label}' + def clean(self, cleaner: Cleaner): + label = cleaner.clean(self._label) + changed = len(label) != len(self.label) + self._label = label + if changed: + raise FileParseException( + filename=self._data.filename, + line_num=self._line_num, + message=cleaner.message + ) + @property def data(self): return self._data.dict() @@ -64,12 +78,17 @@ def __init__(self, self.kwargs = kwargs def __iter__(self) -> Iterator[Record]: + errors = [] for filename in self.filenames: try: yield from self.load(filename) - except UnicodeDecodeError as err: + except (UnicodeDecodeError, FileParseException) as err: message = str(err) raise FileParseException(filename, line_num=-1, message=message) + except FileParseExceptions as err: + errors.extend(err.exceptions) + if errors: + raise FileParseExceptions(errors) def load(self, filename: str) -> Iterator[Record]: """Loads a file content.""" @@ -113,10 +132,16 @@ def from_row(self, filename: str, row: Dict, line_num: int) -> Record: label = [label] if isinstance(label, str) else label try: label = [self.label_class.parse(o) for o in label] - except (pydantic.error_wrappers.ValidationError, TypeError): + except (ValidationError, TypeError): label = [] - data = self.data_class.parse(text=text, filename=filename, meta=row) - record = Record(data=data, label=label) + + try: + data = self.data_class.parse(text=text, filename=filename, meta=row) + except ValidationError: + message = 'The empty text is not allowed.' + raise FileParseException(filename, line_num, message) + + record = Record(data=data, label=label, line_num=line_num) return record @@ -142,17 +167,25 @@ class TextLineDataset(Dataset): def load(self, filename: str) -> Iterator[Record]: encoding = self.detect_encoding(filename) + errors = [] with open(filename, encoding=encoding) as f: - for line in f: - data = self.data_class.parse(filename=filename, text=line.rstrip()) - record = Record(data=data) - yield record + for line_num, line in enumerate(f, start=1): + try: + data = self.data_class.parse(filename=filename, text=line.rstrip()) + record = Record(data=data, line_num=line_num) + yield record + except ValidationError: + message = 'The empty text is not allowed.' + errors.append(FileParseException(filename, line_num, message)) + if errors: + raise FileParseExceptions(errors) class CsvDataset(Dataset): def load(self, filename: str) -> Iterator[Record]: encoding = self.detect_encoding(filename) + errors = [] with open(filename, encoding=encoding) as f: delimiter = self.kwargs.get('delimiter', ',') reader = csv.reader(f, delimiter=delimiter) @@ -165,7 +198,12 @@ def load(self, filename: str) -> Iterator[Record]: for line_num, row in enumerate(reader, start=2): row = dict(zip(header, row)) - yield self.from_row(filename, row, line_num) + try: + yield self.from_row(filename, row, line_num) + except FileParseException as err: + errors.append(err) + if errors: + raise FileParseExceptions(errors) class JSONDataset(Dataset): @@ -186,6 +224,7 @@ class JSONLDataset(Dataset): def load(self, filename: str) -> Iterator[Record]: encoding = self.detect_encoding(filename) + errors = [] with open(filename, encoding=encoding) as f: for line_num, line in enumerate(f, start=1): try: @@ -193,25 +232,34 @@ def load(self, filename: str) -> Iterator[Record]: yield self.from_row(filename, row, line_num) except json.decoder.JSONDecodeError: message = 'Failed to decode the line.' - raise FileParseException(filename, line_num, message) + errors.append(FileParseException(filename, line_num, message)) + if errors: + raise FileParseExceptions(errors) class ExcelDataset(Dataset): def load(self, filename: str) -> Iterator[Record]: records = pyexcel.iget_records(file_name=filename) + errors = [] try: for line_num, row in enumerate(records, start=1): - yield self.from_row(filename, row, line_num) + try: + yield self.from_row(filename, row, line_num) + except FileParseException as err: + errors.append(err) except pyexcel.exceptions.FileTypeNotSupported: message = 'This file type is not supported.' raise FileParseException(filename, line_num=-1, message=message) + if errors: + raise FileParseExceptions(errors) class FastTextDataset(Dataset): def load(self, filename: str) -> Iterator[Record]: encoding = self.detect_encoding(filename) + errors = [] with open(filename, encoding=encoding) as f: for line_num, line in enumerate(f, start=1): labels = [] @@ -220,15 +268,22 @@ def load(self, filename: str) -> Iterator[Record]: if token.startswith('__label__'): if token == '__label__': message = 'Label name is empty.' - raise FileParseException(filename, line_num, message) + errors.append(FileParseException(filename, line_num, message)) + break label_name = token[len('__label__'):] labels.append(self.label_class.parse(label_name)) else: tokens.append(token) text = ' '.join(tokens) - data = self.data_class.parse(filename=filename, text=text) - record = Record(data=data, label=labels) - yield record + try: + data = self.data_class.parse(filename=filename, text=text) + record = Record(data=data, label=labels, line_num=line_num) + yield record + except ValidationError: + message = 'The empty text is not allowed.' + errors.append(FileParseException(filename, line_num, message)) + if errors: + raise FileParseExceptions(errors) class CoNLLDataset(Dataset): @@ -237,7 +292,6 @@ def load(self, filename: str) -> Iterator[Record]: encoding = self.detect_encoding(filename) with open(filename, encoding=encoding) as f: words, tags = [], [] - delimiter = self.kwargs.get('delimiter', ' ') for line_num, line in enumerate(f, start=1): line = line.rstrip() if line: @@ -249,18 +303,18 @@ def load(self, filename: str) -> Iterator[Record]: words.append(word) tags.append(tag) else: - text = delimiter.join(words) - data = self.data_class.parse(filename=filename, text=text) - labels = self.get_label(words, tags, delimiter) - record = Record(data=data, label=labels) - yield record + yield self.create_record(filename, tags, words) words, tags = [], [] if words: - text = delimiter.join(words) - data = self.data_class.parse(filename=filename, text=text) - labels = self.get_label(words, tags, delimiter) - record = Record(data=data, label=labels) - yield record + yield self.create_record(filename, tags, words) + + def create_record(self, filename, tags, words): + delimiter = self.kwargs.get('delimiter', ' ') + text = delimiter.join(words) + data = self.data_class.parse(filename=filename, text=text) + labels = self.get_label(words, tags, delimiter) + record = Record(data=data, label=labels) + return record def get_scheme(self, scheme: str): mapping = { diff --git a/backend/api/views/upload/exception.py b/backend/api/views/upload/exception.py index 6c0d7a577e..b00edd5aa0 100644 --- a/backend/api/views/upload/exception.py +++ b/backend/api/views/upload/exception.py @@ -1,3 +1,6 @@ +from typing import List + + class FileParseException(Exception): def __init__(self, filename: str, line_num: int, message: str): @@ -14,3 +17,16 @@ def dict(self): 'line': self.line_num, 'message': self.message } + + +class FileParseExceptions(Exception): + + def __init__(self, exceptions: List[FileParseException]): + self.exceptions = exceptions + + def __str__(self) -> str: + return f'ParseErrors: you failed to parse {len(self.exceptions)} lines.' + + def __iter__(self) -> FileParseException: + for e in self.exceptions: + yield e.dict() diff --git a/backend/api/views/upload/factory.py b/backend/api/views/upload/factory.py index 93c93b5d98..17ae5c5020 100644 --- a/backend/api/views/upload/factory.py +++ b/backend/api/views/upload/factory.py @@ -1,6 +1,6 @@ from ...models import (DOCUMENT_CLASSIFICATION, IMAGE_CLASSIFICATION, SEQ2SEQ, SEQUENCE_LABELING, SPEECH2TEXT, ENTITY_RECOGNITION) -from . import catalog, data, dataset, label +from . import catalog, cleaners, data, dataset, label def get_data_class(project_type: str): @@ -43,3 +43,15 @@ def get_label_class(project_type: str): if project_type not in mapping: ValueError(f'Invalid project type: {project_type}') return mapping[project_type] + + +def create_cleaner(project): + mapping = { + DOCUMENT_CLASSIFICATION: cleaners.CategoryCleaner, + SEQUENCE_LABELING: cleaners.SpanCleaner, + IMAGE_CLASSIFICATION: cleaners.CategoryCleaner + } + if project.project_type not in mapping: + ValueError(f'Invalid project type: {project.project_type}') + cleaner_class = mapping.get(project.project_type, cleaners.Cleaner) + return cleaner_class(project) diff --git a/docs/advanced/offline_deployment.md b/docs/advanced/offline_deployment.md deleted file mode 100644 index 906161e29d..0000000000 --- a/docs/advanced/offline_deployment.md +++ /dev/null @@ -1,29 +0,0 @@ -# Doccano Offline Deployment - -## Use Case -These offline deployment scripts are suited for deploying Doccano on an air gapped Ubuntu 18.04/20.04 virtual machine (VM 2) with no internet connectivity (such as in clinical environments). - -The preparation requires another machine (VM 1) with internet access and `docker`/`docker-compose` preinstalled (with $USER in `docker` group) and running the same Ubuntu distribution as VM 2. - -The focus is primarily on the `docker-compose`-based production deployment. -The files mentioned in this document are located in the `tools/offline_deployment/` directory. - -## Setup Steps - -Run the following steps on VM 1: -1. Clone this repository -2. Run the scripts `offline_01_*.sh` in ascending order - Skip OR modify and run the script `offline_01_1-optional_use_https` - Do NOT run these scripts as `sudo`! The scripts will ask for sudo-permissions when it is needed. - -Now, move over to VM 2 - -3. Copy the repository folder from VM 1 to VM 2 -4. Run the scripts `offline_02_*.sh` in ascending order - Do NOT run these scripts as `sudo`! The scripts will ask for sudo-permissions when it is needed. -5. Make minor changes on `docker-compose.prod.yml` to change the admin credentials -6. Run `docker-compose -f docker-compose.prod.yml up` in the repository root directory or use the script `offline_03_*.sh` - -## Remarks - -The setup was tested on Ubuntu 18.04 machines. \ No newline at end of file diff --git a/frontend/assets/6737785.png b/frontend/assets/6737785.png new file mode 100644 index 0000000000000000000000000000000000000000..5ca051596e7c79767dfe19e65b8fab477c8f8b17 GIT binary patch literal 20778 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 literal 0 HcmV?d00001 diff --git a/frontend/components/auth/FormLogin.vue b/frontend/components/auth/FormLogin.vue index 12f70b5a02..21e3adb0fd 100644 --- a/frontend/components/auth/FormLogin.vue +++ b/frontend/components/auth/FormLogin.vue @@ -20,7 +20,7 @@ :rules="userNameRules($t('rules.userNameRules'))" :label="$t('user.username')" name="username" - prepend-icon="person" + :prepend-icon="mdiAccount" type="text" autofocus @keyup.enter="tryLogin" @@ -31,7 +31,7 @@ :rules="passwordRules($t('rules.passwordRules'))" :label="$t('user.password')" name="password" - prepend-icon="lock" + :prepend-icon="mdiLock" type="password" @keyup.enter="tryLogin" /> @@ -42,6 +42,7 @@ diff --git a/frontend/components/configAutoLabeling/form/LabelMapping.vue b/frontend/components/configAutoLabeling/form/LabelMapping.vue index 3787a41a19..3cbb8301a3 100644 --- a/frontend/components/configAutoLabeling/form/LabelMapping.vue +++ b/frontend/components/configAutoLabeling/form/LabelMapping.vue @@ -90,13 +90,13 @@ class="mr-2" @click="editItem(item)" > - mdi-pencil + {{ mdiPencil }} - mdi-delete + {{ mdiDelete }} @@ -104,6 +104,7 @@ diff --git a/frontend/components/layout/TheBottomBanner.vue b/frontend/components/layout/TheBottomBanner.vue index 8cabfc130d..6c39d53d8e 100644 --- a/frontend/components/layout/TheBottomBanner.vue +++ b/frontend/components/layout/TheBottomBanner.vue @@ -40,7 +40,7 @@ v-on="on" > {{ $t('home.demoDropDown') }} - mdi-menu-down + {{ mdiMenuDown }} @@ -63,6 +63,8 @@ diff --git a/frontend/components/links/ActionMenu.vue b/frontend/components/links/ActionMenu.vue index f40b9792ea..4bdb8ec0d8 100644 --- a/frontend/components/links/ActionMenu.vue +++ b/frontend/components/links/ActionMenu.vue @@ -10,6 +10,7 @@ diff --git a/frontend/components/tasks/toolbar/buttons/ButtonClear.vue b/frontend/components/tasks/toolbar/buttons/ButtonClear.vue index 76f307c518..08cb8dcf39 100644 --- a/frontend/components/tasks/toolbar/buttons/ButtonClear.vue +++ b/frontend/components/tasks/toolbar/buttons/ButtonClear.vue @@ -7,10 +7,22 @@ @click="$emit('click:clear')" > - mdi-delete-outline + {{ mdiDeleteOutline }} Clear labels + + diff --git a/frontend/components/tasks/toolbar/buttons/ButtonComment.vue b/frontend/components/tasks/toolbar/buttons/ButtonComment.vue index 24932247f4..1521a3361c 100644 --- a/frontend/components/tasks/toolbar/buttons/ButtonComment.vue +++ b/frontend/components/tasks/toolbar/buttons/ButtonComment.vue @@ -7,10 +7,23 @@ @click="$emit('click:comment')" > - mdi-message-text + {{ mdiMessageText }} {{ $t('annotation.commentTooltip') }} + + diff --git a/frontend/components/tasks/toolbar/buttons/ButtonFilter.vue b/frontend/components/tasks/toolbar/buttons/ButtonFilter.vue index 75f4e9bce9..37dc84128d 100644 --- a/frontend/components/tasks/toolbar/buttons/ButtonFilter.vue +++ b/frontend/components/tasks/toolbar/buttons/ButtonFilter.vue @@ -8,7 +8,7 @@ v-on="{ ...tooltip, ...menu }" > - mdi-filter + {{ mdiFilter }} @@ -23,7 +23,7 @@ > - mdi-check + {{ mdiCheck }} @@ -38,6 +38,8 @@ diff --git a/frontend/components/tasks/toolbar/buttons/ButtonLabelSwitch.vue b/frontend/components/tasks/toolbar/buttons/ButtonLabelSwitch.vue index c0b3acb8df..d2a62bf315 100644 --- a/frontend/components/tasks/toolbar/buttons/ButtonLabelSwitch.vue +++ b/frontend/components/tasks/toolbar/buttons/ButtonLabelSwitch.vue @@ -4,19 +4,23 @@ mandatory > - mdi-format-list-bulleted + {{ mdiFormatListBulleted }} - mdi-text + {{ mdiText }} diff --git a/frontend/components/utils/ActionMenu.vue b/frontend/components/utils/ActionMenu.vue index bd229c9b1c..f94e1f26c5 100644 --- a/frontend/components/utils/ActionMenu.vue +++ b/frontend/components/utils/ActionMenu.vue @@ -9,7 +9,7 @@ v-on="on" > {{ text }} - mdi-menu-down + {{ mdiMenuDown }} @@ -31,6 +31,7 @@ diff --git a/frontend/domain/models/comment/comment.ts b/frontend/domain/models/comment/comment.ts index 88a647b7b8..5af448cb15 100644 --- a/frontend/domain/models/comment/comment.ts +++ b/frontend/domain/models/comment/comment.ts @@ -1,38 +1,43 @@ export class CommentItemList { - constructor(public commentItems: CommentItem[]) {} - - static valueOf(items: CommentItem[]): CommentItemList { - return new CommentItemList(items) - } - - add(item: CommentItem) { - this.commentItems.push(item) - } + constructor( + private _count: number, + private _next: string | null, + private _prev: string | null, + private _items: CommentItem[] + ) {} - update(item: CommentItem) { - const index = this.commentItems.findIndex(comment => comment.id === item.id) - this.commentItems.splice(index, 1, item) + static valueOf( + { count, next, previous, results }: + { + count : number, + next : string | null, + previous: string | null, + results : Array } - - delete(item: CommentItem) { - this.commentItems = this.commentItems.filter(comment => comment.id !== item.id) + ): CommentItemList { + const items = results.map(item => CommentItem.valueOf(item)) + return new CommentItemList( + count, + next, + previous, + items + ) } - deleteBulk(items: CommentItemList) { - const ids = items.ids() - this.commentItems = this.commentItems.filter(comment => !ids.includes(comment.id)) + get count() { + return this._count } - count(): Number { - return this.commentItems.length + get next() { + return this._next } - ids(): Number[]{ - return this.commentItems.map(item => item.id) + get prev() { + return this._prev } - toArray(): Object[] { - return this.commentItems.map(item => item.toObject()) + get items(): CommentItem[] { + return this._items } } diff --git a/frontend/domain/models/comment/commentRepository.ts b/frontend/domain/models/comment/commentRepository.ts index b20784503d..4203995303 100644 --- a/frontend/domain/models/comment/commentRepository.ts +++ b/frontend/domain/models/comment/commentRepository.ts @@ -1,4 +1,4 @@ -import { CommentItem } from '~/domain/models/comment/comment' +import { CommentItem, CommentItemList } from '~/domain/models/comment/comment' export interface CommentItemResponse { id: number, @@ -9,8 +9,10 @@ export interface CommentItemResponse { created_at: string } +export type SearchOption = {[key: string]: string | (string | null)[]} + export interface CommentRepository { - listAll(projectId: string, q: string): Promise + listAll(projectId: string, { limit, offset, q }: SearchOption): Promise list(projectId: string, docId: number): Promise diff --git a/frontend/i18n/en/projects/dataset.js b/frontend/i18n/en/projects/dataset.js index b9930daba5..fb5b0b38ae 100644 --- a/frontend/i18n/en/projects/dataset.js +++ b/frontend/i18n/en/projects/dataset.js @@ -15,7 +15,7 @@ export default { exportDataMessage: 'Select a file format', exportDataMessage2: 'Select a file name', deleteDocumentsTitle: 'Delete Document', - deleteDocumentsMessage: 'Are you sure you want to delete these documents from this project?', + deleteDocumentsMessage: 'Are you sure you want to delete {number} items from this project?', deleteBulkDocumentsTitle: 'Delete All Documents', deleteBulkDocumentsMessage: 'Are you sure you want to delete all documents from this project?', pageText: '{0}-{1} of {2}' diff --git a/frontend/nuxt.config.js b/frontend/nuxt.config.js index 4b1bb6a941..23c40e67dd 100644 --- a/frontend/nuxt.config.js +++ b/frontend/nuxt.config.js @@ -14,16 +14,8 @@ export default { { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { hid: 'description', name: 'description', content: process.env.npm_package_description || '' } ], - script: [ - { src: 'https://use.fontawesome.com/releases/v5.0.6/js/all.js' } - ], link: [ { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, - { - rel: 'stylesheet', - href: - 'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' - } ] }, @@ -60,7 +52,6 @@ export default { */ modules: [ ['nuxt-i18n', i18n], - '@nuxtjs/vuetify', // Doc: https://axios.nuxtjs.org/usage '@nuxtjs/axios', '@nuxtjs/eslint-module' @@ -71,7 +62,30 @@ export default { '@nuxtjs/composition-api/module', ['@nuxtjs/google-analytics', { id: process.env.GOOGLE_TRACKING_ID - }] + }], + [ + '@nuxtjs/vuetify', + { + customVariables: ['~/assets/css/fonts.css'], + treeShake: true, + defaultAssets: { + font: false, + icons: ['mdiSvg'], + }, + }, + ], + [ + '@nuxtjs/google-fonts', + { + families: { + Roboto: [100, 300, 400, 500, 700, 900] + }, + display: 'swap', + download: true, + overwriting: true, + inject: true, + } + ] ], /* ** Axios module configuration diff --git a/frontend/package.json b/frontend/package.json index 564291d6a3..e9d2383193 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,12 +54,14 @@ "@babel/core": "^7.14.8", "@babel/eslint-parser": "^7.14.7", "@babel/preset-env": "^7.15.8", + "@mdi/js": "^6.5.95", "@nuxt/types": "^2.15.7", "@nuxt/typescript-build": "^2.1.0", "@nuxtjs/eslint-config": "^6.0.1", "@nuxtjs/eslint-config-typescript": "^6.0.1", "@nuxtjs/eslint-module": "^2.0.0", "@nuxtjs/google-analytics": "^2.4.0", + "@nuxtjs/google-fonts": "^1.3.0", "@types/lodash": "^4.14.171", "@types/wavesurfer.js": "^5.1.0", "@vue/test-utils": "^1.2.2", diff --git a/frontend/pages/demo/image-classification/index.vue b/frontend/pages/demo/image-classification/index.vue index be1957d397..7e877bece3 100644 --- a/frontend/pages/demo/image-classification/index.vue +++ b/frontend/pages/demo/image-classification/index.vue @@ -65,7 +65,7 @@ export default { singleLabel: true, currentDoc: { id: 8, - filename: 'https://avatars.githubusercontent.com/u/6737785?v=4', + filename: '~/assets/6737785.png', annotations: [ { id: 17, diff --git a/frontend/pages/demo/named-entity-recognition/index.vue b/frontend/pages/demo/named-entity-recognition/index.vue index 4439b83407..60c0477a97 100644 --- a/frontend/pages/demo/named-entity-recognition/index.vue +++ b/frontend/pages/demo/named-entity-recognition/index.vue @@ -170,6 +170,11 @@ export default { methods: { deleteEntity(annotationId) { this.currentDoc.annotations = this.currentDoc.annotations.filter(item => item.id !== annotationId) + this.relations.forEach((r) => { + if (r.fromId === annotationId || r.toId === annotationId) { + this.deleteRelation(r.id) + } + }) }, updateEntity(annotationId, labelId) { const index = this.currentDoc.annotations.findIndex(item => item.id === annotationId) diff --git a/frontend/pages/demo/speech-to-text/index.vue b/frontend/pages/demo/speech-to-text/index.vue index 3bc76eeb25..b278658dc3 100644 --- a/frontend/pages/demo/speech-to-text/index.vue +++ b/frontend/pages/demo/speech-to-text/index.vue @@ -5,7 +5,7 @@