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/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
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"