From a6546692a9d3eacf89a61b84933018a85527ee2f Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 3 Mar 2025 11:59:47 +0400 Subject: [PATCH 01/27] Added jsonl option for prepare_export --- src/superannotate/__init__.py | 2 +- src/superannotate/lib/app/interface/sdk_interface.py | 5 ++++- src/superannotate/lib/infrastructure/annotation_adapter.py | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 2e4c077fa..d96b06007 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.32dev2" +__version__ = "4.4.32dev3" os.environ.update({"sa_version": __version__}) sys.path.append(os.path.split(os.path.realpath(__file__))[0]) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 2dfe694b6..0699fb981 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1677,7 +1677,8 @@ def prepare_export( - integration_name: The name of the integration within the platform that is being used. - format: The format in which the data will be exported in multimodal projects. - It can be either CSV or JSON. If None, the data will be exported in the default JSON format. + The data can be exported in CSV, JSON, or JSONL format. If None, the data will be exported + in the default JSON format. :return: metadata object of the prepared export :rtype: dict @@ -1715,6 +1716,8 @@ def prepare_export( export_type = export_type.lower() if export_type == "csv": _export_type = 3 + elif export_type == "jsonl": + _export_type = 4 response = self.controller.prepare_export( project_name=project_name, folder_names=folders, diff --git a/src/superannotate/lib/infrastructure/annotation_adapter.py b/src/superannotate/lib/infrastructure/annotation_adapter.py index c333231cf..13436510a 100644 --- a/src/superannotate/lib/infrastructure/annotation_adapter.py +++ b/src/superannotate/lib/infrastructure/annotation_adapter.py @@ -44,7 +44,9 @@ def get_component_value(self, component_id: str): return None def set_component_value(self, component_id: str, value: Any): - self.annotation.setdefault("data", {}).setdefault(component_id, {})["value"] = value + self.annotation.setdefault("data", {}).setdefault(component_id, {})[ + "value" + ] = value return self From 04904fb7310d04095069c33be4d4ebe22eccdb52 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 4 Mar 2025 13:35:52 +0400 Subject: [PATCH 02/27] Added ability to filter by item category --- .../lib/app/interface/sdk_interface.py | 2 +- .../lib/core/entities/__init__.py | 2 +- .../lib/core/entities/filters.py | 2 + src/superannotate/lib/core/entities/items.py | 12 +++- .../lib/core/entities/project.py | 5 -- .../lib/core/serviceproviders.py | 6 ++ .../lib/core/usecases/annotations.py | 6 +- .../lib/infrastructure/annotation_adapter.py | 4 +- .../lib/infrastructure/query_builder.py | 10 +++ .../lib/infrastructure/serviceprovider.py | 7 +++ src/superannotate/lib/infrastructure/utils.py | 24 ++++++++ tests/integration/items/test_list_items.py | 61 +++++++++++++++++++ 12 files changed, 129 insertions(+), 12 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 2dfe694b6..4eb343861 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3418,7 +3418,7 @@ def list_items( exclude = {"meta", "annotator_email", "qa_email"} if not include_custom_metadata: exclude.add("custom_metadata") - return BaseSerializer.serialize_iterable(res, exclude=exclude) + return BaseSerializer.serialize_iterable(res, exclude=exclude, by_alias=False) def list_projects( self, diff --git a/src/superannotate/lib/core/entities/__init__.py b/src/superannotate/lib/core/entities/__init__.py index 008822c41..c4666973d 100644 --- a/src/superannotate/lib/core/entities/__init__.py +++ b/src/superannotate/lib/core/entities/__init__.py @@ -4,6 +4,7 @@ from lib.core.entities.classes import AnnotationClassEntity from lib.core.entities.folder import FolderEntity from lib.core.entities.integrations import IntegrationEntity +from lib.core.entities.items import CategoryEntity from lib.core.entities.items import ClassificationEntity from lib.core.entities.items import DocumentEntity from lib.core.entities.items import ImageEntity @@ -12,7 +13,6 @@ from lib.core.entities.items import TiledEntity from lib.core.entities.items import VideoEntity from lib.core.entities.project import AttachmentEntity -from lib.core.entities.project import CategoryEntity from lib.core.entities.project import ContributorEntity from lib.core.entities.project import CustomFieldEntity from lib.core.entities.project import ProjectEntity diff --git a/src/superannotate/lib/core/entities/filters.py b/src/superannotate/lib/core/entities/filters.py index 8f6d2369d..0fe71a1d3 100644 --- a/src/superannotate/lib/core/entities/filters.py +++ b/src/superannotate/lib/core/entities/filters.py @@ -29,6 +29,8 @@ class ItemFilters(BaseFilters): assignments__user_role__in: Optional[List[str]] assignments__user_role__ne: Optional[str] assignments__user_role__notin: Optional[List[str]] + categories__value: Optional[str] + categories__value__in: Optional[List[str]] class ProjectFilters(BaseFilters): diff --git a/src/superannotate/lib/core/entities/items.py b/src/superannotate/lib/core/entities/items.py index 3d9f25d19..477cab8dd 100644 --- a/src/superannotate/lib/core/entities/items.py +++ b/src/superannotate/lib/core/entities/items.py @@ -2,7 +2,7 @@ from typing import Optional from lib.core.entities.base import BaseItemEntity -from lib.core.entities.base import TimedBaseModel +from lib.core.entities.project import TimedBaseModel from lib.core.enums import ApprovalStatus from lib.core.enums import ProjectType from lib.core.pydantic_v1 import Extra @@ -18,9 +18,17 @@ class Config: extra = Extra.ignore +class CategoryEntity(TimedBaseModel): + id: int + value: str = Field(None, alias="name") + + class Config: + extra = Extra.ignore + + class MultiModalItemCategoryEntity(TimedBaseModel): id: int = Field(None, alias="category_id") - name: str = Field(None, alias="category_name") + value: str = Field(None, alias="category_name") class Config: extra = Extra.ignore diff --git a/src/superannotate/lib/core/entities/project.py b/src/superannotate/lib/core/entities/project.py index f8306d698..840abf56b 100644 --- a/src/superannotate/lib/core/entities/project.py +++ b/src/superannotate/lib/core/entities/project.py @@ -187,8 +187,3 @@ def is_system(self): class Config: extra = Extra.ignore - - -class CategoryEntity(BaseModel): - id: Optional[int] - name: Optional[str] diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index d8fbab28e..5de7fc235 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -696,6 +696,12 @@ class BaseServiceProvider: def get_role_id(self, project: entities.ProjectEntity, role_name: str) -> int: raise NotImplementedError + @abstractmethod + def get_category_id( + self, project: entities.ProjectEntity, category_name: str + ) -> int: + raise NotImplementedError + @abstractmethod def get_role_name(self, project: entities.ProjectEntity, role_id: int) -> str: raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index faafbb63c..90bd5ba67 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -2098,8 +2098,10 @@ def execute(self): if categorization_enabled: item_id_category_map = {} for item_name in uploaded_annotations: - category = name_annotation_map[item_name]["metadata"].get( - "item_category" + category = ( + name_annotation_map[item_name]["metadata"] + .get("item_category", {}) + .get("value") ) if category: item_id_category_map[name_item_map[item_name].id] = category diff --git a/src/superannotate/lib/infrastructure/annotation_adapter.py b/src/superannotate/lib/infrastructure/annotation_adapter.py index c333231cf..13436510a 100644 --- a/src/superannotate/lib/infrastructure/annotation_adapter.py +++ b/src/superannotate/lib/infrastructure/annotation_adapter.py @@ -44,7 +44,9 @@ def get_component_value(self, component_id: str): return None def set_component_value(self, component_id: str, value: Any): - self.annotation.setdefault("data", {}).setdefault(component_id, {})["value"] = value + self.annotation.setdefault("data", {}).setdefault(component_id, {})[ + "value" + ] = value return self diff --git a/src/superannotate/lib/infrastructure/query_builder.py b/src/superannotate/lib/infrastructure/query_builder.py index 4473e502b..bb83e3009 100644 --- a/src/superannotate/lib/infrastructure/query_builder.py +++ b/src/superannotate/lib/infrastructure/query_builder.py @@ -113,6 +113,8 @@ def handle(self, filters: Dict[str, Any], query: Query = None) -> Query: for key, val in filters.items(): _keys = key.split("__") val = self._handle_special_fields(_keys, val) + if _keys[0] == "categories" and _keys[1] == "value": + _keys[1] = "category_id" condition, _key = determine_condition_and_key(_keys) query &= Filter(_key, val, condition) return super().handle(filters, query) @@ -147,6 +149,14 @@ def _handle_special_fields(self, keys: List[str], val): ] else: val = self._service_provider.get_role_id(self._project, val) + elif keys[0] == "categories" and keys[1] == "value": + if isinstance(val, list): + val = [ + self._service_provider.get_category_id(self._project, i) + for i in val + ] + else: + val = self._service_provider.get_category_id(self._project, val) return val diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 641111a49..01ab57a78 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -79,6 +79,13 @@ def list_custom_field_names(self, entity: CustomFieldEntityEnum) -> List[str]: self.client.team_id, entity=entity ) + def get_category_id( + self, project: entities.ProjectEntity, category_name: str + ) -> int: + return self._cached_work_management_repository.get_category_id( + project, category_name + ) + def get_custom_field_id( self, field_name: str, entity: CustomFieldEntityEnum ) -> int: diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index eaa75e80d..9508e6c40 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -138,6 +138,23 @@ def get(self, key, **kwargs): return self._K_V_map[key] +class CategoryCache(BaseCachedWorkManagementRepository): + def sync(self, project: ProjectEntity): + response = self.work_management.list_project_categories(project.id) + if not response.ok: + raise AppException(response.error) + categories = response.data + self._K_V_map[project.id] = { + "category_name_id_map": { + category.value: category.id for category in categories + }, + "category_id_name_map": { + category.id: category.value for category in categories + }, + } + self._update_cache_timestamp(project.id) + + class RoleCache(BaseCachedWorkManagementRepository): def sync(self, project: ProjectEntity): response = self.work_management.list_workflow_roles( @@ -221,6 +238,7 @@ def get(self, key, **kwargs): class CachedWorkManagementRepository: def __init__(self, ttl_seconds: int, work_management): + self._category_cache = CategoryCache(ttl_seconds, work_management) self._role_cache = RoleCache(ttl_seconds, work_management) self._status_cache = StatusCache(ttl_seconds, work_management) self._project_custom_field_cache = CustomFieldCache( @@ -236,6 +254,12 @@ def __init__(self, ttl_seconds: int, work_management): CustomFieldEntityEnum.TEAM, ) + def get_category_id(self, project, category_name: str) -> int: + data = self._category_cache.get(project.id, project=project) + if category_name in data["category_name_id_map"]: + return data["category_name_id_map"][category_name] + raise AppException("Invalid category provided.") + def get_role_id(self, project, role_name: str) -> int: role_data = self._role_cache.get(project.id, project=project) if role_name in role_data["role_name_id_map"]: diff --git a/tests/integration/items/test_list_items.py b/tests/integration/items/test_list_items.py index 7d2cdabf9..f0a80fb79 100644 --- a/tests/integration/items/test_list_items.py +++ b/tests/integration/items/test_list_items.py @@ -1,10 +1,13 @@ +import json import os import random import string +import time from pathlib import Path from src.superannotate import AppException from src.superannotate import SAClient +from tests import DATA_SET_PATH from tests.integration.base import BaseTestCase sa = SAClient() @@ -61,3 +64,61 @@ def test_list_items_URL_limit(self): sa.attach_items(self.PROJECT_NAME, items_for_attache) items = sa.list_items(self.PROJECT_NAME, name__in=item_names) assert len(items) == 125 + + +class TestListItemsMultimodal(BaseTestCase): + PROJECT_NAME = "TestListItemsMultimodal" + PROJECT_DESCRIPTION = "TestSearchItems" + PROJECT_TYPE = "Multimodal" + TEST_FOLDER_PATH = "data_set/sample_project_vector" + CATEGORIES = ["c_1", "c_2", "c_3"] + ANNOTATIONS = [ + {"metadata": {"name": "item_1", "item_category": {"value": "c1"}}, "data": {}}, + {"metadata": {"name": "item_2", "item_category": {"value": "c2"}}, "data": {}}, + {"metadata": {"name": "item_3", "item_category": {"value": "c3"}}, "data": {}}, + ] + CLASSES_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/from1_classes.json" + EDITOR_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/form1.json" + + def setUp(self, *args, **kwargs): + self.tearDown() + self._project = sa.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + "Multimodal", + settings=[ + {"attribute": "CategorizeItems", "value": 1}, + {"attribute": "TemplateState", "value": 1}, + ], + ) + project = sa.controller.get_project(self.PROJECT_NAME) + time.sleep(10) + with open(self.EDITOR_TEMPLATE_PATH) as f: + res = sa.controller.service_provider.projects.attach_editor_template( + sa.controller.team, project, template=json.load(f) + ) + assert res.ok + sa.create_annotation_classes_from_classes_json( + self.PROJECT_NAME, self.CLASSES_TEMPLATE_PATH + ) + + def test_list_category_filter(self): + sa.upload_annotations( + self.PROJECT_NAME, self.ANNOTATIONS, data_spec="multimodal" + ) + items = sa.list_items( + self.PROJECT_NAME, + include=["categories"], + categories__value__in=["c1", "c2"], + ) + assert [i["categories"][0]["value"] for i in items] == ["c1", "c2"] + assert ( + len( + sa.list_items( + self.PROJECT_NAME, + include=["categories"], + categories__value__in=["c3"], + ) + ) + == 1 + ) From 2b4600838180143c4a70a05e40dce098569984ee Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 3 Mar 2025 17:40:54 +0400 Subject: [PATCH 03/27] Add ability to donwload annotations for multimodal projects tod --- .../lib/app/interface/sdk_interface.py | 27 ++++++++++ .../lib/core/serviceproviders.py | 2 + .../lib/core/usecases/annotations.py | 3 ++ .../lib/infrastructure/annotation_adapter.py | 4 +- .../lib/infrastructure/controller.py | 2 + .../lib/infrastructure/services/annotation.py | 22 ++++++-- ...{from1_classes.json => form1_classes.json} | 0 .../annotations/test_upload_annotations.py | 52 ++++++++++++++----- tests/integration/items/test_item_context.py | 2 +- 9 files changed, 94 insertions(+), 20 deletions(-) rename tests/data_set/editor_templates/{from1_classes.json => form1_classes.json} (100%) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 2dfe694b6..6b857dd54 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3806,6 +3806,7 @@ def download_annotations( items: Optional[List[NotEmptyStr]] = None, recursive: bool = False, callback: Callable = None, + data_spec: Literal["default", "multimodal"] = "default", ): """Downloads annotation JSON files of the selected items to the local directory. @@ -3831,6 +3832,31 @@ def download_annotations( The function receives each annotation as an argument and the returned value will be applied to the download. :type callback: callable + :param data_spec: Specifies the format for processing and transforming annotations before upload. + + Options are: + - default: Retains the annotations in their original format. + - multimodal: Converts annotations for multimodal projects, optimizing for + compact and multimodal-specific data representation. + + :type data_spec: str, optional + + Example Usage of Multimodal Projects:: + + from superannotate import SAClient + + + sa = SAClient() + + # Call the get_annotations function + response = sa.download_annotations( + project="project1/folder1", + path="path/to/download", + items=["item_1", "item_2"], + data_spec='multimodal' + ) + + :return: local path of the downloaded annotations folder. :rtype: str """ @@ -3843,6 +3869,7 @@ def download_annotations( recursive=recursive, item_names=items, callback=callback, + transform_version="llmJsonV2" if data_spec == "multimodal" else None, ) if response.errors: raise AppException(response.errors) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index d8fbab28e..008651cb0 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -501,6 +501,7 @@ async def download_big_annotation( download_path: str, item: entities.BaseItemEntity, callback: Callable = None, + transform_version: str = None, ): raise NotImplementedError @@ -513,6 +514,7 @@ async def download_small_annotations( download_path: str, item_ids: List[int], callback: Callable = None, + transform_version: str = None, ): raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 5409eba6e..281f87fae 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -1634,6 +1634,7 @@ def __init__( item_names: List[str], service_provider: BaseServiceProvider, callback: Callable = None, + transform_version=None, ): super().__init__(reporter) self._config = config @@ -1645,6 +1646,7 @@ def __init__( self._service_provider = service_provider self._callback = callback self._big_file_queue = None + self._transform_version = transform_version def validate_items(self): if self._item_names: @@ -1724,6 +1726,7 @@ async def download_small_annotations( reporter=self.reporter, download_path=f"{export_path}{'/' + self._folder.name if not self._folder.is_root else ''}", callback=self._callback, + transform_version=self._transform_version, ) async def run_workers( diff --git a/src/superannotate/lib/infrastructure/annotation_adapter.py b/src/superannotate/lib/infrastructure/annotation_adapter.py index c333231cf..13436510a 100644 --- a/src/superannotate/lib/infrastructure/annotation_adapter.py +++ b/src/superannotate/lib/infrastructure/annotation_adapter.py @@ -44,7 +44,9 @@ def get_component_value(self, component_id: str): return None def set_component_value(self, component_id: str, value: Any): - self.annotation.setdefault("data", {}).setdefault(component_id, {})["value"] = value + self.annotation.setdefault("data", {}).setdefault(component_id, {})[ + "value" + ] = value return self diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 0e9f703b1..53e21a620 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -932,6 +932,7 @@ def download( recursive: bool, item_names: Optional[List[str]], callback: Optional[Callable], + transform_version: str, ): use_case = usecases.DownloadAnnotations( config=self._config, @@ -943,6 +944,7 @@ def download( item_names=item_names, service_provider=self.service_provider, callback=callback, + transform_version=transform_version, ) return use_case.execute() diff --git a/src/superannotate/lib/infrastructure/services/annotation.py b/src/superannotate/lib/infrastructure/services/annotation.py index 27a27dd39..a1f5d9e46 100644 --- a/src/superannotate/lib/infrastructure/services/annotation.py +++ b/src/superannotate/lib/infrastructure/services/annotation.py @@ -67,7 +67,9 @@ def get_schema(self, project_type: int, version: str): }, ) - async def _sync_large_annotation(self, team_id, project_id, item_id): + async def _sync_large_annotation( + self, team_id, project_id, item_id, transform_version: str = None + ): sync_params = { "team_id": team_id, "project_id": project_id, @@ -77,6 +79,8 @@ async def _sync_large_annotation(self, team_id, project_id, item_id): "current_source": "main", "desired_source": "secondary", } + if transform_version: + sync_params["transform_version"] = transform_version sync_url = urljoin( self.get_assets_provider_url(), self.URL_START_FILE_SYNC.format(item_id=item_id), @@ -120,11 +124,12 @@ async def get_big_annotation( "annotation_type": "MAIN", "version": "V1.00", } - if transform_version: - query_params["desired_transform_version"] = transform_version await self._sync_large_annotation( - team_id=project.team_id, project_id=project.id, item_id=item.id + team_id=project.team_id, + project_id=project.id, + item_id=item.id, + transform_version=transform_version, ) async with AIOHttpSession( @@ -202,6 +207,7 @@ async def download_big_annotation( download_path: str, item: entities.BaseItemEntity, callback: Callable = None, + transform_version: str = None, ): item_id = item.id item_name = item.name @@ -218,7 +224,10 @@ async def download_big_annotation( ) await self._sync_large_annotation( - team_id=project.team_id, project_id=project.id, item_id=item_id + team_id=project.team_id, + project_id=project.id, + item_id=item_id, + transform_version=transform_version, ) async with AIOHttpSession( @@ -252,12 +261,15 @@ async def download_small_annotations( download_path: str, item_ids: List[int], callback: Callable = None, + transform_version: str = None, ): query_params = { "team_id": project.team_id, "project_id": project.id, "folder_id": folder.id, } + if transform_version: + query_params["transform_version"] = transform_version handler = StreamedAnnotations( headers=self.client.default_headers, reporter=reporter, diff --git a/tests/data_set/editor_templates/from1_classes.json b/tests/data_set/editor_templates/form1_classes.json similarity index 100% rename from tests/data_set/editor_templates/from1_classes.json rename to tests/data_set/editor_templates/form1_classes.json diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index 0732b5749..181ace079 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -1,5 +1,6 @@ import json import os +import tempfile import time from pathlib import Path @@ -133,23 +134,19 @@ def test_upload_large_annotations(self): ) == 5 -class MultiModalUploadAnnotations(BaseTestCase): +class MultiModalUploadDownloadAnnotations(BaseTestCase): PROJECT_NAME = "TestMultimodalUploadAnnotations" PROJECT_TYPE = "Multimodal" PROJECT_DESCRIPTION = "DESCRIPTION" - EDITOR_TEMPLATE_PATH = os.path.join( - Path(__file__).parent.parent.parent, "data_set/editor_templates/form1.json" - ) - JSONL_ANNOTATIONS_PATH = os.path.join( - DATA_SET_PATH, "multimodal/annotations/jsonl/form1.jsonl" - ) - JSONL_ANNOTATIONS_WITH_CATEGORIES_PATH = os.path.join( - DATA_SET_PATH, "multimodal/annotations/jsonl/form1_with_categories.jsonl" - ) - CLASSES_TEMPLATE_PATH = os.path.join( - Path(__file__).parent.parent.parent, - "data_set/editor_templates/from1_classes.json", + BASE_PATH = Path(__file__).parent.parent.parent + DATA_SET_PATH = BASE_PATH / "data_set" + + EDITOR_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/form1.json" + JSONL_ANNOTATIONS_PATH = DATA_SET_PATH / "multimodal/annotations/jsonl/form1.jsonl" + JSONL_ANNOTATIONS_WITH_CATEGORIES_PATH = ( + DATA_SET_PATH / "multimodal/annotations/jsonl/form1_with_categories.jsonl" ) + CLASSES_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates" / "form1_classes.json" def setUp(self, *args, **kwargs): self.tearDown() @@ -244,3 +241,32 @@ def test_upload_with_integer_names(self): sa.get_annotations( f"{self.PROJECT_NAME}/test_folder", data_spec="multimodal" ) + + def test_download_annotations(self): + with open(self.JSONL_ANNOTATIONS_PATH) as f: + data = [json.loads(line) for line in f] + sa.upload_annotations( + self.PROJECT_NAME, annotations=data, data_spec="multimodal" + ) + + annotations = sa.get_annotations( + f"{self.PROJECT_NAME}/test_folder", data_spec="multimodal" + ) + + with tempfile.TemporaryDirectory() as tmpdir: + sa.download_annotations( + f"{self.PROJECT_NAME}/test_folder", path=tmpdir, data_spec="multimodal" + ) + downloaded_files = list(Path(f"{tmpdir}/test_folder").glob("*.json")) + assert len(downloaded_files) > 0, "No annotations were downloaded" + downloaded_data = [] + for file_path in downloaded_files: + with open(file_path) as f: + downloaded_data.append(json.load(f)) + + assert len(downloaded_data) == len( + annotations + ), "Mismatch in annotation count" + assert ( + downloaded_data == annotations + ), "Downloaded annotations do not match uploaded annotations" diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 63f7cf310..3a50e2d40 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -19,7 +19,7 @@ class TestMultimodalProjectBasic(BaseTestCase): ) CLASSES_TEMPLATE_PATH = os.path.join( Path(__file__).parent.parent.parent, - "data_set/editor_templates/from1_classes.json", + "data_set/editor_templates/form1_classes.json", ) def setUp(self, *args, **kwargs): From e23b92584985eea3e035abc5e592ef01c29d11a0 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 4 Mar 2025 17:51:09 +0400 Subject: [PATCH 04/27] Added ability to get project users tod --- src/superannotate/__init__.py | 2 +- .../lib/app/interface/sdk_interface.py | 28 +++++- .../lib/core/entities/work_managament.py | 30 ++++++ .../lib/core/serviceproviders.py | 32 +++++- .../lib/core/usecases/projects.py | 12 ++- .../lib/infrastructure/annotation_adapter.py | 4 +- .../lib/infrastructure/controller.py | 68 ++++++++++--- .../lib/infrastructure/query_builder.py | 18 +++- .../lib/infrastructure/serviceprovider.py | 35 +++++-- .../services/work_management.py | 31 ++++-- src/superannotate/lib/infrastructure/utils.py | 97 ++++++++++++++++--- .../test_user_custom_fields.py | 18 ++++ 12 files changed, 311 insertions(+), 64 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index e6e8b7684..cec08ea20 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.32" +__version__ = "4.4.33dev1" os.environ.update({"sa_version": __version__}) sys.path.append(os.path.split(os.path.realpath(__file__))[0]) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 2dfe694b6..c6e091650 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -372,9 +372,18 @@ def set_user_custom_field( parent_entity=CustomFieldEntityEnum.TEAM, ) - def list_users(self, *, include: List[Literal["custom_fields"]] = None, **filters): + def list_users( + self, + *, + project: Union[int, str] = None, + include: List[Literal["custom_fields"]] = None, + **filters, + ): """ Search users by filtering criteria + :param project: Project name or ID, if provided, results will be for project-level, + otherwise results will be for team level. + :type project: str or int :param include: Specifies additional fields to be included in the response. @@ -454,9 +463,22 @@ def list_users(self, *, include: List[Literal["custom_fields"]] = None, **filter } ] """ - return BaseSerializer.serialize_iterable( - self.controller.work_management.list_users(include=include, **filters) + if project is not None: + if isinstance(project, int): + project = self.controller.get_project_by_id(project) + else: + project = self.controller.get_project(project) + response = BaseSerializer.serialize_iterable( + self.controller.work_management.list_users( + project=project, include=include, **filters + ) ) + if project: + for user in response: + user["role"] = self.controller.service_provider.get_role_name( + project, user["role"] + ) + return response def pause_user_activity( self, pk: Union[int, str], projects: Union[List[int], List[str], Literal["*"]] diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 1539236bf..b78fab9de 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -119,3 +119,33 @@ def json(self, **kwargs): if "exclude" not in kwargs: kwargs["exclude"] = {"custom_fields"} return super().json(**kwargs) + + +class WMProjectUserEntity(TimedBaseModel): + id: Optional[int] + team_id: Optional[int] + role: int + email: Optional[str] + state: Optional[WMUserStateEnum] + custom_fields: Optional[dict] = Field(dict(), alias="customField") + + class Config: + extra = Extra.ignore + use_enum_names = True + + json_encoders = { + Enum: lambda v: v.value, + datetime.date: lambda v: v.isoformat(), + datetime.datetime: lambda v: v.isoformat(), + } + + @validator("custom_fields") + def custom_fields_transformer(cls, v): + if v and "custom_field_values" in v: + return v.get("custom_field_values", {}) + return {} + + def json(self, **kwargs): + if "exclude" not in kwargs: + kwargs["exclude"] = {"custom_fields"} + return super().json(**kwargs) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index d8fbab28e..cf9d08441 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -147,7 +147,12 @@ def create_project_categories( @abstractmethod def list_users( - self, body_query: Query, chunk_size=100, include_custom_fields=False + self, + body_query: Query, + parent_entity: str = "Team", + chunk_size=100, + project_id: int = None, + include_custom_fields=False, ) -> WMUserListResponse: raise NotImplementedError @@ -804,23 +809,40 @@ def invite_contributors( raise NotImplementedError @abstractmethod - def list_custom_field_names(self, entity: CustomFieldEntityEnum) -> List[str]: + def list_custom_field_names( + self, pk, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + ) -> List[str]: raise NotImplementedError @abstractmethod def get_custom_field_id( - self, field_name: str, entity: CustomFieldEntityEnum + self, + field_name: str, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> int: raise NotImplementedError @abstractmethod def get_custom_field_name( - self, field_id: int, entity: CustomFieldEntityEnum + self, + field_id: int, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> str: raise NotImplementedError @abstractmethod def get_custom_field_component_id( - self, field_id: int, entity: CustomFieldEntityEnum + self, + field_id: int, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> str: raise NotImplementedError + + @abstractmethod + def get_custom_fields_templates( + self, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + ): + raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 9c9bf56a4..7e5de2148 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -155,7 +155,9 @@ def execute(self): project.users = [] if self._include_custom_fields: custom_fields_names = self._service_provider.list_custom_field_names( - entity=CustomFieldEntityEnum.PROJECT + self._project.team_id, + entity=CustomFieldEntityEnum.PROJECT, + parent=CustomFieldEntityEnum.TEAM, ) if custom_fields_names: project_custom_fields = ( @@ -171,7 +173,9 @@ def execute(self): custom_fields_name_value_map = {} for name in custom_fields_names: field_id = self._service_provider.get_custom_field_id( - name, entity=CustomFieldEntityEnum.PROJECT + name, + entity=CustomFieldEntityEnum.PROJECT, + parent=CustomFieldEntityEnum.TEAM, ) field_value = ( custom_fields_id_value_map[str(field_id)] @@ -180,7 +184,9 @@ def execute(self): ) # timestamp: convert milliseconds to seconds component_id = self._service_provider.get_custom_field_component_id( - field_id, entity=CustomFieldEntityEnum.PROJECT + field_id, + entity=CustomFieldEntityEnum.PROJECT, + parent=CustomFieldEntityEnum.TEAM, ) if ( field_value diff --git a/src/superannotate/lib/infrastructure/annotation_adapter.py b/src/superannotate/lib/infrastructure/annotation_adapter.py index c333231cf..13436510a 100644 --- a/src/superannotate/lib/infrastructure/annotation_adapter.py +++ b/src/superannotate/lib/infrastructure/annotation_adapter.py @@ -44,7 +44,9 @@ def get_component_value(self, component_id: str): return None def set_component_value(self, component_id: str, value: Any): - self.annotation.setdefault("data", {}).setdefault(component_id, {})["value"] = value + self.annotation.setdefault("data", {}).setdefault(component_id, {})[ + "value" + ] = value return self diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 0e9f703b1..5c0179132 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -73,9 +73,22 @@ def build_condition(**kwargs) -> Condition: def serialize_custom_fields( - service_provider: ServiceProvider, data: List[dict], entity: CustomFieldEntityEnum + team_id: int, + project_id: int, + service_provider: ServiceProvider, + data: List[dict], + entity: CustomFieldEntityEnum, + parent_entity: CustomFieldEntityEnum, ) -> List[dict]: - existing_custom_fields = service_provider.list_custom_field_names(entity) + pk = ( + project_id + if entity == CustomFieldEntityEnum.PROJECT + else (team_id if parent_entity == CustomFieldEntityEnum.TEAM else project_id) + ) + + existing_custom_fields = service_provider.list_custom_field_names( + pk, entity, parent=parent_entity + ) for i in range(len(data)): if not data[i]: data[i] = {} @@ -85,7 +98,7 @@ def serialize_custom_fields( field_id = int(custom_field_name) try: component_id = service_provider.get_custom_field_component_id( - field_id, entity=entity + field_id, entity=entity, parent=parent_entity ) except AppException: # The component template can be deleted, but not from the entity, so it will be skipped. @@ -95,7 +108,7 @@ def serialize_custom_fields( field_value /= 1000 # Convert timestamp new_field_name = service_provider.get_custom_field_name( - field_id, entity=entity + field_id, entity=entity, parent=parent_entity ) updated_fields[new_field_name] = field_value @@ -139,10 +152,10 @@ def set_custom_field_value( if entity == CustomFieldEntityEnum.PROJECT: _context["project_id"] = entity_id template_id = self.service_provider.get_custom_field_id( - field_name, entity=entity + field_name, entity=entity, parent=parent_entity ) component_id = self.service_provider.get_custom_field_component_id( - template_id, entity=entity + template_id, entity=entity, parent=parent_entity ) # timestamp: convert seconds to milliseconds if component_id == CustomFieldType.DATE_PICKER.value and value is not None: @@ -159,40 +172,59 @@ def set_custom_field_value( context=_context, ) - def list_users(self, include: List[Literal["custom_fields"]] = None, **filters): + def list_users( + self, include: List[Literal["custom_fields"]] = None, project=None, **filters + ): + if project: + parent_entity = CustomFieldEntityEnum.PROJECT + project_id = project.id + else: + parent_entity = CustomFieldEntityEnum.TEAM + project_id = None valid_fields = generate_schema( UserFilters.__annotations__, self.service_provider.get_custom_fields_templates( - CustomFieldEntityEnum.CONTRIBUTOR + CustomFieldEntityEnum.CONTRIBUTOR, parent=parent_entity ), ) chain = QueryBuilderChain( [ FieldValidationHandler(valid_fields.keys()), UserFilterHandler( + team_id=self.service_provider.client.team_id, + project_id=project_id, service_provider=self.service_provider, entity=CustomFieldEntityEnum.CONTRIBUTOR, + parent=parent_entity, ), ] ) query = chain.handle(filters, EmptyQuery()) if include and "custom_fields" in include: response = self.service_provider.work_management.list_users( - query, include_custom_fields=True + query, + include_custom_fields=True, + parent_entity=parent_entity, + project_id=project_id, ) if not response.ok: raise AppException(response.error) users = response.data custom_fields_list = [user.custom_fields for user in users] serialized_fields = serialize_custom_fields( + self.service_provider.client.team_id, + project_id, self.service_provider, custom_fields_list, - CustomFieldEntityEnum.CONTRIBUTOR, + entity=CustomFieldEntityEnum.CONTRIBUTOR, + parent_entity=parent_entity, ) for users, serialized_custom_fields in zip(users, serialized_fields): users.custom_fields = serialized_custom_fields return response.data - return self.service_provider.work_management.list_users(query).data + return self.service_provider.work_management.list_users( + query, parent_entity=parent_entity, project_id=project_id + ).data def update_user_activity( self, @@ -406,14 +438,18 @@ def list_projects( valid_fields = generate_schema( ProjectFilters.__annotations__, self.service_provider.get_custom_fields_templates( - CustomFieldEntityEnum.PROJECT + CustomFieldEntityEnum.PROJECT, parent=CustomFieldEntityEnum.TEAM ), ) chain = QueryBuilderChain( [ FieldValidationHandler(valid_fields.keys()), ProjectFilterHandler( - self.service_provider, entity=CustomFieldEntityEnum.PROJECT + team_id=self.service_provider.client.team_id, + project_id=None, + service_provider=self.service_provider, + entity=CustomFieldEntityEnum.PROJECT, + parent=CustomFieldEntityEnum.TEAM, ), ] ) @@ -435,7 +471,11 @@ def list_projects( if include_custom_fields: custom_fields_list = [project.custom_fields for project in projects] serialized_fields = serialize_custom_fields( - self.service_provider, custom_fields_list, CustomFieldEntityEnum.PROJECT + self.service_provider.client.team_id, + None, + self.service_provider, + custom_fields_list, + CustomFieldEntityEnum.PROJECT, ) for project, serialized_custom_fields in zip(projects, serialized_fields): project.custom_fields = serialized_custom_fields diff --git a/src/superannotate/lib/infrastructure/query_builder.py b/src/superannotate/lib/infrastructure/query_builder.py index 4473e502b..edb24c5b1 100644 --- a/src/superannotate/lib/infrastructure/query_builder.py +++ b/src/superannotate/lib/infrastructure/query_builder.py @@ -152,23 +152,31 @@ def _handle_special_fields(self, keys: List[str], val): class BaseCustomFieldHandler(AbstractQueryHandler): def __init__( - self, service_provider: BaseServiceProvider, entity: CustomFieldEntityEnum + self, + team_id: int, + project_id: Optional[int], + service_provider: BaseServiceProvider, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ): self._service_provider = service_provider self._entity = entity + self._parent = parent def _handle_custom_field_key(self, key) -> Tuple[str, str, Optional[str]]: for custom_field in sorted( - self._service_provider.list_custom_field_names(entity=self._entity), + self._service_provider.list_custom_field_names( + entity=self._entity, parent=self._parent + ), key=len, reverse=True, ): if custom_field in key: custom_field_id = self._service_provider.get_custom_field_id( - custom_field, entity=self._entity + custom_field, entity=self._entity, parent=self._parent ) component_id = self._service_provider.get_custom_field_component_id( - custom_field_id, entity=self._entity + custom_field_id, entity=self._entity, parent=self._parent ) key = key.replace( custom_field, @@ -209,7 +217,7 @@ def _determine_condition_and_key(keys: List[str]) -> Tuple[OperatorEnum, str]: def _handle_special_fields(self, keys: List[str], val): if keys[0] == "custom_field": component_id = self._service_provider.get_custom_field_component_id( - field_id=int(keys[1]), entity=self._entity + field_id=int(keys[1]), entity=self._entity, parent=self._parent ) if component_id == CustomFieldType.DATE_PICKER.value and val is not None: try: diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 641111a49..e05be536e 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -69,35 +69,50 @@ def __init__(self, client: HttpClient): 5, self.work_management ) - def get_custom_fields_templates(self, entity: CustomFieldEntityEnum): + def get_custom_fields_templates( + self, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + ): return self._cached_work_management_repository.list_templates( - self.client.team_id, entity=entity + self.client.team_id, entity=entity, parent=parent ) - def list_custom_field_names(self, entity: CustomFieldEntityEnum) -> List[str]: + def list_custom_field_names( + self, pk, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + ) -> List[str]: return self._cached_work_management_repository.list_custom_field_names( - self.client.team_id, entity=entity + pk, + entity=entity, + parent=parent, ) def get_custom_field_id( - self, field_name: str, entity: CustomFieldEntityEnum + self, + field_name: str, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> int: return self._cached_work_management_repository.get_custom_field_id( - self.client.team_id, field_name, entity=entity + self.client.team_id, field_name, entity=entity, parent=parent ) def get_custom_field_name( - self, field_id: int, entity: CustomFieldEntityEnum + self, + field_id: int, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> str: return self._cached_work_management_repository.get_custom_field_name( - self.client.team_id, field_id, entity=entity + self.client.team_id, field_id, entity=entity, parent=parent ) def get_custom_field_component_id( - self, field_id: int, entity: CustomFieldEntityEnum + self, + field_id: int, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> str: return self._cached_work_management_repository.get_custom_field_component_id( - self.client.team_id, field_id, entity=entity + self.client.team_id, field_id, entity=entity, parent=parent ) def get_role_id(self, project: entities.ProjectEntity, role_name: str) -> int: diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index df1f1e39f..937de899b 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -6,6 +6,7 @@ from lib.core.entities import CategoryEntity from lib.core.entities import WorkflowEntity from lib.core.entities.work_managament import WMProjectEntity +from lib.core.entities.work_managament import WMProjectUserEntity from lib.core.entities.work_managament import WMUserEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.exceptions import AppException @@ -60,6 +61,7 @@ class WorkManagementService(BaseWorkManagementService): URL_SET_CUSTOM_ENTITIES = "customentities/{pk}" URL_SEARCH_CUSTOM_ENTITIES = "customentities/search" URL_SEARCH_TEAM_USERS = "teamusers/search" + URL_SEARCH_PROJECT_USERS = "projectusers/search" URL_SEARCH_PROJECTS = "projects/search" URL_RESUME_PAUSE_USER = "teams/editprojectsusers" @@ -259,27 +261,42 @@ def search_projects( ) def list_users( - self, body_query: Query, chunk_size=100, include_custom_fields=False + self, + body_query: Query, + chunk_size=100, + parent_entity: str = "Team", + project_id: int = None, + include_custom_fields=False, ) -> WMUserListResponse: if include_custom_fields: url = self.URL_SEARCH_CUSTOM_ENTITIES else: - url = self.URL_SEARCH_TEAM_USERS + if parent_entity == "Team": + url = self.URL_SEARCH_TEAM_USERS + else: + url = self.URL_SEARCH_PROJECT_USERS + if project_id is None: + user_entity = WMUserEntity + entity_context = self._generate_context(team_id=self.client.team_id) + else: + user_entity = WMProjectUserEntity + entity_context = self._generate_context( + team_id=self.client.team_id, + project_id=project_id, + ) return self.client.jsx_paginate( url=url, method="post", body_query=body_query, query_params={ "entity": "Contributor", - "parentEntity": "Team", + "parentEntity": parent_entity, }, headers={ - "x-sa-entity-context": self._generate_context( - team_id=self.client.team_id - ), + "x-sa-entity-context": entity_context, }, chunk_size=chunk_size, - item_type=WMUserEntity, + item_type=user_entity, ) def create_custom_field_template( diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index eaa75e80d..25069d747 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -219,6 +219,38 @@ def get(self, key, **kwargs): return self._K_V_map[key] +class ProjectUserCustomFieldCache(CustomFieldCache): + def sync(self, project_id): + response = self.work_management.list_custom_field_templates( + entity=self._entity, + parent_entity=self._parent_entity, + context={"project_id": project_id}, + ) + if not response.ok: + raise AppException(response.error) + custom_fields_name_id_map = { + field["name"]: field["id"] for field in response.data["data"] + } + custom_fields_id_name_map = { + field["id"]: field["name"] for field in response.data["data"] + } + custom_fields_id_component_id_map = { + field["id"]: field["component_id"] for field in response.data["data"] + } + self._K_V_map[project_id] = { + "custom_fields_name_id_map": custom_fields_name_id_map, + "custom_fields_id_name_map": custom_fields_id_name_map, + "custom_fields_id_component_id_map": custom_fields_id_component_id_map, + "templates": response.data["data"], + } + self._update_cache_timestamp(project_id) + + def get(self, key, **kwargs): + if not self._is_cache_valid(key): + self.sync(project_id=key) + return self._K_V_map[key] + + class CachedWorkManagementRepository: def __init__(self, ttl_seconds: int, work_management): self._role_cache = RoleCache(ttl_seconds, work_management) @@ -229,12 +261,18 @@ def __init__(self, ttl_seconds: int, work_management): CustomFieldEntityEnum.PROJECT, CustomFieldEntityEnum.TEAM, ) - self._user_custom_field_cache = CustomFieldCache( + self._team_user_custom_field_cache = CustomFieldCache( ttl_seconds, work_management, CustomFieldEntityEnum.CONTRIBUTOR, CustomFieldEntityEnum.TEAM, ) + self._project_user_custom_field_cache = ProjectUserCustomFieldCache( + ttl_seconds, + work_management, + CustomFieldEntityEnum.CONTRIBUTOR, + CustomFieldEntityEnum.PROJECT, + ) def get_role_id(self, project, role_name: str) -> int: role_data = self._role_cache.get(project.id, project=project) @@ -261,50 +299,79 @@ def get_annotation_status_name(self, project, status_value: int) -> str: raise AppException("Invalid status value provided.") def get_custom_field_id( - self, team_id: int, field_name: str, entity: CustomFieldEntityEnum + self, + team_id: int, + field_name: str, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> int: if entity == CustomFieldEntityEnum.PROJECT: custom_field_data = self._project_custom_field_cache.get(team_id) else: - custom_field_data = self._user_custom_field_cache.get(team_id) + if parent == CustomFieldEntityEnum.TEAM: + custom_field_data = self._team_user_custom_field_cache.get(team_id) + else: + custom_field_data = self._project_user_custom_field_cache.get(team_id) if field_name in custom_field_data["custom_fields_name_id_map"]: return custom_field_data["custom_fields_name_id_map"][field_name] raise AppException("Invalid custom field name provided.") def get_custom_field_name( - self, team_id: int, field_id: int, entity: CustomFieldEntityEnum + self, + team_id: int, + field_id: int, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> str: if entity == CustomFieldEntityEnum.PROJECT: custom_field_data = self._project_custom_field_cache.get(team_id) else: - custom_field_data = self._user_custom_field_cache.get(team_id) + if parent == CustomFieldEntityEnum.TEAM: + custom_field_data = self._team_user_custom_field_cache.get(team_id) + else: + custom_field_data = self._project_user_custom_field_cache.get(team_id) if field_id in custom_field_data["custom_fields_id_name_map"]: return custom_field_data["custom_fields_id_name_map"][field_id] raise AppException("Invalid custom field ID provided.") def get_custom_field_component_id( - self, team_id: int, field_id: int, entity: CustomFieldEntityEnum + self, + team_id: int, + field_id: int, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> str: if entity == CustomFieldEntityEnum.PROJECT: custom_field_data = self._project_custom_field_cache.get(team_id) else: - custom_field_data = self._user_custom_field_cache.get(team_id) + if parent == CustomFieldEntityEnum.TEAM: + custom_field_data = self._team_user_custom_field_cache.get(team_id) + else: + custom_field_data = self._project_user_custom_field_cache.get(team_id) if field_id in custom_field_data["custom_fields_id_component_id_map"]: return custom_field_data["custom_fields_id_component_id_map"][field_id] raise AppException("Invalid custom field ID provided.") def list_custom_field_names( - self, team_id: int, entity: CustomFieldEntityEnum + self, pk: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum ) -> list: if entity == CustomFieldEntityEnum.PROJECT: - custom_field_data = self._project_custom_field_cache.get(team_id) + custom_field_data = self._project_custom_field_cache.get(pk) else: - custom_field_data = self._user_custom_field_cache.get(team_id) + if parent == CustomFieldEntityEnum.TEAM: + custom_field_data = self._team_user_custom_field_cache.get(pk) + else: + custom_field_data = self._project_user_custom_field_cache.get(pk) return list(custom_field_data["custom_fields_name_id_map"].keys()) - def list_templates(self, team_id: int, entity: CustomFieldEntityEnum): + def list_templates( + self, pk: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + ): if entity == CustomFieldEntityEnum.PROJECT: - custom_field_data = self._project_custom_field_cache.get(team_id) - else: - custom_field_data = self._user_custom_field_cache.get(team_id) - return custom_field_data["templates"] + return self._project_custom_field_cache.get(pk)["templates"] + elif entity == CustomFieldEntityEnum.CONTRIBUTOR: + if parent == CustomFieldEntityEnum.TEAM: + return self._team_user_custom_field_cache.get(pk)["templates"] + else: + return self._project_user_custom_field_cache.get(pk)["templates"] + raise AppException("Invalid entity provided.") diff --git a/tests/integration/work_management/test_user_custom_fields.py b/tests/integration/work_management/test_user_custom_fields.py index f1eea65bf..4955433ae 100644 --- a/tests/integration/work_management/test_user_custom_fields.py +++ b/tests/integration/work_management/test_user_custom_fields.py @@ -5,8 +5,10 @@ from lib.core.exceptions import AppException from src.superannotate import SAClient from src.superannotate.lib.core.enums import CustomFieldEntityEnum +from tests.integration.base import BaseTestCase from tests.integration.work_management.data_set import CUSTOM_FIELD_PAYLOADS + sa = SAClient() @@ -231,3 +233,19 @@ def test_set_user_custom_field_validation(self): error_template_select.format(type="str", options="option1, option2"), ): sa.set_user_custom_field(scapegoat["email"], "SDK_test_single_select", 123) + + +class TestUserProjectCustomFields(BaseTestCase): + PROJECT_NAME = "TestUserProjectCustomFields" + PROJECT_TYPE = "Multimodal" + PROJECT_DESCRIPTION = "Multimodal" + + def test_project_custom_fields(self): + scapegoat = sa.list_users(role="contributor")[0] + sa.add_contributors_to_project( + self.PROJECT_NAME, [scapegoat["email"]], role="QA" + ) + users = sa.list_users(project=self.PROJECT_NAME) + assert users[0]["role"] == "QA" + users = sa.list_users(project=self.PROJECT_NAME, include=["custom_fields"]) + assert users[0]["role"] == "QA" From 883a944ad9cee70d570f82428a1427c9ef5a3a99 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 5 Mar 2025 14:10:19 +0400 Subject: [PATCH 05/27] Update docs --- CHANGELOG.rst | 4 +- docs/source/api_reference/api_item.rst | 4 + src/superannotate/__init__.py | 2 + .../lib/app/interface/sdk_interface.py | 86 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ca592d4b0..f2b84c702 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,11 +7,11 @@ History All release highlights of this project will be documented in this file. 4.4.32 - March 4, 2025 -_____________________ +______________________ **Fixed** - - ``SAClient.item_context`` Fixed an issue where setting a component value would overwrite existing comments and other associated data. + - ``SAClient.item_context`` Fixed an issue where setting a component value would overwrite existing comments and other associated data. 4.4.31 - Feb 27, 2025 _____________________ diff --git a/docs/source/api_reference/api_item.rst b/docs/source/api_reference/api_item.rst index 6dafbd743..55605ea37 100644 --- a/docs/source/api_reference/api_item.rst +++ b/docs/source/api_reference/api_item.rst @@ -9,6 +9,10 @@ Items .. automethod:: superannotate.SAClient.search_items .. automethod:: superannotate.SAClient.attach_items .. automethod:: superannotate.SAClient.item_context +.. autoclass:: superannotate.ItemContext + :members: get_metadata, get_component_value, set_component_value + :member-order: bysource + .. automethod:: superannotate.SAClient.copy_items .. automethod:: superannotate.SAClient.move_items .. automethod:: superannotate.SAClient.delete_items diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index d4ad72cfa..601bbc2f9 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -21,6 +21,7 @@ from superannotate.lib.app.input_converters import export_annotation from superannotate.lib.app.input_converters import import_annotation from superannotate.lib.app.interface.sdk_interface import SAClient +from superannotate.lib.app.interface.sdk_interface import ItemContext SESSIONS = {} @@ -29,6 +30,7 @@ __all__ = [ "__version__", "SAClient", + "ItemContext", # Utils "enums", "AppException", diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index fa20dd3cc..bd9ef2743 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -118,6 +118,20 @@ class Attachment(TypedDict, total=False): class ItemContext: + """ + A context manager for handling annotations and metadata of an item. + + The ItemContext class provides methods to retrieve and manage metadata and component + values for items in the specified context. Below are the descriptions and usage examples for each method. + + Example: + :: + + with sa_client.item_context("project_name/folder_name", "item_name") as context: + metadata = context.get_metadata() + print(metadata) + """ + def __init__( self, controller: Controller, @@ -171,9 +185,26 @@ def annotation(self): return self.annotation_adapter.annotation def __enter__(self): + """ + Enters the context manager. + + Returns: + ItemContext: The instance itself. + """ return self def __exit__(self, exc_type, exc_val, exc_tb): + """ + Exits the context manager, saving changes if no exception occurred. + + Args: + exc_type (Optional[Type[BaseException]]): Exception type if raised. + exc_val (Optional[BaseException]): Exception instance if raised. + exc_tb (Optional[TracebackType]): Traceback if an exception occurred. + + Returns: + bool: True if no exception occurred, False otherwise. + """ if exc_type: return False @@ -188,12 +219,62 @@ def save(self): self._annotation_adapter.save() def get_metadata(self): + """ + Retrieves the metadata associated with the current item context. + + :return: A dictionary containing metadata for the current item. + :rtype: dict + + Request Example: + :: + + with client.item_context(("project_name", "folder_name"), 12345) as context: + metadata = context.get_metadata() + print(metadata) + """ return self.annotation["metadata"] def get_component_value(self, component_id: str): + """ + Retrieves the value of a specific component within the item context. + + :param component_id: The name of the component whose value is to be retrieved. + :type component_id: str + + :return: The value of the specified component. + :rtype: Any + + Request Example: + :: + + with client.item_context((101, 202), "item_name") as context: # (101, 202) project and folder IDs + value = context.get_component_value("component_id") + print(value) + """ return self.annotation_adapter.get_component_value(component_id) def set_component_value(self, component_id: str, value: Any): + """ + Updates the value of a specific component within the item context. + + :param component_id: The component identifier. + :type component_id: str + + :param value: The new value to set for the specified component. + :type value: Any + + :return: The instance itself to allow method chaining. + :rtype: ItemContext + + Request Example: + :: + + with client.item_context("project_name/folder_name", "item_name") as item_context: + metadata = item_context.get_metadata() + value = item_context.get_component_value("component_id") + item_context.set_component_value("component_id", value) + + """ self.annotation_adapter.set_component_value(component_id, value) return self @@ -381,6 +462,7 @@ def list_users( ): """ Search users by filtering criteria + :param project: Project name or ID, if provided, results will be for project-level, otherwise results will be for team level. :type project: str or int @@ -4357,6 +4439,10 @@ def item_context( :return: An `ItemContext` object to manage the specified item's annotations and metadata. :rtype: ItemContext + .. seealso:: + For more details, see :class:`ItemContext`. + + **Examples:** Create an `ItemContext` using a string path and item name: From e2316fed50e0441efa45fab1239b7d6fd86f93bf Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 5 Mar 2025 14:27:16 +0400 Subject: [PATCH 06/27] Update docs --- src/superannotate/lib/app/interface/sdk_interface.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index bd9ef2743..4a44efce2 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -4440,8 +4440,7 @@ def item_context( :rtype: ItemContext .. seealso:: - For more details, see :class:`ItemContext`. - + For more details, see :class:`ItemContext` nested class. **Examples:** From 24741d200d4815bff449cbefa52dc0cf83c7dd3c Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 5 Mar 2025 18:08:28 +0400 Subject: [PATCH 07/27] Fix cache --- pytest.ini | 2 +- src/superannotate/__init__.py | 2 +- .../lib/infrastructure/query_builder.py | 12 +++++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pytest.ini b/pytest.ini index fe005c850..1b210841d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -;addopts = -n 4 --dist loadscope +addopts = -n 4 --dist loadscope diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 601bbc2f9..6dbf12643 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.32dev3" +__version__ = "4.4.3dev2" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/infrastructure/query_builder.py b/src/superannotate/lib/infrastructure/query_builder.py index 58d11ac18..60f07781a 100644 --- a/src/superannotate/lib/infrastructure/query_builder.py +++ b/src/superannotate/lib/infrastructure/query_builder.py @@ -172,11 +172,21 @@ def __init__( self._service_provider = service_provider self._entity = entity self._parent = parent + self._team_id = team_id + self._project_id = project_id + + @property + def pk(self): + if self._entity == CustomFieldEntityEnum.PROJECT: + return self._project_id + if self._parent == CustomFieldEntityEnum.TEAM: + return self._team_id + return self._project_id def _handle_custom_field_key(self, key) -> Tuple[str, str, Optional[str]]: for custom_field in sorted( self._service_provider.list_custom_field_names( - entity=self._entity, parent=self._parent + self.pk, self._entity, parent=self._parent ), key=len, reverse=True, From 38d8106d055f373b1c96a9cfaf4b66851db006ee Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 12 Mar 2025 11:52:09 +0400 Subject: [PATCH 08/27] added get/set_user_scores --- docs/source/api_reference/api_team.rst | 2 + pytest.ini | 2 +- .../lib/app/interface/sdk_interface.py | 160 +++++++++ .../lib/core/entities/work_managament.py | 57 ++++ src/superannotate/lib/core/pydantic_v1.py | 1 + src/superannotate/lib/core/service_types.py | 10 + .../lib/core/serviceproviders.py | 33 ++ .../lib/infrastructure/controller.py | 131 ++++++++ .../lib/infrastructure/serviceprovider.py | 2 + .../lib/infrastructure/services/explore.py | 1 - .../services/telemetry_scoring.py | 53 +++ .../services/work_management.py | 52 ++- tests/integration/work_management/data_set.py | 22 ++ .../work_management/test_user_scoring.py | 313 ++++++++++++++++++ 14 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 src/superannotate/lib/infrastructure/services/telemetry_scoring.py create mode 100644 tests/integration/work_management/test_user_scoring.py diff --git a/docs/source/api_reference/api_team.rst b/docs/source/api_reference/api_team.rst index 1d9cda508..05d2a72b5 100644 --- a/docs/source/api_reference/api_team.rst +++ b/docs/source/api_reference/api_team.rst @@ -12,3 +12,5 @@ Team .. automethod:: superannotate.SAClient.list_users .. automethod:: superannotate.SAClient.pause_user_activity .. automethod:: superannotate.SAClient.resume_user_activity +.. automethod:: superannotate.SAClient.get_user_scores +.. automethod:: superannotate.SAClient.set_user_scores diff --git a/pytest.ini b/pytest.ini index 1b210841d..fe005c850 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -addopts = -n 4 --dist loadscope +;addopts = -n 4 --dist loadscope diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 4a44efce2..c93961428 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -608,6 +608,166 @@ def resume_user_activity( f"User with email {user.email} has been successfully unblocked from the specified projects: {projects}." ) + def get_user_scores( + self, + project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + item: Union[NotEmptyStr, int], + scored_user: NotEmptyStr, + *, + score_names: Optional[List[str]] = None, + ) -> List[Dict[str, Any]]: + """ + Retrieve score metadata for a user for a specific item in a specific project. + + :param project: Project and folder as a tuple, folder is optional. + :type project: Union[str, Tuple[int, int], Tuple[str, str]] + + :param item: The unique ID or name of the item. + :type item: Union[str, int] + + :param scored_user: The email address of the project user. + :type scored_user: str + + :param score_names: A list of score names to filter by. If None, returns all scores. + :type score_names: Optional[List[str]] + + :return: A list of dictionaries containing score metadata for the user. + :rtype: list of dicts + + Request Example: + :: + + client.get_user_scores( + project=("my_multimodal", "folder1"), + item="item1", + scored_user="example@superannotate.com", + score_names=["Accuracy Score", "Speed"] + ) + + Response Example: + :: + + [ + { + "createdAt": "2024-06-01T13:00:00", + "updatedAt": "2024-06-01T13:00:00", + "id": 3217575, + "name": "Accuracy Score", + "value": 98, + "weight": 4, + }, + { + "createdAt": "2024-06-01T13:00:00", + "updatedAt": "2024-06-01T13:00:00", + "id": 5657575, + "name": "Speed", + "value": 9, + "weight": 0.4, + }, + ] + """ + + if isinstance(project, str): + project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder( + project_name, folder_name + ) + else: + project_pk, folder_pk = project + if isinstance(project_pk, int) and isinstance(folder_pk, int): + project = self.controller.get_project_by_id(project_pk).data + folder = self.controller.get_folder_by_id(folder_pk, project.id).data + elif isinstance(project_pk, str) and isinstance(folder_pk, str): + project = self.controller.get_project(project_pk) + folder = self.controller.get_folder(project, folder_pk) + else: + raise AppException("Provided project param is not valid.") + response = BaseSerializer.serialize_iterable( + self.controller.work_management.get_user_scores( + project=project, + folder=folder, + item=item, + scored_user=scored_user, + provided_score_names=score_names, + ) + ) + return response + + def set_user_scores( + self, + project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + item: Union[NotEmptyStr, int], + scored_user: NotEmptyStr, + scores: List[Dict[str, Any]], + ): + """ + Assign score metadata for a user in a scoring component. + + :param project: Project and folder as a tuple, folder is optional. + :type project: Union[str, Tuple[int, int], Tuple[str, str]] + + :param item: The unique ID or name of the item. + :type item: Union[str, int] + + :param scored_user: Set the email of the user being scored. + :type scored_user: str + + :param scores: A list of dictionaries containing the following key-value pairs: + * **name** (*str*): The name of the score (required). + * **value** (*Any*): The score value (required). + * **weight** (*Union[float, int]*, optional): The weight of the score. Defaults to `1` if not provided. + + **Example**: + :: + + scores = [ + { + "name": "Speed", # str (required) + "value": 90, # Any (required) + "weight": 1 # Union[float, int] (optional, defaults to 1.0 if not provided) + } + ] + :type scores: List[Dict[str, Any] + + Request Example: + :: + + client.set_user_scores( + project=("my_multimodal", "folder1"), + item_=12345, + scored_user="example@superannotate.com", + scores=[ + {"name": "Speed", "value": 90}, + {"name": "Accuracy", "value": 9, "weight": 4.0}, + {"name": "Attention to Detail", "value": None, "weight": None}, + ] + ) + + """ + if isinstance(project, str): + project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder( + project_name, folder_name + ) + else: + project_pk, folder_pk = project + if isinstance(project_pk, int) and isinstance(folder_pk, int): + project = self.controller.get_project_by_id(project_pk).data + folder = self.controller.get_folder_by_id(folder_pk, project.id).data + elif isinstance(project_pk, str) and isinstance(folder_pk, str): + project = self.controller.get_project(project_pk) + folder = self.controller.get_folder(project, folder_pk) + else: + raise AppException("Provided project param is not valid.") + self.controller.work_management.set_user_scores( + project=project, + folder=folder, + item=item, + scored_user=scored_user, + scores=scores, + ) + logger.info("Scores successfully set.") + def get_component_config(self, project: Union[NotEmptyStr, int], component_id: str): """ Retrieves the configuration for a given project and component ID. diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index b78fab9de..8924218d6 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -1,13 +1,18 @@ import datetime from enum import auto from enum import Enum +from typing import Any from typing import Optional +from typing import Union from lib.core.entities.base import TimedBaseModel from lib.core.enums import WMUserStateEnum +from lib.core.exceptions import AppException +from lib.core.pydantic_v1 import BaseModel from lib.core.pydantic_v1 import Extra from lib.core.pydantic_v1 import Field from lib.core.pydantic_v1 import parse_datetime +from lib.core.pydantic_v1 import root_validator from lib.core.pydantic_v1 import validator @@ -149,3 +154,55 @@ def json(self, **kwargs): if "exclude" not in kwargs: kwargs["exclude"] = {"custom_fields"} return super().json(**kwargs) + + +class WMScoreEntity(TimedBaseModel): + id: int + team_id: int + name: str + description: Optional[str] + type: str + payload: Optional[dict] + + +class TelemetryScoreEntity(BaseModel): + item_id: int + team_id: int + project_id: int + user_id: str + user_role: str + score_id: int + value: Optional[Any] + weight: Optional[float] + + +class ScoreEntity(TimedBaseModel): + id: int + name: str + value: Optional[Any] + weight: Optional[float] + + +class ScorePayloadEntity(BaseModel): + name: str + value: Any + weight: Optional[Union[float, int]] = 1.0 + + class Config: + extra = Extra.forbid + + @validator("weight", pre=True, always=True) + def validate_weight(cls, v): + if v is not None and (not isinstance(v, (int, float)) or v <= 0): + raise AppException("Please provide a valid number greater than 0") + return v + + @root_validator(pre=True) + def check_weight_and_value(cls, values): + value = values.get("value") + weight = values.get("weight") + if (weight is None and value is not None) or ( + weight is not None and value is None + ): + raise AppException("Weight and Value must both be set or both be None.") + return values diff --git a/src/superannotate/lib/core/pydantic_v1.py b/src/superannotate/lib/core/pydantic_v1.py index 6a00a4114..b1b892870 100644 --- a/src/superannotate/lib/core/pydantic_v1.py +++ b/src/superannotate/lib/core/pydantic_v1.py @@ -22,6 +22,7 @@ ROOT_KEY = pydantic.utils.ROOT_KEY # noqa sequence_like = pydantic.utils.sequence_like # noqa validator = pydantic.validator # noqa +root_validator = pydantic.root_validator # noqa constr = pydantic.constr # noqa conlist = pydantic.conlist # noqa parse_datetime = pydantic.datetime_parse.parse_datetime # noqa diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index 1fc009996..e98c4af62 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -5,7 +5,9 @@ from typing import Union from lib.core import entities +from lib.core.entities.work_managament import TelemetryScoreEntity from lib.core.entities.work_managament import WMProjectEntity +from lib.core.entities.work_managament import WMScoreEntity from lib.core.entities.work_managament import WMUserEntity from lib.core.enums import ProjectType from lib.core.exceptions import AppException @@ -260,6 +262,14 @@ class SettingsListResponse(ServiceResponse): res_data: List[entities.SettingEntity] = None +class WMScoreListResponse(ServiceResponse): + res_data: List[WMScoreEntity] = None + + +class TelemetryScoreListResponse(ServiceResponse): + res_data: List[TelemetryScoreEntity] = None + + PROJECT_TYPE_RESPONSE_MAP = { ProjectType.VECTOR: ImageResponse, ProjectType.OTHER: ClassificationResponse, diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 873a8d84a..e7148ee78 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -29,6 +29,7 @@ from lib.core.service_types import UserResponse from lib.core.service_types import WMCustomFieldResponse from lib.core.service_types import WMProjectListResponse +from lib.core.service_types import WMScoreListResponse from lib.core.service_types import WMUserListResponse from lib.core.service_types import WorkflowListResponse from lib.core.types import Attachment @@ -197,6 +198,24 @@ def update_user_activity( ) -> ServiceResponse: raise NotImplementedError + @abstractmethod + def list_scores(self) -> WMScoreListResponse: + raise NotImplementedError + + @abstractmethod + def create_score( + self, + name: str, + description: Optional[str], + score_type: Literal["rating", "number", "radio"], + payload: dict, + ) -> ServiceResponse: + raise NotImplementedError + + @abstractmethod + def delete_score(self, score_id: int) -> ServiceResponse: + raise NotImplementedError + class BaseProjectService(SuperannotateServiceProvider): @abstractmethod @@ -688,6 +707,20 @@ def query_item_count( raise NotImplementedError +class BaseTelemetryScoringService(SuperannotateServiceProvider): + @abstractmethod + def get_score_values(self, project_id: int, item_id: int, user_id: str): + raise NotImplementedError + + @abstractmethod + def set_score_values( + self, + project_id: int, + data: List[dict], + ) -> ServiceResponse: + raise NotImplementedError + + class BaseServiceProvider: projects: BaseProjectService folders: BaseFolderService diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 162346f11..fb7009858 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -34,6 +34,8 @@ from lib.core.entities.filters import ProjectFilters from lib.core.entities.filters import UserFilters from lib.core.entities.integrations import IntegrationEntity +from lib.core.entities.work_managament import ScoreEntity +from lib.core.entities.work_managament import ScorePayloadEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.enums import CustomFieldType from lib.core.enums import ProjectType @@ -264,6 +266,135 @@ def update_user_activity( ) res.raise_for_status() + def _get_score_item(self, item: Union[int, str], project_id: int, folder_id: int): + if isinstance(item, int): + item = self.service_provider.item_service.get( + project_id=project_id, item_id=item + ).data + else: + items = self.service_provider.item_service.list( + project_id, folder_id, Filter("name", item, OperatorEnum.EQ) + ) + item = next(iter(items), None) + if not item: + raise AppException("Item not found.") + return item + + def get_user_scores( + self, + project: ProjectEntity, + folder: FolderEntity, + item: Union[int, str], + scored_user: str, + provided_score_names: Optional[List[str]] = None, + ): + item = self._get_score_item(item, project.id, folder.id) + + score_fields_res = self.service_provider.work_management.list_scores() + + # validate provided score names + all_score_names = [s.name for s in score_fields_res.data] + if provided_score_names and set(provided_score_names) - set(all_score_names): + raise AppException("Please provide valid score names.") + + score_id_field_options_map = {s.id: s for s in score_fields_res.data} + + score_values = self.service_provider.telemetry_scoring.get_score_values( + project_id=project.id, item_id=item.id, user_id=scored_user + ) + score_id_values_map = {s.score_id: s for s in score_values.data} + + scores = [] + for s_id, s_values in score_id_values_map.items(): + score_field = score_id_field_options_map.get(s_id) + if score_field: + score = ScoreEntity( + id=s_id, + name=score_field.name, + value=s_values.value, + weight=s_values.weight, + createdAt=score_field.createdAt, + updatedAt=score_field.updatedAt, + ) + if provided_score_names: + if score_field.name in provided_score_names: + scores.append(score) + else: + scores.append(score) + return scores + + @staticmethod + def _validate_scores( + scores: List[dict], all_score_names: List[str] + ) -> List[ScorePayloadEntity]: + score_objects: List[ScorePayloadEntity] = [] + + for s in scores: + if "value" not in s: + raise AppException("Invalid Scores.") + try: + score_objects.append(ScorePayloadEntity(**s)) + except AppException: + raise + except Exception: + raise AppException("Invalid Scores.") + + # validate provided score names + provided_score_names = [s.name for s in score_objects] + if set(provided_score_names) - set(all_score_names): + raise AppException("Please provide valid score names.") + + names = [score.name for score in score_objects] + if len(names) != len(set(names)): + raise AppException("Invalid Scores.") + return score_objects + + def set_user_scores( + self, + project: ProjectEntity, + folder: FolderEntity, + item: Union[int, str], + scored_user: str, + scores: List[Dict[str, Any]], + ): + item = self._get_score_item(item, project.id, folder.id) + + filters = {"email": scored_user} + users = self.list_users(project=project, **filters) + if not users: + raise AppException("Please provide a valid email assigned to the project.") + user = users[0] + user_role = user.role + user.role = self.service_provider.get_role_name(project, int(user_role)) + + score_fields_res = self.service_provider.work_management.list_scores() + all_score_names = [s.name for s in score_fields_res.data] + score_name_field_options_map = {s.name: s for s in score_fields_res.data} + + # get validate scores + scores: List[ScorePayloadEntity] = self._validate_scores( + scores, all_score_names + ) + + scores_to_create: List[dict] = [] + for s in scores: + score_to_create = { + "item_id": item.id, + "score_id": score_name_field_options_map[s.name].id, + "user_role_name": user.role, + "user_role": user_role, + "user_id": user.email, + "value": s.value, + "weight": s.weight, + } + scores_to_create.append(score_to_create) + res = self.service_provider.telemetry_scoring.set_score_values( + project_id=project.id, data=scores_to_create + ) + if res.status_code == 400: + res.res_error = "Please provide valid score values." + res.raise_for_status() + class ProjectManager(BaseManager): def __init__(self, service_provider: ServiceProvider, team: TeamEntity): diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 0238c5c6e..7364951fe 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -21,6 +21,7 @@ from lib.infrastructure.services.item import ItemService from lib.infrastructure.services.item_service import ItemService as SeparateItemService from lib.infrastructure.services.project import ProjectService +from lib.infrastructure.services.telemetry_scoring import TelemetryScoringService from lib.infrastructure.services.work_management import WorkManagementService from lib.infrastructure.utils import CachedWorkManagementRepository @@ -51,6 +52,7 @@ def __init__(self, client: HttpClient): self.annotation_classes = AnnotationClassService(client) self.integrations = IntegrationService(client) self.explore = ExploreService(client) + self.telemetry_scoring = TelemetryScoringService(client) self.work_management = WorkManagementService( HttpClient( api_url=self._get_work_management_url(client), diff --git a/src/superannotate/lib/infrastructure/services/explore.py b/src/superannotate/lib/infrastructure/services/explore.py index a646a3e07..842123cd5 100644 --- a/src/superannotate/lib/infrastructure/services/explore.py +++ b/src/superannotate/lib/infrastructure/services/explore.py @@ -30,7 +30,6 @@ class ExploreService(BaseExploreService): @property def explore_service_url(self): - # TODO check urls if self.client.api_url != constants.BACKEND_URL: return ( f"http://explore-service.devsuperannotate.com/api/{self.API_VERSION}/" diff --git a/src/superannotate/lib/infrastructure/services/telemetry_scoring.py b/src/superannotate/lib/infrastructure/services/telemetry_scoring.py new file mode 100644 index 000000000..9d2b84a23 --- /dev/null +++ b/src/superannotate/lib/infrastructure/services/telemetry_scoring.py @@ -0,0 +1,53 @@ +from typing import List +from urllib.parse import urljoin + +import lib.core as constants +from lib.core.entities.work_managament import TelemetryScoreEntity +from lib.core.service_types import ServiceResponse +from lib.core.service_types import TelemetryScoreListResponse +from lib.core.serviceproviders import BaseTelemetryScoringService + + +class TelemetryScoringService(BaseTelemetryScoringService): + API_VERSION = "v1" + + URL_SCORES = "scores" + + @property + def telemetry_service_url(self): + if self.client.api_url != constants.BACKEND_URL: + return f"https://telemetry-scoring.devsuperannotate.com/api/{self.API_VERSION}/" + return f"https://telemetry-scoring.superannotate.com/api/{self.API_VERSION}/" + + def get_score_values( + self, + project_id: int, + item_id: int, + user_id: str, + ) -> TelemetryScoreListResponse: + query_params = { + "team_id": self.client.team_id, + "project_id": project_id, + "item_id": item_id, + "user_id": user_id, + } + return self.client.paginate( + url=urljoin(self.telemetry_service_url, self.URL_SCORES), + query_params=query_params, + item_type=TelemetryScoreEntity, + ) + + def set_score_values( + self, + project_id: int, + data: List[dict], + ) -> ServiceResponse: + params = { + "project_id": project_id, + } + return self.client.request( + url=urljoin(self.telemetry_service_url, self.URL_SCORES), + method="post", + params=params, + data={"data": data}, + ) diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 937de899b..0ab43a5ef 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -1,23 +1,25 @@ import base64 import json from typing import List +from typing import Literal from typing import Optional from lib.core.entities import CategoryEntity from lib.core.entities import WorkflowEntity from lib.core.entities.work_managament import WMProjectEntity from lib.core.entities.work_managament import WMProjectUserEntity +from lib.core.entities.work_managament import WMScoreEntity from lib.core.entities.work_managament import WMUserEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.exceptions import AppException from lib.core.jsx_conditions import Filter from lib.core.jsx_conditions import OperatorEnum from lib.core.jsx_conditions import Query -from lib.core.pydantic_v1 import Literal from lib.core.service_types import ListCategoryResponse from lib.core.service_types import ServiceResponse from lib.core.service_types import WMCustomFieldResponse from lib.core.service_types import WMProjectListResponse +from lib.core.service_types import WMScoreListResponse from lib.core.service_types import WMUserListResponse from lib.core.serviceproviders import BaseWorkManagementService @@ -57,6 +59,8 @@ class WorkManagementService(BaseWorkManagementService): URL_LIST_CATEGORIES = "categories" URL_CREATE_CATEGORIES = "categories/bulk" URL_CUSTOM_FIELD_TEMPLATES = "customfieldtemplates" + URL_SCORES = "scores" + URL_DELETE_SCORE = "scores/{score_id}" URL_CUSTOM_FIELD_TEMPLATE_DELETE = "customfieldtemplates/{template_id}" URL_SET_CUSTOM_ENTITIES = "customentities/{pk}" URL_SEARCH_CUSTOM_ENTITIES = "customentities/search" @@ -400,3 +404,49 @@ def update_user_activity( ), }, ) + + def list_scores(self) -> WMScoreListResponse: + return self.client.paginate( + url=self.URL_SCORES, + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id + ), + }, + item_type=WMScoreEntity, + ) + + def create_score( + self, + name: str, + description: Optional[str], + score_type: Literal["rating", "number", "radio"], + payload: dict, + ) -> ServiceResponse: + data = { + "name": name, + "description": description, + "type": score_type, + "payload": payload, + } + return self.client.request( + url=self.URL_SCORES, + method="post", + headers={ + "x-sa-entity-context": self._generate_context( + team_id=int(self.client.team_id) # TODO delete int after BED fix + ), + }, + data=data, + ) + + def delete_score(self, score_id: int) -> ServiceResponse: + return self.client.request( + url=self.URL_DELETE_SCORE.format(score_id=score_id), + method="delete", + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id + ), + }, + ) diff --git a/tests/integration/work_management/data_set.py b/tests/integration/work_management/data_set.py index 063e9f678..ead40226d 100644 --- a/tests/integration/work_management/data_set.py +++ b/tests/integration/work_management/data_set.py @@ -49,3 +49,25 @@ "SDK_test_single_select": "option1", "SDK_test_multi_select": ["option1", "option2"], } + + +SCORE_TEMPLATES = [ + { + "name": "SDK-my-score-1", + "description": "", + "score_type": "rating", + "payload": {"numberOfStars": 10}, + }, + { + "name": "SDK-my-score-2", + "description": "", + "score_type": "number", + "payload": {"min": 1, "max": 100, "step": 1}, + }, + { + "name": "SDK-my-score-3", + "description": "", + "score_type": "radio", + "payload": {"options": [{"value": "1"}, {"value": "2"}, {"value": "3"}]}, + }, +] diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py new file mode 100644 index 000000000..9796b0429 --- /dev/null +++ b/tests/integration/work_management/test_user_scoring.py @@ -0,0 +1,313 @@ +import json +import os +import time +import uuid +from pathlib import Path +from unittest import TestCase + +from lib.core.exceptions import AppException +from src.superannotate import SAClient +from tests.integration.work_management.data_set import SCORE_TEMPLATES + +sa = SAClient() + + +class TestUserScoring(TestCase): + PROJECT_NAME = "TestUserScoring" + PROJECT_TYPE = "Multimodal" + PROJECT_DESCRIPTION = "DESCRIPTION" + EDITOR_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, "data_set/editor_templates/form1.json" + ) + CLASSES_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1_classes.json", + ) + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + # setup user scores for test + cls.tearDownClass() + + cls._project = sa.create_project( + cls.PROJECT_NAME, + cls.PROJECT_DESCRIPTION, + cls.PROJECT_TYPE, + settings=[{"attribute": "TemplateState", "value": 1}], + ) + team = sa.controller.team + project = sa.controller.get_project(cls.PROJECT_NAME) + time.sleep(5) + with open(cls.EDITOR_TEMPLATE_PATH) as f: + res = sa.controller.service_provider.projects.attach_editor_template( + team, project, template=json.load(f) + ) + assert res.ok + sa.create_annotation_classes_from_classes_json( + cls.PROJECT_NAME, cls.CLASSES_TEMPLATE_PATH + ) + + for data in SCORE_TEMPLATES: + req = sa.controller.service_provider.work_management.create_score(**data) + assert req.status_code == 201 + + users = sa.list_users() + scapegoat = [ + u for u in users if u["role"] == "Contributor" and u["state"] == "Confirmed" + ][0] + cls.scapegoat = scapegoat + sa.add_contributors_to_project( + cls.PROJECT_NAME, [scapegoat["email"]], "Annotator" + ) + + @classmethod + def tearDownClass(cls) -> None: + # cleanup test scores and project + projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + for project in projects: + try: + sa.delete_project(project) + except Exception: + pass + + score_templates_name_id_map = { + s.name: s.id + for s in sa.controller.service_provider.work_management.list_scores().data + } + for data in SCORE_TEMPLATES: + score_id = score_templates_name_id_map.get(data["name"]) + if score_id: + sa.controller.service_provider.work_management.delete_score(score_id) + + @staticmethod + def _attach_item(path, name): + sa.attach_items(path, [{"name": name, "url": "url"}]) + + def test_set_get_scores(self): + scores_payload = [ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 0.5, + }, + { + "name": SCORE_TEMPLATES[1]["name"], + "value": 45, + "weight": 1.5, + }, + { + "name": SCORE_TEMPLATES[2]["name"], + "value": None, + "weight": None, + }, + ] + item_name = f"test_item_{uuid.uuid4()}" + self._attach_item(self.PROJECT_NAME, item_name) + + with self.assertLogs("sa", level="INFO") as cm: + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=scores_payload, + ) + assert cm.output[0] == "INFO:sa:Scores successfully set." + + created_scores = sa.get_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + score_names=[s["name"] for s in SCORE_TEMPLATES], + ) + assert len(created_scores) == len(SCORE_TEMPLATES) + + score_name_payload_map = {s["name"]: s for s in scores_payload} + for score in created_scores: + score_pyload = score_name_payload_map[score["name"]] + assert score["name"] == score_pyload["name"] + assert score["value"] == score_pyload["value"] + assert score["weight"] == score_pyload["weight"] + assert score["id"] + assert score["createdAt"] + assert score["updatedAt"] + + def test_set_get_scores_negative_cases(self): + item_name = f"test_item_{uuid.uuid4()}" + self._attach_item(self.PROJECT_NAME, item_name) + + # case when one of wight and value is None + with self.assertRaisesRegexp( + AppException, "Weight and Value must both be set or both be None." + ): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": None, + "weight": 0.5, + } + ], + ) + + with self.assertRaisesRegexp( + AppException, "Weight and Value must both be set or both be None." + ): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[1]["name"], + "value": 5, + "weight": None, + } + ], + ) + + # case with invalid keys + with self.assertRaisesRegexp(AppException, "Invalid Scores."): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 1.2, + "invalid_key": 123, + } + ], + ) + + # case with invalid score name + with self.assertRaisesRegexp(AppException, "Please provide valid score names."): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": "test_score_invalid", + "value": 5, + "weight": 0.8, + } + ], + ) + + # case without value key in score + with self.assertRaisesRegexp(AppException, "Invalid Scores."): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "weight": 1.2, + } + ], + ) + + # case with duplicated acore names + with self.assertRaisesRegexp(AppException, "Invalid Scores."): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 1.2, + }, + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 1.2, + }, + ], + ) + + # case with invalid weight + with self.assertRaisesRegexp( + AppException, "Please provide a valid number greater than 0" + ): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": -1, + } + ], + ) + + # case with invalid scored_user + with self.assertRaisesRegexp( + AppException, "Please provide a valid email assigned to the project." + ): + sa.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user="invalid_email@mail.com", + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 1, + } + ], + ) + + # case with invalid item + with self.assertRaisesRegexp(AppException, "Item not found."): + sa.set_user_scores( + project=self.PROJECT_NAME, + item="invalid_item_name", + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 1, + } + ], + ) + + # case with invalid project + with self.assertRaisesRegexp(AppException, "Project not found."): + sa.set_user_scores( + project="invalid_project_name", + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 1, + } + ], + ) + + # case with invalid folder + with self.assertRaisesRegexp(AppException, "Folder not found."): + sa.set_user_scores( + project=(self.PROJECT_NAME, "invalid_folder_name"), + item=item_name, + scored_user=self.scapegoat["email"], + scores=[ + { + "name": SCORE_TEMPLATES[0]["name"], + "value": 5, + "weight": 1, + } + ], + ) From cfe0a7a81512d2dafbfe320f1788960079dc8f3a Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 13 Mar 2025 10:37:02 +0400 Subject: [PATCH 09/27] Added empty case handling --- src/superannotate/lib/app/interface/sdk_interface.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 4a44efce2..e24d45cd1 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -640,6 +640,9 @@ def retrieve_context( and component["id"] == component_pk and component["type"] == "webComponent" ): + context = component.get("context") + if context is None or context == "": + return False, None return True, json.loads(component.get("context")) except KeyError as e: From f5c294fd89e6439cae3f000d6bfd5173f8d05fa2 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 13 Mar 2025 11:45:46 +0400 Subject: [PATCH 10/27] Add shortcuts --- .../lib/app/interface/sdk_interface.py | 52 +++------- .../lib/infrastructure/controller.py | 97 ++++++++++--------- 2 files changed, 63 insertions(+), 86 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index c93961428..13f6ee2c7 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -75,7 +75,6 @@ logger = logging.getLogger("sa") -# NotEmptyStr = TypeVar("NotEmptyStr", bound=constr(strict=True, min_length=1)) NotEmptyStr = constr(strict=True, min_length=1) PROJECT_STATUS = Literal["NotStarted", "InProgress", "Completed", "OnHold"] @@ -666,26 +665,11 @@ def get_user_scores( }, ] """ - - if isinstance(project, str): - project_name, folder_name = extract_project_folder(project) - project, folder = self.controller.get_project_folder( - project_name, folder_name - ) - else: - project_pk, folder_pk = project - if isinstance(project_pk, int) and isinstance(folder_pk, int): - project = self.controller.get_project_by_id(project_pk).data - folder = self.controller.get_folder_by_id(folder_pk, project.id).data - elif isinstance(project_pk, str) and isinstance(folder_pk, str): - project = self.controller.get_project(project_pk) - folder = self.controller.get_folder(project, folder_pk) - else: - raise AppException("Provided project param is not valid.") + project, folder = self.controller.get_project_folder(project) + item = self.controller.get_item(project=project, folder=folder, item=item) response = BaseSerializer.serialize_iterable( self.controller.work_management.get_user_scores( project=project, - folder=folder, item=item, scored_user=scored_user, provided_score_names=score_names, @@ -744,24 +728,10 @@ def set_user_scores( ) """ - if isinstance(project, str): - project_name, folder_name = extract_project_folder(project) - project, folder = self.controller.get_project_folder( - project_name, folder_name - ) - else: - project_pk, folder_pk = project - if isinstance(project_pk, int) and isinstance(folder_pk, int): - project = self.controller.get_project_by_id(project_pk).data - folder = self.controller.get_folder_by_id(folder_pk, project.id).data - elif isinstance(project_pk, str) and isinstance(folder_pk, str): - project = self.controller.get_project(project_pk) - folder = self.controller.get_folder(project, folder_pk) - else: - raise AppException("Provided project param is not valid.") + project, folder = self.controller.get_project_folder(project) + item = self.controller.get_item(project=project, folder=folder, item=item) self.controller.work_management.set_user_scores( project=project, - folder=folder, item=item, scored_user=scored_user, scores=scores, @@ -3900,7 +3870,7 @@ def attach_items( f"Attaching {len(_unique_attachments)} file(s) to project {project}." ) project, folder = self.controller.get_project_folder( - project_name, folder_name + (project_name, folder_name) ) response = self.controller.items.attach( project=project, @@ -4128,7 +4098,9 @@ def download_annotations( :rtype: str """ project_name, folder_name = extract_project_folder(project) - project, folder = self.controller.get_project_folder(project_name, folder_name) + project, folder = self.controller.get_project_folder( + (project_name, folder_name) + ) response = self.controller.annotations.download( project=project, folder=folder, @@ -4408,7 +4380,9 @@ def upload_custom_values( """ project_name, folder_name = extract_project_folder(project) - project, folder = self.controller.get_project_folder(project_name, folder_name) + project, folder = self.controller.get_project_folder( + (project_name, folder_name) + ) response = self.controller.custom_fields.upload_values( project=project, folder=folder, items=items ) @@ -4445,7 +4419,9 @@ def delete_custom_values( ) """ project_name, folder_name = extract_project_folder(project) - project, folder = self.controller.get_project_folder(project_name, folder_name) + project, folder = self.controller.get_project_folder( + (project_name, folder_name) + ) response = self.controller.custom_fields.delete_values( project=project, folder=folder, items=items ) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index fb7009858..f8fc16db7 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -266,30 +266,13 @@ def update_user_activity( ) res.raise_for_status() - def _get_score_item(self, item: Union[int, str], project_id: int, folder_id: int): - if isinstance(item, int): - item = self.service_provider.item_service.get( - project_id=project_id, item_id=item - ).data - else: - items = self.service_provider.item_service.list( - project_id, folder_id, Filter("name", item, OperatorEnum.EQ) - ) - item = next(iter(items), None) - if not item: - raise AppException("Item not found.") - return item - def get_user_scores( self, project: ProjectEntity, - folder: FolderEntity, - item: Union[int, str], + item: BaseItemEntity, scored_user: str, provided_score_names: Optional[List[str]] = None, ): - item = self._get_score_item(item, project.id, folder.id) - score_fields_res = self.service_provider.work_management.list_scores() # validate provided score names @@ -297,7 +280,7 @@ def get_user_scores( if provided_score_names and set(provided_score_names) - set(all_score_names): raise AppException("Please provide valid score names.") - score_id_field_options_map = {s.id: s for s in score_fields_res.data} + score_id_form_entity_map = {s.id: s for s in score_fields_res.data} score_values = self.service_provider.telemetry_scoring.get_score_values( project_id=project.id, item_id=item.id, user_id=scored_user @@ -306,18 +289,18 @@ def get_user_scores( scores = [] for s_id, s_values in score_id_values_map.items(): - score_field = score_id_field_options_map.get(s_id) - if score_field: + score_entity = score_id_form_entity_map.get(s_id) + if score_entity: score = ScoreEntity( id=s_id, - name=score_field.name, + name=score_entity.name, value=s_values.value, weight=s_values.weight, - createdAt=score_field.createdAt, - updatedAt=score_field.updatedAt, + createdAt=score_entity.createdAt, + updatedAt=score_entity.updatedAt, ) if provided_score_names: - if score_field.name in provided_score_names: + if score_entity.name in provided_score_names: scores.append(score) else: scores.append(score) @@ -352,28 +335,24 @@ def _validate_scores( def set_user_scores( self, project: ProjectEntity, - folder: FolderEntity, - item: Union[int, str], + item: BaseItemEntity, scored_user: str, scores: List[Dict[str, Any]], ): - item = self._get_score_item(item, project.id, folder.id) - - filters = {"email": scored_user} - users = self.list_users(project=project, **filters) + users = self.list_users(project=project, email=scored_user) if not users: - raise AppException("Please provide a valid email assigned to the project.") + raise AppException("User not found.") user = users[0] - user_role = user.role - user.role = self.service_provider.get_role_name(project, int(user_role)) + role_id = user.role + role_name = self.service_provider.get_role_name(project, int(role_id)) - score_fields_res = self.service_provider.work_management.list_scores() - all_score_names = [s.name for s in score_fields_res.data] - score_name_field_options_map = {s.name: s for s in score_fields_res.data} + score_name_field_options_map = { + s.name: s for s in self.service_provider.work_management.list_scores().data + } # get validate scores scores: List[ScorePayloadEntity] = self._validate_scores( - scores, all_score_names + scores, list(score_name_field_options_map.keys()) ) scores_to_create: List[dict] = [] @@ -381,8 +360,8 @@ def set_user_scores( score_to_create = { "item_id": item.id, "score_id": score_name_field_options_map[s.name].id, - "user_role_name": user.role, - "user_role": user_role, + "user_role_name": role_name, + "user_role": role_id, "user_id": user.email, "value": s.value, "weight": s.weight, @@ -1488,14 +1467,7 @@ def get_project_folder_by_path( self, path: Union[str, Path] ) -> Tuple[ProjectEntity, FolderEntity]: project_name, folder_name = extract_project_folder(path) - return self.get_project_folder(project_name, folder_name) - - def get_project_folder( - self, project_name: str, folder_name: str = None - ) -> Tuple[ProjectEntity, FolderEntity]: - project = self.get_project(project_name) - folder = self.get_folder(project, folder_name) - return project, folder + return self.get_project_folder((project_name, folder_name)) def get_project(self, name: str) -> ProjectEntity: project = self.projects.get_by_name(name).data @@ -1865,3 +1837,32 @@ def query_items_count(self, project_name: str, query: str = None) -> int: if response.errors: raise AppException(response.errors) return response.data["count"] + + def get_project_folder( + self, path: Union[str, Tuple[int, int], Tuple[str, str]] + ) -> Tuple[ProjectEntity, Optional[FolderEntity]]: + if isinstance(path, str): + project_name, folder_name = extract_project_folder(path) + project = self.get_project(project_name) + return project, self.get_folder(project, folder_name) + + if isinstance(path, tuple) and len(path) == 2: + project_pk, folder_pk = path + if all(isinstance(x, int) for x in path): + return ( + self.get_project_by_id(project_pk).data, + self.get_folder_by_id(folder_pk, project_pk).data, + ) + if all(isinstance(x, str) for x in path): + project = self.get_project(project_pk) + return project, self.get_folder(project, folder_pk) + + raise AppException("Provided project param is not valid.") + + def get_item( + self, project: ProjectEntity, folder: FolderEntity, item: Union[int, str] + ) -> BaseItemEntity: + if isinstance(item, int): + return self.get_item_by_id(item_id=item, project=project) + else: + return self.items.get_by_name(project, folder, item) From 6fc224c8611354b7d1218930343ee2b7428ebb61 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Fri, 14 Mar 2025 15:34:51 +0400 Subject: [PATCH 11/27] updated list_users for scoring --- .../lib/app/interface/sdk_interface.py | 104 +++++++++++++++++- .../test_user_custom_fields.py | 12 ++ .../work_management/test_user_scoring.py | 26 +++++ 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index c82a171a1..5ef7f93c3 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -460,7 +460,7 @@ def list_users( **filters, ): """ - Search users by filtering criteria + Search users, including their scores, by filtering criteria. :param project: Project name or ID, if provided, results will be for project-level, otherwise results will be for team level. @@ -470,8 +470,7 @@ def list_users( Possible values are - - "custom_fields": Includes the custom fields assigned to each user. - :type include: list of str, optional + - "custom_fields": Includes custom fields and scores assigned to each user. :param filters: Specifies filtering criteria, with all conditions combined using logical AND. @@ -501,18 +500,35 @@ def list_users( - email__contains: str - email__starts: str - email__ends: str + + Following params if project is not selected:: + - state: Literal[“Confirmed”, “Pending”] - state__in: List[Literal[“Confirmed”, “Pending”]] - role: Literal[“admin”, “contributor”] - role__in: List[Literal[“admin”, “contributor”]] - Custom Fields Filtering: - - Custom fields must be prefixed with `custom_field__`. + Scores and Custom Field Filtering: + + - Scores and other custom fields must be prefixed with `custom_field__` . - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due date is after the given Unix timestamp). + - **Text** custom field only works with the following filter params: __in, __notin, __contains + - **Numeric** custom field only works with the following filter params: __in, __notin, __ne, __gt, __gte, __lt, __lte + - **Single-select** custom field only works with the following filter params: __in, __notin, __contains + - **Multi-select** custom field only works with the following filter params: __in, __notin + - **Date picker** custom field only works with the following filter params: __gt, __gte, __lt, __lte + + **If score name has a space, please use the following format to filter them**: + :: + + user_filters = {"custom_field__accuracy score 30D__lt": 90} + client.list_users(include=["custom_fields"], **user_filters) + + :type filters: UserFilters, optional - :return: A list of team users metadata that matches the filtering criteria + :return: A list of team/project users metadata that matches the filtering criteria :rtype: list of dicts Request Example: @@ -543,6 +559,82 @@ def list_users( "team_id": 44311, } ] + + Request Example: + :: + + # Project level scores + + scores = client.list_users( + include=["custom_fields"], + project="my_multimodal", + email__contains="@superannotate.com", + custom_field__speed__gte=90, + custom_field__weight__lte=1, + ) + + Response Example: + :: + + # Project level scores + + [ + { + "createdAt": "2025-03-07T13:19:59.000Z", + "updatedAt": "2025-03-07T13:19:59.000Z", + "custom_fields": {"speed": 92, "weight": 0.8}, + "email": "example@superannotate.com", + "id": 715121, + "role": "Annotator", + "state": "Confirmed", + "team_id": 1234, + } + ] + + Request Example: + :: + + # Team level scores + + user_filters = { + "custom_field__accuracy score 30D__lt": 95, + "custom_field__speed score 7D__lt": 15 + } + + scores = client.list_users( + include=["custom_fields"], + email__contains="@superannotate.com", + role="Contributor", + **user_filters + ) + + Response Example: + :: + + # Team level scores + + [ + { + "createdAt": "2025-03-07T13:19:59.000Z", + "updatedAt": "2025-03-07T13:19:59.000Z", + "custom_fields": { + "Test custom field": 80, + "Tag custom fields": ["Tag1", "Tag2"], + "accuracy score 30D": 95, + "accuracy score 14D": 47, + "accuracy score 7D": 24, + "speed score 30D": 33, + "speed score 14D": 22, + "speed score 7D": 11, + }, + "email": "example@superannotate.com", + "id": 715121, + "role": "Contributor", + "state": "Confirmed", + "team_id": 1234, + } + ] + """ if project is not None: if isinstance(project, int): diff --git a/tests/integration/work_management/test_user_custom_fields.py b/tests/integration/work_management/test_user_custom_fields.py index 4955433ae..460c51743 100644 --- a/tests/integration/work_management/test_user_custom_fields.py +++ b/tests/integration/work_management/test_user_custom_fields.py @@ -149,6 +149,18 @@ def test_list_users(self): )[0] assert scapegoat["custom_fields"]["SDK_test_date_picker"] == value + # by date_picker with dict **filters + value = round(time.time(), 3) + filters = { + "email": scapegoat["email"], + "custom_field__SDK_test_date_picker": value, + } + scapegoat = sa.list_users( + include=["custom_fields"], + **filters, + )[0] + assert scapegoat["custom_fields"]["SDK_test_date_picker"] == value + # by date_picker None case sa.set_user_custom_field( scapegoat["email"], diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index b473dbdd9..951886c50 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -131,6 +131,32 @@ def test_set_get_scores(self): assert score["createdAt"] assert score["updatedAt"] + def test_list_users_with_scores(self): + # list team users + team_users = sa.list_users(include=["custom_fields"]) + for u in team_users: + for s in SCORE_TEMPLATES: + try: + assert not u["custom_fields"][f"{s['name']} 7D"] + assert not u["custom_fields"][f"{s['name']} 14D"] + assert not u["custom_fields"][f"{s['name']} 30D"] + except KeyError as e: + raise AssertionError(str(e)) + + # filter team users by score + filters = {f"custom_field__{SCORE_TEMPLATES[0]['name']} 7D": None} + team_users = sa.list_users(include=["custom_fields"], **filters) + assert team_users + for u in team_users: + try: + assert not u["custom_fields"][f"{SCORE_TEMPLATES[0]['name']} 7D"] + except KeyError as e: + raise AssertionError(str(e)) + + filters = {f"custom_field__{SCORE_TEMPLATES[1]['name']} 30D": 5} + team_users = sa.list_users(include=["custom_fields"], **filters) + assert not team_users + def test_set_get_scores_negative_cases(self): item_name = f"test_item_{uuid.uuid4()}" self._attach_item(self.PROJECT_NAME, item_name) From 1075075cb11b147468addc598098f8cd3ee79c03 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 17 Mar 2025 10:54:42 +0400 Subject: [PATCH 12/27] Version update --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 6dbf12643..6127fd2a9 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.3dev2" +__version__ = "4.4.33dev3" os.environ.update({"sa_version": __version__}) From 97c7f7092e0da04e01366fc65b5dfdcfa346ab2e Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 18 Mar 2025 10:55:22 +0400 Subject: [PATCH 13/27] Add folder_id in the item metadata --- src/superannotate/__init__.py | 2 +- src/superannotate/lib/core/entities/base.py | 1 + src/superannotate/lib/infrastructure/annotation_adapter.py | 4 +++- tests/integration/items/test_get_item_metadata.py | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index e6e8b7684..fdcd9106d 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.32" +__version__ = "4.4.33b1" os.environ.update({"sa_version": __version__}) sys.path.append(os.path.split(os.path.realpath(__file__))[0]) diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 24f947cad..17d4826d8 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -57,6 +57,7 @@ class TimedBaseModel(BaseModel): class BaseItemEntity(TimedBaseModel): id: Optional[int] name: Optional[str] + folder_id: Optional[int] path: Optional[str] = Field( None, description="Item’s path in SuperAnnotate project" ) diff --git a/src/superannotate/lib/infrastructure/annotation_adapter.py b/src/superannotate/lib/infrastructure/annotation_adapter.py index c333231cf..13436510a 100644 --- a/src/superannotate/lib/infrastructure/annotation_adapter.py +++ b/src/superannotate/lib/infrastructure/annotation_adapter.py @@ -44,7 +44,9 @@ def get_component_value(self, component_id: str): return None def set_component_value(self, component_id: str, value: Any): - self.annotation.setdefault("data", {}).setdefault(component_id, {})["value"] = value + self.annotation.setdefault("data", {}).setdefault(component_id, {})[ + "value" + ] = value return self diff --git a/tests/integration/items/test_get_item_metadata.py b/tests/integration/items/test_get_item_metadata.py index e95d77046..413772d9a 100644 --- a/tests/integration/items/test_get_item_metadata.py +++ b/tests/integration/items/test_get_item_metadata.py @@ -20,6 +20,7 @@ class TestGetEntityMetadataVector(BaseTestCase): EXPECTED_ITEM_METADATA = { "name": "example_image_1.jpg", "path": "TestGetEntityMetadataVector", + "folder_id": None, "url": None, "annotator_email": None, "qa_email": None, From 76b93012d41645d83a8343000f7a012e7a92f61f Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Mon, 17 Mar 2025 18:31:47 +0400 Subject: [PATCH 14/27] updated set_scores logic --- .../lib/app/interface/sdk_interface.py | 24 ++- .../lib/core/entities/work_managament.py | 2 +- .../lib/core/serviceproviders.py | 2 +- .../lib/infrastructure/controller.py | 84 ++++++---- .../lib/infrastructure/services/project.py | 8 +- .../editor_templates/form_with_scores.json | 146 ++++++++++++++++++ .../work_management/test_user_scoring.py | 9 +- 7 files changed, 229 insertions(+), 46 deletions(-) create mode 100644 tests/data_set/editor_templates/form_with_scores.json diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 5ef7f93c3..1a1153109 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -758,6 +758,11 @@ def get_user_scores( ] """ project, folder = self.controller.get_project_folder(project) + if project.type != ProjectType.MULTIMODAL: + raise AppException( + "This function is only supported for Multimodal projects." + ) + item = self.controller.get_item(project=project, folder=folder, item=item) response = BaseSerializer.serialize_iterable( self.controller.work_management.get_user_scores( @@ -789,7 +794,7 @@ def set_user_scores( :type scored_user: str :param scores: A list of dictionaries containing the following key-value pairs: - * **name** (*str*): The name of the score (required). + * **component_id** (*str*): The component_id of the score (required). * **value** (*Any*): The score value (required). * **weight** (*Union[float, int]*, optional): The weight of the score. Defaults to `1` if not provided. @@ -798,7 +803,7 @@ def set_user_scores( scores = [ { - "name": "Speed", # str (required) + "component_id": "", # str (required) "value": 90, # Any (required) "weight": 1 # Union[float, int] (optional, defaults to 1.0 if not provided) } @@ -813,20 +818,27 @@ def set_user_scores( item_=12345, scored_user="example@superannotate.com", scores=[ - {"name": "Speed", "value": 90}, - {"name": "Accuracy", "value": 9, "weight": 4.0}, - {"name": "Attention to Detail", "value": None, "weight": None}, + {"component_id": "r_kfrp3n", "value": 90}, + {"component_id": "h_jbrp4v", "value": 9, "weight": 4.0}, + {"component_id": "m_kf8pss", "value": None, "weight": None}, ] ) """ project, folder = self.controller.get_project_folder(project) + if project.type != ProjectType.MULTIMODAL: + raise AppException( + "This function is only supported for Multimodal projects." + ) item = self.controller.get_item(project=project, folder=folder, item=item) + editor_template = self.controller.projects.get_editor_template(project.id) + components = editor_template.get("components", []) self.controller.work_management.set_user_scores( project=project, item=item, scored_user=scored_user, scores=scores, + components=components, ) logger.info("Scores successfully set.") @@ -879,7 +891,7 @@ def retrieve_context( "This function is only supported for Multimodal projects." ) - editor_template = self.controller.projects.get_editor_template(project) + editor_template = self.controller.projects.get_editor_template(project.id) components = editor_template.get("components", []) _found, _context = retrieve_context(components, component_id) diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 8924218d6..6664164a9 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -184,7 +184,7 @@ class ScoreEntity(TimedBaseModel): class ScorePayloadEntity(BaseModel): - name: str + component_id: str value: Any weight: Optional[Union[float, int]] = 1.0 diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index e7148ee78..697408a13 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -234,7 +234,7 @@ def attach_editor_template( @abstractmethod def get_editor_template( - self, team: entities.TeamEntity, project: entities.ProjectEntity + self, organization_id: str, project_id: int ) -> ServiceResponse: raise NotImplementedError diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index f8fc16db7..bdb47eec8 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -1,3 +1,4 @@ +import copy import io import logging import os @@ -307,9 +308,7 @@ def get_user_scores( return scores @staticmethod - def _validate_scores( - scores: List[dict], all_score_names: List[str] - ) -> List[ScorePayloadEntity]: + def _validate_scores(scores: List[dict]) -> List[ScorePayloadEntity]: score_objects: List[ScorePayloadEntity] = [] for s in scores: @@ -322,53 +321,79 @@ def _validate_scores( except Exception: raise AppException("Invalid Scores.") - # validate provided score names - provided_score_names = [s.name for s in score_objects] - if set(provided_score_names) - set(all_score_names): - raise AppException("Please provide valid score names.") - - names = [score.name for score in score_objects] - if len(names) != len(set(names)): - raise AppException("Invalid Scores.") + component_ids = [score.component_id for score in score_objects] + if len(component_ids) != len(set(component_ids)): + raise AppException("Component IDs in scores data must be unique.") return score_objects + @staticmethod + def retrieve_scores( + components: List[dict], score_component_ids: List[str] + ) -> Dict[str, Dict]: + score_component_ids = copy.copy(score_component_ids) + found_scores = {} + try: + + def _retrieve_score_recursive( + all_components: List[dict], component_ids: List[str] + ): + for component in all_components: + if "children" in component: + _retrieve_score_recursive(component["children"], component_ids) + if "scoring" in component and component["id"] in component_ids: + component_ids.remove(component["id"]) + found_scores[component["id"]] = { + "score_id": component["scoring"]["id"], + "user_role_name": component["scoring"]["role"]["name"], + "user_role": component["scoring"]["role"]["id"], + } + + _retrieve_score_recursive(components, score_component_ids) + except KeyError: + raise AppException("An error occurred while parsing the editor template.") + return found_scores + def set_user_scores( self, project: ProjectEntity, item: BaseItemEntity, scored_user: str, scores: List[Dict[str, Any]], + components: List[dict], ): - users = self.list_users(project=project, email=scored_user) + users = self.list_users(email=scored_user) if not users: raise AppException("User not found.") user = users[0] - role_id = user.role - role_name = self.service_provider.get_role_name(project, int(role_id)) - - score_name_field_options_map = { - s.name: s for s in self.service_provider.work_management.list_scores().data - } # get validate scores - scores: List[ScorePayloadEntity] = self._validate_scores( - scores, list(score_name_field_options_map.keys()) + scores: List[ScorePayloadEntity] = self._validate_scores(scores) + + provided_score_component_ids = [s.component_id for s in scores] + component_id_score_data_map = self.retrieve_scores( + components, provided_score_component_ids ) - scores_to_create: List[dict] = [] + if len(component_id_score_data_map) != len(scores): + raise AppException("Invalid component_id provided") + + scores_to_set: List[dict] = [] for s in scores: - score_to_create = { + score_data = { "item_id": item.id, - "score_id": score_name_field_options_map[s.name].id, - "user_role_name": role_name, - "user_role": role_id, + "score_id": component_id_score_data_map[s.component_id]["score_id"], + "user_role_name": component_id_score_data_map[s.component_id][ + "user_role_name" + ], + "user_role": component_id_score_data_map[s.component_id]["user_role"], "user_id": user.email, "value": s.value, "weight": s.weight, + "component_id": s.component_id, } - scores_to_create.append(score_to_create) + scores_to_set.append(score_data) res = self.service_provider.telemetry_scoring.set_score_values( - project_id=project.id, data=scores_to_create + project_id=project.id, data=scores_to_set ) if res.status_code == 400: res.res_error = "Please provide valid score values." @@ -533,9 +558,10 @@ def upload_priority_scores( ) return use_case.execute() - def get_editor_template(self, project: ProjectEntity) -> dict: + @timed_lru_cache(seconds=5) + def get_editor_template(self, project_id: int) -> dict: response = self.service_provider.projects.get_editor_template( - team=self._team, project=project + organization_id=self._team.owner_id, project_id=project_id ) response.raise_for_status() return response.data diff --git a/src/superannotate/lib/infrastructure/services/project.py b/src/superannotate/lib/infrastructure/services/project.py index 0203661c4..0b1b43455 100644 --- a/src/superannotate/lib/infrastructure/services/project.py +++ b/src/superannotate/lib/infrastructure/services/project.py @@ -49,12 +49,10 @@ def attach_editor_template( url, "post", data=template, content_type=ServiceResponse, params=params ) - def get_editor_template( - self, team: entities.TeamEntity, project: entities.ProjectEntity - ) -> bool: - url = self.URL_EDITOR_TEMPLATE.format(project_id=project.id) + def get_editor_template(self, organization_id: str, project_id: int) -> bool: + url = self.URL_EDITOR_TEMPLATE.format(project_id=project_id) params = { - "organization_id": team.owner_id, + "organization_id": organization_id, } return self.client.request( url, "get", content_type=ServiceResponse, params=params diff --git a/tests/data_set/editor_templates/form_with_scores.json b/tests/data_set/editor_templates/form_with_scores.json new file mode 100644 index 000000000..9e9c3ce8b --- /dev/null +++ b/tests/data_set/editor_templates/form_with_scores.json @@ -0,0 +1,146 @@ +{ + "components": [ + { + "id": "component_id_0", + "type": "select", + "permissions": [], + "hasTooltip": false, + "label": "Select", + "isRequired": false, + "value": [], + "exclude": false, + "disablePasting": false, + "options": [ + { + "value": "Partially complete, needs review", + "checked": false + }, + { + "value": "Incomplete", + "checked": false + }, + { + "value": "Complete", + "checked": false + }, + { + "value": "4", + "checked": false + } + ], + "isMultiselect": true, + "placeholder": "Select" + }, + { + "id": "component_id_1", + "type": "input", + "permissions": [], + "hasTooltip": false, + "label": "Text input", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false, + "placeholder": "Placeholder", + "min": 0, + "max": 300 + }, + { + "id": "component_id_2", + "type": "number", + "permissions": [], + "hasTooltip": false, + "label": "Number", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false, + "min": null, + "max": null, + "step": 1 + }, + { + "id": "r_34k7k7", + "type": "rating", + "permissions": [], + "hasTooltip": false, + "scoring": { + "id": null, + "role": { + "id": 1, + "name": "Annotator" + }, + "name": "SDK-my-score-1" + }, + "label": "SDK-my-score-1", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false, + "numberOfStars": 10 + }, + { + "id": "r_ioc7wd", + "type": "number", + "permissions": [], + "hasTooltip": false, + "scoring": { + "id": null, + "role": { + "id": 1, + "name": "Annotator" + }, + "name": "SDK-my-score-2" + }, + "label": "SDK-my-score-2", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false, + "min": 1, + "max": 100, + "step": 1 + }, + { + "id": "r_tcof7o", + "type": "radio", + "permissions": [], + "hasTooltip": false, + "scoring": { + "id": null, + "role": { + "id": 1, + "name": "Annotator" + }, + "name": "SDK-my-score-3" + }, + "label": "SDK-my-score-3", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false, + "options": [ + { + "value": "1", + "checked": null + }, + { + "value": "2", + "checked": null + }, + { + "value": "3", + "checked": null + } + ], + "layout": "column" + } + ], + "code": [ + [ + "__init__", + "from typing import List, Union\n# import requests.asyncs as requests\nimport requests\nimport sa\n\nnumber_component_id_2 = ['component_id_2']\nselect_component_id_0 = ['component_id_0']\ninput_component_id_1 = ['component_id_1']\n\ndef before_save_hook(old_status: str, new_status: str) -> bool:\n # Your code goes here\n return\n\ndef on_saved_hook():\n # Your code goes here\n return\n\ndef before_status_change_hook(old_status: str, new_status: str) -> bool:\n # Your code goes here\n return\n\ndef on_status_changed_hook(old_status: str, new_status: str):\n # Your code goes here\n return\n\ndef post_hook():\n # Your code goes here\n return\n\ndef on_component_id_2_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_component_id_0_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_component_id_1_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n" + ] + ], + "environments": [] +} \ No newline at end of file diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index 951886c50..3f217350a 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -38,6 +38,11 @@ def setUpClass(cls, *args, **kwargs) -> None: team = sa.controller.team project = sa.controller.get_project(cls.PROJECT_NAME) time.sleep(5) + + for data in SCORE_TEMPLATES: + req = sa.controller.service_provider.work_management.create_score(**data) + assert req.status_code == 201 + with open(cls.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( team, project, template=json.load(f) @@ -47,10 +52,6 @@ def setUpClass(cls, *args, **kwargs) -> None: cls.PROJECT_NAME, cls.CLASSES_TEMPLATE_PATH ) - for data in SCORE_TEMPLATES: - req = sa.controller.service_provider.work_management.create_score(**data) - assert req.status_code == 201 - users = sa.list_users() scapegoat = [ u for u in users if u["role"] == "Contributor" and u["state"] == "Confirmed" From 3c8a2adda8b743873ae3a86efdc269a2c35d1ccb Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 18 Mar 2025 16:40:57 +0400 Subject: [PATCH 15/27] fix list_users docs --- src/superannotate/lib/app/interface/sdk_interface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 9f08f6820..5b8622882 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -511,7 +511,7 @@ def list_users( Scores and Custom Field Filtering: - Scores and other custom fields must be prefixed with `custom_field__` . - - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due date is after the given Unix timestamp). + - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due_date is after the given Unix timestamp). - **Text** custom field only works with the following filter params: __in, __notin, __contains - **Numeric** custom field only works with the following filter params: __in, __notin, __ne, __gt, __gte, __lt, __lte @@ -519,7 +519,7 @@ def list_users( - **Multi-select** custom field only works with the following filter params: __in, __notin - **Date picker** custom field only works with the following filter params: __gt, __gte, __lt, __lte - **If score name has a space, please use the following format to filter them**: + **If custom field has a space, please use the following format to filter them**: :: user_filters = {"custom_field__accuracy score 30D__lt": 90} @@ -3821,7 +3821,7 @@ def list_projects( Custom Fields Filtering: - Custom fields must be prefixed with `custom_field__`. - - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due date is after the given Unix timestamp). + - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due_date is after the given Unix timestamp). - If include does not include "custom_fields" but filter contains custom_fields, an error will be returned :type filters: ProjectFilters, optional From 994c45a11e64a9e8cb966388810aeeac03b03f36 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 18 Mar 2025 18:01:52 +0400 Subject: [PATCH 16/27] updated list_projects docs --- .../lib/app/interface/sdk_interface.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 5b8622882..e5144dc04 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3820,9 +3820,24 @@ def list_projects( - status__notin: List[Literal[“NotStarted”, “InProgress”, “Completed”, “OnHold”]] Custom Fields Filtering: + - Custom fields must be prefixed with `custom_field__`. - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due_date is after the given Unix timestamp). - - If include does not include "custom_fields" but filter contains custom_fields, an error will be returned + - If include does not include “custom_fields” but filter contains ‘custom_field’, an error will be returned + + - **Text** custom field only works with the following filter params: __in, __notin, __contains + - **Numeric** custom field only works with the following filter params: __in, __notin, __ne, __gt, __gte, __lt, __lte + - **Single-select** custom field only works with the following filter params: __in, __notin, __contains + - **Multi-select** custom field only works with the following filter params: __in, __notin + - **Date picker** custom field only works with the following filter params: __gt, __gte, __lt, __lte + + **If custom field has a space, please use the following format to filter them**: + :: + + project_filters = { + "custom_field__new single select custom field__contains": "text" + } + client.list_projects(include=["custom_fields"], **project_filters) :type filters: ProjectFilters, optional @@ -3833,10 +3848,10 @@ def list_projects( :: client.list_projects( - name__contains="Medical", include=["custom_fields"], status__in=["InProgress", "Completed"], - custom_fields__Tag__in=["Tag1", "Tag3"] + name__contains="Medical", + custom_field__Tag__in=["Tag1", "Tag3"] ) Response Example: From 15fdddbab8f2aaf86a21ef69560c221f08b2a9d8 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 18 Mar 2025 19:28:54 +0400 Subject: [PATCH 17/27] updated test_user_scoring --- .../work_management/test_user_scoring.py | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index 3f217350a..c337097bb 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -13,11 +13,16 @@ class TestUserScoring(TestCase): + """ + Test using mock Multimodal form template with dynamically generated scores created during setup. + """ + PROJECT_NAME = "TestUserScoring" PROJECT_TYPE = "Multimodal" PROJECT_DESCRIPTION = "DESCRIPTION" EDITOR_TEMPLATE_PATH = os.path.join( - Path(__file__).parent.parent.parent, "data_set/editor_templates/form1.json" + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form_with_scores.json", ) CLASSES_TEMPLATE_PATH = os.path.join( Path(__file__).parent.parent.parent, @@ -39,13 +44,20 @@ def setUpClass(cls, *args, **kwargs) -> None: project = sa.controller.get_project(cls.PROJECT_NAME) time.sleep(5) - for data in SCORE_TEMPLATES: - req = sa.controller.service_provider.work_management.create_score(**data) - assert req.status_code == 201 - + # setup form template from crated scores with open(cls.EDITOR_TEMPLATE_PATH) as f: + template_data = json.load(f) + for data in SCORE_TEMPLATES: + req = sa.controller.service_provider.work_management.create_score( + **data + ) + assert req.status_code == 201 + for component in template_data["components"]: + if "scoring" in component and component["type"] == req.data["type"]: + component["scoring"]["id"] = req.data["id"] + res = sa.controller.service_provider.projects.attach_editor_template( - team, project, template=json.load(f) + team, project, template=template_data ) assert res.ok sa.create_annotation_classes_from_classes_json( @@ -85,23 +97,23 @@ def _attach_item(path, name): sa.attach_items(path, [{"name": name, "url": "url"}]) def test_set_get_scores(self): - scores_payload = [ - { - "name": SCORE_TEMPLATES[0]["name"], + scores_name_payload_map = { + "SDK-my-score-1": { + "component_id": "r_34k7k7", # rating type score "value": 5, "weight": 0.5, }, - { - "name": SCORE_TEMPLATES[1]["name"], + "SDK-my-score-2": { + "component_id": "r_ioc7wd", # number type score "value": 45, "weight": 1.5, }, - { - "name": SCORE_TEMPLATES[2]["name"], + "SDK-my-score-3": { + "component_id": "r_tcof7o", # radio type score "value": None, "weight": None, }, - ] + } item_name = f"test_item_{uuid.uuid4()}" self._attach_item(self.PROJECT_NAME, item_name) @@ -110,7 +122,7 @@ def test_set_get_scores(self): project=self.PROJECT_NAME, item=item_name, scored_user=self.scapegoat["email"], - scores=scores_payload, + scores=list(scores_name_payload_map.values()), ) assert cm.output[0] == "INFO:sa:Scores successfully set." @@ -122,10 +134,8 @@ def test_set_get_scores(self): ) assert len(created_scores) == len(SCORE_TEMPLATES) - score_name_payload_map = {s["name"]: s for s in scores_payload} for score in created_scores: - score_pyload = score_name_payload_map[score["name"]] - assert score["name"] == score_pyload["name"] + score_pyload = scores_name_payload_map[score["name"]] assert score["value"] == score_pyload["value"] assert score["weight"] == score_pyload["weight"] assert score["id"] @@ -172,7 +182,7 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": None, "weight": 0.5, } @@ -188,7 +198,7 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[1]["name"], + "component_id": "r_ioc7wd", "value": 5, "weight": None, } @@ -203,7 +213,7 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": 1.2, "invalid_key": 123, @@ -212,14 +222,14 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid score name - with self.assertRaisesRegexp(AppException, "Please provide valid score names."): + with self.assertRaisesRegexp(AppException, "Invalid component_id provided"): sa.set_user_scores( project=self.PROJECT_NAME, item=item_name, scored_user=self.scapegoat["email"], scores=[ { - "name": "test_score_invalid", + "component_id": "invalid_component_id", "value": 5, "weight": 0.8, } @@ -234,26 +244,26 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "weight": 1.2, } ], ) # case with duplicated acore names - with self.assertRaisesRegexp(AppException, "Invalid Scores."): + with self.assertRaisesRegexp(AppException, "Component IDs in scores data must be unique."): sa.set_user_scores( project=self.PROJECT_NAME, item=item_name, scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": 1.2, }, { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": 1.2, }, @@ -270,7 +280,7 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": -1, } @@ -285,7 +295,7 @@ def test_set_get_scores_negative_cases(self): scored_user="invalid_email@mail.com", scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": 1, } @@ -300,7 +310,7 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": 1, } @@ -315,7 +325,7 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": 1, } @@ -330,7 +340,7 @@ def test_set_get_scores_negative_cases(self): scored_user=self.scapegoat["email"], scores=[ { - "name": SCORE_TEMPLATES[0]["name"], + "component_id": "r_34k7k7", "value": 5, "weight": 1, } From 4823683b9fb2167ed19cd64803277839eb39364f Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 20 Mar 2025 12:23:27 +0400 Subject: [PATCH 18/27] Update links --- docs/source/userguide/quickstart.rst | 2 +- docs/source/userguide/setup_project.rst | 2 +- pytest.ini | 2 +- src/superannotate/__init__.py | 2 +- .../lib/app/interface/sdk_interface.py | 2 +- .../lib/infrastructure/stream_data_handler.py | 9 +- tests/integration/classes/classes.json | 269 ++++++++++++++++++ .../work_management/test_user_scoring.py | 4 +- 8 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 tests/integration/classes/classes.json diff --git a/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index 5e2d63cdd..144557f56 100644 --- a/docs/source/userguide/quickstart.rst +++ b/docs/source/userguide/quickstart.rst @@ -32,7 +32,7 @@ Initialization and authorization ================================ To use the SDK, you need to create a config file with a team-specific authentication token. The token is available -to team admins on the team settings page at https://app.superannotate.com/team. +to team admins on the team settings page at https://doc.superannotate.com/docs/token-for-python-sdk#generate-a-token-for-python-sdk. SAClient can be used with or without arguments ______________________________________________ diff --git a/docs/source/userguide/setup_project.rst b/docs/source/userguide/setup_project.rst index 928fc6410..9315e50ca 100644 --- a/docs/source/userguide/setup_project.rst +++ b/docs/source/userguide/setup_project.rst @@ -74,7 +74,7 @@ An annotation class for a project can be created with SDK's: To create annotation classes in bulk with SuperAnnotate export format :file:`classes.json` (documentation at: -https://app.superannotate.com/documentation Management Tools +https://superannotate.readthedocs.io/en/stable/userguide/setup_project.html#working-with-annotation-classes Ma`nagement Tools -> Project Workflow part): .. code-block:: python diff --git a/pytest.ini b/pytest.ini index fe005c850..d9f7f6cc3 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -;addopts = -n 4 --dist loadscope +;addopts = -n 6 --dist loadscope diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 02c9d0bf2..db52476b3 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.33dev4" +__version__ = "4.4.33dev5" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index e5144dc04..974aa9e49 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3116,7 +3116,7 @@ def add_contributors_to_project( :param emails: users email :type emails: list - :param role: user role to apply, one of Admin , Annotator , QA + :param role: user role to apply, one of ProjectAdmin , Annotator , QA :type role: str :return: lists of added, skipped contributors of the project diff --git a/src/superannotate/lib/infrastructure/stream_data_handler.py b/src/superannotate/lib/infrastructure/stream_data_handler.py index 0471dd488..4742a49f2 100644 --- a/src/superannotate/lib/infrastructure/stream_data_handler.py +++ b/src/superannotate/lib/infrastructure/stream_data_handler.py @@ -5,6 +5,7 @@ import threading import time import typing +from contextlib import suppress from functools import lru_cache from typing import Callable @@ -42,6 +43,7 @@ def __init__( self._callback: Callable = callback self._map_function = map_function self._items_downloaded = 0 + self._active_sessions = set() def get_json(self, data: bytes): try: @@ -113,7 +115,7 @@ async def fetch( def _get_session(self, thread_id, ttl=None): # noqa del ttl del thread_id - return AIOHttpSession( + session = AIOHttpSession( headers=self._headers, timeout=TIMEOUT, connector=aiohttp.TCPConnector( @@ -121,6 +123,8 @@ def _get_session(self, thread_id, ttl=None): # noqa ), raise_for_status=True, ) + self._active_sessions.add(session) + return session def get_session(self): return self._get_session( @@ -128,6 +132,9 @@ def get_session(self): ) def rest_session(self): + for s in self._active_sessions: + with suppress(Exception): + s.close() self._get_session.cache_clear() async def list_annotations( diff --git a/tests/integration/classes/classes.json b/tests/integration/classes/classes.json new file mode 100644 index 000000000..146bf5760 --- /dev/null +++ b/tests/integration/classes/classes.json @@ -0,0 +1,269 @@ +[ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566574, + "project_id": 934113, + "type": 1, + "name": "r_1hb7yn", + "color": "#52C1D3", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417151, + "group_type": "text", + "class_id": 5566574, + "name": "value_r_1hb7yn", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566575, + "project_id": 934113, + "type": 1, + "name": "r_djuko8", + "color": "#31E52E", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417152, + "group_type": "radio", + "class_id": 5566575, + "name": "value_r_djuko8", + "attributes": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107671, + "group_id": 5417152, + "project_id": 934113, + "name": "Option 1", + "default": 0 + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107672, + "group_id": 5417152, + "project_id": 934113, + "name": "Option 2", + "default": 0 + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107673, + "group_id": 5417152, + "project_id": 934113, + "name": "Option 3", + "default": 0 + } + ], + "default_value": null + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566576, + "project_id": 934113, + "type": 1, + "name": "r_vezcsh", + "color": "#4E5C9F", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417153, + "group_type": "text", + "class_id": 5566576, + "name": "value_r_vezcsh", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566577, + "project_id": 934113, + "type": 1, + "name": "r_r85m12", + "color": "#665539", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417154, + "group_type": "text", + "class_id": 5566577, + "name": "value_r_r85m12", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566578, + "project_id": 934113, + "type": 1, + "name": "r_81z4ae", + "color": "#938CF1", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417155, + "group_type": "numeric", + "class_id": 5566578, + "name": "value_r_81z4ae", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566579, + "project_id": 934113, + "type": 1, + "name": "r_4dxiz5", + "color": "#46D225", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417156, + "group_type": "checklist", + "class_id": 5566579, + "name": "value_r_4dxiz5", + "attributes": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107674, + "group_id": 5417156, + "project_id": 934113, + "name": "Option 1", + "default": 0 + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107675, + "group_id": 5417156, + "project_id": 934113, + "name": "Option 2", + "default": 0 + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107676, + "group_id": 5417156, + "project_id": 934113, + "name": "Option 3", + "default": 0 + } + ], + "default_value": [] + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566580, + "project_id": 934113, + "type": 1, + "name": "r_9xs2b6", + "color": "#F99F14", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417157, + "group_type": "numeric", + "class_id": 5566580, + "name": "value_r_9xs2b6", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566581, + "project_id": 934113, + "type": 1, + "name": "r_mxhymc", + "color": "#6FF2EF", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417158, + "group_type": "radio", + "class_id": 5566581, + "name": "value_r_mxhymc", + "attributes": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107677, + "group_id": 5417158, + "project_id": 934113, + "name": "Option 1", + "default": 0 + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107678, + "group_id": 5417158, + "project_id": 934113, + "name": "Option 2", + "default": 0 + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 11107679, + "group_id": 5417158, + "project_id": 934113, + "name": "Option 3", + "default": 0 + } + ], + "default_value": null + } + ] + }, + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5566582, + "project_id": 934113, + "type": 1, + "name": "r_ydqb51", + "color": "#06FBF2", + "attribute_groups": [ + { + "createdAt": "2025-02-28T07:47:01.000Z", + "updatedAt": "2025-02-28T07:47:01.000Z", + "id": 5417159, + "group_type": "numeric", + "class_id": 5566582, + "name": "value_r_ydqb51", + "attributes": [] + } + ] + } +] \ No newline at end of file diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index c337097bb..a4f8d6f9d 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -251,7 +251,9 @@ def test_set_get_scores_negative_cases(self): ) # case with duplicated acore names - with self.assertRaisesRegexp(AppException, "Component IDs in scores data must be unique."): + with self.assertRaisesRegexp( + AppException, "Component IDs in scores data must be unique." + ): sa.set_user_scores( project=self.PROJECT_NAME, item=item_name, From cfd7d5dda5d5af6aa82d620246f9313e7dbc7b3a Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 20 Mar 2025 16:03:41 +0400 Subject: [PATCH 19/27] fix in set_score --- src/superannotate/__init__.py | 2 +- src/superannotate/lib/core/entities/work_managament.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 6127fd2a9..267471d95 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.33dev3" +__version__ = "4.4.33dev6" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 6664164a9..40aff7cb5 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -197,7 +197,7 @@ def validate_weight(cls, v): raise AppException("Please provide a valid number greater than 0") return v - @root_validator(pre=True) + @root_validator() def check_weight_and_value(cls, values): value = values.get("value") weight = values.get("weight") From 1948d346f51ace67d5e06e85f6720b192a830ded Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 21 Mar 2025 10:58:39 +0400 Subject: [PATCH 20/27] Fix project fields serializer --- src/superannotate/__init__.py | 2 +- src/superannotate/lib/infrastructure/controller.py | 7 ++++--- .../integration/work_management/test_user_custom_fields.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 267471d95..892a07051 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.33dev6" +__version__ = "4.4.33dev7" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index bdb47eec8..619a58d7d 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -77,7 +77,7 @@ def build_condition(**kwargs) -> Condition: def serialize_custom_fields( team_id: int, - project_id: int, + project_id: Optional[int], service_provider: ServiceProvider, data: List[dict], entity: CustomFieldEntityEnum, @@ -610,8 +610,9 @@ def list_projects( self.service_provider.client.team_id, None, self.service_provider, - custom_fields_list, - CustomFieldEntityEnum.PROJECT, + data=custom_fields_list, + entity=CustomFieldEntityEnum.PROJECT, + parent_entity=CustomFieldEntityEnum.TEAM, ) for project, serialized_custom_fields in zip(projects, serialized_fields): project.custom_fields = serialized_custom_fields diff --git a/tests/integration/work_management/test_user_custom_fields.py b/tests/integration/work_management/test_user_custom_fields.py index 460c51743..293fb79e6 100644 --- a/tests/integration/work_management/test_user_custom_fields.py +++ b/tests/integration/work_management/test_user_custom_fields.py @@ -142,6 +142,7 @@ def test_list_users(self): custom_field_name="SDK_test_date_picker", value=value, ) + time.sleep(1) scapegoat = sa.list_users( include=["custom_fields"], email=scapegoat["email"], @@ -150,7 +151,6 @@ def test_list_users(self): assert scapegoat["custom_fields"]["SDK_test_date_picker"] == value # by date_picker with dict **filters - value = round(time.time(), 3) filters = { "email": scapegoat["email"], "custom_field__SDK_test_date_picker": value, From 86a3dc1f97e3e1412182b4244f1bad00ed9a5fea Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 24 Mar 2025 17:56:31 +0400 Subject: [PATCH 21/27] Fix set folder status --- src/superannotate/__init__.py | 2 +- src/superannotate/lib/app/interface/sdk_interface.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 892a07051..8c89095ab 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.33dev7" +__version__ = "4.4.33dev8" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 974aa9e49..c52bdeb94 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1626,9 +1626,7 @@ def set_folder_status( * OnHold :type status: str """ - project, folder = self.controller.get_project_folder( - project_name=project, folder_name=folder - ) + project, folder = self.controller.get_project_folder((project, folder)) folder.status = constants.FolderStatus(status).value response = self.controller.update(project, folder) if response.errors: From aaefe9b8f841588190fb346505a098521d538fed Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 26 Mar 2025 12:25:06 +0400 Subject: [PATCH 22/27] Fix unclosed client session issue --- src/superannotate/__init__.py | 2 +- .../lib/infrastructure/stream_data_handler.py | 124 +++++++----------- 2 files changed, 50 insertions(+), 76 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 8c89095ab..ee33f468c 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.33dev8" +__version__ = "4.4.33dev9" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/infrastructure/stream_data_handler.py b/src/superannotate/lib/infrastructure/stream_data_handler.py index 4742a49f2..32c6e59c4 100644 --- a/src/superannotate/lib/infrastructure/stream_data_handler.py +++ b/src/superannotate/lib/infrastructure/stream_data_handler.py @@ -2,11 +2,7 @@ import json import logging import os -import threading -import time import typing -from contextlib import suppress -from functools import lru_cache from typing import Callable import aiohttp @@ -63,79 +59,60 @@ async def fetch( kwargs = {"params": params, "json": data} if data: kwargs["json"].update(data) - response = await self.get_session().request( - method, url, **kwargs, timeout=TIMEOUT - ) # noqa - if not response.ok: - logger.error(response.text) - buffer = "" - line_groups = b"" - decoder = json.JSONDecoder() - data_received = False - async for line in response.content.iter_any(): - line_groups += line - try: - buffer += line_groups.decode("utf-8") - line_groups = b"" - except UnicodeDecodeError: - continue - while buffer: - try: - if buffer.startswith(self.DELIMITER): - buffer = buffer[self.DELIMITER_LEN :] - json_obj, index = decoder.raw_decode(buffer) - if not annotation_is_valid(json_obj): - logger.warning( - f"Invalid JSON detected in small annotations stream process, json: {json_obj}." - ) - if data_received: - raise AppException( - "Invalid JSON detected in small annotations stream process." - ) - else: - self.rest_session() - raise BackendError( - "Invalid JSON detected at the start of the small annotations stream process." - ) - data_received = True - yield json_obj - if len(buffer[index:]) >= self.DELIMITER_LEN: - buffer = buffer[index + self.DELIMITER_LEN :] - else: - buffer = buffer[index:] - break - except json.decoder.JSONDecodeError as e: - logger.debug( - f"Failed to parse buffer, buffer_len: {len(buffer)} || start buffer:" - f" {buffer[:50]} || buffer_end: ...{buffer[-50:]} || error: {e}" - ) - break - - @lru_cache(maxsize=32) - def _get_session(self, thread_id, ttl=None): # noqa - del ttl - del thread_id - session = AIOHttpSession( + async with AIOHttpSession( headers=self._headers, timeout=TIMEOUT, connector=aiohttp.TCPConnector( ssl=self.VERIFY_SSL, keepalive_timeout=2**32 ), raise_for_status=True, - ) - self._active_sessions.add(session) - return session - - def get_session(self): - return self._get_session( - thread_id=threading.get_ident(), ttl=round(time.time() / 360) - ) - - def rest_session(self): - for s in self._active_sessions: - with suppress(Exception): - s.close() - self._get_session.cache_clear() + ) as session: + response = await session.request( + method, url, **kwargs, timeout=TIMEOUT + ) # noqa + if not response.ok: + logger.error(response.text) + buffer = "" + line_groups = b"" + decoder = json.JSONDecoder() + data_received = False + async for line in response.content.iter_any(): + line_groups += line + try: + buffer += line_groups.decode("utf-8") + line_groups = b"" + except UnicodeDecodeError: + continue + while buffer: + try: + if buffer.startswith(self.DELIMITER): + buffer = buffer[self.DELIMITER_LEN :] + json_obj, index = decoder.raw_decode(buffer) + if not annotation_is_valid(json_obj): + logger.warning( + f"Invalid JSON detected in small annotations stream process, json: {json_obj}." + ) + if data_received: + raise AppException( + "Invalid JSON detected in small annotations stream process." + ) + else: + raise BackendError( + "Invalid JSON detected at the start of the small annotations stream process." + ) + data_received = True + yield json_obj + if len(buffer[index:]) >= self.DELIMITER_LEN: + buffer = buffer[index + self.DELIMITER_LEN :] + else: + buffer = buffer[index:] + break + except json.decoder.JSONDecodeError as e: + logger.debug( + f"Failed to parse buffer, buffer_len: {len(buffer)} || start buffer:" + f" {buffer[:50]} || buffer_end: ...{buffer[-50:]} || error: {e}" + ) + break async def list_annotations( self, @@ -197,7 +174,4 @@ def _store_annotation(path, annotation: dict, callback: Callable = None): def _process_data(self, data): if data and self._map_function: return self._map_function(data) - return data - - def __del__(self): - self.rest_session() + return data \ No newline at end of file From e1b29fd39cd5ebe4ec1aa34796259dc2ff7252c3 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 27 Mar 2025 10:59:26 +0400 Subject: [PATCH 23/27] Fic cache context checks --- pytest.ini | 2 +- .../lib/core/serviceproviders.py | 12 +++- .../lib/core/usecases/projects.py | 4 +- .../lib/infrastructure/controller.py | 24 +++---- .../lib/infrastructure/query_builder.py | 13 +--- .../lib/infrastructure/serviceprovider.py | 23 ++++-- .../services/work_management.py | 4 +- .../lib/infrastructure/stream_data_handler.py | 2 +- src/superannotate/lib/infrastructure/utils.py | 72 +++++++++++++------ .../work_management/test_user_scoring.py | 1 + 10 files changed, 100 insertions(+), 57 deletions(-) diff --git a/pytest.ini b/pytest.ini index d9f7f6cc3..c0f66b58e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -;addopts = -n 6 --dist loadscope +addopts = -n 6 --dist loadscope diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 697408a13..207f17775 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -851,13 +851,17 @@ def invite_contributors( @abstractmethod def list_custom_field_names( - self, pk, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + self, + context: dict, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> List[str]: raise NotImplementedError @abstractmethod def get_custom_field_id( self, + context: dict, field_name: str, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, @@ -867,6 +871,7 @@ def get_custom_field_id( @abstractmethod def get_custom_field_name( self, + context: dict, field_id: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, @@ -884,6 +889,9 @@ def get_custom_field_component_id( @abstractmethod def get_custom_fields_templates( - self, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + self, + context: dict, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ): raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 7e5de2148..26f35d938 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -154,8 +154,9 @@ def execute(self): else: project.users = [] if self._include_custom_fields: + context = {"team_id": self._project.team_id} custom_fields_names = self._service_provider.list_custom_field_names( - self._project.team_id, + context, entity=CustomFieldEntityEnum.PROJECT, parent=CustomFieldEntityEnum.TEAM, ) @@ -173,6 +174,7 @@ def execute(self): custom_fields_name_value_map = {} for name in custom_fields_names: field_id = self._service_provider.get_custom_field_id( + context, name, entity=CustomFieldEntityEnum.PROJECT, parent=CustomFieldEntityEnum.TEAM, diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 619a58d7d..7e7919f91 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -83,14 +83,10 @@ def serialize_custom_fields( entity: CustomFieldEntityEnum, parent_entity: CustomFieldEntityEnum, ) -> List[dict]: - pk = ( - project_id - if entity == CustomFieldEntityEnum.PROJECT - else (team_id if parent_entity == CustomFieldEntityEnum.TEAM else project_id) - ) + context = {"team_id": team_id, "project_id": project_id} existing_custom_fields = service_provider.list_custom_field_names( - pk, entity, parent=parent_entity + context, entity, parent=parent_entity ) for i in range(len(data)): if not data[i]: @@ -111,7 +107,7 @@ def serialize_custom_fields( field_value /= 1000 # Convert timestamp new_field_name = service_provider.get_custom_field_name( - field_id, entity=entity, parent=parent_entity + context, field_id, entity=entity, parent=parent_entity ) updated_fields[new_field_name] = field_value @@ -151,11 +147,12 @@ def set_custom_field_value( field_name: str, value: Any, ): - _context = {} + _context = {"team_id": self.service_provider.client.team_id} if entity == CustomFieldEntityEnum.PROJECT: _context["project_id"] = entity_id + template_id = self.service_provider.get_custom_field_id( - field_name, entity=entity, parent=parent_entity + _context, field_name, entity=entity, parent=parent_entity ) component_id = self.service_provider.get_custom_field_component_id( template_id, entity=entity, parent=parent_entity @@ -178,16 +175,17 @@ def set_custom_field_value( def list_users( self, include: List[Literal["custom_fields"]] = None, project=None, **filters ): + context = {"team_id": self.service_provider.client.team_id} if project: parent_entity = CustomFieldEntityEnum.PROJECT - project_id = project.id + project_id = context["project_id"] = project.id else: parent_entity = CustomFieldEntityEnum.TEAM project_id = None valid_fields = generate_schema( UserFilters.__annotations__, self.service_provider.get_custom_fields_templates( - CustomFieldEntityEnum.CONTRIBUTOR, parent=parent_entity + context, CustomFieldEntityEnum.CONTRIBUTOR, parent=parent_entity ), ) chain = QueryBuilderChain( @@ -574,7 +572,9 @@ def list_projects( valid_fields = generate_schema( ProjectFilters.__annotations__, self.service_provider.get_custom_fields_templates( - CustomFieldEntityEnum.PROJECT, parent=CustomFieldEntityEnum.TEAM + {"team_id": self.service_provider.client.team_id}, + CustomFieldEntityEnum.PROJECT, + parent=CustomFieldEntityEnum.TEAM, ), ) chain = QueryBuilderChain( diff --git a/src/superannotate/lib/infrastructure/query_builder.py b/src/superannotate/lib/infrastructure/query_builder.py index 60f07781a..4cc69046e 100644 --- a/src/superannotate/lib/infrastructure/query_builder.py +++ b/src/superannotate/lib/infrastructure/query_builder.py @@ -175,25 +175,18 @@ def __init__( self._team_id = team_id self._project_id = project_id - @property - def pk(self): - if self._entity == CustomFieldEntityEnum.PROJECT: - return self._project_id - if self._parent == CustomFieldEntityEnum.TEAM: - return self._team_id - return self._project_id - def _handle_custom_field_key(self, key) -> Tuple[str, str, Optional[str]]: + context = {"team_id": self._team_id, "project_id": self._project_id} for custom_field in sorted( self._service_provider.list_custom_field_names( - self.pk, self._entity, parent=self._parent + context, self._entity, parent=self._parent ), key=len, reverse=True, ): if custom_field in key: custom_field_id = self._service_provider.get_custom_field_id( - custom_field, entity=self._entity, parent=self._parent + context, custom_field, entity=self._entity, parent=self._parent ) component_id = self._service_provider.get_custom_field_component_id( custom_field_id, entity=self._entity, parent=self._parent diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 7364951fe..79c5a38fb 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -24,6 +24,7 @@ from lib.infrastructure.services.telemetry_scoring import TelemetryScoringService from lib.infrastructure.services.work_management import WorkManagementService from lib.infrastructure.utils import CachedWorkManagementRepository +from lib.infrastructure.utils import EntityContext class ServiceProvider(BaseServiceProvider): @@ -72,17 +73,23 @@ def __init__(self, client: HttpClient): ) def get_custom_fields_templates( - self, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + self, + context: EntityContext, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ): return self._cached_work_management_repository.list_templates( - self.client.team_id, entity=entity, parent=parent + context, entity=entity, parent=parent ) def list_custom_field_names( - self, pk, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + self, + context: EntityContext, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> List[str]: return self._cached_work_management_repository.list_custom_field_names( - pk, + context, entity=entity, parent=parent, ) @@ -96,22 +103,24 @@ def get_category_id( def get_custom_field_id( self, + context: EntityContext, field_name: str, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, ) -> int: return self._cached_work_management_repository.get_custom_field_id( - self.client.team_id, field_name, entity=entity, parent=parent + context, field_name, entity=entity, parent=parent ) def get_custom_field_name( self, + context: EntityContext, field_id: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, ) -> str: return self._cached_work_management_repository.get_custom_field_name( - self.client.team_id, field_id, entity=entity, parent=parent + context, field_id, entity=entity, parent=parent ) def get_custom_field_component_id( @@ -121,7 +130,7 @@ def get_custom_field_component_id( parent: CustomFieldEntityEnum, ) -> str: return self._cached_work_management_repository.get_custom_field_component_id( - self.client.team_id, field_id, entity=entity, parent=parent + {"team_id": self.client.team_id}, field_id, entity=entity, parent=parent ) def get_role_id(self, project: entities.ProjectEntity, role_name: str) -> int: diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 0ab43a5ef..375a6932b 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -375,9 +375,7 @@ def set_custom_field_value( url=self.URL_SET_CUSTOM_ENTITIES.format(pk=entity_id), method="patch", headers={ - "x-sa-entity-context": self._generate_context( - team_id=self.client.team_id, **context - ), + "x-sa-entity-context": self._generate_context(**context), }, data={"customField": {"custom_field_values": {template_id: data}}}, params={ diff --git a/src/superannotate/lib/infrastructure/stream_data_handler.py b/src/superannotate/lib/infrastructure/stream_data_handler.py index 32c6e59c4..f8a3759ee 100644 --- a/src/superannotate/lib/infrastructure/stream_data_handler.py +++ b/src/superannotate/lib/infrastructure/stream_data_handler.py @@ -174,4 +174,4 @@ def _store_annotation(path, annotation: dict, callback: Callable = None): def _process_data(self, data): if data and self._map_function: return self._map_function(data) - return data \ No newline at end of file + return data diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index ce80988ae..c3934d833 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -1,6 +1,7 @@ import asyncio import logging import time +import typing from abc import ABC from abc import abstractmethod from functools import wraps @@ -24,6 +25,11 @@ logger = logging.getLogger("sa") +class EntityContext(typing.TypedDict, total=False): + team_id: int + project_id: Optional[int] + + def divide_to_chunks(it, size): it = iter(it) return iter(lambda: tuple(islice(it, size)), ()) @@ -324,78 +330,104 @@ def get_annotation_status_name(self, project, status_value: int) -> str: def get_custom_field_id( self, - team_id: int, + context: EntityContext, field_name: str, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, ) -> int: if entity == CustomFieldEntityEnum.PROJECT: - custom_field_data = self._project_custom_field_cache.get(team_id) + custom_field_data = self._project_custom_field_cache.get(context["team_id"]) else: if parent == CustomFieldEntityEnum.TEAM: - custom_field_data = self._team_user_custom_field_cache.get(team_id) + custom_field_data = self._team_user_custom_field_cache.get( + context["team_id"] + ) else: - custom_field_data = self._project_user_custom_field_cache.get(team_id) + custom_field_data = self._project_user_custom_field_cache.get( + context["project_id"] + ) if field_name in custom_field_data["custom_fields_name_id_map"]: return custom_field_data["custom_fields_name_id_map"][field_name] raise AppException("Invalid custom field name provided.") def get_custom_field_name( self, - team_id: int, + context: EntityContext, field_id: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, ) -> str: if entity == CustomFieldEntityEnum.PROJECT: - custom_field_data = self._project_custom_field_cache.get(team_id) + custom_field_data = self._project_custom_field_cache.get(context["team_id"]) else: if parent == CustomFieldEntityEnum.TEAM: - custom_field_data = self._team_user_custom_field_cache.get(team_id) + custom_field_data = self._team_user_custom_field_cache.get( + context["team_id"] + ) else: - custom_field_data = self._project_user_custom_field_cache.get(team_id) + custom_field_data = self._project_user_custom_field_cache.get( + context["project_id"] + ) if field_id in custom_field_data["custom_fields_id_name_map"]: return custom_field_data["custom_fields_id_name_map"][field_id] raise AppException("Invalid custom field ID provided.") def get_custom_field_component_id( self, - team_id: int, + context: EntityContext, field_id: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, ) -> str: if entity == CustomFieldEntityEnum.PROJECT: - custom_field_data = self._project_custom_field_cache.get(team_id) + custom_field_data = self._project_custom_field_cache.get(context["team_id"]) else: if parent == CustomFieldEntityEnum.TEAM: - custom_field_data = self._team_user_custom_field_cache.get(team_id) + custom_field_data = self._team_user_custom_field_cache.get( + context["team_id"] + ) else: - custom_field_data = self._project_user_custom_field_cache.get(team_id) + custom_field_data = self._project_user_custom_field_cache.get( + context["project_id"] + ) if field_id in custom_field_data["custom_fields_id_component_id_map"]: return custom_field_data["custom_fields_id_component_id_map"][field_id] raise AppException("Invalid custom field ID provided.") def list_custom_field_names( - self, pk: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + self, + context: EntityContext, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ) -> list: if entity == CustomFieldEntityEnum.PROJECT: - custom_field_data = self._project_custom_field_cache.get(pk) + custom_field_data = self._project_custom_field_cache.get(context["team_id"]) else: if parent == CustomFieldEntityEnum.TEAM: - custom_field_data = self._team_user_custom_field_cache.get(pk) + custom_field_data = self._team_user_custom_field_cache.get( + context["team_id"] + ) else: - custom_field_data = self._project_user_custom_field_cache.get(pk) + custom_field_data = self._project_user_custom_field_cache.get( + context["project_id"] + ) return list(custom_field_data["custom_fields_name_id_map"].keys()) def list_templates( - self, pk: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum + self, + context: EntityContext, + entity: CustomFieldEntityEnum, + parent: CustomFieldEntityEnum, ): if entity == CustomFieldEntityEnum.PROJECT: - return self._project_custom_field_cache.get(pk)["templates"] + return self._project_custom_field_cache.get(context["team_id"])["templates"] elif entity == CustomFieldEntityEnum.CONTRIBUTOR: if parent == CustomFieldEntityEnum.TEAM: - return self._team_user_custom_field_cache.get(pk)["templates"] + return self._team_user_custom_field_cache.get(context["team_id"])[ + "templates" + ] else: - return self._project_user_custom_field_cache.get(pk)["templates"] + return self._project_user_custom_field_cache.get(context["project_id"])[ + "templates" + ] raise AppException("Invalid entity provided.") diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index a4f8d6f9d..267d78f70 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -145,6 +145,7 @@ def test_set_get_scores(self): def test_list_users_with_scores(self): # list team users team_users = sa.list_users(include=["custom_fields"]) + print(team_users) for u in team_users: for s in SCORE_TEMPLATES: try: From 65848bd32150a41877dc1ae0e2bae43202a8c189 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 27 Mar 2025 12:43:48 +0400 Subject: [PATCH 24/27] update in tests --- .../integration/work_management/test_project_custom_fields.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/work_management/test_project_custom_fields.py b/tests/integration/work_management/test_project_custom_fields.py index 88b3ad7f4..fea40a5b8 100644 --- a/tests/integration/work_management/test_project_custom_fields.py +++ b/tests/integration/work_management/test_project_custom_fields.py @@ -331,4 +331,5 @@ def test_list_projects_by_custom_invalid_field(self): # TODO BED issue (custom_field filter without join) def test_list_projects_by_custom_fields_without_join(self): self._set_custom_field_values() - assert sa.list_projects(custom_field__SDK_test_numeric=123) + with self.assertRaisesRegexp(AppException, "Internal server error"): + assert sa.list_projects(custom_field__SDK_test_numeric=123) From 8c386d4cd9c40523f6a7e77683e42f9302c6fd89 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 27 Mar 2025 15:42:55 +0400 Subject: [PATCH 25/27] Fix custom field id handling --- .../lib/core/serviceproviders.py | 1 + .../lib/core/usecases/projects.py | 1 + .../lib/infrastructure/controller.py | 4 ++-- .../lib/infrastructure/query_builder.py | 19 ++++++++++++++----- .../lib/infrastructure/serviceprovider.py | 3 ++- .../test_pause_resume_user_activity.py | 2 ++ 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 207f17775..1e0a30a13 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -881,6 +881,7 @@ def get_custom_field_name( @abstractmethod def get_custom_field_component_id( self, + context: dict, field_id: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 26f35d938..aca47eb26 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -186,6 +186,7 @@ def execute(self): ) # timestamp: convert milliseconds to seconds component_id = self._service_provider.get_custom_field_component_id( + context, field_id, entity=CustomFieldEntityEnum.PROJECT, parent=CustomFieldEntityEnum.TEAM, diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 7e7919f91..c1654b5a4 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -97,7 +97,7 @@ def serialize_custom_fields( field_id = int(custom_field_name) try: component_id = service_provider.get_custom_field_component_id( - field_id, entity=entity, parent=parent_entity + context, field_id, entity=entity, parent=parent_entity ) except AppException: # The component template can be deleted, but not from the entity, so it will be skipped. @@ -155,7 +155,7 @@ def set_custom_field_value( _context, field_name, entity=entity, parent=parent_entity ) component_id = self.service_provider.get_custom_field_component_id( - template_id, entity=entity, parent=parent_entity + _context, template_id, entity=entity, parent=parent_entity ) # timestamp: convert seconds to milliseconds if component_id == CustomFieldType.DATE_PICKER.value and value is not None: diff --git a/src/superannotate/lib/infrastructure/query_builder.py b/src/superannotate/lib/infrastructure/query_builder.py index 4cc69046e..c12aaea63 100644 --- a/src/superannotate/lib/infrastructure/query_builder.py +++ b/src/superannotate/lib/infrastructure/query_builder.py @@ -174,22 +174,28 @@ def __init__( self._parent = parent self._team_id = team_id self._project_id = project_id + self._context = {"team_id": self._team_id, "project_id": self._project_id} def _handle_custom_field_key(self, key) -> Tuple[str, str, Optional[str]]: - context = {"team_id": self._team_id, "project_id": self._project_id} for custom_field in sorted( self._service_provider.list_custom_field_names( - context, self._entity, parent=self._parent + self._context, self._entity, parent=self._parent ), key=len, reverse=True, ): if custom_field in key: custom_field_id = self._service_provider.get_custom_field_id( - context, custom_field, entity=self._entity, parent=self._parent + self._context, + custom_field, + entity=self._entity, + parent=self._parent, ) component_id = self._service_provider.get_custom_field_component_id( - custom_field_id, entity=self._entity, parent=self._parent + self._context, + custom_field_id, + entity=self._entity, + parent=self._parent, ) key = key.replace( custom_field, @@ -230,7 +236,10 @@ def _determine_condition_and_key(keys: List[str]) -> Tuple[OperatorEnum, str]: def _handle_special_fields(self, keys: List[str], val): if keys[0] == "custom_field": component_id = self._service_provider.get_custom_field_component_id( - field_id=int(keys[1]), entity=self._entity, parent=self._parent + self._context, + field_id=int(keys[1]), + entity=self._entity, + parent=self._parent, ) if component_id == CustomFieldType.DATE_PICKER.value and val is not None: try: diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 79c5a38fb..c50b0d3ae 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -125,12 +125,13 @@ def get_custom_field_name( def get_custom_field_component_id( self, + context: EntityContext, field_id: int, entity: CustomFieldEntityEnum, parent: CustomFieldEntityEnum, ) -> str: return self._cached_work_management_repository.get_custom_field_component_id( - {"team_id": self.client.team_id}, field_id, entity=entity, parent=parent + context, field_id, entity=entity, parent=parent ) def get_role_id(self, project: entities.ProjectEntity, role_name: str) -> int: diff --git a/tests/integration/work_management/test_pause_resume_user_activity.py b/tests/integration/work_management/test_pause_resume_user_activity.py index 50ae15919..a706d8443 100644 --- a/tests/integration/work_management/test_pause_resume_user_activity.py +++ b/tests/integration/work_management/test_pause_resume_user_activity.py @@ -36,6 +36,8 @@ def test_pause_and_resume_user_activity(self): scapegoat = [ u for u in users if u["role"] == "Contributor" and u["state"] == "Confirmed" ][0] + import pdb + pdb.set_trace() sa.add_contributors_to_project(self.PROJECT_NAME, [scapegoat["email"]], "QA") with self.assertLogs("sa", level="INFO") as cm: sa.pause_user_activity(pk=scapegoat["email"], projects=[self.PROJECT_NAME]) From 99c8fac5075a7abb8ee20c6d2f23c9ce042ea4ee Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Tue, 1 Apr 2025 10:59:36 +0400 Subject: [PATCH 26/27] Update __init__.py --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index ee33f468c..b771ad9ad 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.33dev9" +__version__ = "4.4.33" os.environ.update({"sa_version": __version__}) From 5cb77b96cfbf613a9984592830d08de0470894d4 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Tue, 1 Apr 2025 12:16:38 +0400 Subject: [PATCH 27/27] Update CHANGELOG.rst --- CHANGELOG.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f2b84c702..5f4771759 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,23 @@ History All release highlights of this project will be documented in this file. +4.4.33 - April 1, 2025 +______________________ + +**Added** + + - ``SAClient.get_user_scores`` Retrieves score metadata for a user on a specific item within a project. + - ``SAClient.user_scores`` Assigns or updates score metadata for a user on a specific item in a project. + +**Updated** + + - ``SAClient.prepare_export`` Added option for JSONL download type. + - ``SAClient.download_annotations`` Added data_spec parameter enabling annotation downloads in JSONL format for multimodal projects. + - ``SAClient.list_items`` Introduced a new parameter to filter results by item category. + - ``SAClient.list_users`` Now retrieves a list of users with their scores and includes filtering options. Added an optional project parameter to fetch project-level scores instead of team-level scores. + - ``SAClient.item_context`` Added information about the ItemContext nested class. + - ``SAClient.list_projects`` Enhanced docstrings for to improve clarity and usability. + 4.4.32 - March 4, 2025 ______________________