diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 9d4625cd..42e96815 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -57,6 +57,7 @@ jobs: env: CYPRESS_CI_ENV: true with: + component: true working-directory: frontend start: | poetry run python ../manage.py runserver diff --git a/backend/api/admin.py b/backend/api/admin/__init__.py similarity index 79% rename from backend/api/admin.py rename to backend/api/admin/__init__.py index b4f69c9a..80a0b6fb 100644 --- a/backend/api/admin.py +++ b/backend/api/admin/__init__.py @@ -2,9 +2,8 @@ # Python admin has too many false-positives on the following warnings: # pylint: disable=too-many-function-args, R0801 +from django.contrib import admin, messages from django import forms -from django.contrib import admin -from django.contrib import messages from django.db import IntegrityError, transaction from django.utils.html import format_html @@ -16,7 +15,6 @@ AnnotationCampaign, AnnotationTask, AnnotationComment, - AnnotationResult, AnnotationSession, SpectroConfig, DatasetType, @@ -26,6 +24,12 @@ ConfidenceIndicator, ConfidenceIndicatorSet, ) +from backend.api.admin.annotation import ( + DetectorAdmin, + DetectorConfigurationAdmin, + AnnotationResultAdmin, + AnnotationResultValidationAdmin, +) def get_many_to_many(obj, field_name, related_field_name="name"): @@ -55,7 +59,7 @@ class NewItemsForm(forms.ModelForm): class ConfidenceIndicatorAdmin(admin.ModelAdmin): - """Collection presentation in DjangoAdmin""" + """ConfidenceIndicatorAdmin presentation in DjangoAdmin""" list_display = ( "id", @@ -75,7 +79,7 @@ def save_model(self, request, obj, form, change): class ConfidenceIndicatorSetAdmin(admin.ModelAdmin): - """Collection presentation in DjangoAdmin""" + """ConfidenceIndicatorSet presentation in DjangoAdmin""" list_display = ( "id", @@ -84,12 +88,6 @@ class ConfidenceIndicatorSetAdmin(admin.ModelAdmin): ) -class CollectionAdmin(admin.ModelAdmin): - """Collection presentation in DjangoAdmin""" - - list_display = ("name", "desc", "owner") - - class DatasetTypeAdmin(admin.ModelAdmin): """DatasetType presentation in DjangoAdmin""" @@ -113,9 +111,7 @@ class DatasetAdmin(admin.ModelAdmin): "dataset_type", "geo_metadatum", "owner", - "tabular_metadatum", "show_spectro_configs", - "show_collections", ) fields = ( "name", @@ -129,18 +125,12 @@ class DatasetAdmin(admin.ModelAdmin): "dataset_type", "geo_metadatum", "owner", - "tabular_metadatum", - "collections", ) def show_spectro_configs(self, obj): """show_spectro_configs""" return get_many_to_many(obj, "spectro_configs") - def show_collections(self, obj): - """show_collections""" - return get_many_to_many(obj, "collections") - def show_audio_metadatum_url(self, obj): """show_audio_metadatum_url""" return format_html( @@ -159,7 +149,6 @@ class DatasetFileAdmin(admin.ModelAdmin): "size", "dataset", "audio_metadatum", - "tabular_metadatum", ) @@ -175,7 +164,6 @@ class AnnotationSetAdmin(admin.ModelAdmin): list_display = ( "name", "desc", - "owner", "show_tags", ) @@ -190,8 +178,6 @@ class AnnotationCommentAdmin(admin.ModelAdmin): list_display = ( "id", "comment", - "annotation_task", - "annotation_result", ) @@ -212,7 +198,11 @@ class AnnotationCampaignAdmin(admin.ModelAdmin): "show_datasets", "show_annotators", "confidence_indicator_set", + "usage", ) + search_fields = ("name", "desc") + + list_filter = ("datasets", "usage") def show_spectro_configs(self, obj): """show_spectro_configs""" @@ -234,9 +224,10 @@ class AnnotationTaskAdmin(admin.ModelAdmin): "status", "annotation_campaign", "dataset_file", - "annotator_id", + "annotator", ) - list_filter = ("status", "annotation_campaign", "annotator_id") + search_fields = ("dataset_file__filename",) + list_filter = ("status", "annotation_campaign", "annotator") def clean_duplicates(self, request, queryset): """Clean duplicated annotation task""" @@ -260,21 +251,6 @@ def clean_duplicates(self, request, queryset): ] -class AnnotationResultAdmin(admin.ModelAdmin): - """AnnotationResult presentation in DjangoAdmin""" - - list_display = ( - "id", - "start_time", - "end_time", - "start_frequency", - "end_frequency", - "annotation_tag", - "annotation_task", - "confidence_indicator", - ) - - class AnnotationSessionAdmin(admin.ModelAdmin): """AnnotationSession presentation in DjangoAdmin""" @@ -353,40 +329,6 @@ class SpectroConfigAdmin(admin.ModelAdmin): ) -class TabularMetadatumAdmin(admin.ModelAdmin): - """TabularMetadatum presentation in DjangoAdmin""" - - list_display = ( - "name", - "desc", - "dimension_count", - "variable_count", - ) - - -class TabularMetadataVariableAdmin(admin.ModelAdmin): - """TabularMetadataVariable presentation in DjangoAdmin""" - - list_display = ( - "name", - "desc", - "data_type", - "dimension_size", - "variable_position", - "tabular_metadata", - ) - - -class TabularMetadataShapeAdmin(admin.ModelAdmin): - """TabularMetadataShape presentation in DjangoAdmin""" - - list_display = ( - "dimension_position", - "tabular_metadata_dimension", - "tabular_metadata_variable", - ) - - admin.site.register(ConfidenceIndicator, ConfidenceIndicatorAdmin) admin.site.register(ConfidenceIndicatorSet, ConfidenceIndicatorSetAdmin) admin.site.register(DatasetType, DatasetTypeAdmin) @@ -397,14 +339,8 @@ class TabularMetadataShapeAdmin(admin.ModelAdmin): admin.site.register(AnnotationCampaign, AnnotationCampaignAdmin) admin.site.register(AnnotationComment, AnnotationCommentAdmin) admin.site.register(AnnotationTask, AnnotationTaskAdmin) -admin.site.register(AnnotationResult, AnnotationResultAdmin) admin.site.register(AnnotationSession, AnnotationSessionAdmin) admin.site.register(AudioMetadatum, AudioMetadatumAdmin) admin.site.register(GeoMetadatum, GeoMetadatumAdmin) admin.site.register(SpectroConfig, SpectroConfigAdmin) admin.site.register(WindowType, WindowTypeAdmin) - -# admin.site.register(Collection, CollectionAdmin) -# admin.site.register(TabularMetadatum, TabularMetadatumAdmin) -# admin.site.register(TabularMetadataVariable, TabularMetadataVariableAdmin) -# admin.site.register(TabularMetadataShape, TabularMetadataShapeAdmin) diff --git a/backend/api/admin/annotation/__init__.py b/backend/api/admin/annotation/__init__.py new file mode 100644 index 00000000..eb05d0b4 --- /dev/null +++ b/backend/api/admin/annotation/__init__.py @@ -0,0 +1,23 @@ +""" Annotation admin management """ +from django.contrib import admin + +from backend.api.admin.annotation.detector import ( + DetectorAdmin, + DetectorConfigurationAdmin, +) +from backend.api.admin.annotation.result import ( + AnnotationResultAdmin, + AnnotationResultValidationAdmin, +) +from backend.api.models.annotation import ( + Detector, + DetectorConfiguration, + AnnotationResult, + AnnotationResultValidation, +) + +admin.site.register(Detector, DetectorAdmin) +admin.site.register(DetectorConfiguration, DetectorConfigurationAdmin) + +admin.site.register(AnnotationResult, AnnotationResultAdmin) +admin.site.register(AnnotationResultValidation, AnnotationResultValidationAdmin) diff --git a/backend/api/admin/annotation/detector.py b/backend/api/admin/annotation/detector.py new file mode 100644 index 00000000..89a5e082 --- /dev/null +++ b/backend/api/admin/annotation/detector.py @@ -0,0 +1,16 @@ +"""Detector model""" +from django.contrib import admin + + +class DetectorAdmin(admin.ModelAdmin): + """Detector in DjangoAdmin""" + + search_fields = ["name", "configurations__configuration"] + list_display = ["name"] + + +class DetectorConfigurationAdmin(admin.ModelAdmin): + """Detector in DjangoAdmin""" + + search_fields = ["configuration", "detector"] + list_display = ["configuration", "detector"] diff --git a/backend/api/admin/annotation/result.py b/backend/api/admin/annotation/result.py new file mode 100644 index 00000000..f2ca4952 --- /dev/null +++ b/backend/api/admin/annotation/result.py @@ -0,0 +1,66 @@ +"""Detector model""" +from django.contrib import admin + + +class AnnotationResultAdmin(admin.ModelAdmin): + """AnnotationResult presentation in DjangoAdmin""" + + list_display = ( + "id", + "start_time", + "end_time", + "start_frequency", + "end_frequency", + "annotation_tag", + "confidence_indicator", + "annotation_campaign", + "dataset_file", + "annotator", + "detector_configuration", + ) + search_fields = ( + "annotation_tag__name", + "confidence_indicator__label", + "annotator__username", + "annotator__first_name", + "annotator__last_name", + "detector_configuration__detector__name", + ) + list_filter = ( + "annotation_campaign", + "annotator", + ) + + +class AnnotationResultValidationAdmin(admin.ModelAdmin): + """AnnotationResultValidation presentation in DjangoAdmin""" + + list_display = ( + "id", + "is_valid", + "annotator", + "result", + "get_campaign", + "get_detector", + ) + search_fields = ( + "annotator__username", + "annotator__first_name", + "annotator__last_name", + "result__annotation_campaign__name", + "result__detector_configuration__detector__name", + ) + list_filter = ("is_valid",) + + @admin.display(description="Campaign") + def get_campaign(self, result_validation): + """Get campaign for given result validation""" + return result_validation.result.annotation_campaign + + @admin.display(description="Detector") + def get_detector(self, result_validation): + """Get detector for given result validation""" + conf = result_validation.result.detector_configuration + if conf is None: + return None + return conf.detector diff --git a/backend/api/management/commands/seed.py b/backend/api/management/commands/seed.py index 4afbc3a7..4b4199f2 100644 --- a/backend/api/management/commands/seed.py +++ b/backend/api/management/commands/seed.py @@ -78,17 +78,32 @@ def _create_users(self): self.admin = User.objects.create_user( "admin", "admin@osmose.xyz", password, is_superuser=True, is_staff=True ) - users = [] # WARNING : names like TestUserX are used for Cypress tests, do not change or remove - names = ["TestUser1", "TestUser2"] + [ - self.fake.unique.first_name() for _ in range(40) + users = [ + User( + username="TestUser1", + email="TestUser1@osmose.xyz", + password=make_password(password), + first_name="User1", + last_name="Test", + ), + User( + username="TestUser2", + email="TestUser2@osmose.xyz", + password=make_password(password), + first_name="User2", + last_name="Test", + ), ] + names = [self.fake.unique.first_name() for _ in range(40)] for name in names: users.append( User( username=name, email=f"{name}@osmose.xyz", password=make_password(password), + first_name=name, + last_name=self.fake.last_name(), ) ) User.objects.bulk_create(users) @@ -156,21 +171,6 @@ def _create_datasets(self): dataset=dataset, ) ) - start = parse_datetime("2012-10-03T12:00:00+0200") - end = start + timedelta(minutes=15) - audio_metadatum = AudioMetadatum( - start=(start + timedelta(hours=1)), end=(end + timedelta(hours=1)) - ) - audio_metadata.append(audio_metadatum) - files.append( - DatasetFile( - filename=f"sound{1:03d}.wav", - filepath="data/audio/50h_0.wav", - size=58982478, - audio_metadatum=audio_metadatum, - dataset=dataset, - ) - ) configs.append( SpectroConfig( name="4096_4096_90", @@ -217,7 +217,7 @@ def _create_annotation_sets(self): self.annotation_sets = [] for seed_set in sets: annotation_set = AnnotationSet.objects.create( - name=seed_set["name"], desc=seed_set["desc"], owner=self.admin + name=seed_set["name"], desc=seed_set["desc"] ) for tag in seed_set["tags"]: annotation_set.tags.create(name=tag) @@ -253,7 +253,7 @@ def _create_annotation_campaigns(self): start=timezone.make_aware(datetime.strptime("2010-08-19", "%Y-%m-%d")), end=timezone.make_aware(datetime.strptime("2010-11-02", "%Y-%m-%d")), instructions_url=self.fake.uri(), - annotation_scope=1, + annotation_scope=2, annotation_set=AnnotationSet.objects.first(), confidence_indicator_set=ConfidenceIndicatorSet.objects.first(), owner=self.admin, @@ -291,13 +291,15 @@ def _create_annotation_results(self): for _ in range(randint(1, 5)): start_time = randint(0, 600) start_frequency = randint(0, 10000) - task.results.create( + campaign.results.create( start_time=start_time, end_time=start_time + randint(30, 300), start_frequency=start_frequency, end_frequency=start_frequency + randint(2000, 5000), annotation_tag_id=choice(tags), confidence_indicator=choice(self.confidences_indicators), + dataset_file_id=task.dataset_file_id, + annotator_id=task.annotator_id, ) task.status = 2 task.save() @@ -312,7 +314,11 @@ def _create_comments(self): comments.append( AnnotationComment( comment=f"a comment : {result.annotation_tag.name}", - annotation_task=result.annotation_task, + annotation_task=AnnotationTask.objects.filter( + annotation_campaign_id=result.annotation_campaign_id, + dataset_file_id=result.dataset_file_id, + annotator_id=result.annotator_id, + ).first(), annotation_result=result, ) ) diff --git a/backend/api/migrations/0033_double_check_feature.py b/backend/api/migrations/0033_double_check_feature.py new file mode 100644 index 00000000..393f5723 --- /dev/null +++ b/backend/api/migrations/0033_double_check_feature.py @@ -0,0 +1,241 @@ +# Generated by Django 3.2.23 on 2024-02-15 12:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0032_merge_20240108_1138"), + ] + + operations = [ + migrations.CreateModel( + name="AnnotationResultValidation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_valid", models.BooleanField(blank=True, null=True)), + ], + ), + migrations.AddField( + model_name="annotationresultvalidation", + name="annotator", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="annotation_results_validation", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="annotationresultvalidation", + name="result", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="validations", + to="api.annotationresult", + ), + ), + migrations.CreateModel( + name="Detector", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="DetectorConfiguration", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("configuration", models.TextField(blank=True, null=True)), + ], + ), + migrations.AddField( + model_name="detectorconfiguration", + name="detector", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="configurations", + to="api.detector", + ), + ), + migrations.AddField( + model_name="annotationcampaign", + name="usage", + field=models.IntegerField(choices=[(0, "Create"), (1, "Check")], default=0), + ), + migrations.AddField( + model_name="annotationresult", + name="annotation_campaign", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="results", + to="api.annotationcampaign", + ), + ), + migrations.AddField( + model_name="annotationresult", + name="annotator", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="annotation_results", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="annotationresult", + name="dataset_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="annotation_results", + to="api.datasetfile", + ), + ), + migrations.AddField( + model_name="annotationresult", + name="detector_configuration", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="annotation_results", + to="api.detectorconfiguration", + ), + ), + migrations.RunSQL( + """ + UPDATE annotation_results + SET + annotation_campaign_id=t.annotation_campaign_id, + annotator_id=t.annotator_id, + dataset_file_id=t.dataset_file_id + FROM annotation_results r LEFT JOIN annotation_tasks t on t.id = r.annotation_task_id + """ + ), + migrations.AlterField( + model_name="annotationresult", + name="annotation_campaign", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="results", + to="api.annotationcampaign", + ), + ), + migrations.AlterField( + model_name="annotationresult", + name="dataset_file", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="annotation_results", + to="api.datasetfile", + ), + ), + migrations.RemoveField( + model_name="collection", + name="owner", + ), + migrations.RemoveField( + model_name="tabularmetadatashape", + name="tabular_metadata_dimension", + ), + migrations.RemoveField( + model_name="tabularmetadatashape", + name="tabular_metadata_variable", + ), + migrations.RemoveField( + model_name="tabularmetadatavariable", + name="tabular_metadata", + ), + migrations.RemoveField( + model_name="annotationresult", + name="annotation_task", + ), + migrations.RemoveField( + model_name="annotationset", + name="owner", + ), + migrations.RemoveField( + model_name="dataset", + name="collections", + ), + migrations.RemoveField( + model_name="dataset", + name="tabular_metadatum", + ), + migrations.RemoveField( + model_name="datasetfile", + name="tabular_metadatum", + ), + migrations.AlterField( + model_name="confidenceindicator", + name="label", + field=models.CharField(max_length=255), + ), + migrations.AlterUniqueTogether( + name="confidenceindicator", + unique_together={("confidence_indicator_set", "label")}, + ), + migrations.AddConstraint( + model_name="annotationresult", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("annotator__isnull", True), + ("detector_configuration__isnull", False), + ), + models.Q( + ("annotator__isnull", False), + ("detector_configuration__isnull", True), + ), + _connector="OR", + ), + name="require_user_or_detector", + ), + ), + migrations.DeleteModel( + name="Collection", + ), + migrations.DeleteModel( + name="TabularMetadataShape", + ), + migrations.DeleteModel( + name="TabularMetadataVariable", + ), + migrations.DeleteModel( + name="TabularMetadatum", + ), + ] diff --git a/backend/api/migrations/0034_alter_annotationresult_dataset_file.py b/backend/api/migrations/0034_alter_annotationresult_dataset_file.py new file mode 100644 index 00000000..446a7ae7 --- /dev/null +++ b/backend/api/migrations/0034_alter_annotationresult_dataset_file.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.23 on 2024-02-19 15:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0033_double_check_feature"), + ] + + operations = [ + migrations.AlterField( + model_name="annotationresult", + name="dataset_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="annotation_results", + to="api.datasetfile", + ), + ), + ] diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py index 1d08364f..bce96df7 100644 --- a/backend/api/models/__init__.py +++ b/backend/api/models/__init__.py @@ -2,14 +2,11 @@ from django.contrib.auth import get_user_model -from backend.api.models.datasets import Collection, DatasetType, Dataset, DatasetFile +from backend.api.models.datasets import DatasetType, Dataset, DatasetFile from backend.api.models.metadata import ( AudioMetadatum, GeoMetadatum, SpectroConfig, - TabularMetadatum, - TabularMetadataVariable, - TabularMetadataShape, WindowType, ) from backend.api.models.annotations import ( @@ -19,9 +16,15 @@ AnnotationSet, AnnotationCampaign, AnnotationComment, - AnnotationResult, AnnotationSession, AnnotationTask, + AnnotationCampaignUsage, +) +from backend.api.models.annotation import ( + Detector, + DetectorConfiguration, + AnnotationResult, + AnnotationResultValidation, ) -User = get_user_model() +from .user import User diff --git a/backend/api/models/annotation/__init__.py b/backend/api/models/annotation/__init__.py new file mode 100644 index 00000000..f12261dd --- /dev/null +++ b/backend/api/models/annotation/__init__.py @@ -0,0 +1,6 @@ +""" Models for Annotations """ +from backend.api.models.annotation.detector import Detector, DetectorConfiguration +from backend.api.models.annotation.result import ( + AnnotationResult, + AnnotationResultValidation, +) diff --git a/backend/api/models/annotation/detector.py b/backend/api/models/annotation/detector.py new file mode 100644 index 00000000..d72e0b14 --- /dev/null +++ b/backend/api/models/annotation/detector.py @@ -0,0 +1,30 @@ +"""Detector model""" +from django.db import models + + +class Detector(models.Model): + """ + This table represents a detector + """ + + class Meta: + ordering = ["name"] + + name = models.CharField(max_length=255, unique=True) + + def __str__(self): + return self.name + + +class DetectorConfiguration(models.Model): + """ + This table represents a detector + """ + + configuration = models.TextField(null=True, blank=True) + detector = models.ForeignKey( + Detector, on_delete=models.CASCADE, related_name="configurations" + ) + + def __str__(self): + return self.detector.name + ": " + self.configuration diff --git a/backend/api/models/annotation/result.py b/backend/api/models/annotation/result.py new file mode 100644 index 00000000..58bab64d --- /dev/null +++ b/backend/api/models/annotation/result.py @@ -0,0 +1,80 @@ +"""Results model""" +from django.db import models +from django.conf import settings + +from .detector import DetectorConfiguration +from ..user import User + + +class AnnotationResult(models.Model): + """ + This table contains the resulting tag associations for specific annotation_tasks + """ + + class Meta: + db_table = "annotation_results" + constraints = [ + models.CheckConstraint( + name="require_user_or_detector", + check=( + models.Q( + annotator__isnull=True, detector_configuration__isnull=False + ) + | models.Q( + annotator__isnull=False, detector_configuration__isnull=True + ) + ), + ) + ] + + start_time = models.FloatField(null=True, blank=True) + end_time = models.FloatField(null=True, blank=True) + start_frequency = models.FloatField(null=True, blank=True) + end_frequency = models.FloatField(null=True, blank=True) + + annotation_tag = models.ForeignKey("AnnotationTag", on_delete=models.CASCADE) + confidence_indicator = models.ForeignKey( + "ConfidenceIndicator", on_delete=models.SET_NULL, null=True, blank=True + ) + annotation_campaign = models.ForeignKey( + "AnnotationCampaign", + on_delete=models.CASCADE, + related_name="results", + ) + annotator = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="annotation_results", + null=True, + blank=True, + ) + dataset_file = models.ForeignKey( + "DatasetFile", + on_delete=models.CASCADE, + related_name="annotation_results", + null=True, + blank=True, + ) + detector_configuration = models.ForeignKey( + DetectorConfiguration, + on_delete=models.CASCADE, + related_name="annotation_results", + null=True, + blank=True, + ) + + +class AnnotationResultValidation(models.Model): + """ + This table contains the resulting tag associations for specific annotation_tasks + """ + + is_valid = models.BooleanField(null=True, blank=True) + annotator = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="annotation_results_validation", + ) + result = models.ForeignKey( + AnnotationResult, on_delete=models.CASCADE, related_name="validations" + ) diff --git a/backend/api/models/annotations.py b/backend/api/models/annotations.py index 88d0832f..4241ddf8 100644 --- a/backend/api/models/annotations.py +++ b/backend/api/models/annotations.py @@ -1,17 +1,19 @@ """Annotation-related models""" from collections import defaultdict -from random import shuffle -from django.utils import timezone -from django.db import models + from django.conf import settings +from django.db import models from django.db.models import Q +from django.utils import timezone + +from .annotation import AnnotationResult class ConfidenceIndicatorSet(models.Model): """ This table contains collections of confidence indicator to be used for annotation campaign. - An confidence indicator set is created by a staff user. + A confidence indicator set is created by a staff user. """ class Meta: @@ -44,11 +46,12 @@ class Meta: condition=Q(is_default=True), ), ] + unique_together = ("confidence_indicator_set", "label") def __str__(self): return str(self.label) - label = models.CharField(max_length=255, unique=True) + label = models.CharField(max_length=255) level = models.IntegerField() confidence_indicator_set = models.ForeignKey( ConfidenceIndicatorSet, @@ -91,13 +94,24 @@ def __str__(self): desc = models.TextField(null=True, blank=True) tags = models.ManyToManyField(AnnotationTag) - owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + +class AnnotationCampaignUsage(models.IntegerChoices): + """Annotation campaign usage""" + + CREATE = ( + 0, + "Create", + ) + CHECK = ( + 1, + "Check", + ) class AnnotationCampaign(models.Model): """ Table containing an annotation_campaign, to be used with the table annotation_campaign_datasets. A researcher - wanting to have a number of annotated datasets will chose a annotation_set and launch a campaign. + wanting to have a number of annotated datasets will choose an annotation_set and launch a campaign. For AnnotationScope RECTANGLE means annotating through boxes first, WHOLE means annotating presence/absence for the whole file first (boxes can be used to augment annotation). @@ -127,6 +141,9 @@ def __str__(self): annotation_scope = models.IntegerField( choices=AnnotationScope.choices, default=AnnotationScope.RECTANGLE ) + usage = models.IntegerField( + choices=AnnotationCampaignUsage.choices, default=AnnotationCampaignUsage.CREATE + ) owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) annotators = models.ManyToManyField( @@ -138,10 +155,8 @@ def __str__(self): ConfidenceIndicatorSet, on_delete=models.SET_NULL, null=True, blank=True ) - def add_annotator(self, annotator, files_target=None, method="sequential"): - """Create a files_target number of annotation tasks assigned to annotator for a given method""" - if method not in ["sequential", "random"]: - raise ValueError(f'Given method argument "{method}" is not supported') + def add_annotator(self, annotator, files_target=None): + """Create a files_target number of annotation tasks assigned to annotator""" dataset_files = self.datasets.values_list("files__id", flat=True) if files_target > len(dataset_files): raise ValueError(f"Cannot annotate {files_target} files, not enough files") @@ -161,8 +176,6 @@ def add_annotator(self, annotator, files_target=None, method="sequential"): dataset_files = [] for key in sorted(file_groups.keys()): group_files = file_groups[key] - if method == "random": - shuffle(group_files) dataset_files += group_files[:files_target] if len(dataset_files) >= files_target: break @@ -210,28 +223,6 @@ class Meta: ) -class AnnotationResult(models.Model): - """ - This table contains the resulting tag associations for specific annotation_tasks - """ - - class Meta: - db_table = "annotation_results" - - start_time = models.FloatField(null=True, blank=True) - end_time = models.FloatField(null=True, blank=True) - start_frequency = models.FloatField(null=True, blank=True) - end_frequency = models.FloatField(null=True, blank=True) - - annotation_tag = models.ForeignKey(AnnotationTag, on_delete=models.CASCADE) - annotation_task = models.ForeignKey( - AnnotationTask, on_delete=models.CASCADE, related_name="results" - ) - confidence_indicator = models.ForeignKey( - ConfidenceIndicator, on_delete=models.SET_NULL, null=True, blank=True - ) - - class AnnotationComment(models.Model): """ This table contains comment of annotation result and task. @@ -259,7 +250,7 @@ class Meta: class AnnotationSession(models.Model): """ This table contains the AudioAnnotator sessions output linked to the annotation of a specific dataset file. There - can be multiple AA sessions for a annotation_tasks, the result of the latest session should be equal to the + can be multiple AA sessions for an annotation_tasks, the result of the latest session should be equal to the dataset’s file annotation. """ diff --git a/backend/api/models/datasets.py b/backend/api/models/datasets.py index c9d6fbc6..8d9d971c 100644 --- a/backend/api/models/datasets.py +++ b/backend/api/models/datasets.py @@ -5,21 +5,6 @@ from django.utils import timezone -class Collection(models.Model): - """ - This table contains collections which are groups of datasets which are meant to be logical groupings, for example - datasets coming from a common project. - """ - - class Meta: - db_table = "collections" - - name = models.CharField(max_length=255, unique=True) - desc = models.TextField(null=True, blank=True) - - owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - - class DatasetType(models.Model): """ Table containing the possible data types for dataset’s. These data types are not about the technical storage of the @@ -76,12 +61,6 @@ def __str__(self): "GeoMetadatum", on_delete=models.CASCADE, null=True, blank=True ) owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - tabular_metadatum = models.ForeignKey( - "TabularMetadatum", on_delete=models.CASCADE, null=True, blank=True - ) - collections = models.ManyToManyField( - Collection, related_name="datasets", blank=True - ) class DatasetFile(models.Model): @@ -107,9 +86,6 @@ def __str__(self): audio_metadatum = models.ForeignKey( "AudioMetadatum", on_delete=models.CASCADE, null=True, blank=True ) - tabular_metadatum = models.ForeignKey( - "TabularMetadatum", on_delete=models.CASCADE, null=True, blank=True - ) @property def dataset_sr(self): diff --git a/backend/api/models/metadata.py b/backend/api/models/metadata.py index dc055b58..330a0b73 100644 --- a/backend/api/models/metadata.py +++ b/backend/api/models/metadata.py @@ -114,55 +114,3 @@ def zoom_tiles(self, tile_name): zoom_level = 2**zoom_power for zoom_tile in range(0, zoom_level): yield f"{tile_name}_{zoom_level}_{zoom_tile}.png" - - -class TabularMetadatum(models.Model): - """ - This table contains metadata of matrix-like data, for example NetCDF and CSV files. It is used in conjunction with - tabular_metadata_variables and tabular_metadata_shapes. - """ - - class Meta: - db_table = "tabular_metadata" - - name = models.CharField(max_length=255, unique=True) - desc = models.TextField() - dimension_count = models.IntegerField() - variable_count = models.IntegerField() - - -class TabularMetadataVariable(models.Model): - """ - This table contains the variables of a tabular_metadata. This representation is inspired by the NetCDF format and as - such variables can be dimensions. A dimension is here represented by a variable where dimension_size is not NULL. - """ - - class Meta: - db_table = "tabular_metadata_variables" - - name = models.CharField(max_length=255) - desc = models.TextField() - data_type = models.CharField(max_length=255) - dimension_size = models.IntegerField() - variable_position = models.IntegerField() - - tabular_metadata = models.ForeignKey(TabularMetadatum, on_delete=models.CASCADE) - - -class TabularMetadataShape(models.Model): - """ - Table representing variables’ shapes. The shape of a variable is represented by specifying the dimensions of the - different axes alongside which the variable data is defined. - """ - - class Meta: - db_table = "tabular_metadata_shapes" - - dimension_position = models.IntegerField() - - tabular_metadata_dimension = models.ForeignKey( - TabularMetadataVariable, on_delete=models.CASCADE, related_name="dimension" - ) - tabular_metadata_variable = models.ForeignKey( - TabularMetadataVariable, on_delete=models.CASCADE, related_name="variable" - ) diff --git a/backend/api/models/user.py b/backend/api/models/user.py new file mode 100644 index 00000000..a034882c --- /dev/null +++ b/backend/api/models/user.py @@ -0,0 +1,4 @@ +"""User-related models""" +from django.contrib.auth import get_user_model + +User = get_user_model() diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py index a6a87c87..b6cba74c 100644 --- a/backend/api/serializers/__init__.py +++ b/backend/api/serializers/__init__.py @@ -18,8 +18,6 @@ AnnotationCampaignRetrieveAuxCampaignSerializer, AnnotationCampaignRetrieveAuxTaskSerializer, AnnotationCampaignRetrieveSerializer, - AnnotationCampaignCreateSerializer, - AnnotationCampaignAddAnnotatorsSerializer, ) from backend.api.serializers.annotation_task import ( AnnotationTaskSerializer, @@ -31,3 +29,10 @@ AnnotationTaskOneResultUpdateSerializer, AnnotationTaskUpdateOutputCampaignSerializer, ) +from .annotation import ( + DetectorSerializer, + DetectorConfigurationSerializer, + AnnotationCampaignCreateCreateAnnotationsSerializer, + AnnotationCampaignCreateCheckAnnotationsSerializer, + AnnotationCampaignAddAnnotatorsSerializer, +) diff --git a/backend/api/serializers/annotation/__init__.py b/backend/api/serializers/annotation/__init__.py new file mode 100644 index 00000000..8368823f --- /dev/null +++ b/backend/api/serializers/annotation/__init__.py @@ -0,0 +1,12 @@ +""" +DRF serializers module to be used in viewsets +""" +from .detector import ( + DetectorSerializer, + DetectorConfigurationSerializer, +) +from .campaign import ( + AnnotationCampaignCreateCheckAnnotationsSerializer, + AnnotationCampaignCreateCreateAnnotationsSerializer, + AnnotationCampaignAddAnnotatorsSerializer, +) diff --git a/backend/api/serializers/annotation/campaign/__init__.py b/backend/api/serializers/annotation/campaign/__init__.py new file mode 100644 index 00000000..b8ba74e8 --- /dev/null +++ b/backend/api/serializers/annotation/campaign/__init__.py @@ -0,0 +1,11 @@ +""" +DRF serializers module to be used in viewsets +""" + +from .create import ( + AnnotationCampaignCreateCheckAnnotationsSerializer, + AnnotationCampaignCreateCreateAnnotationsSerializer, +) +from .update import ( + AnnotationCampaignAddAnnotatorsSerializer, +) diff --git a/backend/api/serializers/annotation/campaign/_utils_.py b/backend/api/serializers/annotation/campaign/_utils_.py new file mode 100644 index 00000000..43a7c782 --- /dev/null +++ b/backend/api/serializers/annotation/campaign/_utils_.py @@ -0,0 +1,50 @@ +"""Annotation campaign utils to add annotators to campaign""" + +from django.db.models import Count +from rest_framework import serializers + +from backend.api.models import ( + AnnotationCampaign, + User, +) + + +def create_campaign_with_annotators( + campaign: AnnotationCampaign, goal: int, annotators: list[User] +) -> AnnotationCampaign: + """Finalize campaign creation""" + file_count = sum( + campaign.datasets.annotate(Count("files")).values_list( + "files__count", flat=True + ) + ) + total_goal = file_count * goal + annotator_goal, remainder = divmod(total_goal, len(annotators)) + for annotator in User.objects.filter(id__in=annotators): + files_target = annotator_goal + if remainder > 0: + files_target += 1 + remainder -= 1 + campaign.add_annotator(annotator, files_target) + return campaign + + +def check_annotation_goal(attrs: dict) -> None: + """Max for annotation_goal""" + annotators_nb = len(attrs["annotators"]) + if attrs["annotation_goal"] > annotators_nb: + error = f"Ensure this value is lower than or equal to the number of annotators {annotators_nb}" + raise serializers.ValidationError({"annotation_goal": error}) + + +def check_spectro_configs_in_datasets(attrs: dict) -> None: + """Validates that chosen spectros correspond to chosen datasets""" + spectro_configs = attrs["spectro_configs"] # type: list[SpectroConfig] + datasets = attrs["datasets"] # type: list[Dataset] + bad_vals = [] + for spectro in spectro_configs: + if spectro.dataset not in datasets: + bad_vals.append(str(spectro)) + if bad_vals: + error = f"{bad_vals} not valid ids for spectro configs of given datasets ({[str(d) for d in datasets]})" + raise serializers.ValidationError({"spectro_configs": error}) diff --git a/backend/api/serializers/annotation/campaign/create.py b/backend/api/serializers/annotation/campaign/create.py new file mode 100644 index 00000000..e5fe5ff1 --- /dev/null +++ b/backend/api/serializers/annotation/campaign/create.py @@ -0,0 +1,360 @@ +"""Annotation campaign create DRF serializers file""" + +from datetime import datetime, timedelta +from typing import Optional + +from dateutil import parser +from rest_framework import serializers + +from backend.api.models import ( + User, + AnnotationCampaign, + Dataset, + AnnotationSet, + SpectroConfig, + ConfidenceIndicatorSet, + AnnotationCampaignUsage, + AnnotationTag, + ConfidenceIndicator, + Detector, + DetectorConfiguration, + AnnotationResult, +) +from backend.api.serializers.utils import EnumField +from backend.utils.validators import valid_model_ids +from ._utils_ import ( + create_campaign_with_annotators, + check_annotation_goal, + check_spectro_configs_in_datasets, +) + + +class AnnotationCampaignCreateCreateAnnotationsSerializer(serializers.ModelSerializer): + """Serializer meant for AnnotationCampaign creation with corresponding tasks""" + + annotators = serializers.ListField( + child=serializers.IntegerField(), validators=[valid_model_ids(User)] + ) + annotation_goal = serializers.IntegerField(min_value=1) + usage = EnumField(enum=AnnotationCampaignUsage) + + class Meta: + model = AnnotationCampaign + # pylint:disable=duplicate-code + fields = [ + "name", + "desc", + "instructions_url", + "start", + "end", + "datasets", + "spectro_configs", + "annotation_set", + "confidence_indicator_set", + "annotators", + "annotation_goal", + "annotation_scope", + "usage", + ] + + def validate(self, attrs): + """Validates given data""" + + check_annotation_goal(attrs) + check_spectro_configs_in_datasets(attrs) + + # Handle non-present confidence set + if "confidence_indicator_set" not in attrs: + attrs["confidence_indicator_set"] = None + return attrs + + def create(self, validated_data): + annotation_set: AnnotationSet = validated_data["annotation_set"] + confidence_indicator_set: Optional[ConfidenceIndicatorSet] = validated_data[ + "confidence_indicator_set" + ] + + campaign = AnnotationCampaign( + name=validated_data["name"], + desc=validated_data.get("desc"), + start=validated_data.get("start"), + end=validated_data.get("end"), + annotation_set=annotation_set, + confidence_indicator_set=confidence_indicator_set, + annotation_scope=validated_data["annotation_scope"], + usage=validated_data["usage"], + owner_id=validated_data["owner_id"], + instructions_url=validated_data.get("instructions_url"), + ) + + spectro_configs = validated_data["spectro_configs"] # type: list[SpectroConfig] + datasets = validated_data["datasets"] # type: list[Dataset] + + campaign.save() + campaign.datasets.set(datasets) + campaign.spectro_configs.set(spectro_configs) + + return create_campaign_with_annotators( + campaign=campaign, + goal=int(validated_data["annotation_goal"]), + annotators=validated_data["annotators"], + ) + + +def to_seconds(delta: timedelta) -> float: + """Format seconds timedelta as float""" + return delta.seconds + delta.microseconds / 1000000 + + +class AnnotationCampaignCreateCheckAnnotationsSerializer(serializers.ModelSerializer): + """Serializer meant for AnnotationCampaign creation with corresponding tasks""" + + annotation_goal = serializers.IntegerField(min_value=1) + annotation_set_labels = serializers.ListField(child=serializers.CharField()) + confidence_set_indicators = serializers.ListField( + allow_empty=True, + required=False, + ) + detectors = serializers.ListField() + usage = EnumField(enum=AnnotationCampaignUsage) + results = serializers.ListField() + annotators = serializers.ListField( + child=serializers.IntegerField(), + validators=[valid_model_ids(User)], + ) + force = serializers.BooleanField(required=False, allow_null=True) + + class Meta: + model = AnnotationCampaign + # pylint:disable=duplicate-code + fields = [ + "id", + "name", + "desc", + "instructions_url", + "start", + "end", + "datasets", + "spectro_configs", + "spectro_configs", + "annotators", + "annotation_goal", + "annotation_scope", + "usage", + "annotation_set_labels", + "confidence_set_indicators", + "detectors", + "results", + "force", + ] + + def validate(self, attrs): + """Validates given data""" + + check_annotation_goal(attrs) + check_spectro_configs_in_datasets(attrs) + + # Handle non-present confidence set + if "confidence_set_indicators" not in attrs: + attrs["confidence_set_indicators"] = None + return attrs + + def get_annotation_set_name(self, target_name: str) -> str: + """Create automatically new annotation set name""" + if AnnotationSet.objects.filter(name=target_name): + return self.get_annotation_set_name(target_name + "_1") + return target_name + + def get_confidence_set_name(self, target_name: str) -> str: + """Create automatically new confidence set name""" + if ConfidenceIndicatorSet.objects.filter(name=target_name): + return self.get_confidence_set_name(target_name + "_1") + return target_name + + def get_annotation_set(self, campaign_name: str, labels: [str]) -> AnnotationSet: + """Get annotation set for creating annotation campaign""" + annotation_set = AnnotationSet.objects.create( + name=self.get_annotation_set_name(f"{campaign_name}_set"), + desc=f"Annotation set for {campaign_name} campaign", + ) + for label in labels: + tag = AnnotationTag.objects.get_or_create(name=label) + annotation_set.tags.add(tag[0]) + annotation_set.save() + return annotation_set + + def get_confidence_set( + self, campaign_name: str, indicators: list + ) -> Optional[ConfidenceIndicatorSet]: + """Get confidence set for creating annotation campaign""" + confidence_set = None + for data in indicators or []: + if data[0] is None or data[1] is None: + continue + if confidence_set is None: + confidence_set = ConfidenceIndicatorSet.objects.create( + name=self.get_confidence_set_name(f"{campaign_name}_set"), + desc=f"Confidence set for {campaign_name} campaign", + ) + confidence_set.confidence_indicators.get_or_create( + label=data[0], + level=data[1], + ) + if confidence_set is not None: + confidence_set.save() + return confidence_set + + def manage_detectors(self, detectors_data: list): + """Manage detectors for creating annotation campaign""" + for detector in detectors_data: + detector_name = detector.pop("detectorName") + detector_id = None + detector_config_id = None + try: + detector_id = detector.pop("detectorId") + detector_config_id = detector.pop("configurationId") + except KeyError as key: + print(f"No {key} provided for detector") + detector_config = detector.pop("configuration") + detector_obj = None + if detector_id is not None: + detector_obj = Detector.objects.get(pk=detector_id) + if detector_obj is None: + detector_obj = Detector.objects.get_or_create(name=detector_name)[0] + if detector_config_id is None: + detector_obj.configurations.get_or_create(configuration=detector_config) + detector_obj.save() + + def get_dataset_files(self, dataset: Dataset, start: datetime, end: datetime): + """Get dataset files from absolute start and ends""" + dataset_files_start = dataset.files.filter( + audio_metadatum__start__lte=start, + audio_metadatum__end__gte=start, + ) + dataset_files_while = dataset.files.filter( + audio_metadatum__start__gt=start, + audio_metadatum__end__lt=end, + ) + dataset_files_end = dataset.files.filter( + audio_metadatum__start__lte=end, + audio_metadatum__end__gte=end, + ) + return dataset_files_start | dataset_files_while | dataset_files_end + + def create_results( + self, + campaign: AnnotationCampaign, + confidence_set: Optional[ConfidenceIndicatorSet], + results: list, + force: bool, + ): + """Create results objects""" + missing_matches = 0 + for result in results: + dataset = Dataset.objects.get(name=result["dataset"]) + is_box = bool(result["is_box"]) + start = parser.parse(result["start_datetime"]) + end = parser.parse(result["end_datetime"]) + dataset_files = self.get_dataset_files(dataset, start, end) + if not dataset_files: + missing_matches = missing_matches + 1 + detector = Detector.objects.filter(name=result["detector"]).first() + detector_config = DetectorConfiguration.objects.filter( + detector=detector, configuration=result["detector_config"] + ).first() + confidence_indicator = None + if "confidence" in result: + confidence_indicator = ConfidenceIndicator.objects.get( + label=result["confidence"], + confidence_indicator_set=confidence_set, + ) + + if not is_box and dataset_files.count() == 1: + AnnotationResult.objects.create( + annotation_campaign=campaign, + detector_configuration=detector_config, + annotation_tag=AnnotationTag.objects.get(name=result["tag"]), + confidence_indicator=confidence_indicator, + dataset_file=dataset_files.first(), + ) + continue + + for dataset_file in dataset_files: + if start < dataset_file.audio_metadatum.start: + start_time = 0 + else: + start_time = to_seconds(start - dataset_file.audio_metadatum.start) + if end > dataset_file.audio_metadatum.end: + end_time = to_seconds( + dataset_file.audio_metadatum.end + - dataset_file.audio_metadatum.start + ) + else: + end_time = to_seconds(end - dataset_file.audio_metadatum.start) + + AnnotationResult.objects.create( + annotation_campaign=campaign, + detector_configuration=detector_config, + annotation_tag=AnnotationTag.objects.get(name=result["tag"]), + confidence_indicator=confidence_indicator, + dataset_file=dataset_file, + start_frequency=result["min_frequency"] + if "min_frequency" in result and is_box + else 0, + end_frequency=result["max_frequency"] + if "max_frequency" in result and is_box + else dataset.audio_metadatum.dataset_sr / 2, + start_time=start_time, + end_time=end_time, + ) + + if missing_matches and not force: + raise serializers.ValidationError( + { + "dataset_file_not_found": f"Didn't find any corresponding file for {missing_matches} " + f"result{'s' if missing_matches > 0 else ''}" + } + ) + + def create(self, validated_data): + """Create annotation campaign""" + + annotation_set = self.get_annotation_set( + validated_data["name"], validated_data["annotation_set_labels"] + ) + confidence_set = self.get_confidence_set( + validated_data["name"], validated_data["confidence_set_indicators"] + ) + self.manage_detectors(validated_data["detectors"]) + + campaign = AnnotationCampaign( + name=validated_data["name"], + desc=validated_data.get("desc"), + start=validated_data.get("start"), + end=validated_data.get("end"), + annotation_set_id=annotation_set.id, + confidence_indicator_set_id=confidence_set.id + if confidence_set is not None + else None, + annotation_scope=validated_data["annotation_scope"], + usage=validated_data["usage"], + owner_id=validated_data["owner_id"], + instructions_url=validated_data.get("instructions_url"), + ) + campaign.save() + campaign.datasets.set(validated_data["datasets"]) + campaign.spectro_configs.set(validated_data["spectro_configs"]) + + # Create results + self.create_results( + campaign, + confidence_set, + results=validated_data["results"], + force=validated_data["force"] if "force" in validated_data else False, + ) + + return create_campaign_with_annotators( + campaign=campaign, + goal=int(validated_data["annotation_goal"]), + annotators=validated_data["annotators"], + ) diff --git a/backend/api/serializers/annotation/campaign/update.py b/backend/api/serializers/annotation/campaign/update.py new file mode 100644 index 00000000..f0bd2f8f --- /dev/null +++ b/backend/api/serializers/annotation/campaign/update.py @@ -0,0 +1,35 @@ +"""Annotation campaign update DRF serializers file""" +# pylint: disable=W0223 +from django.db.models import Count +from rest_framework import serializers + +from backend.api.models import User +from backend.utils.validators import valid_model_ids + + +class AnnotationCampaignAddAnnotatorsSerializer(serializers.Serializer): + """ + Serializer meant to update AnnotationCampaign with new annotators and corresponding tasks. + + If annotation_goal (the number of files wanted to be annotated) is not given then the whole + dataset will be targeted. + + The parameter annotation_method is 0 for sequential and 1 for random. + """ + + annotators = serializers.ListField( + child=serializers.IntegerField(), validators=[valid_model_ids(User)] + ) + annotation_goal = serializers.IntegerField(min_value=0) + + def update(self, instance, validated_data): + files_target = validated_data["annotation_goal"] + if files_target == 0: + files_target = sum( + instance.datasets.annotate(Count("files")).values_list( + "files__count", flat=True + ) + ) + for annotator in User.objects.filter(id__in=validated_data["annotators"]): + instance.add_annotator(annotator, files_target) + return instance diff --git a/backend/api/serializers/annotation/detector.py b/backend/api/serializers/annotation/detector.py new file mode 100644 index 00000000..d5d357ff --- /dev/null +++ b/backend/api/serializers/annotation/detector.py @@ -0,0 +1,52 @@ +"""APLOSE - Detector""" +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field +from backend.api.models import Detector, DetectorConfiguration + +DetectorFields = [ + "id", + "name", + "configurations", + # "configuration" +] + + +class DetectorConfigurationSerializer(serializers.ModelSerializer): + """Serializer meant to output Collaborator data""" + + class Meta: + model = DetectorConfiguration + fields = [ + "id", + "configuration", + ] + + +class DetectorSerializer(serializers.ModelSerializer): + """Serializer meant to output Collaborator data""" + + configurations = serializers.SerializerMethodField() + _conf = None + + class Meta: + model = Detector + fields = DetectorFields + + def __init__(self, *args, **kwargs): + if "configuration" in kwargs: + self._conf = kwargs.pop("configuration") + super().__init__(*args, **kwargs) + + @extend_schema_field(DetectorConfigurationSerializer(many=True, allow_null=True)) + def get_configurations(self, detector): + """Get configuration for detector""" + if self._conf is None: + return DetectorConfigurationSerializer( + detector.configurations, many=True + ).data + return DetectorConfigurationSerializer( + [ + self._conf, + ], + many=True, + ).data diff --git a/backend/api/serializers/annotation_campaign.py b/backend/api/serializers/annotation_campaign.py index 63627e7c..c18ba3dc 100644 --- a/backend/api/serializers/annotation_campaign.py +++ b/backend/api/serializers/annotation_campaign.py @@ -7,20 +7,15 @@ from rest_framework import serializers from drf_spectacular.utils import extend_schema_field - -from backend.utils.validators import valid_model_ids from backend.api.models import ( - User, AnnotationCampaign, - Dataset, - AnnotationSet, - SpectroConfig, - ConfidenceIndicatorSet, + AnnotationCampaignUsage, ) from backend.api.serializers.confidence_indicator_set import ( ConfidenceIndicatorSetSerializer, ) from backend.api.serializers.annotation_set import AnnotationSetSerializer +from .utils import EnumField class AnnotationCampaignListSerializer(serializers.ModelSerializer): @@ -31,16 +26,17 @@ class AnnotationCampaignListSerializer(serializers.ModelSerializer): attr_names """ - tasks_count = serializers.IntegerField() user_tasks_count = serializers.IntegerField() complete_tasks_count = serializers.IntegerField() user_complete_tasks_count = serializers.IntegerField() files_count = serializers.IntegerField() - annotation_set = AnnotationSetSerializer(with_tags=False) - confidence_indicator_set = ConfidenceIndicatorSetSerializer(with_indicators=False) + confidence_indicator_set_name = serializers.CharField() + annotation_set_name = serializers.CharField() + mode = EnumField(enum=AnnotationCampaignUsage, source="usage") class Meta: model = AnnotationCampaign + # pylint:disable=duplicate-code fields = [ "id", "name", @@ -48,13 +44,13 @@ class Meta: "instructions_url", "start", "end", - "annotation_set", - "confidence_indicator_set", - "tasks_count", + "annotation_set_name", + "confidence_indicator_set_name", "user_tasks_count", "complete_tasks_count", "user_complete_tasks_count", "files_count", + "mode", "created_at", ] @@ -66,6 +62,7 @@ class AnnotationCampaignRetrieveAuxCampaignSerializer(serializers.ModelSerialize annotation_set = AnnotationSetSerializer() confidence_indicator_set = ConfidenceIndicatorSetSerializer() + dataset_files_count = serializers.SerializerMethodField() class Meta: model = AnnotationCampaign @@ -80,8 +77,19 @@ class Meta: "confidence_indicator_set", "datasets", "created_at", + "usage", + "dataset_files_count", ] + @extend_schema_field(serializers.IntegerField) + def get_dataset_files_count(self, campaign): + # type: (AnnotationCampaign) -> int + return sum( + campaign.datasets.annotate(Count("files")).values_list( + "files__count", flat=True + ) + ) + class AnnotationCampaignRetrieveAuxTaskSerializer(serializers.Serializer): """ @@ -110,137 +118,3 @@ def get_tasks(self, campaign): count=Count("status") ) ) - - -class AnnotationCampaignCreateSerializer(serializers.ModelSerializer): - """Serializer meant for AnnotationCampaign creation with corresponding tasks""" - - desc = serializers.CharField(allow_blank=True) - instructions_url = serializers.CharField(allow_blank=True) - start = serializers.DateTimeField(required=False) - end = serializers.DateTimeField(required=False) - annotation_set_id = serializers.IntegerField( - validators=[valid_model_ids(AnnotationSet)] - ) - confidence_indicator_set_id = serializers.IntegerField( - validators=[valid_model_ids(ConfidenceIndicatorSet)], - required=False, - ) - datasets = serializers.ListField( - child=serializers.IntegerField(), - validators=[valid_model_ids(Dataset)], - allow_empty=False, - ) - spectro_configs = serializers.ListField( - child=serializers.IntegerField(), - validators=[valid_model_ids(SpectroConfig)], - allow_empty=False, - ) - annotators = serializers.ListField( - child=serializers.IntegerField(), validators=[valid_model_ids(User)] - ) - annotation_method = serializers.IntegerField(min_value=0, max_value=1) - annotation_goal = serializers.IntegerField(min_value=1) - - class Meta: - model = AnnotationCampaign - fields = [ - "id", - "name", - "desc", - "instructions_url", - "start", - "end", - "annotation_set_id", - "confidence_indicator_set_id", - "spectro_configs", - "datasets", - "annotators", - "annotation_method", - "annotation_goal", - "annotation_scope", - ] - - def validate(self, attrs): - """Validates that chosen spectros correspond to chosen datasets""" - db_spectros = Dataset.objects.filter(id__in=attrs["datasets"]).values_list( - "spectro_configs", flat=True - ) - bad_vals = set(attrs["spectro_configs"]) - set(db_spectros) - if bad_vals: - raise serializers.ValidationError( - f"{bad_vals} not valid ids for spectro configs of given datasets" - ) - return attrs - - def create(self, validated_data): - campaign = AnnotationCampaign( - name=validated_data["name"], - desc=validated_data.get("desc"), - start=validated_data.get("start"), - end=validated_data.get("end"), - annotation_set_id=validated_data["annotation_set_id"], - confidence_indicator_set_id=validated_data.get( - "confidence_indicator_set_id" - ), - annotation_scope=validated_data["annotation_scope"], - owner_id=validated_data["owner_id"], - instructions_url=validated_data.get("instructions_url"), - ) - - campaign.save() - campaign.datasets.set(validated_data["datasets"]) - campaign.spectro_configs.set(validated_data["spectro_configs"]) - file_count = sum( - campaign.datasets.annotate(Count("files")).values_list( - "files__count", flat=True - ) - ) - total_goal = file_count * int(validated_data["annotation_goal"]) - annotator_goal, remainder = divmod( - total_goal, len(validated_data["annotators"]) - ) - annotation_method = ["random", "sequential"][ - int(validated_data["annotation_method"]) - ] - for annotator in User.objects.filter(id__in=validated_data["annotators"]): - files_target = annotator_goal - if remainder > 0: - files_target += 1 - remainder -= 1 - campaign.add_annotator(annotator, files_target, annotation_method) - return campaign - - -class AnnotationCampaignAddAnnotatorsSerializer(serializers.Serializer): - """ - Serializer meant to update AnnotationCampagin with new annotators and corresponding tasks. - - If annotation_goal (the number of files wanted to be annotated) is not given then the whole - dataset will be targeted. - - The parameter annotation_method is 0 for sequential and 1 for random. - """ - - annotators = serializers.ListField( - child=serializers.IntegerField(), validators=[valid_model_ids(User)] - ) - annotation_method = serializers.IntegerField(min_value=0, max_value=1) - annotation_goal = serializers.IntegerField(min_value=1, required=False) - - def update(self, instance, validated_data): - files_target = 0 - if "annotation_goal" in validated_data: - files_target = validated_data["annotation_goal"] - else: - files_target = sum( - instance.datasets.annotate(Count("files")).values_list( - "files__count", flat=True - ) - ) - annotation_method = ["random", "sequential"][ - int(validated_data["annotation_method"]) - ] - for annotator in User.objects.filter(id__in=validated_data["annotators"]): - instance.add_annotator(annotator, files_target, annotation_method) - return instance diff --git a/backend/api/serializers/annotation_set.py b/backend/api/serializers/annotation_set.py index 70f70593..b96270e9 100644 --- a/backend/api/serializers/annotation_set.py +++ b/backend/api/serializers/annotation_set.py @@ -19,12 +19,6 @@ class Meta: model = AnnotationSet fields = ["id", "name", "desc", "tags"] - def __init__(self, *args, **kwargs): - with_tags = kwargs.pop("with_tags", True) - super().__init__(*args, **kwargs) - if not with_tags: - self.fields.pop("tags") - @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_tags(self, annotation_set): return list(annotation_set.tags.values_list("name", flat=True)) diff --git a/backend/api/serializers/annotation_task.py b/backend/api/serializers/annotation_task.py index a10b607d..1ca6804a 100644 --- a/backend/api/serializers/annotation_task.py +++ b/backend/api/serializers/annotation_task.py @@ -4,19 +4,23 @@ # pylint: disable=missing-function-docstring, abstract-method from datetime import datetime +from typing import Optional -from django.utils.http import urlquote from django.conf import settings - -from rest_framework import serializers - +from django.utils.http import urlquote from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers from backend.api.models import ( AnnotationTask, AnnotationResult, SpectroConfig, AnnotationComment, + AnnotationCampaignUsage, + AnnotationResultValidation, +) +from backend.api.serializers.annotation import ( + DetectorSerializer, ) from backend.api.serializers.annotation_comment import ( AnnotationCommentSerializer, @@ -24,19 +28,29 @@ from backend.api.serializers.confidence_indicator_set import ( ConfidenceIndicatorSetSerializer, ) +from .utils import EnumField class AnnotationTaskSerializer(serializers.ModelSerializer): """Serializer meant to output basic AnnotationTask data""" - filename = serializers.CharField(source="dataset_file.filename") - dataset_name = serializers.CharField(source="dataset_file.dataset.name") - start = serializers.DateTimeField(source="dataset_file.audio_metadatum.start") - end = serializers.DateTimeField(source="dataset_file.audio_metadatum.end") + filename = serializers.CharField() + dataset_name = serializers.CharField() + start = serializers.DateTimeField() + end = serializers.DateTimeField() + results_count = serializers.IntegerField() class Meta: model = AnnotationTask - fields = ["id", "status", "filename", "dataset_name", "start", "end"] + fields = [ + "id", + "status", + "filename", + "dataset_name", + "start", + "end", + "results_count", + ] class AnnotationTaskBoundarySerializer(serializers.Serializer): @@ -55,6 +69,7 @@ class AnnotationTaskResultSerializer(serializers.ModelSerializer): It is used for prevAnnotations field AnnotationTaskRetrieveSerializer """ + id = serializers.IntegerField(allow_null=True) annotation = serializers.CharField(source="annotation_tag.name") startTime = serializers.FloatField(source="start_time", allow_null=True) endTime = serializers.FloatField(source="end_time", allow_null=True) @@ -64,6 +79,8 @@ class AnnotationTaskResultSerializer(serializers.ModelSerializer): source="confidence_indicator", allow_null=True ) result_comments = AnnotationCommentSerializer(many=True, allow_null=True) + detector = serializers.SerializerMethodField() + validation = serializers.SerializerMethodField() class Meta: model = AnnotationResult @@ -76,6 +93,68 @@ class Meta: "endFrequency", "confidenceIndicator", "result_comments", + "detector", + "validation", + ] + + def __init__(self, *args, **kwargs): + if "user_id" in kwargs: + self.user_id = kwargs.pop("user_id") + super().__init__(*args, **kwargs) + + @extend_schema_field(DetectorSerializer(allow_null=True)) + def get_detector(self, result): + # type: (AnnotationResult) -> any + if result.detector_configuration is None: + return None + return DetectorSerializer( + result.detector_configuration.detector, + configuration=result.detector_configuration, + ).data + + @extend_schema_field(serializers.BooleanField(allow_null=True)) + def get_validation(self, result: AnnotationResult) -> Optional[bool]: + if result.annotation_campaign.usage == AnnotationCampaignUsage.CREATE: + return None + if self.user_id is None: + return None + validation = result.validations.filter(annotator_id=self.user_id) + if not validation.exists(): + return None + return validation.first().is_valid + + +class AnnotationTaskUpdateResultSerializer(serializers.ModelSerializer): + """ + Serializer meant to output basic AnnotationResult data + + It is used for prevAnnotations field AnnotationTaskRetrieveSerializer + """ + + id = serializers.IntegerField(allow_null=True, required=False) + annotation = serializers.CharField(source="annotation_tag.name") + startTime = serializers.FloatField(source="start_time", allow_null=True) + endTime = serializers.FloatField(source="end_time", allow_null=True) + startFrequency = serializers.FloatField(source="start_frequency", allow_null=True) + endFrequency = serializers.FloatField(source="end_frequency", allow_null=True) + confidenceIndicator = serializers.CharField( + source="confidence_indicator", allow_null=True + ) + result_comments = AnnotationCommentSerializer(many=True, allow_null=True) + validation = serializers.BooleanField(required=False) + + class Meta: + model = AnnotationResult + fields = [ + "id", + "annotation", + "startTime", + "endTime", + "startFrequency", + "endFrequency", + "confidenceIndicator", + "result_comments", + "validation", ] @@ -134,21 +213,33 @@ class AnnotationTaskRetrieveSerializer(serializers.Serializer): confidenceIndicatorSet = ConfidenceIndicatorSetSerializer( source="annotation_campaign.confidence_indicator_set" ) + mode = EnumField(enum=AnnotationCampaignUsage, source="annotation_campaign.usage") + instructions_url = serializers.CharField( + source="annotation_campaign.instructions_url" + ) + + def __init__(self, *args, **kwargs): + if "user_id" in kwargs: + self.user_id = kwargs.pop("user_id") + super().__init__(*args, **kwargs) @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_annotationTags(self, task): + # type:(AnnotationTask) -> list[str] return list( task.annotation_campaign.annotation_set.tags.values_list("name", flat=True) ) @extend_schema_field(ConfidenceIndicatorSetSerializer) def get_confidenceIndicatorSet(self, task): + # type:(AnnotationTask) -> any return ConfidenceIndicatorSetSerializer( task.annotation_campaign.confidence_indicator_set ).data @extend_schema_field(AnnotationTaskBoundarySerializer) def get_boundaries(self, task): + # type:(AnnotationTask) -> dict return { "startTime": task.dataset_file.audio_metadatum.start, "endTime": task.dataset_file.audio_metadatum.end, @@ -158,15 +249,18 @@ def get_boundaries(self, task): @extend_schema_field(serializers.CharField()) def get_audioUrl(self, task): # pylint: disable=invalid-name + # type:(AnnotationTask) -> str root_url = settings.STATIC_URL + task.dataset_file.dataset.dataset_path return f"{root_url}/{task.dataset_file.filepath}" @extend_schema_field(serializers.IntegerField()) def get_audioRate(self, task): + # type:(AnnotationTask) -> float return task.dataset_file.dataset_sr @extend_schema_field(AnnotationTaskSpectroSerializer(many=True)) def get_spectroUrls(self, task): + # type:(AnnotationTask) -> any spectros_configs = set(task.dataset_file.dataset.spectro_configs.all()) & set( task.annotation_campaign.spectro_configs.all() ) @@ -176,14 +270,30 @@ def get_spectroUrls(self, task): @extend_schema_field(AnnotationTaskResultSerializer(many=True)) def get_prevAnnotations(self, task): - queryset = task.results.prefetch_related( + # type:(AnnotationTask) -> any + queryset = AnnotationResult.objects.filter( + annotation_campaign_id=task.annotation_campaign_id, + dataset_file_id=task.dataset_file_id, + ) + if task.annotation_campaign.usage == AnnotationCampaignUsage.CREATE: + queryset = queryset.filter( + annotator_id=task.annotator_id, + ) + + queryset = queryset.prefetch_related( "annotation_tag", "confidence_indicator", "result_comments", + "validations", + "detector_configuration", + "detector_configuration__detector", ) - return AnnotationTaskResultSerializer(queryset, many=True).data + return AnnotationTaskResultSerializer( + queryset, many=True, user_id=self.user_id + ).data def get_prevAndNextAnnotation(self, task): + # type:(AnnotationTask) -> {"prev": int, "next": int} qs_list = list( AnnotationTask.objects.all() .filter( @@ -205,18 +315,20 @@ def get_prevAndNextAnnotation(self, task): @extend_schema_field(AnnotationCommentSerializer(many=True)) def get_taskComment(self, task): + print("get_taskComment", task.task_comment) return AnnotationCommentSerializer(task.task_comment, many=True).data class AnnotationTaskUpdateSerializer(serializers.Serializer): """This serializer is responsible for updating a task with new results from the annotator""" - annotations = AnnotationTaskResultSerializer(many=True) + annotations = AnnotationTaskUpdateResultSerializer(many=True) task_start_time = serializers.IntegerField() task_end_time = serializers.IntegerField() def validate_annotations(self, annotations): """Validates that annotations correspond to annotation set tags and set confidence indicator""" + print("validation in progress", annotations) set_tags = set( self.instance.annotation_campaign.annotation_set.tags.values_list( "name", flat=True @@ -255,7 +367,20 @@ def validate_annotations(self, annotations): return annotations + def _create_comments(self, comments_data, result, task): + if comments_data is not None: + for comment_data in comments_data: + comment_data.pop("annotation_result") + comment_data.pop("annotation_task") + comment = AnnotationComment.objects.create( + annotation_result=result, + annotation_task=task, + **comment_data, + ) + result.result_comments.set([comment]) + def _create_results(self, instance, validated_data): + # type:(AnnotationTask, any) -> AnnotationTask """The update of an AnnotationTask will delete previous results and add new ones (new annotations).""" tags = dict( @@ -288,31 +413,54 @@ def _create_results(self, instance, validated_data): annotation["confidence_indicator_id"] = confidence_indicators.get( annotation.pop("confidence_indicator") ) - new_result = instance.results.create(**annotation) - - if comments_data is not None: - for comment_data in comments_data: - comment_data.pop("annotation_result") - comment_data.pop("annotation_task") - comment = AnnotationComment.objects.create( - annotation_result=new_result, - annotation_task=instance, - **comment_data, - ) - new_result.result_comments.set([comment]) + new_result = AnnotationResult.objects.create( + dataset_file_id=instance.dataset_file_id, + annotation_campaign_id=instance.annotation_campaign_id, + annotator_id=instance.annotator_id, + **annotation, + ) - instance.sessions.create( - start=datetime.fromtimestamp(validated_data["task_start_time"]), - end=datetime.fromtimestamp(validated_data["task_end_time"]), - session_output=validated_data, - ) + self._create_comments( + comments_data=comments_data, result=new_result, task=instance + ) return instance def update(self, instance, validated_data): + # type:(AnnotationTask, any) -> AnnotationTask """The update of an AnnotationTask and change status.""" - instance.results.all().delete() - instance = self._create_results(instance, validated_data) + if instance.annotation_campaign.usage == AnnotationCampaignUsage.CREATE: + AnnotationResult.objects.filter( + annotation_campaign_id=instance.annotation_campaign_id, + annotator_id=instance.annotator_id, + dataset_file_id=instance.dataset_file_id, + ).delete() + instance = self._create_results(instance, validated_data) + else: + AnnotationResultValidation.objects.filter( + annotator_id=instance.annotator_id, + result__annotation_campaign_id=instance.annotation_campaign_id, + result__dataset_file_id=instance.dataset_file_id, + ).delete() + print(validated_data["annotations"]) + for annotation in validated_data["annotations"]: + print(annotation) + result = AnnotationResult.objects.get(pk=int(annotation.pop("id"))) + AnnotationResultValidation.objects.create( + is_valid=bool(annotation.pop("validation")), + annotator_id=instance.annotator_id, + result=result, + ) + comments_data = annotation.pop("result_comments") + self._create_comments( + comments_data=comments_data, result=result, task=instance + ) + + instance.sessions.create( + start=datetime.fromtimestamp(validated_data["task_start_time"]), + end=datetime.fromtimestamp(validated_data["task_end_time"]), + session_output=validated_data, + ) instance.status = 2 instance.save() @@ -326,6 +474,7 @@ class AnnotationTaskOneResultUpdateSerializer(AnnotationTaskUpdateSerializer): """ def update(self, instance, validated_data): + # type:(AnnotationTask, any) -> AnnotationTask instance = self._create_results(instance, validated_data) return instance diff --git a/backend/api/serializers/confidence_indicator_set.py b/backend/api/serializers/confidence_indicator_set.py index 7837db0b..3437d73f 100644 --- a/backend/api/serializers/confidence_indicator_set.py +++ b/backend/api/serializers/confidence_indicator_set.py @@ -25,9 +25,7 @@ class Meta: class ConfidenceIndicatorSetSerializer(serializers.ModelSerializer): """Serializer meant to output basic ConfidenceIndicatorSet data""" - confidenceIndicators = ConfidenceIndicatorSerializer( - many=True, source="confidence_indicators" - ) + confidence_indicators = ConfidenceIndicatorSerializer(many=True) class Meta: model = ConfidenceIndicatorSet @@ -35,11 +33,5 @@ class Meta: "id", "name", "desc", - "confidenceIndicators", + "confidence_indicators", ] - - def __init__(self, *args, **kwargs): - with_indicators = kwargs.pop("with_indicators", True) - super().__init__(*args, **kwargs) - if not with_indicators: - self.fields.pop("confidenceIndicators") diff --git a/backend/api/serializers/dataset.py b/backend/api/serializers/dataset.py index d6fbacd6..02d06bac 100644 --- a/backend/api/serializers/dataset.py +++ b/backend/api/serializers/dataset.py @@ -5,8 +5,6 @@ from rest_framework import serializers -from drf_spectacular.utils import extend_schema_field - from backend.api.models import Dataset, SpectroConfig @@ -21,8 +19,8 @@ class Meta: class DatasetSerializer(serializers.ModelSerializer): """Serializer meant to output basic Dataset data""" - files_count = serializers.SerializerMethodField() - type = serializers.SerializerMethodField() + files_count = serializers.IntegerField() + type = serializers.CharField() spectros = SpectroConfigSerializer(many=True, source="spectro_configs") class Meta: @@ -39,11 +37,3 @@ class Meta: "created_at", ] depth = 1 - - @extend_schema_field(serializers.IntegerField) - def get_files_count(self, dataset): - return dataset.files__count - - @extend_schema_field(serializers.CharField) - def get_type(self, dataset): - return dataset.dataset_type.name diff --git a/backend/api/serializers/user.py b/backend/api/serializers/user.py index 1a137802..f7d8feec 100644 --- a/backend/api/serializers/user.py +++ b/backend/api/serializers/user.py @@ -13,7 +13,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["id", "username", "email"] + fields = ["id", "username", "email", "first_name", "last_name"] class UserCreateSerializer(serializers.Serializer): diff --git a/backend/api/serializers/utils.py b/backend/api/serializers/utils.py new file mode 100644 index 00000000..623b7afa --- /dev/null +++ b/backend/api/serializers/utils.py @@ -0,0 +1,24 @@ +""" Serializer util functions """ +from rest_framework import serializers + + +class EnumField(serializers.ChoiceField): + """Serializer for enums""" + + def __init__(self, enum, **kwargs): + self.enum = enum + self.choices = enum.choices + kwargs["choices"] = [(e.name, e.name) for e in enum] + super().__init__(**kwargs) + + def to_representation(self, value): + return self.enum(value).label + + def to_internal_value(self, data): + + index = self.enum.labels.index(data) + value = self.enum.values[index] + try: + return self.enum(value) + except KeyError: + return self.fail("invalid_choice", input=data) diff --git a/backend/api/urls.py b/backend/api/urls.py new file mode 100644 index 00000000..87cf3414 --- /dev/null +++ b/backend/api/urls.py @@ -0,0 +1,33 @@ +""" APLOSE API Routing""" +from rest_framework import routers +from backend.api.views import ( + DatasetViewSet, + UserViewSet, + AnnotationSetViewSet, + AnnotationTaskViewSet, + AnnotationCampaignViewSet, + AnnotationCommentViewSet, + ConfidenceIndicatorSetViewSet, + DetectorViewSet, +) + +# API urls are meant to be used by our React frontend +api_router = routers.DefaultRouter() +api_router.register(r"dataset", DatasetViewSet, basename="dataset") +api_router.register(r"user", UserViewSet, basename="user") +api_router.register(r"detector", DetectorViewSet, basename="detector") +api_router.register(r"annotation-set", AnnotationSetViewSet, basename="annotation-set") +api_router.register( + r"annotation-campaign", AnnotationCampaignViewSet, basename="annotation-campaign" +) +api_router.register( + r"annotation-comment", AnnotationCommentViewSet, basename="annotation-comment" +) +api_router.register( + r"annotation-task", AnnotationTaskViewSet, basename="annotation-task" +) +api_router.register( + r"confidence-indicator", + ConfidenceIndicatorSetViewSet, + basename="confidence-indicator", +) diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py index a64e486e..a44bfe23 100644 --- a/backend/api/views/__init__.py +++ b/backend/api/views/__init__.py @@ -9,3 +9,4 @@ from backend.api.views.annotation_comment import AnnotationCommentViewSet from backend.api.views.annotation_task import AnnotationTaskViewSet from backend.api.views.confidence_indicators import ConfidenceIndicatorSetViewSet +from .annotation import DetectorViewSet diff --git a/backend/api/views/annotation/__init__.py b/backend/api/views/annotation/__init__.py new file mode 100644 index 00000000..19875dd0 --- /dev/null +++ b/backend/api/views/annotation/__init__.py @@ -0,0 +1,2 @@ +"""Annotation related views""" +from .detector import DetectorViewSet diff --git a/backend/api/views/annotation/detector.py b/backend/api/views/annotation/detector.py new file mode 100644 index 00000000..120fa8e2 --- /dev/null +++ b/backend/api/views/annotation/detector.py @@ -0,0 +1,14 @@ +"""APLOSE - Detector""" +from rest_framework import viewsets + +from backend.api.models import Detector +from backend.api.serializers import DetectorSerializer + + +class DetectorViewSet(viewsets.ReadOnlyModelViewSet): + """ + `list`, `retrieve` detectors + """ + + queryset = Detector.objects.all() + serializer_class = DetectorSerializer diff --git a/backend/api/views/annotation_campaign.py b/backend/api/views/annotation_campaign.py index 004b8ee6..264a52ed 100644 --- a/backend/api/views/annotation_campaign.py +++ b/backend/api/views/annotation_campaign.py @@ -1,29 +1,30 @@ """Annotation campaign DRF-Viewset file""" -from datetime import timedelta - from django.db import transaction -from django.db.models import Q from django.db.models.functions import Lower from django.http import HttpResponse from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema, OpenApiExample from rest_framework import viewsets from rest_framework.decorators import action +from rest_framework.request import Request from rest_framework.response import Response from backend.api.models import ( AnnotationCampaign, AnnotationResult, + AnnotationResultValidation, AnnotationTask, AnnotationComment, + AnnotationCampaignUsage, ) from backend.api.serializers import ( AnnotationCampaignListSerializer, AnnotationCampaignRetrieveSerializer, - AnnotationCampaignCreateSerializer, AnnotationCampaignRetrieveAuxCampaignSerializer, AnnotationCampaignAddAnnotatorsSerializer, + AnnotationCampaignCreateCheckAnnotationsSerializer, + AnnotationCampaignCreateCreateAnnotationsSerializer, ) from backend.utils.renderers import CSVRenderer @@ -40,22 +41,23 @@ def list(self, request): """List annotation campaigns""" queryset = self.queryset.raw( """ - SELECT id, - name, + SELECT campaign.id, + campaign.name, "desc", instructions_url, start, "end", - tasks.count as tasks_count, + usage, tasks.complete_count as complete_tasks_count, tasks.user_count as user_tasks_count, tasks.user_complete_count as user_complete_tasks_count, files.count as files_count, + confidence.name as confidence_indicator_set_name, + annotation.name as annotation_set_name, created_at FROM annotation_campaigns campaign - LEFT OUTER JOIN + LEFT OUTER JOIN (SELECT annotation_campaign_id, - count(*), count(*) filter(where status = 2) as complete_count, count(*) filter(where annotator_id = %s) as user_count, count(*) filter(where annotator_id = %s and status = 2) as user_complete_count @@ -63,14 +65,22 @@ def list(self, request): WHERE %s or annotator_id = %s group by annotation_campaign_id) tasks on tasks.annotation_campaign_id = campaign.id - LEFT OUTER JOIN + LEFT OUTER JOIN (SELECT annotationcampaign_id, count(*) FROM dataset_files LEFT OUTER JOIN annotation_campaigns_datasets on dataset_files.dataset_id = annotation_campaigns_datasets.dataset_id group by annotationcampaign_id) files on files.annotationcampaign_id = campaign.id + LEFT OUTER JOIN + (SELECT id, name + FROM confidence_sets) confidence + on confidence.id = campaign.confidence_indicator_set_id + LEFT OUTER JOIN + (SELECT id, name + FROM annotation_sets) annotation + on annotation.id = campaign.annotation_set_id WHERE tasks.user_count is not null or %s - ORDER BY lower(name), created_at""", + ORDER BY lower(campaign.name), created_at""", ( request.user.id, request.user.id, @@ -91,12 +101,20 @@ def retrieve(self, request, pk=None): @transaction.atomic @extend_schema( - request=AnnotationCampaignCreateSerializer, responses=AnnotationCampaignRetrieveAuxCampaignSerializer, ) def create(self, request): + # type: (Request) -> Response """Create a new annotation campaign""" - create_serializer = AnnotationCampaignCreateSerializer(data=request.data) + create_serializer = None + if request.data["usage"] == AnnotationCampaignUsage.CREATE.label: + create_serializer = AnnotationCampaignCreateCreateAnnotationsSerializer( + data=request.data + ) + elif request.data["usage"] == AnnotationCampaignUsage.CHECK.label: + create_serializer = AnnotationCampaignCreateCheckAnnotationsSerializer( + data=request.data + ) create_serializer.is_valid(raise_exception=True) campaign = create_serializer.save(owner_id=request.user.id) serializer = AnnotationCampaignRetrieveAuxCampaignSerializer(campaign) @@ -139,6 +157,7 @@ def add_annotators(self, request, pk=None): ) @action(detail=True, renderer_classes=[CSVRenderer]) def report(self, request, pk=None): + # type: (any, int) -> Response """Returns the CSV report for the given campaign""" # pylint: disable=too-many-locals campaign = get_object_or_404( @@ -147,8 +166,133 @@ def report(self, request, pk=None): ), pk=pk, ) + + results = AnnotationResult.objects.raw( + """ + SELECT dataset_name, + filename, + COALESCE(start_time, 0) as start_time, + COALESCE(end_time, duration) as end_time, + COALESCE(start_frequency, 0) as start_frequency, + COALESCE(end_frequency, sample_rate / 2) as end_frequency, + tag.name as annotation, + username as annotator_name, + detector.name as detector_name, + (extract(EPOCH FROM start) + COALESCE(start_time, 0)) * 1000 as start_date, + (extract(EPOCH FROM start) + COALESCE(end_time, duration)) * 1000 as end_date, + CASE + WHEN campaign.annotation_scope = 1 or + not (start_time is null or end_time is null or start_frequency is null or end_frequency is null) + THEN 1 + else 0 end as is_box, + CASE + WHEN confidence.label is null then '' + else confidence.label end as confidence_label, + CASE + WHEN confidence_indicator_id is null then '' + else CONCAT(confidence.level, '/', max_confidence_level) end as confidence_level, + CASE + WHEN comment is null then '' + else concat(comment, ' |- ', username) end as comment, + concat(comment, ' |- ', username) as comment_content, + result.id + FROM annotation_results result + + LEFT OUTER JOIN (SELECT f.id, + filename, + d.name as dataset_name, + start, + "end", + duration, + COALESCE(m.dataset_sr, d.dataset_sr) as sample_rate + FROM dataset_files f + + LEFT OUTER JOIN (SELECT datasets.id, name, dataset_sr + FROM datasets + LEFT OUTER JOIN audio_metadata am + on datasets.audio_metadatum_id = am.id) d + on d.id = f.dataset_id + + LEFT OUTER JOIN (SELECT id, + start, + "end", + dataset_sr, + extract(EPOCH FROM ("end" - start)) as duration + FROM audio_metadata) m on m.id = f.audio_metadatum_id) file + on file.id = result.dataset_file_id + + LEFT OUTER JOIN (SELECT id, name + FROM annotation_tags) tag on tag.id = result.annotation_tag_id + + LEFT OUTER JOIN (SELECT id, username + FROM auth_user) annotator on annotator.id = result.annotator_id + + LEFT OUTER JOIN (SELECT id, detector_id + FROM api_detectorconfiguration) detector_config + on detector_config.id = result.detector_configuration_id + + LEFT OUTER JOIN (SELECT id, name + FROM api_detector) detector on detector.id = detector_config.detector_id + + LEFT OUTER JOIN (SELECT indicator.id, label, level + FROM confidence_indicator indicator) confidence + on confidence.id = result.confidence_indicator_id + + LEFT OUTER JOIN (SELECT campaign.id, + annotation_scope, + max_confidence_level + FROM annotation_campaigns campaign + LEFT OUTER JOIN (SELECT confidence_indicator_set_id, + max(level) as max_confidence_level + FROM confidence_indicator indicator + GROUP BY confidence_indicator_set_id) confidence + on confidence.confidence_indicator_set_id = + campaign.confidence_indicator_set_id) campaign + on campaign.id = result.annotation_campaign_id + + LEFT OUTER JOIN (SELECT annotation_result_id, comment + FROM annotation_comment) comments + on comments.annotation_result_id = result.id + WHERE annotation_campaign_id = %s + """, + (pk,), + ) + comments = AnnotationComment.objects.raw( + """ + SELECT name as dataset_name, + filename, + '' as start_time, + '' as end_time, + '' as start_frequency, + '' as end_frequency, + '' as annotation, + username as annotator_name, + '' as start_date, + '' as end_date, + '' as is_box, + '' as confidence_label, + '' as confidence_level, + concat(comment, ' |- ', username) as comment, + concat(comment, ' |- ', username) as comment_content, + annotation_comment.id + FROM annotation_comment + LEFT OUTER JOIN (select id, dataset_file_id, annotator_id, annotation_campaign_id + FROM annotation_tasks) t on t.id = annotation_comment.annotation_task_id + + LEFT OUTER JOIN (select id, dataset_id, filename + FROM dataset_files) f on f.id = t.dataset_file_id + + LEFT OUTER JOIN (select id, name + FROM datasets) d on d.id = f.dataset_id + + LEFT OUTER JOIN (select id, username + FROM auth_user) u on u.id = t.annotator_id + WHERE annotation_result_id is null and t.annotation_campaign_id = %s + """, + (pk,), + ) data = [ - [ + [ # headers "dataset", "filename", "start_time", @@ -165,106 +309,92 @@ def report(self, request, pk=None): "comments", ] ] - - results = AnnotationResult.objects.prefetch_related( - "annotation_task", - "confidence_indicator", - "annotation_task__annotator", - "annotation_task__dataset_file", - "annotation_task__dataset_file__dataset", - "annotation_task__dataset_file__audio_metadatum", - "annotation_tag", - "result_comments", - ).filter(annotation_task__annotation_campaign_id=pk) - - task_comments = AnnotationComment.objects.prefetch_related( - "annotation_task" - ).filter( - Q(annotation_task__annotation_campaign_id=pk) - & Q(annotation_result__isnull=True) - ) - - for result in results: - confidence_indicator_and_lvl_max = "" - if ( - campaign.confidence_indicator_set is not None - and result.confidence_indicator is not None - ): - max_level = campaign.confidence_indicator_set.max_level - confidence_indicator_and_lvl_max = ( - f"{result.confidence_indicator.level}/{max_level}" + if campaign.usage == AnnotationCampaignUsage.CREATE: + for row in list(results) + list(comments): + data.append( + [ + row.dataset_name, + row.filename, + str(row.start_time), + str(row.end_time), + str(row.start_frequency), + str(row.end_frequency), + row.annotation, + row.annotator_name, + str(row.start_date), + str(row.end_date), + str(row.is_box), + str(row.confidence_label), + str(row.confidence_level), + str(row.comment), + ] ) - confidence_indicator_label = "" - if result.confidence_indicator is not None: - confidence_indicator_label = result.confidence_indicator.label - - audio_meta = result.annotation_task.dataset_file.audio_metadatum - max_frequency = result.annotation_task.dataset_file.dataset_sr / 2 - max_time = (audio_meta.end - audio_meta.start).seconds - is_box = ( - campaign.annotation_scope - == AnnotationCampaign.AnnotationScope.RECTANGLE - or ( - result.start_time is not None - and result.end_time is not None - and result.start_frequency is not None - and result.end_frequency is not None + if campaign.usage == AnnotationCampaignUsage.CHECK: + validations = ( + AnnotationResultValidation.objects.filter( + result__annotation_campaign=campaign ) + .prefetch_related("annotator") + .order_by("annotator__username") ) - result_comments = result.result_comments.all() - if result_comments: - task = result.annotation_task - comment = f"{result_comments[0].comment} |- {task.annotator.username}" - else: - comment = "" - - data.append( - [ - result.annotation_task.dataset_file.dataset.name, - result.annotation_task.dataset_file.filename, - str(result.start_time or "0"), - str(result.end_time or str(max_time)), - str(result.start_frequency or "0"), - str(result.end_frequency or max_frequency), - result.annotation_tag.name, - result.annotation_task.annotator.username, - ( - audio_meta.start + timedelta(seconds=(result.start_time or 0)) - ).isoformat(timespec="milliseconds"), - ( - audio_meta.start - + timedelta(seconds=(result.end_time or max_time)) - ).isoformat(timespec="milliseconds"), - "1" if is_box else "0", - confidence_indicator_label, - confidence_indicator_and_lvl_max, - comment, - ] + print( + ">>> ", + list( + validations.values_list("annotator__username", flat=True).distinct() + ), ) - - for task_comment in task_comments: - task = task_comment.annotation_task - comment = f"{task_comment.comment} |- {task.annotator.username} : {task.annotator.email}" - - data.append( - [ - task_comment.annotation_task.dataset_file.dataset.name, - task_comment.annotation_task.dataset_file.filename, - "", - "", - "", - "", - "", - task_comment.annotation_task.annotator.username, - "", - "", - "", - "", - "", - comment, - ] + data[0] = data[0] + list( + validations.values_list("annotator__username", flat=True).distinct() ) + print(">>> ", data) + for row in list(results): + val_data = validations.filter(result__id=row.id) + r_data = [ + row.dataset_name, + row.filename, + str(row.start_time), + str(row.end_time), + str(row.start_frequency), + str(row.end_frequency), + row.annotation, + row.detector_name, + str(row.start_date), + str(row.end_date), + str(row.is_box), + str(row.confidence_label), + str(row.confidence_level), + str(row.comment), + ] + for user_val in val_data: + print( + ">>> ", + user_val.annotator.username, + user_val.is_valid, + "'" + str(user_val.is_valid) + "'", + ) + r_data.append(str(user_val.is_valid)) + data.append(r_data) + for row in list(comments): + data.append( + [ + row.dataset_name, + row.filename, + str(row.start_time), + str(row.end_time), + str(row.start_frequency), + str(row.end_frequency), + row.annotation, + row.annotator_name, + str(row.start_date), + str(row.end_date), + str(row.is_box), + str(row.confidence_label), + str(row.confidence_level), + str(row.comment), + ] + ) + print(">>> ", data) response = Response(data) response[ "Content-Disposition" diff --git a/backend/api/views/annotation_task.py b/backend/api/views/annotation_task.py index 60117c83..aea215e8 100644 --- a/backend/api/views/annotation_task.py +++ b/backend/api/views/annotation_task.py @@ -1,19 +1,14 @@ """Annotation task DRF-Viewset file""" -from django.http import HttpResponse -from django.shortcuts import get_object_or_404 -from django.db.models import Prefetch - from django.db import transaction - +from django.db.models import Prefetch, F, OuterRef, Subquery, Func +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework import viewsets -from rest_framework.response import Response from rest_framework.decorators import action - -from drf_spectacular.utils import extend_schema, OpenApiParameter +from rest_framework.response import Response from backend.api.models import ( - User, AnnotationCampaign, AnnotationTask, AnnotationComment, @@ -48,34 +43,20 @@ class AnnotationTaskViewSet(viewsets.ViewSet): @action(detail=False, url_path="campaign/(?P[^/.]+)") def campaign_list(self, request, campaign_id): """List tasks for given annotation campaign""" - get_object_or_404(AnnotationCampaign, pk=campaign_id) - queryset = self.queryset.filter( - annotator_id=request.user.id, annotation_campaign_id=campaign_id - ).prefetch_related("dataset_file", "dataset_file__dataset") - serializer = self.serializer_class(queryset, many=True) - return Response(serializer.data) + campaign = get_object_or_404(AnnotationCampaign, pk=campaign_id) + print(campaign.results.count()) + queryset = campaign.tasks.filter(annotator_id=request.user.id).annotate( + filename=F("dataset_file__filename"), + start=F("dataset_file__audio_metadatum__start"), + end=F("dataset_file__audio_metadatum__end"), + dataset_name=F("dataset_file__dataset__name"), + results_count=Subquery( + campaign.results.filter(dataset_file_id=OuterRef("dataset_file_id")) + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ), + ) - @extend_schema( - parameters=[ - OpenApiParameter("campaign_id", int, OpenApiParameter.PATH), - OpenApiParameter("user_id", int, OpenApiParameter.PATH), - ], - responses=AnnotationTaskSerializer(many=True), - ) - @action( - detail=False, - url_path="campaign/(?P[^/.]+)/user/(?P[^/.]+)", - ) - def campaign_user_list(self, request, campaign_id, user_id): - """List tasks for given annotation campaign and user""" - annotation_campaign = get_object_or_404(AnnotationCampaign, pk=campaign_id) - _user = get_object_or_404(User, pk=user_id) - if not request.user.is_staff and not request.user == annotation_campaign.owner: - return HttpResponse("Unauthorized", status=403) - - queryset = self.queryset.filter( - annotator_id=user_id, annotation_campaign_id=campaign_id - ).prefetch_related("dataset_file", "dataset_file__dataset") serializer = self.serializer_class(queryset, many=True) return Response(serializer.data) @@ -102,7 +83,7 @@ def retrieve(self, request, pk): if task.status == 0: task.status = 1 task.save() - serializer = AnnotationTaskRetrieveSerializer(task) + serializer = AnnotationTaskRetrieveSerializer(task, user_id=request.user.id) return Response(serializer.data) @@ -123,6 +104,7 @@ def update(self, request, pk): if ( "task_comments" in request.data.keys() and request.data["task_comments"] is not None + and len(request.data["task_comments"]) > 0 ): for comment in request.data["task_comments"]: comment.pop("annotation_task") @@ -133,6 +115,10 @@ def update(self, request, pk): ) comment_obj.comment = message comment_obj.save() + else: + AnnotationComment.objects.filter( + annotation_task=task, annotation_result=None + ).delete() task_date = task.dataset_file.audio_metadatum.start next_tasks = self.queryset.filter( diff --git a/backend/api/views/dataset.py b/backend/api/views/dataset.py index ad38250e..535fc156 100644 --- a/backend/api/views/dataset.py +++ b/backend/api/views/dataset.py @@ -1,21 +1,19 @@ """Dataset DRF-Viewset file""" import csv -from django.db.models import Count -from django.db.models.functions import Lower -from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.conf import settings - +from django.db.models import Count, OuterRef, Subquery +from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from rest_framework import viewsets -from rest_framework.response import Response from rest_framework.decorators import action - +from rest_framework.response import Response from sentry_sdk import capture_exception -from backend.api.models import Dataset + from backend.api.actions import datawork_import from backend.api.actions.check_new_spectro_config_errors import ( check_new_spectro_config_errors, ) +from backend.api.models import Dataset from backend.api.serializers import DatasetSerializer @@ -28,12 +26,25 @@ class DatasetViewSet(viewsets.ViewSet): def list(self, request): """List available datasets""" - queryset = ( - Dataset.objects.annotate(Count("files")) - .select_related("dataset_type") - .prefetch_related("spectro_configs") - .order_by(Lower("name"), "created_at") - ) + queryset = Dataset.objects.raw( + """ + SELECT datasets.id, + datasets.name, + files_type, + start_date, + end_date, + created_at, + files.count as files_count, + type.name as type + FROM datasets + LEFT OUTER JOIN (SELECT dataset_id, count(*) + FROM dataset_files group by dataset_id) files + on files.dataset_id = datasets.id + LEFT OUTER JOIN (SELECT id, name + FROM dataset_types) type + on type.id = datasets.dataset_type_id + """ + ).prefetch_related("spectro_configs") serializer = self.serializer_class(queryset, many=True) @@ -70,7 +81,7 @@ def list_to_import(self, request): def datawork_import(self, request): """Import new datasets from datawork""" if not request.user.is_staff: - return HttpResponse("Unauthorized", status=403) + return HttpResponse("Forbidden", status=403) try: new_datasets = datawork_import( @@ -90,7 +101,13 @@ def datawork_import(self, request): status=400, ) - queryset = new_datasets.annotate(Count("files")).select_related("dataset_type") + queryset = new_datasets.annotate( + files_count=Count("files"), + type=Subquery( + new_datasets.filter(pk=OuterRef("pk")).values("dataset_type__name")[:1] + ), + ) + print(queryset) serializer = self.serializer_class(queryset, many=True) errors = check_new_spectro_config_errors() diff --git a/backend/tests/fixtures/annotation_results_sessions.yaml b/backend/tests/fixtures/annotation_results_sessions.yaml index d672467f..ce404729 100644 --- a/backend/tests/fixtures/annotation_results_sessions.yaml +++ b/backend/tests/fixtures/annotation_results_sessions.yaml @@ -6,8 +6,11 @@ start_frequency: 6432.0 end_frequency: 12864.0 annotation_tag: 2 - annotation_task: 7 +# annotation_task: 7 + annotator: 4 + annotation_campaign: 1 confidence_indicator: 1 + dataset_file: 7 - model: api.annotationresult pk: 2 fields: @@ -16,8 +19,11 @@ start_frequency: 3936.0 end_frequency: 9888.0 annotation_tag: 4 - annotation_task: 7 +# annotation_task: 7 + annotator: 4 + annotation_campaign: 1 confidence_indicator: 2 + dataset_file: 7 - model: api.annotationresult pk: 3 fields: @@ -26,8 +32,11 @@ start_frequency: 11232.0 end_frequency: 14976.0 annotation_tag: 5 - annotation_task: 7 +# annotation_task: 7 + annotator: 4 + annotation_campaign: 1 confidence_indicator: 1 + dataset_file: 7 - model: api.annotationresult pk: 4 fields: @@ -36,8 +45,11 @@ start_frequency: 5216.0 end_frequency: 13024.0 annotation_tag: 1 - annotation_task: 8 +# annotation_task: 8 + annotator: 4 + annotation_campaign: 1 confidence_indicator: 1 + dataset_file: 8 - model: api.annotationresult pk: 5 fields: @@ -46,8 +58,11 @@ start_frequency: 11104.0 end_frequency: 14912.0 annotation_tag: 3 - annotation_task: 8 +# annotation_task: 8 + annotator: 4 + annotation_campaign: 1 confidence_indicator: 1 + dataset_file: 8 - model: api.annotationresult pk: 6 fields: @@ -56,8 +71,11 @@ start_frequency: 4160.0 end_frequency: 7680.0 annotation_tag: 1 - annotation_task: 8 +# annotation_task: 8 + annotator: 4 + annotation_campaign: 1 confidence_indicator: 2 + dataset_file: 8 - model: api.annotationresult pk: 7 fields: @@ -66,8 +84,11 @@ start_frequency: 7520.0 end_frequency: 13696.0 annotation_tag: 2 - annotation_task: 1 +# annotation_task: 1 + annotator: 1 + annotation_campaign: 1 confidence_indicator: 1 + dataset_file: 1 - model: api.annotationresult pk: 8 fields: @@ -76,8 +97,11 @@ start_frequency: 7008.0 end_frequency: 13056.0 annotation_tag: 5 - annotation_task: 1 +# annotation_task: 1 + annotation_campaign: 1 + annotator: 1 confidence_indicator: 1 + dataset_file: 1 - model: api.annotationresult pk: 9 fields: @@ -86,8 +110,11 @@ start_frequency: 5088.0 end_frequency: 10880.0 annotation_tag: 4 - annotation_task: 1 +# annotation_task: 1 + annotator: 1 + annotation_campaign: 1 confidence_indicator: 2 + dataset_file: 1 - model: api.annotationsession pk: 1 fields: @@ -113,6 +140,8 @@ task_end_time: 1643037084 task_start_time: 1643037067 annotation_task: 7 +# annotator: 4 +# annotation_campaign: 1 - model: api.annotationsession pk: 2 fields: @@ -138,6 +167,8 @@ task_end_time: 1643037095 task_start_time: 1643037085 annotation_task: 8 +# annotator: 4 +# annotation_campaign: 1 - model: api.annotationsession pk: 3 fields: @@ -163,3 +194,5 @@ task_end_time: 1643037163 task_start_time: 1643037155 annotation_task: 1 +# annotator: 1 +# annotation_campaign: 1 diff --git a/backend/tests/fixtures/annotation_sets.yaml b/backend/tests/fixtures/annotation_sets.yaml index 1e1e3cdf..aeaf7dd1 100644 --- a/backend/tests/fixtures/annotation_sets.yaml +++ b/backend/tests/fixtures/annotation_sets.yaml @@ -31,7 +31,6 @@ fields: name: Test SPM campaign desc: Annotation set made for Test SPM campaign - owner: 1 tags: - 1 - 2 @@ -43,7 +42,6 @@ fields: name: Test DCLDE LF campaign desc: Test annotation campaign DCLDE LF 2015 - owner: 1 tags: - 6 - 7 diff --git a/backend/tests/fixtures/datasets.yaml b/backend/tests/fixtures/datasets.yaml index 6bd72602..e770feef 100644 --- a/backend/tests/fixtures/datasets.yaml +++ b/backend/tests/fixtures/datasets.yaml @@ -19,7 +19,6 @@ dataset_type: 1 geo_metadatum: 1 owner: 1 - tabular_metadatum: null - model: api.dataset pk: 2 fields: @@ -36,7 +35,6 @@ dataset_type: 1 geo_metadatum: 1 owner: 1 - tabular_metadatum: null - model: api.datasetfile pk: 1 fields: @@ -45,7 +43,6 @@ size: 58982478 dataset: 1 audio_metadatum: 2 - tabular_metadatum: null - model: api.datasetfile pk: 2 fields: @@ -54,7 +51,6 @@ size: 58982478 dataset: 1 audio_metadatum: 3 - tabular_metadatum: null - model: api.datasetfile pk: 3 fields: @@ -63,7 +59,6 @@ size: 58982478 dataset: 1 audio_metadatum: 4 - tabular_metadatum: null - model: api.datasetfile pk: 4 fields: @@ -72,7 +67,6 @@ size: 58982478 dataset: 1 audio_metadatum: 5 - tabular_metadatum: null - model: api.datasetfile pk: 5 fields: @@ -81,7 +75,6 @@ size: 58982478 dataset: 1 audio_metadatum: 6 - tabular_metadatum: null - model: api.datasetfile pk: 6 fields: @@ -90,7 +83,6 @@ size: 58982478 dataset: 1 audio_metadatum: 7 - tabular_metadatum: null - model: api.datasetfile pk: 7 fields: @@ -99,7 +91,6 @@ size: 58982478 dataset: 1 audio_metadatum: 8 - tabular_metadatum: null - model: api.datasetfile pk: 8 fields: @@ -108,7 +99,6 @@ size: 58982478 dataset: 1 audio_metadatum: 9 - tabular_metadatum: null - model: api.datasetfile pk: 9 fields: @@ -117,7 +107,6 @@ size: 58982478 dataset: 1 audio_metadatum: 10 - tabular_metadatum: null - model: api.datasetfile pk: 10 fields: @@ -126,7 +115,6 @@ size: 58982478 dataset: 1 audio_metadatum: 11 - tabular_metadatum: null - model: api.datasetfile pk: 11 fields: @@ -135,14 +123,13 @@ size: 58982478 dataset: 1 audio_metadatum: 12 - tabular_metadatum: null - model: api.audiometadatum pk: 1 fields: start: null end: null channel_count: 1 - dataset_sr: 32768.0 + dataset_sr: 128000.0 total_samples: 88473600 sample_bits: 16 gain_db: 22.0 @@ -155,7 +142,7 @@ start: 2012-10-03 10:00:00+00:00 end: 2012-10-03 10:15:00+00:00 channel_count: null - dataset_sr: null + dataset_sr: 128000 total_samples: null sample_bits: null gain_db: null @@ -168,7 +155,7 @@ start: 2012-10-03 11:00:00+00:00 end: 2012-10-03 11:15:00+00:00 channel_count: null - dataset_sr: null + dataset_sr: 128000 total_samples: null sample_bits: null gain_db: null diff --git a/backend/tests/serializers/annotation_campaign.py b/backend/tests/serializers/annotation_campaign.py index 0f3bad7e..032fede3 100644 --- a/backend/tests/serializers/annotation_campaign.py +++ b/backend/tests/serializers/annotation_campaign.py @@ -6,8 +6,8 @@ from copy import deepcopy from backend.api.serializers import ( - AnnotationCampaignCreateSerializer, AnnotationCampaignAddAnnotatorsSerializer, + AnnotationCampaignCreateCreateAnnotationsSerializer, ) from backend.api.models import AnnotationCampaign, AnnotationTask @@ -29,7 +29,7 @@ class AnnotationCampaignCreateSerializerTestCase(TestCase): "instructions_url": "https://instructions.org", "start": "2022-01-25T10:42:15Z", "end": "2022-01-30T10:42:15Z", - "annotation_set_id": 1, + "annotation_set": 1, "confidence_indicator_set_id": 1, "datasets": [1], "annotators": [1, 2], @@ -37,6 +37,7 @@ class AnnotationCampaignCreateSerializerTestCase(TestCase): "annotation_goal": 1, "annotation_scope": 1, "spectro_configs": [1], + "usage": "Create", } def test_with_valid_data(self): @@ -44,7 +45,9 @@ def test_with_valid_data(self): old_count = AnnotationCampaign.objects.count() old_tasks_count = AnnotationTask.objects.count() - create_serializer = AnnotationCampaignCreateSerializer(data=self.creation_data) + create_serializer = AnnotationCampaignCreateCreateAnnotationsSerializer( + data=self.creation_data + ) create_serializer.is_valid(raise_exception=True) create_serializer.save(owner_id=1) self.assertEqual(AnnotationCampaign.objects.count(), old_count + 1) @@ -60,15 +63,14 @@ def test_with_wrong_spectros(self): update_data = deepcopy(self.creation_data) update_data["spectro_configs"] = [3] - create_serializer = AnnotationCampaignCreateSerializer( + create_serializer = AnnotationCampaignCreateCreateAnnotationsSerializer( campaign, data=update_data ) self.assertFalse(create_serializer.is_valid()) - self.assertEqual(list(create_serializer.errors.keys()), ["non_field_errors"]) - self.assertEqual(len(create_serializer.errors["non_field_errors"]), 1) + self.assertEqual(list(create_serializer.errors.keys()), ["spectro_configs"]) self.assertEqual( - str(create_serializer.errors["non_field_errors"][0]), - "{3} not valid ids for spectro configs of given datasets", + str(create_serializer.errors["spectro_configs"][0]), + "['spectro_config2 - Another Dataset'] not valid ids for spectro configs of given datasets (['SPM Aural A 2010'])", ) @@ -112,16 +114,10 @@ def test_without_annotation_goal(self): """Fails validation when given an unknown tag with correct message""" add_annotators_data = deepcopy(self.add_annotators_data) add_annotators_data.pop("annotation_goal") - new_annotator = User.objects.get(id=add_annotators_data["annotators"][0]) - old_user_count = new_annotator.annotation_tasks.count() campaign = AnnotationCampaign.objects.first() - old_tasks_count = campaign.tasks.count() update_serializer = AnnotationCampaignAddAnnotatorsSerializer( campaign, data=add_annotators_data ) - update_serializer.is_valid(raise_exception=True) - update_serializer.save() - campaign.refresh_from_db() - self.assertEqual(new_annotator.annotation_tasks.count(), old_user_count + 11) - self.assertEqual(campaign.tasks.count(), old_tasks_count + 11) + self.assertFalse(update_serializer.is_valid()) + self.assertEqual(list(update_serializer.errors.keys()), ["annotation_goal"]) diff --git a/backend/tests/serializers/annotation_task.py b/backend/tests/serializers/annotation_task.py index 1b615d1b..dd14bd3f 100644 --- a/backend/tests/serializers/annotation_task.py +++ b/backend/tests/serializers/annotation_task.py @@ -4,8 +4,8 @@ from django.test import TestCase +from backend.api.models import AnnotationTask, AnnotationResult from backend.api.serializers import AnnotationTaskUpdateSerializer -from backend.api.models import AnnotationTask class AnnotationTaskUpdateSerializerTestCase(TestCase): @@ -37,21 +37,32 @@ class AnnotationTaskUpdateSerializerTestCase(TestCase): def test_with_valid_data(self): """Updates correctly the DB when serializer saves with correct data""" - task = AnnotationTask.objects.first() + task = AnnotationTask.objects.first() # type: AnnotationTask self.assertEqual(task.status, 0) - self.assertEqual(task.results.count(), 3) + results_count = AnnotationResult.objects.filter( + annotation_campaign_id=task.annotation_campaign_id, + dataset_file_id=task.dataset_file_id, + annotator_id=task.annotator_id, + ).count() + self.assertEqual(results_count, 3) update_serializer = AnnotationTaskUpdateSerializer(task, data=self.update_data) update_serializer.is_valid(raise_exception=True) update_serializer.save() task.refresh_from_db() + results_count = AnnotationResult.objects.filter( + annotation_campaign_id=task.annotation_campaign_id, + dataset_file_id=task.dataset_file_id, + annotator_id=task.annotator_id, + ).count() self.assertEqual(task.status, 2) - self.assertEqual(task.results.count(), 1) + self.assertEqual(results_count, 1) def test_with_unknown_tags(self): """Fails validation when given an unknown tag with correct message""" task = AnnotationTask.objects.first() update_data = deepcopy(self.update_data) update_data["annotations"][0]["annotation"] = "Unknown" + update_data["id"] = task.id update_serializer = AnnotationTaskUpdateSerializer(task, data=update_data) self.assertFalse(update_serializer.is_valid()) self.assertEqual(list(update_serializer.errors.keys()), ["annotations"]) diff --git a/backend/tests/views/annotation_campaign.py b/backend/tests/views/annotation_campaign.py index 89996830..0d8688d0 100644 --- a/backend/tests/views/annotation_campaign.py +++ b/backend/tests/views/annotation_campaign.py @@ -1,15 +1,14 @@ """Annotation campaign DRF-Viewset test file""" -from freezegun import freeze_time -from django.urls import reverse -from django.utils.dateparse import parse_datetime from django.contrib.auth.models import User - +from django.urls import reverse +from freezegun import freeze_time from rest_framework import status from rest_framework.test import APITestCase from backend.api.models import ( AnnotationCampaign, AnnotationTask, + Dataset, ) @@ -65,8 +64,8 @@ class AnnotationCampaignViewSetTestCase(APITestCase): "instructions_url": "string", "start": "2022-01-25T10:42:15Z", "end": "2022-01-30T10:42:15Z", - "annotation_set_id": 1, - "confidence_indicator_set_id": 1, + "annotation_set": 1, + "confidence_indicator_set": 1, "datasets": [1], "spectro_configs": [1], "annotators": [1, 2], @@ -74,6 +73,84 @@ class AnnotationCampaignViewSetTestCase(APITestCase): "annotation_goal": 1, "annotation_scope": 1, "created_at": "2012-01-14T00:00:00Z", + "usage": "Create", + } + check_creation_data = { + "name": "string", + "desc": "string", + "instructions_url": "string", + "start": "2022-01-25T10:42:15Z", + "end": "2022-01-30T10:42:15Z", + "annotation_set": 1, + "confidence_indicator_set": 1, + "datasets": [1], + "spectro_configs": [1], + "annotators": [1], + "annotation_goal": 1, + "annotation_scope": 2, + "created_at": "2012-01-14T00:00:00Z", + "usage": "Check", + "annotation_set_labels": ["click"], + "confidence_set_indicators": [], + "detectors": [{"detectorName": "nninni", "configuration": "test"}], + "results": [ + { # Weak annotation on a specific file + "is_box": False, + "dataset": "SPM Aural A 2010", + "dataset_file": "sound001.wav", + "detector": "nninni", + "detector_config": "test", + "start_datetime": "2012-10-03T10:00:00+00:00", + "end_datetime": "2012-10-03T10:15:00+00:00", + "min_time": 0, + "max_time": 0, + "min_frequency": 0, + "max_frequency": 64000, + "tag": "click", + }, + { # Weak annotation on 2 files + "is_box": False, + "dataset": "SPM Aural A 2010", + "dataset_file": "sound001.wav", + "detector": "nninni", + "detector_config": "test", + "start_datetime": "2012-10-03T10:00:00+00:00", + "end_datetime": "2012-10-03T11:10:00+00:00", + "min_time": 0, + "max_time": 0, + "min_frequency": 0, + "max_frequency": 64000, + "tag": "click", + }, + { # Strong annotation on a specific file + "is_box": True, + "dataset": "SPM Aural A 2010", + "dataset_file": "sound001.wav", + "detector": "nninni", + "detector_config": "test", + "start_datetime": "2012-10-03T10:00:00.800+00:00", + "end_datetime": "2012-10-03T10:00:01.800+00:00", + "min_time": 0.8, + "max_time": 1.8, + "min_frequency": 32416, + "max_frequency": 53916, + "tag": "click", + }, + { # Strong annotation on 2 files + "is_box": True, + "dataset": "SPM Aural A 2010", + "dataset_file": "sound001.wav", + "detector": "nninni", + "detector_config": "test", + "start_datetime": "2012-10-03T10:00:00.800+00:00", + "end_datetime": "2012-10-03T11:00:08+00:00", + "min_time": 0.8, + "max_time": 3608, + "min_frequency": 32416, + "max_frequency": 53916, + "tag": "click", + }, + ], } add_annotators_data = { @@ -106,20 +183,19 @@ def test_list_user_is_staff(self): "instructions_url", "start", "end", - "annotation_set", - "confidence_indicator_set", - "tasks_count", + "annotation_set_name", + "confidence_indicator_set_name", "user_tasks_count", "complete_tasks_count", "user_complete_tasks_count", "files_count", + "mode", "created_at", ], ) self.assertEqual(response.data[0]["name"], "Test DCLDE LF campaign") self.assertEqual(response.data[1]["name"], "Test RTF campaign") - self.assertEqual(response.data[2]["tasks_count"], 11) def test_list_user_no_campaign(self): """AnnotationCampaign view 'list' returns list of campaigns""" @@ -145,13 +221,13 @@ def test_list_user_two_campaign(self): "instructions_url", "start", "end", - "annotation_set", - "confidence_indicator_set", - "tasks_count", + "annotation_set_name", + "confidence_indicator_set_name", "user_tasks_count", "complete_tasks_count", "user_complete_tasks_count", "files_count", + "mode", "created_at", ], ) @@ -180,6 +256,8 @@ def test_retrieve(self): "confidence_indicator_set", "datasets", "created_at", + "usage", + "dataset_files_count", ], ) @@ -208,7 +286,7 @@ def test_create(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(AnnotationCampaign.objects.count(), old_count + 1) self.assertEqual(AnnotationTask.objects.count(), old_tasks_count + 11) - expected_reponse = { + expected_response = { key: self.creation_data[key] for key in [ "name", @@ -221,22 +299,106 @@ def test_create(self): ] } - expected_reponse["id"] = AnnotationCampaign.objects.latest("id").id - reponse_data = dict(response.data) + expected_response["id"] = AnnotationCampaign.objects.latest("id").id + response_data = dict(response.data) - confidence_indicator_set = reponse_data.pop("confidence_indicator_set") + confidence_indicator_set = response_data.pop("confidence_indicator_set") self.assertEqual(confidence_indicator_set["name"], "Confidence/NoConfidence") self.assertEqual(confidence_indicator_set["desc"], "Lorem ipsum") - self.assertEqual(len(confidence_indicator_set["confidenceIndicators"]), 2) + self.assertEqual(len(confidence_indicator_set["confidence_indicators"]), 2) - annotation_set = reponse_data.pop("annotation_set") + annotation_set = response_data.pop("annotation_set") self.assertEqual(annotation_set["name"], "Test SPM campaign") self.assertEqual( annotation_set["desc"], "Annotation set made for Test SPM campaign" ) self.assertEqual(len(annotation_set["tags"]), 5) + expected_response["usage"] = 0 + expected_response["dataset_files_count"] = Dataset.objects.get( + pk=self.creation_data["datasets"][0] + ).files.count() + self.assertEqual(response_data, expected_response) - self.assertEqual(reponse_data, expected_reponse) + def test_create_check(self): + """AnnotationCampaign view 'create' adds new campaign to DB and returns campaign info""" + self.maxDiff = None # this is to avoid diff truncation in test error log + old_count = AnnotationCampaign.objects.count() + old_tasks_count = AnnotationTask.objects.count() + url = reverse("annotation-campaign-list") + response = self.client.post(url, self.check_creation_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(AnnotationCampaign.objects.count(), old_count + 1) + self.assertEqual(AnnotationTask.objects.count(), old_tasks_count + 11) + expected_response = { + key: self.creation_data[key] + for key in [ + "name", + "desc", + "instructions_url", + "start", + "end", + "datasets", + "created_at", + ] + } + + expected_response["id"] = AnnotationCampaign.objects.latest("id").id + response_data = dict(response.data) + + self.assertEqual(response_data.pop("id"), expected_response["id"]) + self.assertEqual(response_data.pop("confidence_indicator_set"), None) + + annotation_set = response_data.pop("annotation_set") + self.assertEqual(len(annotation_set["tags"]), 1) + expected_response["usage"] = 1 + + campaign: AnnotationCampaign = AnnotationCampaign.objects.get( + pk=expected_response["id"] + ) + self.assertEqual(campaign.results.all().count(), 6) + + # Result n°0 == Weak annotation on a specific file + self.assertEqual(campaign.results.all()[0].start_time, None) + self.assertEqual(campaign.results.all()[0].end_time, None) + self.assertEqual(campaign.results.all()[0].start_frequency, None) + self.assertEqual(campaign.results.all()[0].end_frequency, None) + self.assertEqual(campaign.results.all()[0].dataset_file.id, 1) + + # Result n°1 == Weak annotation on 2 files | part 1 + self.assertEqual(campaign.results.all()[1].start_time, 0) + self.assertEqual(campaign.results.all()[1].end_time, 15 * 60) + self.assertEqual(campaign.results.all()[1].start_frequency, 0) + self.assertEqual(campaign.results.all()[1].end_frequency, 64000) + self.assertEqual(campaign.results.all()[1].dataset_file.id, 1) + + # Result n°2 == Weak annotation on 2 files | part 2 + self.assertEqual(campaign.results.all()[2].start_time, 0) + self.assertEqual(campaign.results.all()[2].end_time, 10 * 60) + self.assertEqual(campaign.results.all()[2].start_frequency, 0) + self.assertEqual(campaign.results.all()[2].end_frequency, 64000) + self.assertEqual(campaign.results.all()[2].dataset_file.id, 2) + + # Result n°3 == Strong annotation on a specific file + self.assertEqual(campaign.results.all()[3].start_time, 0.8) + self.assertEqual(campaign.results.all()[3].end_time, 1.8) + self.assertEqual(campaign.results.all()[3].start_frequency, 32416) + self.assertEqual(campaign.results.all()[3].end_frequency, 53916) + self.assertEqual(campaign.results.all()[3].dataset_file.id, 1) + + # Result n°4 == Strong annotation on 2 files | part 1 + self.assertEqual(campaign.results.all()[4].start_time, 0.8) + self.assertEqual(campaign.results.all()[4].end_time, 15 * 60) + self.assertEqual(campaign.results.all()[4].start_frequency, 32416) + self.assertEqual(campaign.results.all()[4].end_frequency, 53916) + self.assertEqual(campaign.results.all()[4].dataset_file.id, 1) + + # Result n°51 == Strong annotation on 2 files | part 2 + self.assertEqual(campaign.results.all()[5].start_time, 0) + self.assertEqual(campaign.results.all()[5].end_time, 8) + self.assertEqual(campaign.results.all()[5].start_frequency, 32416) + self.assertEqual(campaign.results.all()[5].end_frequency, 53916) + self.assertEqual(campaign.results.all()[5].dataset_file.id, 2) # Testing 'add_annotators' @@ -302,17 +464,17 @@ def test_report(self): ) self.assertEqual( response.data[1], - [ + [ # annotationresult id=7 ; because ordered by dataset_file and not id "SPM Aural A 2010", - "sound007.wav", - "119.63596249310535", - "278.48869277440707", - "6432.0", - "12864.0", + "sound001.wav", + "108.21842250413678", + "224.87589630446772", + "7520.0", + "13696.0", "Odoncetes", - "user2", - "2012-10-03T16:01:59.635+00:00", - "2012-10-03T16:04:38.488+00:00", + "admin", + "1349258508218.4224", + "1349258624875.8962", "1", "confident", "0/1", diff --git a/backend/tests/views/annotation_task.py b/backend/tests/views/annotation_task.py index 17e656bb..af3478a5 100644 --- a/backend/tests/views/annotation_task.py +++ b/backend/tests/views/annotation_task.py @@ -6,7 +6,7 @@ from rest_framework import status from rest_framework.test import APITestCase -from backend.api.models import AnnotationTask +from backend.api.models import AnnotationTask, AnnotationResult class AnnotationTaskViewSetUnauthenticatedTestCase(APITestCase): @@ -18,15 +18,6 @@ def test_campaign_list_unauthenticated(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_campaign_user_list_unauthenticated(self): - """AnnotationTask view 'campaign_user_list' returns 401 if no user is authenticated""" - url = reverse( - "annotation-task-campaign-user-list", - kwargs={"campaign_id": 1, "user_id": 1}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_retrieve_unauthenticated(self): """AnnotationTask view 'retrieve' returns 401 if no user is authenticated""" url = reverse("annotation-task-detail", kwargs={"pk": 1}) @@ -91,73 +82,10 @@ def test_campaign_list_for_user2(self): "id": 7, "start": "2012-10-03T16:00:00Z", "status": 0, + "results_count": 3, }, ) - # Testing 'campaign_user_list' - - def test_campaign_user_list_for_staff(self): - """AnnotationTask view 'campaign_user_list' returns list for staff""" - self.client.login(username="staff", password="osmose29") - url = reverse( - "annotation-task-campaign-user-list", - kwargs={"campaign_id": 1, "user_id": 1}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 6) - self.assertEqual( - dict(response.data[0]), - { - "dataset_name": "SPM Aural A 2010", - "end": "2012-10-03T10:15:00Z", - "filename": "sound001.wav", - "id": 1, - "start": "2012-10-03T10:00:00Z", - "status": 0, - }, - ) - - def test_campaign_user_list_for_staff_plus_unknown_campaign(self): - """AnnotationTask view 'campaign_user_list' returns 404 for staff with unknown campaign""" - self.client.login(username="staff", password="osmose29") - url = reverse( - "annotation-task-campaign-user-list", - kwargs={"campaign_id": 42, "user_id": 1}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_campaign_user_list_for_owner(self): - """AnnotationTask view 'campaign_user_list' returns list for owner""" - self.client.login(username="user1", password="osmose29") - url = reverse( - "annotation-task-campaign-user-list", - kwargs={"campaign_id": 1, "user_id": 1}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 6) - - def test_campaign_user_list_for_owner_plus_unknown_user(self): - """AnnotationTask view 'campaign_user_list' returns 404 for owner with unknown user""" - self.client.login(username="user1", password="osmose29") - url = reverse( - "annotation-task-campaign-user-list", - kwargs={"campaign_id": 1, "user_id": 42}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_campaign_user_list_for_user(self): - """AnnotationTask view 'campaign_user_list' forbidden for unauthorized user""" - url = reverse( - "annotation-task-campaign-user-list", - kwargs={"campaign_id": 1, "user_id": 1}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # Testing 'retrieve' def test_retrieve(self): @@ -181,6 +109,8 @@ def test_retrieve(self): "prevAndNextAnnotation", "taskComment", "confidenceIndicatorSet", + "mode", + "instructions_url", ], ) self.assertEqual( @@ -190,7 +120,7 @@ def test_retrieve(self): self.assertEqual( dict(response.data["boundaries"]), { - "endFrequency": 16384.0, + "endFrequency": 64000.0, "endTime": parse_datetime("2012-10-03T10:15:00Z"), "startFrequency": 0, "startTime": parse_datetime("2012-10-03T10:00:00Z"), @@ -219,7 +149,12 @@ def test_update(self): """AnnotationTask view 'update' returns next task info and updates task results""" task = AnnotationTask.objects.get(id=9) self.assertEqual(task.status, 0) - self.assertEqual(task.results.count(), 0) + results_count = AnnotationResult.objects.filter( + annotation_campaign_id=task.annotation_campaign_id, + annotator_id=task.annotator_id, + dataset_file_id=task.dataset_file_id, + ).count() + self.assertEqual(results_count, 0) url = reverse("annotation-task-detail", kwargs={"pk": 9}) response = self.client.put( url, @@ -245,7 +180,12 @@ def test_update(self): self.assertEqual(dict(response.data), {"next_task": 10, "campaign_id": None}) task.refresh_from_db() self.assertEqual(task.status, 2) - self.assertEqual(task.results.count(), 1) + results_count = AnnotationResult.objects.filter( + annotation_campaign_id=task.annotation_campaign_id, + annotator_id=task.annotator_id, + dataset_file_id=task.dataset_file_id, + ).count() + self.assertEqual(results_count, 1) def test_update_unknown(self): """AnnotationTask view 'update' returns 404 for unknown task""" diff --git a/backend/tests/views/user.py b/backend/tests/views/user.py index b4fc45c1..57000f40 100644 --- a/backend/tests/views/user.py +++ b/backend/tests/views/user.py @@ -53,7 +53,13 @@ def test_list(self): self.assertEqual(len(response.data), User.objects.count()) self.assertEqual( dict(response.data[0]), - {"id": 1, "username": "admin", "email": "admin@osmose.xyz"}, + { + "id": 1, + "username": "admin", + "email": "admin@osmose.xyz", + "first_name": "", + "last_name": "", + }, ) def test_is_staff_for_user(self): @@ -88,5 +94,11 @@ def test_create_for_staff(self): self.assertEqual(User.objects.last().username, "new_user") self.assertEqual( dict(response.data), - {"id": 6, "username": "new_user", "email": "user@example.com"}, + { + "id": 6, + "username": "new_user", + "email": "user@example.com", + "first_name": "", + "last_name": "", + }, ) diff --git a/backend/urls.py b/backend/urls.py index 5dc9c296..12d9e7e6 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -27,20 +27,11 @@ from django.contrib import admin from django.urls import path, include -from rest_framework import routers from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView -from backend.api.views import ( - DatasetViewSet, - UserViewSet, - AnnotationSetViewSet, - AnnotationTaskViewSet, - AnnotationCampaignViewSet, - AnnotationCommentViewSet, - ConfidenceIndicatorSetViewSet, -) +from backend.api.urls import api_router from backend.osmosewebsite.urls import website_router # Backend urls are for admin & api documentation @@ -54,24 +45,6 @@ backend_urlpatterns += [path("__debug__/", include("debug_toolbar.urls"))] # API urls are meant to be used by our React frontend -api_router = routers.DefaultRouter() -api_router.register(r"dataset", DatasetViewSet, basename="dataset") -api_router.register(r"user", UserViewSet, basename="user") -api_router.register(r"annotation-set", AnnotationSetViewSet, basename="annotation-set") -api_router.register( - r"annotation-campaign", AnnotationCampaignViewSet, basename="annotation-campaign" -) -api_router.register( - r"annotation-comment", AnnotationCommentViewSet, basename="annotation-comment" -) -api_router.register( - r"annotation-task", AnnotationTaskViewSet, basename="annotation-task" -) -api_router.register( - r"confidence-indicator", - ConfidenceIndicatorSetViewSet, - basename="confidence-indicator", -) api_urlpatterns = [ path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), diff --git a/dockerfiles/nginx.conf.template b/dockerfiles/nginx.conf.template index 1abfad92..154fe28b 100644 --- a/dockerfiles/nginx.conf.template +++ b/dockerfiles/nginx.conf.template @@ -11,12 +11,14 @@ server { root /usr/share/nginx/; proxy_intercept_errors on; error_page 404 = /app/index.html; + proxy_no_cache 1; } location / { root /usr/share/nginx/website/; proxy_intercept_errors on; error_page 404 = /index.html; + proxy_no_cache 1; } location /static { diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js index d1798245..427ada47 100644 --- a/frontend/cypress.config.js +++ b/frontend/cypress.config.js @@ -3,12 +3,16 @@ import {defineConfig} from "cypress"; export default defineConfig({ env: { aploseURL: 'http://localhost:5173/', - wholeFileCampaign: 'Whole file campaign', - boxCampaign: 'Box campaign', }, e2e: { setupNodeEvents(on, config) { // implement node event listeners here }, }, -}); \ No newline at end of file + component: { + devServer: { + framework: "react", + bundler: "vite", + }, + }, +}); diff --git a/frontend/cypress/component/drag-n-drop-file-input.cy.tsx b/frontend/cypress/component/drag-n-drop-file-input.cy.tsx new file mode 100644 index 00000000..2801961b --- /dev/null +++ b/frontend/cypress/component/drag-n-drop-file-input.cy.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { setupIonicReact } from '@ionic/react'; +import { DragNDropFileInput, DragNDropState } from '../../src/components/form' +import { ACCEPT_CSV_MIME_TYPE } from "../../src/consts/csv"; + +setupIonicReact({ + mode: 'md', + spinner: 'crescent', +}); + +const label = 'My label'; +const filename = "My file.csv"; + +describe('Drag-n-drop file input', () => { + + describe('Available state', () => { + beforeEach(() => { + const stub = cy.stub().as('onFileImported') + cy.mount() + }) + + + it('renders', () => { + cy.get('#drag-n-drop-zone').should('contain', label) + }) + + it('imports CSV', () => { + cy.get('#drag-n-drop-zone').selectFile('cypress/fixtures/annotation_results.csv', { force: true, action: 'drag-drop' }); + cy.get('@onFileImported').should('have.been.called') + }) + + it('doesn\'t imports other files types', () => { + cy.get('#drag-n-drop-zone').selectFile('cypress/fixtures/example.json', { force: true, action: 'drag-drop' }); + cy.get('@onFileImported').should('not.have.been.called') + }) + }) + + describe('Loading state', () => { + beforeEach(() => { + cy.mount() + }) + + + it('renders', () => { + cy.get('#drag-n-drop-zone').should('have.descendants', 'ion-spinner') + }) + }) + + describe('Loaded state', () => { + beforeEach(() => { + const stub = cy.stub().as('onReset') + cy.mount() + }) + + it('renders', () => { + cy.get('#drag-n-drop-zone').should('contain', filename) + }) + + it('can reset', () => { + cy.get('#drag-n-drop-zone').contains('Import another file', { matchCase: false }).click() + cy.get('@onReset').should('have.been.called') + }) + }) +}) diff --git a/frontend/cypress/component/form/chips-input.cy.tsx b/frontend/cypress/component/form/chips-input.cy.tsx new file mode 100644 index 00000000..482276b2 --- /dev/null +++ b/frontend/cypress/component/form/chips-input.cy.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { setupIonicReact } from '@ionic/react'; +import { ChipsInput } from '@/components/form'; +import { Item } from "@/types/item"; + +const label = 'My label'; +const options: Array = [ + { label: 'First', value: 1 }, + { label: 'Second', value: 2 }, +]; +const activeItems = [1]; + +setupIonicReact({ + mode: 'md', + spinner: 'crescent', +}); + +describe('Chips input', () => { + beforeEach(() => { + const setActiveItemsValues = cy.stub().as('setActiveItemsValues') + cy.mount() + }) + + it('renders', () => { + cy.get('#aplose-input').should('contain', label) + for (const item of options) { + cy.get('#aplose-input ion-chip').should('contain', item.label) + if (activeItems.includes(item.value)) { + cy.get('#aplose-input ion-chip').contains(item.label).should('have.descendants', 'ion-icon') + } + } + }) + + it('can select new item', () => { + const selectItem = options[1]; + cy.get('#aplose-input ion-chip').contains(selectItem.label).click() + cy.get('@setActiveItemsValues').should('have.been.calledWithMatch', [...activeItems, selectItem.value]) + }) + + it('can unselect item', () => { + const selectItem = options[0]; + cy.get('#aplose-input ion-chip').contains(selectItem.label).click() + cy.get('@setActiveItemsValues').should('have.been.calledWithMatch', activeItems.filter(i => i !== selectItem.value)) + }) + + it('can be required', () => { + cy.mount() + cy.get('#aplose-input').should('contain', `${ label }*`) + }) +}) diff --git a/frontend/cypress/component/form/input.cy.tsx b/frontend/cypress/component/form/input.cy.tsx new file mode 100644 index 00000000..e5fbed26 --- /dev/null +++ b/frontend/cypress/component/form/input.cy.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Input } from '@/components/form/inputs/input' + +const label = 'My label'; +const placeholder = 'My placeholder'; +const value = 'My value'; +const note = 'My note'; + +describe('Input', () => { + beforeEach(() => { + cy.mount() + }) + + it('renders', () => { + cy.get('#aplose-input').should('contain', label) + cy.get('#aplose-input input').should('have.attr', 'placeholder', placeholder) + cy.get('#aplose-input ion-note').should('include.text', note) + }) + + it('can be edited', () => { + cy.get('#aplose-input input').type(value); + cy.get('#aplose-input input').should('contain.value', value) + }) + + it('can be required', () => { + cy.mount() + cy.get('#aplose-input').should('contain', `${ label }*`) + cy.get('#aplose-input input').should('have.attr', 'required') + }) +}) diff --git a/frontend/cypress/component/form/select.cy.tsx b/frontend/cypress/component/form/select.cy.tsx new file mode 100644 index 00000000..c40b00e6 --- /dev/null +++ b/frontend/cypress/component/form/select.cy.tsx @@ -0,0 +1,130 @@ +import React from 'react' +import { setupIonicReact } from '@ionic/react'; +import { Select } from "../../../src/components/form"; +import { Item } from "../../../src/types/item"; + +const label = 'My label'; +const placeholder = 'My placeholder'; +const options: Array = [ + { label: 'First', value: 1 }, + { label: 'Second', value: 2 }, +]; +const noneLabel = "My none label"; + + +setupIonicReact({ + mode: 'md', + spinner: 'crescent', +}); + +describe('Select', () => { + + it('renders', () => { + cy.mount() + }) + + it('should display options', () => { + cy.contains(placeholder).click(); + for (const option of options) { + cy.get('#aplose-input').should('contain.text', option.label) + } + }) + + it('should select option', () => { + cy.contains(placeholder).click(); + const selectedOption = options[1]; + + cy.get('div.item').contains(selectedOption.label).click() + .then(() => { + cy.get('@onValueSelected').should('have.been.calledWithMatch', selectedOption.value); + }) + }) + + it('should select none', () => { + cy.contains(placeholder).click(); + + cy.get('div.item').contains(noneLabel).click() + .then(() => { + cy.get('@onValueSelected').should('have.been.calledWithMatch', undefined); + }) + }) + + it('can be required', () => { + cy.mount() + }) + + it('should display options', () => { + cy.contains(placeholder).click(); + for (const option of options) { + cy.get('button').should('contain.text', option.label) + } + }) + + it('should select option', () => { + cy.contains(placeholder).click(); + const selectedOption = options[1]; + + cy.get('button').contains(selectedOption.label).click() + cy.contains('Ok', { matchCase: false }).click() + .then(() => { + cy.get('@onValueSelected').should('have.been.calledWithMatch', selectedOption.value); + }) + }) + + it('should select none', () => { + cy.contains(placeholder).click(); + + cy.get('button').contains(noneLabel).click() + cy.contains('Ok', { matchCase: false }).click() + .then(() => { + cy.get('@onValueSelected').should('have.been.calledWithMatch', undefined); + }) + }) + + it('can be required', () => { + cy.mount( + value={ focusedComment?.comment ?? '' } + onChange={ e => dispatch(updateFocusComment(e.target.value)) } + onFocus={ () => dispatch(disableShortcuts()) } + onBlur={ () => dispatch(enableShortcuts()) }>
- diff --git a/frontend/src/view/audio-annotator/components/bloc/confidence-indicator-bloc.component.tsx b/frontend/src/view/audio-annotator/components/bloc/confidence-indicator-bloc.component.tsx index b5f96846..9f11755d 100644 --- a/frontend/src/view/audio-annotator/components/bloc/confidence-indicator-bloc.component.tsx +++ b/frontend/src/view/audio-annotator/components/bloc/confidence-indicator-bloc.component.tsx @@ -1,26 +1,27 @@ -import React, { Fragment, useContext } from "react"; +import React, { Fragment } from "react"; import OverlayTrigger from "react-bootstrap/OverlayTrigger"; import Tooltip from "react-bootstrap/Tooltip"; -import { - AnnotationsContext, - AnnotationsContextDispatch, -} from "../../../../services/annotator/annotations/annotations.context.tsx"; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { selectConfidence } from "@/slices/annotator/annotations.ts"; import { IonChip, IonIcon } from "@ionic/react"; import { checkmarkOutline } from "ionicons/icons"; export const ConfidenceIndicatorBloc: React.FC = () => { + const dispatch = useAppDispatch(); + const { + focusedConfidence, + confidenceDescription, + allConfidences + } = useAppSelector(state => state.annotator.annotations) - const context = useContext(AnnotationsContext); - const dispatch = useContext(AnnotationsContextDispatch); - - if (!context.focusedConfidence) return ; + if (!focusedConfidence) return ; const tooltip = (

Description

-

{ context.confidenceDescription }

+

{ confidenceDescription }

) @@ -30,13 +31,13 @@ export const ConfidenceIndicatorBloc: React.FC = () => {
Confidence indicator
- { context.allConfidences.map((confidence, key) => ( + { allConfidences.map((confidence, key) => ( dispatch!({ type: 'selectConfidence', confidence }) } - className={ context.focusedConfidence === confidence ? 'active m-2' : 'm-2' }> + onClick={ () => dispatch(selectConfidence(confidence)) } + className={ focusedConfidence === confidence ? 'active m-2' : 'm-2' }> { confidence } - { context.focusedConfidence === confidence && } + { focusedConfidence === confidence && } )) }
diff --git a/frontend/src/view/audio-annotator/components/bloc/current-annotation-bloc.component.tsx b/frontend/src/view/audio-annotator/components/bloc/current-annotation-bloc.component.tsx index 54ac1393..3a7cce4a 100644 --- a/frontend/src/view/audio-annotator/components/bloc/current-annotation-bloc.component.tsx +++ b/frontend/src/view/audio-annotator/components/bloc/current-annotation-bloc.component.tsx @@ -1,13 +1,16 @@ -import React, { useContext } from "react"; -import { formatTimestamp } from "../../../../services/annotator/format/format.util.tsx"; -import { AnnotationsContext } from "../../../../services/annotator/annotations/annotations.context.tsx"; +import React from "react"; +import { formatTimestamp } from "@/services/utils/format.tsx"; +import { useAppSelector } from "@/slices/app"; export const CurrentAnnotationBloc: React.FC = () => { - const context = useContext(AnnotationsContext); + const { + focusedResult, + wholeFileBoundaries + } = useAppSelector(state => state.annotator.annotations); - if (!context.focusedResult) return ( + if (!focusedResult) return (
Selected annotation
@@ -17,10 +20,9 @@ export const CurrentAnnotationBloc: React.FC = () => { ) let max_time = "00:00.000"; - if (context.focusedResult?.endTime === -1) { - const timeInSeconds = (context.wholeFileBoundaries.endTime.getTime() - context.wholeFileBoundaries.startTime.getTime()) / 1000 - const minutes = Math.floor(timeInSeconds / 60); - const seconds = timeInSeconds - minutes * 60; + if (focusedResult?.endTime === -1) { + const minutes = Math.floor(wholeFileBoundaries.duration / 60); + const seconds = wholeFileBoundaries.duration - minutes * 60; max_time = `${ minutes.toFixed().padStart(2, "0") }:${ seconds.toFixed().padStart(2, "0") }:000`; } @@ -30,15 +32,15 @@ export const CurrentAnnotationBloc: React.FC = () => {

:  - { context.focusedResult.startTime === -1 ? "00:00.000" : formatTimestamp(context.focusedResult.startTime) } >  - { context.focusedResult.endTime === -1 ? max_time : formatTimestamp(context.focusedResult.endTime) }
+ { focusedResult.startTime === -1 ? "00:00.000" : formatTimestamp(focusedResult.startTime) } >  + { focusedResult.endTime === -1 ? max_time : formatTimestamp(focusedResult.endTime) }
:  - { context.focusedResult.startFrequency === -1 ? context.wholeFileBoundaries.startFrequency : context.focusedResult.startFrequency.toFixed(2) } >  - { context.focusedResult.endFrequency === -1 ? context.wholeFileBoundaries.endFrequency : context.focusedResult.endFrequency.toFixed(2) } Hz
+ { focusedResult.startFrequency === -1 ? wholeFileBoundaries.startFrequency : focusedResult.startFrequency.toFixed(2) } >  + { focusedResult.endFrequency === -1 ? wholeFileBoundaries.endFrequency : focusedResult.endFrequency.toFixed(2) } Hz
: { context.focusedResult.annotation ? context.focusedResult.annotation : "None" }
- { context.focusedResult.confidenceIndicator && :  { context.focusedResult.confidenceIndicator }
} + className="fa fa-tag"> : { focusedResult.annotation ? focusedResult.annotation : "None" }
+ { focusedResult.confidenceIndicator && :  { focusedResult.confidenceIndicator }
}

diff --git a/frontend/src/view/audio-annotator/components/bloc/detection-list.component.tsx b/frontend/src/view/audio-annotator/components/bloc/detection-list.component.tsx new file mode 100644 index 00000000..630b6cc4 --- /dev/null +++ b/frontend/src/view/audio-annotator/components/bloc/detection-list.component.tsx @@ -0,0 +1,145 @@ +import React, { useMemo } from "react"; +import { IonButton, IonIcon, IonNote } from "@ionic/react"; +import { focusResult, invalidateResult, validateResult } from "@/slices/annotator/annotations.ts"; +import { formatTimestamp } from "@/services/utils/format.tsx"; +import { Annotation, AnnotationType, AnnotationMode } from "@/types/annotations.ts"; +import { checkmarkOutline, closeOutline } from "ionicons/icons"; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { RetrieveAnnotation } from "@/services/api/annotation-task-api.service.tsx"; + + +export const DetectionList: React.FC = () => { + + const { + results, + currentMode + } = useAppSelector(state => state.annotator.annotations); + + const annotations: Array = useMemo( + () => { + // Need the spread to sort this readonly array + return [...results].sort((a, b) => { + if (currentMode === AnnotationMode.wholeFile) { + if (a.annotation !== b.annotation) { + return a.annotation.localeCompare(b.annotation); + } + } + return a.startTime - b.startTime; + }) + }, + [results, currentMode]) + + return ( +
+ + + + + + + + { annotations.map((annotation: Annotation, idx: number) => ( + + )) } + { annotations.length === 0 && No detections} + +
Detections
+
+ ) +} + +interface ItemProps { + detection: Annotation +} + +const DetectionItem: React.FC = ({ detection }) => { + + const focusedResult = useAppSelector(state => state.annotator.annotations.focusedResult); + const dispatch = useAppDispatch(); + + switch (detection.type) { + case AnnotationType.box: + return ( + dispatch(focusResult(detection)) }> + +   + { formatTimestamp(detection.startTime) } >  + { formatTimestamp(detection.endTime) } + + +   + { detection.startFrequency.toFixed(2) } >  + { detection.endFrequency.toFixed(2) } Hz + + +   + { (detection.annotation !== '') ? detection.annotation : '-' } + + +   + { (detection as RetrieveAnnotation).detector?.name } + + +   + { (detection.confidenceIndicator !== '') ? detection.confidenceIndicator : '-' } + + + { detection.result_comments.filter(c => c.comment).length > 0 ? : + } + + + dispatch(validateResult(detection)) }> + + + dispatch(invalidateResult(detection)) }> + + + + + ); + case AnnotationType.tag: + return ( + dispatch(focusResult(detection)) }> + + +   + { detection.annotation } + + + +   + { (detection as RetrieveAnnotation).detector?.name } + + +   + { (detection.confidenceIndicator !== '') ? detection.confidenceIndicator : '-' } + + + { detection.result_comments.filter(c => c.comment).length > 0 ? : + } + + + dispatch(validateResult(detection)) }> + + + dispatch(invalidateResult(detection)) }> + + + + + ); + } +} diff --git a/frontend/src/view/audio-annotator/components/bloc/presence-bloc.component.tsx b/frontend/src/view/audio-annotator/components/bloc/presence-bloc.component.tsx index 4da6a80f..c5ed3a4a 100644 --- a/frontend/src/view/audio-annotator/components/bloc/presence-bloc.component.tsx +++ b/frontend/src/view/audio-annotator/components/bloc/presence-bloc.component.tsx @@ -1,30 +1,35 @@ -import React, { Fragment, useContext, useImperativeHandle } from "react"; +import React, { Fragment, useImperativeHandle } from "react"; import OverlayTrigger from "react-bootstrap/OverlayTrigger"; import { TooltipComponent } from "../tooltip.component.tsx"; -import { AnnotationMode } from "../../../../enum/annotation.enum.tsx"; -import { DEFAULT_COLOR } from "../../../../consts/colors.const.tsx"; -import { AlphanumericKeys } from "../../../../consts/shorcuts.const.tsx"; +import { AnnotationMode } from "@/types/annotations.ts"; +import { DEFAULT_COLOR } from "@/consts/colors.const.tsx"; +import { AlphanumericKeys } from "@/consts/shorcuts.const.tsx"; import Tooltip from "react-bootstrap/Tooltip"; -import { confirm } from "../../../global-components"; +import { confirm } from "@/view/global-components"; import { KeypressHandler } from "../../audio-annotator.page.tsx"; -import { - AnnotationsContext, - AnnotationsContextDispatch, -} from "../../../../services/annotator/annotations/annotations.context.tsx"; -import { AnnotatorContext, AnnotatorDispatchContext } from "../../../../services/annotator/annotator.context.tsx"; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { disableShortcuts, enableShortcuts } from "@/slices/annotator/global-annotator.ts"; +import { addPresence, focusTag, removePresence } from "@/slices/annotator/annotations.ts"; export const PresenceBloc = React.forwardRef((_, ref) => { - const context = useContext(AnnotationsContext); - const dispatch = useContext(AnnotationsContextDispatch); - - const annotatorContext = useContext(AnnotatorContext); - const annotatorDispatch = useContext(AnnotatorDispatchContext); + const { + areShortcutsEnabled, + } = useAppSelector(state => state.annotator.global); + const { + allTags, + presenceTags, + focusedTag, + results, + currentMode, + tagColors + } = useAppSelector(state => state.annotator.annotations); + const dispatch = useAppDispatch() const handleKeyPressed = (event: KeyboardEvent) => { - if (!annotatorContext.areShortcutsEnabled) return; - const active_alphanumeric_keys = AlphanumericKeys[0].slice(0, context.allTags.length); + if (!areShortcutsEnabled) return; + const active_alphanumeric_keys = AlphanumericKeys[0].slice(0, allTags.length); if (event.key === "'") { event.preventDefault(); @@ -33,9 +38,9 @@ export const PresenceBloc = React.forwardRef((_, ref) => { for (const i in active_alphanumeric_keys) { if (event.key !== AlphanumericKeys[0][i] && event.key !== AlphanumericKeys[1][i]) continue; - const calledTag = context.allTags[i]; - if (context.presenceTags.includes(calledTag) && context.focusedTag !== calledTag) { - dispatch!({ type: 'focusTag', tag: calledTag }) + const calledTag = allTags[i]; + if (presenceTags.includes(calledTag) && focusedTag !== calledTag) { + dispatch(focusTag(calledTag)) } else toggle(calledTag); } } @@ -43,39 +48,39 @@ export const PresenceBloc = React.forwardRef((_, ref) => { useImperativeHandle(ref, () => ({ handleKeyPressed })); const toggle = async (tag: string) => { - if (context.presenceTags.includes(tag)) { + if (presenceTags.includes(tag)) { // Remove presence - if (context.results.find(a => a.annotation === tag)) { + if (results.find(a => a.annotation === tag)) { // if annotations exists with this tag: wait for confirmation - annotatorDispatch!({ type: 'disableShortcuts' }) - const response = await confirm(`You are about to remove ${context.results.filter(r => r.annotation === tag).length} annotations using "${tag}" label. Are you sure ?`, `Remove "${tag}" annotations`); - annotatorDispatch!({ type: 'enableShortcuts' }) + dispatch(disableShortcuts()) + const response = await confirm(`You are about to remove ${results.filter(r => r.annotation === tag).length} annotations using "${tag}" label. Are you sure ?`, `Remove "${tag}" annotations`); + dispatch(enableShortcuts()) if (!response) return; } - dispatch!({ type: 'removePresence', tag }) + dispatch(removePresence(tag)); } else { // Add presence - dispatch!({ type: 'addPresence', tag }) + dispatch(addPresence(tag)); } } - if (context.currentMode !== AnnotationMode.wholeFile) return ; + if (currentMode !== AnnotationMode.wholeFile) return ; return (
Presence / Absence
    - { context.allTags.map((tag, key) => ( + { allTags.map((tag, key) => (
  • toggle(tag) } - checked={ context.presenceTags.includes(tag) }/> + checked={ presenceTags.includes(tag) }/> } placement="top"> diff --git a/frontend/src/view/audio-annotator/components/bloc/tag-list-bloc.component.tsx b/frontend/src/view/audio-annotator/components/bloc/tag-list-bloc.component.tsx index 4d62e0f5..5a46aa0a 100644 --- a/frontend/src/view/audio-annotator/components/bloc/tag-list-bloc.component.tsx +++ b/frontend/src/view/audio-annotator/components/bloc/tag-list-bloc.component.tsx @@ -1,29 +1,32 @@ -import React, { useContext } from "react"; +import React from "react"; import OverlayTrigger from "react-bootstrap/OverlayTrigger"; import { TooltipComponent } from "../tooltip.component.tsx"; -import { AnnotationMode } from "../../../../enum/annotation.enum.tsx"; -import { DEFAULT_COLOR } from "../../../../consts/colors.const.tsx"; +import { AnnotationMode } from "@/types/annotations.ts"; +import { DEFAULT_COLOR } from "@/consts/colors.const.tsx"; import Tooltip from "react-bootstrap/Tooltip"; -import { - AnnotationsContext, AnnotationsContextDispatch, -} from "../../../../services/annotator/annotations/annotations.context.tsx"; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { focusTag } from "@/slices/annotator/annotations.ts"; export const TagListBloc: React.FC = () => { - const context = useContext(AnnotationsContext); + const { + allTags, + presenceTags, + focusedTag + } = useAppSelector(state => state.annotator.annotations); return (
    Tags list
      - { context.allTags.map((tag, key) => ( + { allTags.map((tag, key) => ( + isEnabled={ presenceTags.includes(tag) } + isActive={ focusedTag === tag }> )) }
    @@ -45,10 +48,13 @@ const TagItem: React.FC = ({ isActive, }) => { - const context = useContext(AnnotationsContext); - const dispatch = useContext(AnnotationsContextDispatch); + const { + tagColors, + currentMode + } = useAppSelector(state => state.annotator.annotations); + const dispatch = useAppDispatch() - const color = context.tagColors.get(tag) ?? DEFAULT_COLOR; + const color = tagColors[tag] ?? DEFAULT_COLOR; const style = { inactive: { backgroundColor: color, @@ -67,9 +73,9 @@ const TagItem: React.FC = ({
  • diff --git a/frontend/src/view/audio-annotator/components/navigation-buttons.component.tsx b/frontend/src/view/audio-annotator/components/navigation-buttons.component.tsx index c3d9199d..79c45181 100644 --- a/frontend/src/view/audio-annotator/components/navigation-buttons.component.tsx +++ b/frontend/src/view/audio-annotator/components/navigation-buttons.component.tsx @@ -1,15 +1,14 @@ -import React, { Fragment, ReactNode, useContext, useEffect, useImperativeHandle, useState } from "react"; +import React, { Fragment, ReactNode, useEffect, useImperativeHandle, useState } from "react"; import OverlayTrigger from "react-bootstrap/OverlayTrigger"; import { useHistory } from "react-router-dom"; import { KeypressHandler } from "../audio-annotator.page.tsx"; -import { useAnnotationTaskAPI } from "../../../services/api"; -import { - AnnotationsContext -} from "../../../services/annotator/annotations/annotations.context.tsx"; -import { AnnotationType } from "../../../enum/annotation.enum.tsx"; -import { AnnotatorContext } from "../../../services/annotator/annotator.context.tsx"; +import { AnnotationTaskDto, useAnnotationTaskAPI } from "@/services/api"; +import { AnnotationType } from "@/types/annotations.ts"; import { confirm } from "../../global-components"; import Tooltip from "react-bootstrap/Tooltip"; +import { IonButton, IonIcon } from "@ionic/react"; +import { caretBack, caretForward } from "ionicons/icons"; +import { useAppSelector } from "@/slices/app"; interface Props { shortcut: ReactNode; @@ -17,9 +16,9 @@ interface Props { } export const NavigationShortcutOverlay = React.forwardRef(({ - shortcut, - description, - }, ref) => ( + shortcut, + description, + }, ref) => (

    Shortcut

    @@ -35,17 +34,28 @@ export const NavigationShortcutOverlay = React.forwardRef export const NavigationButtons = React.forwardRef(({ start }, ref) => { const history = useHistory(); - const context = useContext(AnnotatorContext); const [siblings, setSiblings] = useState<{ prev?: number, next?: number } | undefined>() const taskAPI = useAnnotationTaskAPI(); - const resultsContext = useContext(AnnotationsContext); + + const { + prevAndNextAnnotation, + areShortcutsEnabled, + taskId, + mode, + campaignId, + } = useAppSelector(state => state.annotator.global); + const { + results, + taskComment, + hasChanged + } = useAppSelector(state => state.annotator.annotations); useEffect(() => { - setSiblings(context.prevAndNextAnnotation); - }, [context.prevAndNextAnnotation]) + setSiblings(prevAndNextAnnotation); + }, [prevAndNextAnnotation]) const handleKeyPressed = (event: KeyboardEvent) => { - if (!context.areShortcutsEnabled) return; + if (!areShortcutsEnabled) return; switch (event.code) { case 'Enter': case 'NumpadEnter': @@ -65,15 +75,15 @@ export const NavigationButtons = React.forwardRef { const now = new Date().getTime(); - const response = await taskAPI.update(context.taskId!, { - annotations: resultsContext.results.map(r => { + const response = await taskAPI.update(taskId!, { + annotations: results.map(r => { const isBox = r.type === AnnotationType.box; const startTime = isBox ? r.startTime : null; const endTime = isBox ? r.endTime : null; const startFrequency = isBox ? r.startFrequency : null; const endFrequency = isBox ? r.endFrequency : null; const result_comments = r.result_comments.filter(c => c.comment.length > 0); - return { + const result: AnnotationTaskDto = { id: r.id, startTime, endTime, @@ -82,12 +92,13 @@ export const NavigationButtons = React.forwardRef { if (!siblings?.prev) return; - if (resultsContext.hasChanged) { + if (hasChanged) { const response = await confirm(`You have unsaved changes. Are you sure you want to forget all of them ?`, `Forget my changes`); if (!response) return; } @@ -109,7 +120,7 @@ export const NavigationButtons = React.forwardRef { if (!siblings?.next) return; - if (resultsContext.hasChanged) { + if (hasChanged) { const response = await confirm(`You have unsaved changes. Are you sure you want to forget all of them ?`, `Forget my changes`); if (!response) return; } @@ -118,27 +129,30 @@ export const NavigationButtons = React.forwardRef; return ( -
    - } - description="load previous recording"/> }> - +
    + } + description="load previous recording"/> }> + + + - }> - + - } + } description="load next recording"/> }> - + + +
    ) diff --git a/frontend/src/view/audio-annotator/components/region.component.tsx b/frontend/src/view/audio-annotator/components/region.component.tsx index 9e60b43e..59bfb99a 100644 --- a/frontend/src/view/audio-annotator/components/region.component.tsx +++ b/frontend/src/view/audio-annotator/components/region.component.tsx @@ -1,11 +1,10 @@ -import React, { RefObject, useContext } from 'react' -import { Annotation } from "../../../interface/annotation.interface.tsx"; -import { DEFAULT_COLOR } from "../../../consts/colors.const.tsx"; +import React, { RefObject } from 'react' +import { Annotation } from "@/types/annotations.ts"; +import { DEFAULT_COLOR } from "@/consts/colors.const.tsx"; import { AudioPlayer } from "./audio-player.component.tsx"; import { SPECTRO_HEIGHT } from "./spectro-render.component.tsx"; -import { - AnnotationsContext, AnnotationsContextDispatch, -} from "../../../services/annotator/annotations/annotations.context.tsx"; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { focusResult, removeResult } from "@/slices/annotator/annotations.ts"; // Component dimensions constants const HEADER_HEIGHT: number = 18; @@ -21,19 +20,25 @@ type RegionProps = { export const Region: React.FC = ({ - annotation, - // canvasWrapperRef, - freqPxRatio, - timePxRatio, - audioPlayer, - }) => { - // const spectroContext = useContext(SpectroContext); - - const resultContext = useContext(AnnotationsContext); - const resultDispatch = useContext(AnnotationsContextDispatch); + annotation, + // canvasWrapperRef, + freqPxRatio, + timePxRatio, + audioPlayer, + }) => { + + const { + mode, + } = useAppSelector(state => state.annotator.global); + const { + wholeFileBoundaries, + tagColors, + focusedResult, + } = useAppSelector(state => state.annotator.annotations); + const dispatch = useAppDispatch() const offsetLeft = annotation.startTime * timePxRatio; - const freqOffset: number = (annotation.endFrequency - (resultContext.wholeFileBoundaries.startFrequency ?? 0)) * freqPxRatio; + const freqOffset: number = (annotation.endFrequency - (wholeFileBoundaries.startFrequency ?? 0)) * freqPxRatio; const offsetTop: number = SPECTRO_HEIGHT - freqOffset; // const distanceToMarginLeft: number = (+(canvasWrapperRef.current?.style.width ?? 0) * spectroContext.currentZoom) - Math.floor(offsetLeft); @@ -46,8 +51,8 @@ export const Region: React.FC = ({ const headerPositionIsTop = offsetTop > HEADER_HEIGHT + HEADER_MARGIN; - const color = resultContext.tagColors.get(annotation.annotation) ?? DEFAULT_COLOR; - const isActive = annotation.id === resultContext.focusedResult?.id && annotation.newId === resultContext.focusedResult?.newId; + const color = tagColors[annotation.annotation] ?? DEFAULT_COLOR; + const isActive = annotation.id === focusedResult?.id && annotation.newId === focusedResult?.newId; const currentColor = isActive ? color : `${ color }88`; const styles = { header: { @@ -85,11 +90,11 @@ export const Region: React.FC = ({

    - resultDispatch!({ type: 'focusResult', result: annotation }) } + onClick={ () => dispatch(focusResult(annotation)) } style={ styles.headerSpan }> { annotation.annotation } @@ -98,8 +103,8 @@ export const Region: React.FC = ({ : } - + { mode === 'Create' && }

    { headerPositionIsTop && regionBody } diff --git a/frontend/src/view/audio-annotator/components/spectro-render.component.tsx b/frontend/src/view/audio-annotator/components/spectro-render.component.tsx index f72100b6..55ed5ca9 100644 --- a/frontend/src/view/audio-annotator/components/spectro-render.component.tsx +++ b/frontend/src/view/audio-annotator/components/spectro-render.component.tsx @@ -1,26 +1,22 @@ import React, { Fragment, PointerEvent, - useContext, useEffect, useMemo, useRef, useState, WheelEvent } from "react"; -import { AnnotationMode, AnnotationType } from "../../../enum/annotation.enum.tsx"; -import { Annotation } from "../../../interface/annotation.interface.tsx"; +import { Annotation, AnnotationMode, AnnotationType } from "@/types/annotations.ts"; import { Region } from "./region.component.tsx"; -import { buildErrorMessage, formatTimestamp } from "../../../services/annotator/format/format.util.tsx"; -import { AudioContext } from "../../../services/annotator/audio/audio.context.tsx"; +import { formatTimestamp } from "@/services/utils/format.tsx"; import { AudioPlayer } from "./audio-player.component.tsx"; -import { - SpectroContext, SpectroDispatchContext -} from "../../../services/annotator/spectro/spectro.context.tsx"; -import { - AnnotationsContext, AnnotationsContextDispatch, -} from "../../../services/annotator/annotations/annotations.context.tsx"; -import { AnnotatorDispatchContext } from "../../../services/annotator/annotator.context.tsx"; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { setDangerToast } from "@/slices/annotator/global-annotator.ts"; +import { addResult } from "@/slices/annotator/annotations.ts"; +import { leavePointer, updatePointerPosition, zoom } from "@/slices/annotator/spectro.ts"; +import { SpectrogramImage } from "@/types/spectro.ts"; +import { Usage } from "../../../types/annotations.ts"; export const SPECTRO_HEIGHT: number = 512; export const SPECTRO_WIDTH: number = 1813; @@ -75,19 +71,32 @@ class EditAnnotation { } } -export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { - const audioContext = useContext(AudioContext); - const spectroContext = useContext(SpectroContext); - const spectroDispatch = useContext(SpectroDispatchContext); - - const resultContext = useContext(AnnotationsContext); - const resultDispatch = useContext(AnnotationsContextDispatch); - - const annotatorDispatch = useContext(AnnotatorDispatchContext); - - const [ zoom, setZoom ] = useState(1); - const [ currenTime, setCurrenTime ] = useState(0); - const [ newAnnotation, setNewAnnotation ] = useState(undefined); +export const SpectroRenderComponent: React.FC = ({ audioPlayer, }) => { + + const { + currentMode, + focusedTag, + wholeFileBoundaries, + focusedConfidence, + results, + } = useAppSelector(state => state.annotator.annotations); + const { + time, + } = useAppSelector(state => state.annotator.audio); + const { + mode, + } = useAppSelector(state => state.annotator.global); + const { + currentZoom, + currentZoomOrigin, + currentImages + } = useAppSelector(state => state.annotator.spectro); + const dispatch = useAppDispatch() + + const [_zoom, _setZoom] = useState(1); + const [currenTime, setCurrenTime] = useState(0); + const [newAnnotation, setNewAnnotation] = useState(undefined); + const [images, setImages] = useState>(new Map()); /** * Ref to canvas wrapper is used to modify its scrollLeft property. @@ -104,17 +113,17 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { const xAxisRef = useRef(null); // Is drawing enabled? (always in box mode, when a tag is selected in presence mode) - const isDrawingEnabled = useMemo(() => resultContext.currentMode === AnnotationMode.boxes || ( - resultContext.currentMode === AnnotationMode.wholeFile && !!resultContext.focusedTag - ), [ resultContext.focusedTag, resultContext.currentMode ]); + const isDrawingEnabled = useMemo(() => mode === Usage.create && (currentMode === AnnotationMode.boxes || ( + currentMode === AnnotationMode.wholeFile && !!focusedTag) + ), [focusedTag, currentMode, mode]); const frequencyRange = useMemo( - () => resultContext.wholeFileBoundaries.endFrequency - resultContext.wholeFileBoundaries.startFrequency, - [ resultContext.wholeFileBoundaries ] + () => wholeFileBoundaries.endFrequency - wholeFileBoundaries.startFrequency, + [wholeFileBoundaries.endFrequency, wholeFileBoundaries.startFrequency] ); - const timePixelRatio = useMemo(() => SPECTRO_WIDTH * spectroContext.currentZoom / resultContext.wholeFileBoundaries.duration, [ resultContext.wholeFileBoundaries.duration, spectroContext.currentZoom ]); - const frequencyPixelRatio = useMemo(() => SPECTRO_HEIGHT / frequencyRange, [ resultContext.wholeFileBoundaries ]); + const timePixelRatio = useMemo(() => SPECTRO_WIDTH * currentZoom / wholeFileBoundaries.duration, [wholeFileBoundaries.duration, currentZoom]); + const frequencyPixelRatio = useMemo(() => SPECTRO_HEIGHT / frequencyRange, [wholeFileBoundaries]); const isInCanvas = (event: PointerEvent) => { const bounds = spectroRef.current?.getBoundingClientRect(); @@ -139,33 +148,33 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { if (!canvas || !timeAxis || !wrapper) return; // If zoom factor has changed - if (spectroContext.currentZoom === zoom) return; + if (currentZoom === _zoom) return; // New timePxRatio - const newTimePxRatio: number = SPECTRO_WIDTH * spectroContext.currentZoom / resultContext.wholeFileBoundaries.duration; + const newTimePxRatio: number = SPECTRO_WIDTH * currentZoom / wholeFileBoundaries.duration; // Resize canvases and scroll - canvas.width = SPECTRO_WIDTH * spectroContext.currentZoom; - timeAxis.width = SPECTRO_WIDTH * spectroContext.currentZoom; + canvas.width = SPECTRO_WIDTH * currentZoom; + timeAxis.width = SPECTRO_WIDTH * currentZoom; // Compute new center (before resizing) let newCenter: number; - if (spectroContext.currentZoomOrigin) { + if (currentZoomOrigin) { // x-coordinate has been given, center on it const bounds = canvas.getBoundingClientRect(); - newCenter = (spectroContext.currentZoomOrigin.x - bounds.left) * spectroContext.currentZoom / zoom; + newCenter = (currentZoomOrigin.x - bounds.left) * currentZoom / _zoom; } else { // If no x-coordinate: center on currentTime - newCenter = audioContext.time * newTimePxRatio; + newCenter = time * newTimePxRatio; } wrapper.scrollLeft = Math.floor(newCenter - SPECTRO_WIDTH / 2); - setZoom(spectroContext.currentZoom); - }, [ spectroContext.currentZoom ]); + _setZoom(currentZoom); + }, [currentZoom]); // On current params loaded/changed useEffect(() => { loadX(); loadSpectro(); - }, [ spectroContext.currentImages ]) + }, [currentImages]) // On current audio time changed useEffect(() => { @@ -175,19 +184,19 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { const wrapper = wrapperRef.current; const canvas = spectroRef.current; if (!wrapper || !canvas) return; - const oldX: number = Math.floor(canvas.width * currenTime / resultContext.wholeFileBoundaries.duration); - const newX: number = Math.floor(canvas.width * audioContext.time / resultContext.wholeFileBoundaries.duration); + const oldX: number = Math.floor(canvas.width * currenTime / wholeFileBoundaries.duration); + const newX: number = Math.floor(canvas.width * time / wholeFileBoundaries.duration); if ((oldX - wrapper.scrollLeft) < SPECTRO_WIDTH && (newX - wrapper.scrollLeft) >= SPECTRO_WIDTH) { wrapper.scrollLeft += SPECTRO_WIDTH; } - setCurrenTime(audioContext.time); - }, [ audioContext.time ]) + setCurrenTime(time); + }, [time]) // On current newAnnotation changed useEffect(() => { loadSpectro(); - }, [ newAnnotation?.currentTime, newAnnotation?.currentFrequency ]) + }, [newAnnotation?.currentTime, newAnnotation?.currentFrequency]) const getTimeFromClientX = (clientX: number): number => { const canvas = spectroRef.current; @@ -204,15 +213,15 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { const bounds = canvas.getBoundingClientRect(); const pixel = Math.min(Math.max(clientY, bounds.top), bounds.bottom); - return (resultContext.wholeFileBoundaries.startFrequency + bounds.bottom - pixel) / frequencyPixelRatio; + return (wholeFileBoundaries.startFrequency + bounds.bottom - pixel) / frequencyPixelRatio; } const getXSteps = (duration: number) => { - if (duration <= 60) return {step: 1, bigStep: 5} - else if (duration > 60 && duration <= 120) return {step: 2, bigStep: 5} - else if (duration > 120 && duration <= 500) return {step: 4, bigStep: 5} - else if (duration > 500 && duration <= 1000) return {step: 10, bigStep: 60} - else return {step: 30, bigStep: 120} + if (duration <= 60) return { step: 1, bigStep: 5 } + else if (duration > 60 && duration <= 120) return { step: 2, bigStep: 5 } + else if (duration > 120 && duration <= 500) return { step: 4, bigStep: 5 } + else if (duration > 500 && duration <= 1000) return { step: 10, bigStep: 60 } + else return { step: 30, bigStep: 120 } } const loadX = (): void => { @@ -257,11 +266,11 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { } const getYSteps = () => { - if (frequencyRange <= 200) return {step: 5, bigStep: 20} - else if (frequencyRange > 200 && frequencyRange <= 500) return {step: 10, bigStep: 100} - else if (frequencyRange > 500 && frequencyRange <= 2000) return {step: 20, bigStep: 100} - else if (frequencyRange > 2000 && frequencyRange <= 20000) return {step: 500, bigStep: 2000} - else return {step: 2000, bigStep: 10000} + if (frequencyRange <= 200) return { step: 5, bigStep: 20 } + else if (frequencyRange > 200 && frequencyRange <= 500) return { step: 10, bigStep: 100 } + else if (frequencyRange > 500 && frequencyRange <= 2000) return { step: 20, bigStep: 100 } + else if (frequencyRange > 2000 && frequencyRange <= 20000) return { step: 500, bigStep: 2000 } + else return { step: 2000, bigStep: 10000 } } const loadY = (): void => { @@ -272,8 +281,8 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { canvasContext.clearRect(0, 0, freqAxis.width, freqAxis.height); const bounds: DOMRect = freqAxis.getBoundingClientRect(); - const startFreq: number = Math.ceil(resultContext.wholeFileBoundaries.startFrequency ?? 0); - const endFreq: number = Math.floor((resultContext.wholeFileBoundaries.startFrequency ?? 0) + frequencyRange); + const startFreq: number = Math.ceil(wholeFileBoundaries.startFrequency ?? 0); + const endFreq: number = Math.floor((wholeFileBoundaries.startFrequency ?? 0) + frequencyRange); canvasContext.fillStyle = 'rgba(0, 0, 0)'; canvasContext.font = '10px Arial'; @@ -303,43 +312,45 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { } const loadSpectroImages = (): Promise => { - return new Promise((resolve, reject) => { - if (!spectroContext.currentImages.length) return reject('no images to load'); - if (spectroContext.currentImages.filter(i => !i.image).length === 0) return resolve(); - let nbLoaded = 0; - for (const data of spectroContext.currentImages) { - data.image = new Image(); - data.image.src = data.src; - data.image.onload = () => { - if (++nbLoaded === spectroContext.currentImages.length) resolve(); + if (!currentImages.length) throw 'no images to load'; + const promises = []; + for (const data of currentImages) { + if (images.get(data)) continue; + const image = new Image(); + image.src = data.src; + promises.push(new Promise((resolve, reject) => { + image.onload = () => { + images.set(data, image); + setImages(images) + resolve(); } - data.image.onerror = e => { - annotatorDispatch!({type: 'setDangerToast', message: buildErrorMessage(e)}); + image.onerror = e => { + dispatch(setDangerToast(`Cannot load spectrogram image with source: ${ image.src }`)) reject(e); } - } - }); + })) + } + return Promise.all(promises).then(); } const loadSpectro = async (): Promise => { const canvas = spectroRef.current; - const canvasContext = canvas?.getContext('2d', {alpha: false}); + const canvasContext = canvas?.getContext('2d', { alpha: false }); if (!canvas || !canvasContext) return; canvasContext.clearRect(0, 0, canvas.width, canvas.height); // Draw spectro images await loadSpectroImages(); - spectroContext.currentImages - .forEach(spectro => canvasContext.drawImage( - spectro.image!, - spectro.start * timePixelRatio, - 0, - Math.floor((spectro.end - spectro.start) * timePixelRatio), - canvas.height - )); + currentImages.forEach(spectro => canvasContext.drawImage( + images.get(spectro)!, + spectro.start * timePixelRatio, + 0, + Math.floor((spectro.end - spectro.start) * timePixelRatio), + canvas.height + )); // Progress bar - const newX: number = Math.floor(canvas.width * audioContext.time / resultContext.wholeFileBoundaries.duration); + const newX: number = Math.floor(canvas.width * time / wholeFileBoundaries.duration); canvasContext.fillStyle = 'rgba(0, 0, 0)'; canvasContext.fillRect(newX, 0, 1, canvas.height); @@ -347,7 +358,7 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { if (newAnnotation) { const a = newAnnotation; const x: number = Math.floor(a.startTime * timePixelRatio); - const freqOffset: number = (a.startFrequency - (resultContext.wholeFileBoundaries.startFrequency ?? 0)) * frequencyPixelRatio; + const freqOffset: number = (a.startFrequency - (wholeFileBoundaries.startFrequency ?? 0)) * frequencyPixelRatio; const y: number = Math.floor(canvas.height - freqOffset); const width: number = Math.floor((a.endTime - a.startTime) * timePixelRatio); const height: number = -Math.floor((a.endFrequency - a.startFrequency) * frequencyPixelRatio); @@ -361,9 +372,9 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { const frequency = getFrequencyFromClientY(e.clientY) if (isInCanvas(e)) { - spectroDispatch!({type: 'updatePointerPosition', position: {time, frequency}}) + dispatch(updatePointerPosition({ time, frequency })) newAnnotation?.update(time, frequency); - } else spectroDispatch!({type: 'leavePointer'}) + } else dispatch(leavePointer()) } const onStartNewAnnotation = (e: PointerEvent) => { @@ -385,18 +396,17 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { const width = Math.abs(newAnnotation.startTime - newAnnotation.endTime) * timePixelRatio const height = Math.abs(newAnnotation.startFrequency - newAnnotation.endFrequency) * frequencyPixelRatio if (width > 2 && height > 2) { - resultDispatch!({ - type: 'addResult', result: { - type: AnnotationType.box, - annotation: resultContext.focusedTag ?? '', - confidenceIndicator: resultContext.focusedConfidence, - startTime: newAnnotation.startTime, - endTime: newAnnotation.endTime, - startFrequency: newAnnotation.startFrequency, - endFrequency: newAnnotation.endFrequency, - result_comments: [] - } - }) + dispatch(addResult({ + type: AnnotationType.box, + annotation: focusedTag ?? '', + confidenceIndicator: focusedConfidence, + startTime: newAnnotation.startTime, + endTime: newAnnotation.endTime, + startFrequency: newAnnotation.startFrequency, + endFrequency: newAnnotation.endFrequency, + result_comments: [], + validation: null + })) } } setNewAnnotation(undefined); @@ -412,8 +422,8 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { y: event.clientY } - if (event.deltaY < 0) spectroDispatch!({type: 'zoom', direction: 'in', origin}); - else if (event.deltaY > 0) spectroDispatch!({type: 'zoom', direction: 'out', origin}); + if (event.deltaY < 0) dispatch(zoom({ direction: 'in', origin })) + else if (event.deltaY > 0) dispatch(zoom({ direction: 'out', origin })) } return ( @@ -422,13 +432,14 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { ref={ yAxisRef } height={ SPECTRO_HEIGHT } width={ Y_WIDTH } - style={ {top: `${ CONTROLS_AREA_SIZE }px`} }> + style={ { top: `${ CONTROLS_AREA_SIZE }px` } }>
    dispatch(leavePointer())} onPointerUp={ onEndNewAnnotation } style={ { width: `${ SPECTRO_WIDTH }px`, @@ -447,9 +458,9 @@ export const SpectroRenderComponent: React.FC = ({audioPlayer,}) => { ref={ xAxisRef } height={ X_HEIGHT } width={ SPECTRO_WIDTH } - style={ {top: `${ SPECTRO_HEIGHT }px`} }> + style={ { top: `${ SPECTRO_HEIGHT }px` } }> - { resultContext.results + { results .filter(a => a.type === AnnotationType.box) .map((annotation: Annotation, key: number) => ( = ({ audioPlayer, }) => { - const spectroContext = useContext(SpectroContext); - const spectroDispatch = useContext(SpectroDispatchContext); - const resultContext = useContext(AnnotationsContext); - const context = useContext(AnnotatorContext); + + const { + audioURL, + audioRate, + } = useAppSelector(state => state.annotator.global); + const { + wholeFileBoundaries, + } = useAppSelector(state => state.annotator.annotations); + const { + currentParams, + availableParams, + currentZoom, + pointerPosition + } = useAppSelector(state => state.annotator.spectro); + const dispatch = useAppDispatch() const style = { workbench: { @@ -38,13 +46,9 @@ export const Workbench: React.FC = ({ audioPlayer, }) => { style={ style.workbench }>

    + onClick={ () => dispatch(zoom({ direction: 'in'})) }> - { spectroContext.currentZoom }x + onClick={ () => dispatch(zoom({ direction: 'out'})) }> + { currentZoom }x

    - { spectroContext.pointerPosition &&

    - { spectroContext.pointerPosition.frequency.toFixed(2) }Hz / { formatTimestamp(spectroContext.pointerPosition.time, false) } + { pointerPosition &&

    + { pointerPosition.frequency.toFixed(2) }Hz / { formatTimestamp(pointerPosition.time, false) }

    }

    - File : { context.audioURL?.split('/').pop() ?? '' } - Sampling - : { context.audioRate ?? 0 } Hz
    - Start date : { resultContext.wholeFileBoundaries.startTime.toUTCString() } + File : { audioURL?.split('/').pop() ?? '' } - Sampling + : { audioRate ?? 0 } Hz
    + Start date : { new Date(wholeFileBoundaries.startTime).toUTCString() }

    diff --git a/frontend/src/view/create-campaign/blocs/annotations.bloc.tsx b/frontend/src/view/create-campaign/blocs/annotations.bloc.tsx new file mode 100644 index 00000000..62bb6082 --- /dev/null +++ b/frontend/src/view/create-campaign/blocs/annotations.bloc.tsx @@ -0,0 +1,218 @@ +import React, { Fragment, useEffect, useState } from "react"; +import { IonButton, IonIcon, IonNote } from "@ionic/react"; +import { cloudUploadOutline, trashOutline } from "ionicons/icons"; +import { createCampaignActions, } from "@/slices/create-campaign"; +import { useBlur } from "@/services/utils/clic"; +import { + AnnotationSetList, + ConfidenceSetList, + DetectorList, + useAnnotationSetAPI, + useConfidenceSetAPI, + useDetectorsAPI +} from '@/services/api'; +import { ChipsInput, FormBloc, Select } from "@/components/form"; +import { Usage } from "@/types/annotations.ts"; +import { ImportModal } from "./import-modal/import-modal.component.tsx"; +import { useAppDispatch, useAppSelector } from "@/slices/app"; +import { importAnnotationsActions } from "@/slices/create-campaign/import-annotations.ts"; +import { useToast } from "@/services/utils/toast.ts"; + + +export const AnnotationsBloc: React.FC = () => { + // API Data + const [allAnnotationSets, setAllAnnotationSets] = useState([]); + const annotationSetAPI = useAnnotationSetAPI(); + const [allConfidenceSets, setAllConfidenceSets] = useState([]); + const confidenceSetAPI = useConfidenceSetAPI(); + const [allDetectors, setAllDetectors] = useState([]); + const detectorsAPI = useDetectorsAPI(); + + // Services + const blurUtil = useBlur(); + const toast = useToast(); + + useEffect(() => { + blurUtil.addListener(blur) + let isCancelled = false; + Promise.all([ + annotationSetAPI.list().then(setAllAnnotationSets), + confidenceSetAPI.list().then(setAllConfidenceSets), + detectorsAPI.list().then(setAllDetectors), + ]).catch(e => !isCancelled && toast.presentError(e)); + + return () => { + isCancelled = true; + annotationSetAPI.abort(); + confidenceSetAPI.abort(); + detectorsAPI.abort(); + } + }, []) + + // Form data + const dispatch = useAppDispatch(); + const usage = useAppSelector(state => state.createCampaignForm.global.usage); + + const onUsageChange = (value: string | number | undefined) => { + dispatch(createCampaignActions.updateUsage(value as Usage)); + } + + + return ( + + ({ value: s.id, label: s.name })) } + optionsContainer="alert" + value={ annotationSet?.id } + onValueSelected={ onAnnotatorSetChange }> + { !!annotationSet && ( + + { annotationSet.desc } +

    Tags: { annotationSet.tags.join(', ') }

    +
    ) + } + + + + + ) +} + +interface CheckAnnotationsProps { + allDetectors: DetectorList, +} + +const CheckAnnotationsInputs: React.FC = ({ + allDetectors, + }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + // Form data + const dispatch = useAppDispatch(); + const { + dataset, + detectors, + } = useAppSelector(state => state.createCampaignForm.global); + + const onDetectorsChange = (array: Array) => { + dispatch(createCampaignActions.updateDetectors(detectors.filter(d => array.includes(d.initialName)))); + } + + const openImportModal = () => { + setIsModalOpen(true) + } + + const deleteDetectors = () => { + dispatch(createCampaignActions.setDetectors([])) + dispatch(importAnnotationsActions.clear()); + } + + + return ( + + { !detectors.length && +
    + + Import annotations + + + { + } }/> +
    + { !dataset && + + You must select a dataset to import annotations + } +
    } + + { !!dataset && } + + + { detectors.length > 0 &&
    + { + const name = d.existingDetector?.name ?? d.initialName; + const oldName = d.existingDetector && d.existingDetector.name !== d.initialName ? ` (${d.initialName})` : ''; + return { + value: d.initialName, + label: name + oldName + } + }) } + activeItemsValues={ detectors.map(d => d.initialName) } + setActiveItemsValues={ onDetectorsChange }/> + + + + +
    } +
    + ) +} diff --git a/frontend/src/view/create-campaign/blocs/annotators.bloc.tsx b/frontend/src/view/create-campaign/blocs/annotators.bloc.tsx new file mode 100644 index 00000000..5194d1dc --- /dev/null +++ b/frontend/src/view/create-campaign/blocs/annotators.bloc.tsx @@ -0,0 +1,91 @@ +import React, { ChangeEvent, useEffect, useMemo, useState } from "react"; +import { createCampaignActions } from "@/slices/create-campaign"; +import { useUsersAPI } from '@/services/api/user'; +import { FormBloc, Searchbar, ChipsInput, Input } from "@/components/form"; +import { Item } from "@/types/item"; +import { User } from '@/types/userInterface.ts'; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { useToast } from "@/services/utils/toast"; + + +export const AnnotatorsBloc: React.FC = () => { + + // API Data + const [users, setUsers] = useState>([]); + const usersAPI = useUsersAPI(); + + // Services + const toast = useToast(); + + // Form data + const dispatch = useAppDispatch(); + const { + annotators, + annotatorsPerFile, + dataset + } = useAppSelector(state => state.createCampaignForm.global); + + + useEffect(() => { + let isCancelled = false; + usersAPI.list().then(setUsers).catch(e => !isCancelled && toast.presentError(e)); + + return () => { + isCancelled = true; + usersAPI.abort(); + } + }, []) + + const onAddAnnotator = (item: Item) => { + const annotator = users.find(a => a.id === item.value); + if (!annotator) return; + dispatch(createCampaignActions.updateAnnotators([...annotators, annotator])) + dispatch(createCampaignActions.updateAnnotatorsPerFile(annotators.length + 1)) + } + + const onAnnotatorsChange = (array: Array) => { + const newAnnotators = annotators.filter(a => array.includes(a.id)) + dispatch(createCampaignActions.updateAnnotators(newAnnotators)); + dispatch(createCampaignActions.updateAnnotatorsPerFile(newAnnotators.length)) + } + + const onAnnotatorsPerFileChange = (e: ChangeEvent) => { + dispatch(createCampaignActions.updateAnnotatorsPerFile(e.target.value ? +e.target.value : undefined)) + } + + // Calculated data + const annotatedFilesCount = useMemo(() => { + if (!dataset?.files_count || !annotators.length) return 0; + return Math.round(dataset.files_count * annotatorsPerFile / annotators.length); + }, [dataset?.files_count, annotatorsPerFile, annotators.length]) + const annotatedFilesPercent = useMemo(() => { + if (!dataset?.files_count || !annotatedFilesCount) return 0; + return Math.round(annotatedFilesCount / dataset?.files_count * 100); + }, [dataset?.files_count, annotatedFilesCount]) + + return ( + + + !annotators.find(annotator => annotator.id === u.id)) + .map(a => ({ value: a.id, label: a.display_name })) + } + onValueSelected={ onAddAnnotator }/> + + ({ value: a.id, label: a.display_name })) } + required={ true } + activeItemsValues={ annotators.map(a => a.id) } + setActiveItemsValues={ onAnnotatorsChange }/> + + 0 ? 1 : 0 } + value={ annotatorsPerFile } + onChange={ onAnnotatorsPerFileChange } + note={ ` Each annotator will annotate ${ annotatedFilesCount } / ${ dataset?.files_count ?? 0 } files (${ annotatedFilesPercent }%)` }/> + + ) +} diff --git a/frontend/src/view/create-campaign/blocs/dataset.bloc.tsx b/frontend/src/view/create-campaign/blocs/dataset.bloc.tsx new file mode 100644 index 00000000..7207639b --- /dev/null +++ b/frontend/src/view/create-campaign/blocs/dataset.bloc.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { createCampaignActions } from "@/slices/create-campaign"; +import { DatasetList, useDatasetsAPI } from "@/services/api"; +import { FormBloc, Select, ChipsInput } from "@/components/form"; +import { useAppSelector, useAppDispatch } from "@/slices/app"; +import { useToast } from "@/services/utils/toast.ts"; + +export const DatasetBloc: React.FC = () => { + + // API Data + const [datasets, setDatasets] = useState([]); + const datasetsAPI = useDatasetsAPI(); + + // Form data + const { + dataset, + datasetSpectroConfigs + } = useAppSelector(state => state.createCampaignForm.global); + const dispatch = useAppDispatch(); + const availableSpectro = useMemo(() => dataset?.spectros ? dataset.spectros : [], [dataset?.spectros]) + + // Services + const toast = useToast(); + + useEffect(() => { + let isCancelled = false; + datasetsAPI.list('.wav').then(setDatasets).catch(e => !isCancelled && toast.presentError(e)); + + return () => { + isCancelled = true; + datasetsAPI.abort(); + } + }, []) + + const onDatasetChange = (value: string | number | undefined) => { + const newDataset = datasets.find(d => d.id === value); + dispatch(createCampaignActions.updateDataset(newDataset)); + dispatch(createCampaignActions.updateDatasetSpectroConfigs(newDataset?.spectros ?? [])) + } + + const onSpectroConfigsChange = (array: Array) => { + dispatch(createCampaignActions.updateDatasetSpectroConfigs(availableSpectro.filter(d => array.includes(d.id)))) + } + + return ( + + + +