From 769ced2e0e3eb0bab198ef87f930811d285970a4 Mon Sep 17 00:00:00 2001 From: Sergei Ivashchenko Date: Fri, 13 Dec 2024 14:56:52 +0000 Subject: [PATCH 1/2] fix: LEAP-1352: API for Bulk Annotation Configuration Validation (#6757) Co-authored-by: hlomzik Co-authored-by: MihajloHoma Co-authored-by: triklozoid --- label_studio/projects/serializers.py | 83 +++++++++++++++++++ .../tests/config_validation.tavern.yml | 71 +++++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/label_studio/projects/serializers.py b/label_studio/projects/serializers.py index c54be0470f5b..1df08fdd524a 100644 --- a/label_studio/projects/serializers.py +++ b/label_studio/projects/serializers.py @@ -3,6 +3,30 @@ import bleach from constants import SAFE_HTML_ATTRIBUTES, SAFE_HTML_TAGS from django.db.models import Q +from label_studio_sdk.label_interface import LabelInterface +from label_studio_sdk.label_interface.control_tags import ( + BrushLabelsTag, + BrushTag, + ChoicesTag, + DateTimeTag, + EllipseLabelsTag, + EllipseTag, + HyperTextLabelsTag, + KeyPointLabelsTag, + KeyPointTag, + LabelsTag, + NumberTag, + ParagraphLabelsTag, + PolygonLabelsTag, + PolygonTag, + RatingTag, + RectangleLabelsTag, + RectangleTag, + TaxonomyTag, + TextAreaTag, + TimeSeriesLabelsTag, + VideoRectangleTag, +) from projects.models import Project, ProjectImport, ProjectOnboarding, ProjectReimport, ProjectSummary from rest_flex_fields import FlexFieldsModelSerializer from rest_framework import serializers @@ -66,6 +90,9 @@ class ProjectSerializer(FlexFieldsModelSerializer): config_has_control_tags = SerializerMethodField( default=None, read_only=True, help_text='Flag to detect is project ready for labeling' ) + config_suitable_for_bulk_annotation = serializers.SerializerMethodField( + default=None, read_only=True, help_text='Flag to detect is project ready for bulk annotation' + ) finished_task_number = serializers.IntegerField(default=None, read_only=True, help_text='Finished tasks') queue_total = serializers.SerializerMethodField() @@ -82,6 +109,61 @@ def user_id(self): def get_config_has_control_tags(project): return len(project.get_parsed_config()) > 0 + @staticmethod + def get_config_suitable_for_bulk_annotation(project): + li = LabelInterface(project.label_config) + + # List of tags that should not be present + disallowed_tags = [ + LabelsTag, + BrushTag, + BrushLabelsTag, + EllipseTag, + EllipseLabelsTag, + KeyPointTag, + KeyPointLabelsTag, + PolygonTag, + PolygonLabelsTag, + RectangleTag, + RectangleLabelsTag, + HyperTextLabelsTag, + ParagraphLabelsTag, + TimeSeriesLabelsTag, + VideoRectangleTag, + ] + + # Return False if any disallowed tag is present + for tag_class in disallowed_tags: + if li.find_tags_by_class(tag_class): + return False + + # Check perRegion/perItem for expanded list of tags, plus value="no" for Choices/Taxonomy + allowed_tags_for_checks = [ChoicesTag, TaxonomyTag, DateTimeTag, NumberTag, RatingTag, TextAreaTag] + for tag_class in allowed_tags_for_checks: + tags = li.find_tags_by_class(tag_class) + for tag in tags: + per_region = tag.attr.get('perRegion', 'false').lower() == 'true' + per_item = tag.attr.get('perItem', 'false').lower() == 'true' + if per_region or per_item: + return False + # For ChoicesTag and TaxonomyTag, the value attribute must not be set at all + if tag_class in [ChoicesTag, TaxonomyTag]: + if 'value' in tag.attr: + return False + + # For TaxonomyTag, check labeling and apiUrl + taxonomy_tags = li.find_tags_by_class(TaxonomyTag) + for tag in taxonomy_tags: + labeling = tag.attr.get('labeling', 'false').lower() == 'true' + if labeling: + return False + api_url = tag.attr.get('apiUrl', None) + if api_url is not None: + return False + + # If all checks pass, return True + return True + @staticmethod def get_parsed_label_config(project): return project.get_parsed_config() @@ -156,6 +238,7 @@ class Meta: 'finished_task_number', 'queue_total', 'queue_done', + 'config_suitable_for_bulk_annotation', ] def validate_label_config(self, value): diff --git a/label_studio/tests/config_validation.tavern.yml b/label_studio/tests/config_validation.tavern.yml index 3dca2afeff3b..0320c072b97d 100644 --- a/label_studio/tests/config_validation.tavern.yml +++ b/label_studio/tests/config_validation.tavern.yml @@ -1435,4 +1435,73 @@ stages: method: POST url: '{django_live_url}/api/projects/{pk}/validate' response: - status_code: 200 \ No newline at end of file + status_code: 200 + +--- +test_name: check_config_suitable_for_bulk_annotation +strict: false +marks: +- usefixtures: + - django_live_url +stages: + +- id: signup + type: ref + +- name: create classification project + request: + data: + label_config: | + + + + + + + + + method: POST + url: '{django_live_url}/api/projects' + response: + status_code: 201 + save: + json: + classification_project_id: id + +- name: check classification project property + request: + method: GET + url: '{django_live_url}/api/projects/{classification_project_id}' + response: + status_code: 200 + json: + config_suitable_for_bulk_annotation: true + +- name: create object detection project + request: + data: + label_config: | + + + + + + method: POST + url: '{django_live_url}/api/projects' + response: + status_code: 201 + save: + json: + detection_project_id: id + +- name: check object detection project property + request: + method: GET + url: '{django_live_url}/api/projects/{detection_project_id}' + response: + status_code: 200 + json: + config_suitable_for_bulk_annotation: false From 510fe943faf2474c03666bcbe5f4578783c24da4 Mon Sep 17 00:00:00 2001 From: Jo Booth Date: Fri, 13 Dec 2024 12:12:13 -0500 Subject: [PATCH 2/2] chore: BEG-7: Django 5 upgrade (#6614) Co-authored-by: Wesley Lima Co-authored-by: niklub Co-authored-by: nik --- .github/workflows/tests.yml | 2 +- label_studio/core/settings/base.py | 18 +++++++++----- label_studio/data_manager/managers.py | 10 ++++---- poetry.lock | 34 +++++++++++++-------------- pyproject.toml | 6 ++--- 5 files changed, 37 insertions(+), 33 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17b3f99aac96..b192657d0313 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -179,7 +179,7 @@ jobs: fail-fast: false matrix: python-version: - - '3.9' + - '3.10' # required for poetry action # see https://github.com/marketplace/actions/install-poetry-action#running-on-windows diff --git a/label_studio/core/settings/base.py b/label_studio/core/settings/base.py index 1c641009e816..6258a78710b3 100644 --- a/label_studio/core/settings/base.py +++ b/label_studio/core/settings/base.py @@ -411,7 +411,14 @@ 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', ) -STATICFILES_STORAGE = 'core.storage.SkipMissedManifestStaticFilesStorage' +STORAGES = { + 'default': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + }, + 'staticfiles': { + 'BACKEND': 'core.storage.SkipMissedManifestStaticFilesStorage', + }, +} # Sessions and CSRF SESSION_COOKIE_SECURE = bool(int(get_env('SESSION_COOKIE_SECURE', False))) @@ -648,7 +655,7 @@ def collect_versions_dummy(**kwargs): if get_env('MINIO_STORAGE_ENDPOINT') and not get_bool_env('MINIO_SKIP', False): CLOUD_FILE_STORAGE_ENABLED = True - DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + STORAGES['default']['BACKEND'] = 'storages.backends.s3boto3.S3Boto3Storage' AWS_STORAGE_BUCKET_NAME = get_env('MINIO_STORAGE_BUCKET_NAME') AWS_ACCESS_KEY_ID = get_env('MINIO_STORAGE_ACCESS_KEY') AWS_SECRET_ACCESS_KEY = get_env('MINIO_STORAGE_SECRET_KEY') @@ -661,7 +668,7 @@ def collect_versions_dummy(**kwargs): if get_env('STORAGE_TYPE') == 's3': CLOUD_FILE_STORAGE_ENABLED = True - DEFAULT_FILE_STORAGE = 'core.storage.CustomS3Boto3Storage' + STORAGES['default']['BACKEND'] = 'core.storage.CustomS3Boto3Storage' if get_env('STORAGE_AWS_ACCESS_KEY_ID'): AWS_ACCESS_KEY_ID = get_env('STORAGE_AWS_ACCESS_KEY_ID') if get_env('STORAGE_AWS_SECRET_ACCESS_KEY'): @@ -681,7 +688,7 @@ def collect_versions_dummy(**kwargs): if get_env('STORAGE_TYPE') == 'azure': CLOUD_FILE_STORAGE_ENABLED = True - DEFAULT_FILE_STORAGE = 'core.storage.CustomAzureStorage' + STORAGES['default']['BACKEND'] = 'core.storage.CustomAzureStorage' AZURE_ACCOUNT_NAME = get_env('STORAGE_AZURE_ACCOUNT_NAME') AZURE_ACCOUNT_KEY = get_env('STORAGE_AZURE_ACCOUNT_KEY') AZURE_CONTAINER = get_env('STORAGE_AZURE_CONTAINER_NAME') @@ -690,8 +697,7 @@ def collect_versions_dummy(**kwargs): if get_env('STORAGE_TYPE') == 'gcs': CLOUD_FILE_STORAGE_ENABLED = True - # DEFAULT_FILE_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage' - DEFAULT_FILE_STORAGE = 'core.storage.AlternativeGoogleCloudStorage' + STORAGES['default']['BACKEND'] = 'core.storage.AlternativeGoogleCloudStorage' GS_PROJECT_ID = get_env('STORAGE_GCS_PROJECT_ID') GS_BUCKET_NAME = get_env('STORAGE_GCS_BUCKET_NAME') GS_EXPIRATION = timedelta(seconds=int(get_env('STORAGE_GCS_EXPIRATION_SECS', '86400'))) diff --git a/label_studio/data_manager/managers.py b/label_studio/data_manager/managers.py index b8d5365233ac..85c433a51250 100644 --- a/label_studio/data_manager/managers.py +++ b/label_studio/data_manager/managers.py @@ -599,7 +599,7 @@ def annotate_annotations_results(queryset): ) ) else: - return queryset.annotate(annotations_results=ArrayAgg('annotations__result', distinct=True)) + return queryset.annotate(annotations_results=ArrayAgg('annotations__result', distinct=True, default=Value([]))) def annotate_predictions_results(queryset): @@ -610,7 +610,7 @@ def annotate_predictions_results(queryset): ) ) else: - return queryset.annotate(predictions_results=ArrayAgg('predictions__result', distinct=True)) + return queryset.annotate(predictions_results=ArrayAgg('predictions__result', distinct=True, default=Value([]))) def annotate_annotators(queryset): @@ -619,7 +619,7 @@ def annotate_annotators(queryset): annotators=Coalesce(GroupConcat('annotations__completed_by'), Value(''), output_field=models.CharField()) ) else: - return queryset.annotate(annotators=ArrayAgg('annotations__completed_by', distinct=True)) + return queryset.annotate(annotators=ArrayAgg('annotations__completed_by', distinct=True, default=Value([]))) def annotate_predictions_score(queryset): @@ -653,7 +653,7 @@ def annotate_annotations_ids(queryset): if settings.DJANGO_DB == settings.DJANGO_DB_SQLITE: return queryset.annotate(annotations_ids=GroupConcat('annotations__id', output_field=models.CharField())) else: - return queryset.annotate(annotations_ids=ArrayAgg('annotations__id')) + return queryset.annotate(annotations_ids=ArrayAgg('annotations__id', default=Value([]))) def annotate_predictions_model_versions(queryset): @@ -662,7 +662,7 @@ def annotate_predictions_model_versions(queryset): predictions_model_versions=GroupConcat('predictions__model_version', output_field=models.CharField()) ) else: - return queryset.annotate(predictions_model_versions=ArrayAgg('predictions__model_version')) + return queryset.annotate(predictions_model_versions=ArrayAgg('predictions__model_version', default=Value([]))) def annotate_avg_lead_time(queryset): diff --git a/poetry.lock b/poetry.lock index 2a8e9a36228d..83ea851df632 100644 --- a/poetry.lock +++ b/poetry.lock @@ -60,13 +60,13 @@ test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] [[package]] name = "asgiref" -version = "3.7.2" +version = "3.8.1" description = "ASGI specs, helper code, and adapters" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, - {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, ] [package.dependencies] @@ -628,7 +628,6 @@ packaging = "*" pydantic = [ {version = ">=1.10.0,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, {version = ">=1.10.0,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, - {version = ">=1.5.1,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version < \"3.10\""}, {version = ">=1.9.0,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, ] pyyaml = ">=6.0.1" @@ -675,17 +674,17 @@ files = [ [[package]] name = "django" -version = "4.2.17" +version = "5.1.4" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "Django-4.2.17-py3-none-any.whl", hash = "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0"}, - {file = "Django-4.2.17.tar.gz", hash = "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc"}, + {file = "Django-5.1.4-py3-none-any.whl", hash = "sha256:236e023f021f5ce7dee5779de7b286565fdea5f4ab86bae5338e3f7b69896cf0"}, + {file = "Django-5.1.4.tar.gz", hash = "sha256:de450c09e91879fa5a307f696e57c851955c910a438a35e6b4c895e86bedc82a"}, ] [package.dependencies] -asgiref = ">=3.6.0,<4" +asgiref = ">=3.8.1,<4" sqlparse = ">=0.3.1" tzdata = {version = "*", markers = "sys_platform == \"win32\""} @@ -787,17 +786,17 @@ Django = ">=3.2" [[package]] name = "django-filter" -version = "2.4.0" +version = "24.3" description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "django-filter-2.4.0.tar.gz", hash = "sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06"}, - {file = "django_filter-2.4.0-py3-none-any.whl", hash = "sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1"}, + {file = "django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64"}, + {file = "django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3"}, ] [package.dependencies] -Django = ">=2.2" +Django = ">=4.2" [[package]] name = "django-migration-linter" @@ -3477,7 +3476,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -4616,5 +4614,5 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.0" -python-versions = ">=3.9,<4" -content-hash = "045667fad3d575ef8b624a03314d14107671c0a90211cba420859eedef23a84a" +python-versions = ">=3.10,<4" +content-hash = "5124c308584e6f9d7493d37f6d471947f30ab4eaedb1b13c3c6ac9a71c3eaccd" diff --git a/pyproject.toml b/pyproject.toml index bbd3e293c08d..b1e374e588cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,7 +143,7 @@ exclude = [ label-studio = "label_studio.server:main" [tool.poetry.dependencies] -python = ">=3.9,<4" +python = ">=3.10,<4" wheel = "<=0.40.0,>=0.38.1" appdirs = ">=1.4.3" attr = "0.3.1" @@ -154,12 +154,12 @@ boto = "^2.49.0" boto3 = "^1.28.58" botocore = "^1.31.58" bleach = "~=5.0.0" -Django = "~=4.2.13" +Django = "~=5.1.4" django-storages = "1.12.3" django-annoying = "0.10.6" django-debug-toolbar = "3.2.1" django-environ = "0.10.0" -django-filter = "2.4.0" +django-filter = "24.3" django-model-utils = "4.1.1" django-rq = "2.5.1" django-cors-headers = "3.6.0"