From ad8ffd376c1eaba78216b60e042ebbdb2aa0217e Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 19 Nov 2025 11:37:02 +0400 Subject: [PATCH 01/13] Docs update, project argument handling update --- CHANGELOG.rst | 2 +- docs/source/api_reference/api_metadata.rst | 2 +- docs/source/userguide/setup_project.rst | 116 ++++++- pytest.ini | 2 +- src/superannotate/__init__.py | 2 +- .../lib/app/interface/base_interface.py | 6 +- .../lib/app/interface/sdk_interface.py | 292 +++++++++--------- .../lib/core/usecases/projects.py | 46 ++- .../lib/infrastructure/controller.py | 31 +- .../lib/infrastructure/serviceprovider.py | 5 +- .../lib/infrastructure/services/project.py | 2 +- src/superannotate/lib/infrastructure/utils.py | 36 +++ tests/integration/test_team_metadata.py | 12 +- .../test_pause_resume_user_activity.py | 4 + 14 files changed, 358 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 91b0d5763..f19be4b24 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,7 +7,7 @@ History All release highlights of this project will be documented in this file. 4.4.39 - November 13, 2025 -________________________ +__________________________ **Updated** diff --git a/docs/source/api_reference/api_metadata.rst b/docs/source/api_reference/api_metadata.rst index f3670d318..f6c2110b3 100644 --- a/docs/source/api_reference/api_metadata.rst +++ b/docs/source/api_reference/api_metadata.rst @@ -18,7 +18,7 @@ Project metadata example: "creator_id": "admin@superannotate.com", "updatedAt": "2020-08-31T05:43:43.118Z", "createdAt": "2020-08-31T05:43:43.118Z" - "type": "Vector", + "type": "Vector", # Pixel, Video, Multimodal "attachment_name": None, "attachment_path": None, "entropy_status": 1, diff --git a/docs/source/userguide/setup_project.rst b/docs/source/userguide/setup_project.rst index 9315e50ca..6c2884262 100644 --- a/docs/source/userguide/setup_project.rst +++ b/docs/source/userguide/setup_project.rst @@ -3,8 +3,110 @@ Setup Project ============= -Creating a project ------------------- +Creating a Multimodal project +------------------------------ + +For Multimodal projects you **must** provide a ``form`` JSON object that +conforms to SuperAnnotate's Multimodal form template schema. The form +defines the project's UI layout and component behavior in the Multimodal +Form Editor. + +.. code-block:: python + + minimal_form = { + "components": [ + { + "id": "component_id_0", + "type": "select", + "permissions": [], + "hasTooltip": False, + "label": "Select", + "isRequired": False, + "value": [], + "options": [ + {"value": "Partially complete, needs review", "checked": False}, + {"value": "Incomplete", "checked": False}, + {"value": "Complete", "checked": False}, + {"value": "4", "checked": False} + ], + "exclude": False, + "isMultiselect": True, + "placeholder": "Select" + }, + { + "id": "component_id_1", + "type": "input", + "permissions": [], + "hasTooltip": False, + "label": "Text input", + "placeholder": "Placeholder", + "isRequired": False, + "value": "", + "min": 0, + "max": 300, + "exclude": False + }, + { + "id": "component_id_2", + "type": "number", + "permissions": [], + "hasTooltip": False, + "label": "Number", + "exclude": False, + "isRequired": False, + "value": None, + "min": None, + "max": None, + "step": 1 + } + ], + "code": "", + "environments": [] + } + + response = sa.create_project( + project_name="My Multimodal Project", + project_description="Example multimodal project created via SDK", + project_type="Multimodal", + form=minimal_form + ) + +After creating the project, you can create folders and generate items: + +.. code-block:: python + + # Create a new folder in the project + sa.create_folder( + project="My Multimodal Project", + folder_name="First Folder" + ) + + # Generate multiple items in the specific project and folder + # If there are no items in the folder, it will generate a blank item + # otherwise, it will generate items based on the Custom Form + sa.generate_items( + project="My Multimodal Project/First Folder", + count=10, + name="My Item" + ) + +To upload annotations to these items: + +.. code-block:: python + + annotations = [ + # list of annotation dicts + ] + + sa.upload_annotations( + project="My Multimodal Project/First Folder", + annotations=annotations, + keep_status=True, + data_spec="multimodal" + ) + +Creating a Vector project +-------------------------- To create a new "Vector" project with name "Example Project 1" and description "test": @@ -17,7 +119,7 @@ To create a new "Vector" project with name "Example Project 1" and description Uploading images to project ---------------------------- +=========================== To upload all images with extensions "jpg" or "png" from the @@ -42,7 +144,7 @@ See the full argument options for Creating a folder in a project -______________________________ +============================== To create a new folder "folder1" in the project "Example Project 1": @@ -63,7 +165,7 @@ point to that folder with slash after the project name, e.g., sa.upload_images_from_folder_to_project(project + "/folder1", "") Working with annotation classes -_______________________________ +=============================== An annotation class for a project can be created with SDK's: @@ -94,7 +196,7 @@ The :file:`classes.json` file will be downloaded to :file:`" tuple: elif value is None: properties[key] = value elif key == "project": - properties["project_name"], folder_name = extract_project_folder(value) - if folder_name: - properties["folder_name"] = folder_name + properties.update(extract_project_folder_inputs(value)) elif isinstance(value, (str, int, float, bool)): properties[key] = value elif isinstance(value, dict): diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 806d89465..03cbae36e 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -761,7 +761,7 @@ def resume_user_activity( def get_user_scores( self, - project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], item: Union[NotEmptyStr, int], scored_user: NotEmptyStr, *, @@ -771,7 +771,7 @@ def get_user_scores( 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]] + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param item: The unique ID or name of the item. :type item: Union[str, int] @@ -836,7 +836,7 @@ def get_user_scores( def set_user_scores( self, - project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], item: Union[NotEmptyStr, int], scored_user: NotEmptyStr, scores: List[Dict[str, Any]], @@ -845,7 +845,7 @@ def set_user_scores( 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]] + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param item: The unique ID or name of the item. :type item: Union[str, int] @@ -1922,7 +1922,7 @@ def set_project_status(self, project: NotEmptyStr, status: PROJECT_STATUS): :type status: str """ - project = self.controller.get_project(name=project) + project = self.controller.get_project(project) project.status = constants.ProjectStatus(status).value response = self.controller.projects.update(project) if response.errors: @@ -2023,20 +2023,22 @@ def set_project_default_image_quality_in_editor( def pin_image( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], image_name: str, pin: Optional[bool] = True, ): """Pins (or unpins) image - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] + :param image_name: image name :type image_name: str + :param pin: sets to pin if True, else unpins image :type pin: bool """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) items = self.controller.items.list_items(project, folder, name=image_name) item = next(iter(items), None) if not items: @@ -2044,15 +2046,20 @@ def pin_image( item.is_pinned = int(pin) self.controller.items.update(project=project, item=item) - def delete_items(self, project: str, items: Optional[List[str]] = None): + def delete_items( + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + items: Optional[List[str]] = None, + ): """Delete items in a given project. - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] + :param items: to be deleted items' names. If None, all the items will be deleted :type items: list of str """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.items.delete( project=project, folder=folder, item_names=items ) @@ -2060,15 +2067,18 @@ def delete_items(self, project: str, items: Optional[List[str]] = None): raise AppException(response.errors) def assign_items( - self, project: Union[NotEmptyStr, dict], items: List[str], user: str + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + items: List[str], + user: str, ): """Assigns items to a user. The assignment role, QA or Annotator, will be deduced from the user's role in the project. The type of the objects` image, video or text will be deduced from the project type. With SDK, the user can be assigned to a role in the project with the share_project function. - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param items: list of items to assign :type items: list of str @@ -2077,7 +2087,7 @@ def assign_items( :type user: str """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.projects.assign_items( project, folder, item_names=items, user=user ) @@ -2086,18 +2096,21 @@ def assign_items( raise AppException(response.errors) def unassign_items( - self, project: Union[NotEmptyStr, dict], items: List[NotEmptyStr] + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + items: List[NotEmptyStr], ): """Removes assignment of given items for all assignees. With SDK, the user can be assigned to a role in the project with the share_project function. - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] + :param items: list of items to unassign :type items: list of str """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.projects.un_assign_items( project, folder, item_names=items ) @@ -2293,15 +2306,15 @@ def upload_images_from_folder_to_project( def download_image_annotations( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], image_name: NotEmptyStr, local_dir_path: Union[str, Path], ): """Downloads annotations of the image (JSON and mask if pixel type project) to local_dir_path. - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param image_name: image name :type image_name: str @@ -2313,7 +2326,7 @@ def download_image_annotations( :rtype: tuple """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) res = self.controller.annotations.download_image_annotations( project=project, folder=folder, @@ -2601,7 +2614,7 @@ def upload_video_to_project( def create_annotation_class( self, - project: Union[Project, NotEmptyStr], + project: Union[NotEmptyStr, int], name: NotEmptyStr, color: NotEmptyStr, attribute_groups: Optional[List[AttributeGroup]] = None, @@ -2609,8 +2622,9 @@ def create_annotation_class( ): """Create annotation class in project - :param project: project name - :type project: str + :param project: The project name, project ID, or folder path (e.g., "project1") to search within. + This can refer to the root of the project or a specific subfolder. + :type project: Union[str, int] :param name: name for the class :type name: str @@ -2694,8 +2708,7 @@ def create_annotation_class( ) """ - if isinstance(project, Project): - project = project.dict() + attribute_groups = ( list(map(lambda x: x.dict(), attribute_groups)) if attribute_groups else [] ) @@ -2708,7 +2721,7 @@ def create_annotation_class( ) except ValidationError as e: raise AppException(wrap_error(e)) - project = self.controller.projects.get_by_name(project).data + project = self.controller.get_project(project) if ( project.type != ProjectType.DOCUMENT and annotation_class.type == ClassTypeEnum.RELATIONSHIP @@ -2944,7 +2957,7 @@ def set_project_steps( def download_image( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], image_name: NotEmptyStr, local_dir_path: Optional[Union[str, Path]] = "./", include_annotations: Optional[bool] = False, @@ -2954,8 +2967,8 @@ def download_image( ): """Downloads the image (and annotation if not None) to local_dir_path - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param image_name: image name :type image_name: str @@ -2979,10 +2992,10 @@ def download_image( :return: paths of downloaded image and annotations if included :rtype: tuple """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.download_image( - project_name=project_name, - folder_name=folder_name, + project=project, + folder=folder, image_name=image_name, download_path=str(local_dir_path), image_variant=variant, @@ -2997,7 +3010,7 @@ def download_image( def upload_annotations( self, - project: NotEmptyStr, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], annotations: List[dict], keep_status: bool = None, *, @@ -3005,9 +3018,8 @@ def upload_annotations( ): """Uploads a list of annotation dictionaries to the specified SuperAnnotate project or folder. - :param project: The project name or folder path where annotations will be uploaded - (e.g., "project1/folder1"). - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param annotations: A list of annotation dictionaries formatted according to the SuperAnnotate standards. :type annotations: list of dict @@ -3051,10 +3063,10 @@ def upload_annotations( annotations.append(json.loads(line)) # Initialize the SuperAnnotate client - sa = SAClient() + sa_client = SAClient() # Call the upload_annotations function - response = sa.upload_annotations( + response = sa_client.upload_annotations( project="project1/folder1", annotations=annotations, keep_status=True, @@ -3068,7 +3080,7 @@ def upload_annotations( "Please use the “set_annotation_statuses” function instead." ) ) - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.annotations.upload_multiple( project=project, folder=folder, @@ -3083,7 +3095,7 @@ def upload_annotations( def upload_annotations_from_folder_to_project( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], folder_path: Union[str, Path], from_s3_bucket=None, recursive_subfolders: Optional[bool] = False, @@ -3100,8 +3112,8 @@ def upload_annotations_from_folder_to_project( Existing annotations will be overwritten. - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str or dict + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param folder_path: from which folder to upload annotations :type folder_path: str or dict @@ -3147,9 +3159,7 @@ def upload_annotations_from_folder_to_project( logger.info( f"Uploading {len(annotation_paths)} annotations from {folder_path} to the project {project_folder_name}." ) - project, folder = self.controller.get_project_folder( - (project_name, folder_name) - ) + project, folder = self.controller.get_project_folder(project) response = self.controller.annotations.upload_from_folder( project=project, folder=folder, @@ -3165,7 +3175,7 @@ def upload_annotations_from_folder_to_project( def upload_image_annotations( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], image_name: str, annotation_json: Union[str, Path, dict], mask: Optional[Union[str, Path, bytes]] = None, @@ -3175,8 +3185,8 @@ def upload_image_annotations( """Upload annotations from JSON (also mask for pixel annotations) to the image. - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param image_name: image name :type image_name: str @@ -3196,7 +3206,7 @@ def upload_image_annotations( """ - project_name, folder_name = extract_project_folder(project) + _, folder_name = extract_project_folder(project) if keep_status is not None: warnings.warn( DeprecationWarning( @@ -3204,7 +3214,7 @@ def upload_image_annotations( "Please use the “set_annotation_statuses” function instead." ) ) - project = self.controller.projects.get_by_name(project_name).data + project, folder = self.controller.get_project_folder(project) if project.type not in constants.ProjectType.images: raise AppException(LIMITED_FUNCTIONS[project.type]) @@ -3442,18 +3452,21 @@ def aggregate_annotations_as_df( ).aggregate_annotations_as_df() def delete_annotations( - self, project: NotEmptyStr, item_names: Optional[List[NotEmptyStr]] = None + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + item_names: Optional[List[NotEmptyStr]] = None, ): """ Delete item annotations from a given list of items. - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] + :param item_names: item names. If None, all the annotations in the specified directory will be deleted. :type item_names: list of strs """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.annotations.delete( project=project, folder=folder, item_names=item_names ) @@ -3550,15 +3563,15 @@ def invite_contributors_to_team( def get_annotations( self, - project: Union[NotEmptyStr, int], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], items: Optional[Union[List[NotEmptyStr], List[int]]] = None, *, data_spec: Literal["default", "multimodal"] = "default", ): """Returns annotations for the given list of items. - :param project: project id or project name or folder path (e.g., “project1/folder1”). - :type project: str or int + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param items: item names. If None, all the items in the specified directory will be used. :type items: list of strs or list of ints @@ -3577,10 +3590,10 @@ def get_annotations( from superannotate import SAClient - sa = SAClient() + sa_client = SAClient() # Call the get_annotations function - response = sa.get_annotations( + response = sa_client.get_annotations( project="project1/folder1", items=["item_1", "item_2"], data_spec='multimodal' @@ -3589,13 +3602,7 @@ def get_annotations( :return: list of annotations :rtype: list of dict """ - if isinstance(project, str): - project, folder = self.controller.get_project_folder_by_path(project) - else: - project = self.controller.get_project_by_id(project_id=project).data - folder = self.controller.get_folder_by_id( - project_id=project.id, folder_id=project.folder_id - ).data + project, folder = self.controller.get_project_folder(project) response = self.controller.annotations.list( project, folder, @@ -3607,13 +3614,16 @@ def get_annotations( return response.data def get_annotations_per_frame( - self, project: NotEmptyStr, video: NotEmptyStr, fps: int = 1 + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + video: NotEmptyStr, + fps: int = 1, ): """Returns per frame annotations for the given video. - :param project: project name or folder path (e.g., “project1/folder1”). - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param video: video name :type video: str @@ -3625,19 +3635,23 @@ def get_annotations_per_frame( :return: list of annotation objects :rtype: list of dicts """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.get_annotations_per_frame( - project_name, folder_name, video_name=video, fps=fps + project, folder, video_name=video, fps=fps ) if response.errors: raise AppException(response.errors) return response.data - def upload_priority_scores(self, project: NotEmptyStr, scores: List[PriorityScore]): + def upload_priority_scores( + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + scores: List[PriorityScore], + ): """Upload priority scores for the given list of items. - :param project: project name or folder path (e.g., “project1/folder1”) - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param scores: list of score objects :type scores: list of dicts @@ -3646,11 +3660,8 @@ def upload_priority_scores(self, project: NotEmptyStr, scores: List[PriorityScor :rtype: tuple (2 members) of lists of strs """ scores = parse_obj_as(List[PriorityScoreEntity], scores) - project_name, folder_name = extract_project_folder(project) - project_folder_name = project - project, folder = self.controller.get_project_folder( - (project_name, folder_name) - ) + project, folder = self.controller.get_project_folder(project) + project_folder_name = project.name + "" if folder.is_root else f"/{folder.name}" response = self.controller.projects.upload_priority_scores( project, folder, scores, project_folder_name ) @@ -3776,15 +3787,15 @@ def attach_items_from_integrated_storage( def query( self, - project: NotEmptyStr, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], query: Optional[NotEmptyStr] = None, subset: Optional[NotEmptyStr] = None, ): """Return items that satisfy the given query. Query syntax should be in SuperAnnotate query language(https://doc.superannotate.com/docs/explore-overview). - :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param query: SAQuL query string. :type query: str @@ -3796,8 +3807,8 @@ def query( :return: queried items' metadata list :rtype: list of dicts """ - project_name, folder_name = extract_project_folder(project) - items = self.controller.query_entities(project_name, folder_name, query, subset) + project, folder = self.controller.get_project_folder(project) + items = self.controller.query_entities(project, folder, query, subset) exclude = { "meta", } @@ -3805,14 +3816,14 @@ def query( def get_item_metadata( self, - project: NotEmptyStr, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], item_name: NotEmptyStr, include_custom_metadata: bool = False, ): """Returns item metadata - :param project: project name or folder path (e.g., “project1/folder1”) - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param item_name: item name. :type item_name: str @@ -3853,7 +3864,7 @@ def get_item_metadata( } } """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) items = self.controller.items.list_items( project, folder, name=item_name, include=["assignments"] ) @@ -3873,7 +3884,7 @@ def get_item_metadata( def search_items( self, - project: NotEmptyStr, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], name_contains: NotEmptyStr = None, annotation_status: str = None, annotator_email: Optional[NotEmptyStr] = None, @@ -3883,9 +3894,8 @@ def search_items( ): """Search items by filtering criteria. - :param project: project name or folder path (e.g., “project1/folder1”). - If recursive=False=True, then only the project name is required. - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param name_contains: returns those items, where the given string is found anywhere within an item’s name. If None, all items returned, in accordance with the recursive=False parameter. @@ -3946,7 +3956,7 @@ def search_items( } ] """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) query_kwargs = {"include": ["assignments"]} if name_contains: query_kwargs["name__contains"] = name_contains @@ -4305,15 +4315,15 @@ def list_projects( def attach_items( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], attachments: Union[NotEmptyStr, Path, conlist(Attachment, min_items=1)], annotation_status: str = None, ): """ Link items from external storage to SuperAnnotate using URLs. - :param project: project name or folder path (e.g., “project1/folder1”) - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param attachments: path to CSV file or list of dicts containing attachments URLs. :type attachments: path-like (str or Path) or list of dicts @@ -4356,7 +4366,7 @@ def attach_items( "Please use the “set_annotation_statuses” function instead." ) ) - project_name, folder_name = extract_project_folder(project) + try: attachments = parse_obj_as(List[AttachmentEntity], attachments) unique_attachments = set(attachments) @@ -4375,6 +4385,7 @@ def attach_items( unique_attachments = parse_obj_as(List[AttachmentEntity], unique_attachments) uploaded, fails, duplicated = [], [], [] _unique_attachments = [] + project, folder = self.controller.get_project_folder(project) if any(i.integration for i in unique_attachments): integtation_item_map = { i.name: i @@ -4404,9 +4415,7 @@ def attach_items( logger.info( f"Attaching {len(_unique_attachments)} file(s) to project {project}." ) - project, folder = self.controller.get_project_folder( - (project_name, folder_name) - ) + response = self.controller.items.attach( project=project, folder=folder, @@ -4425,7 +4434,7 @@ def attach_items( def generate_items( self, - project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], count: int, name: str, ): @@ -4434,7 +4443,7 @@ def generate_items( If there are no items in the folder, it will generate a blank item otherwise, it will generate items based on the Custom Form. :param project: Project and folder as a tuple, folder is optional. - :type project: Union[str, Tuple[int, int], Tuple[str, str]] + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param count: the count of items to generate :type count: int @@ -4572,7 +4581,7 @@ def move_items( def set_items_category( self, - project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], items: List[Union[int, str]], category: NotEmptyStr, ): @@ -4580,7 +4589,7 @@ def set_items_category( Add categories to one or more items. :param project: Project and folder as a tuple, folder is optional. - :type project: Union[str, Tuple[int, int], Tuple[str, str]] + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param items: A list of names or IDs of the items to modify. :type items: List[Union[int, str]] @@ -4610,14 +4619,14 @@ def set_items_category( def remove_items_category( self, - project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], items: List[Union[int, str]], ): """ Remove categories from one or more items. :param project: Project and folder as a tuple, folder is optional. - :type project: Union[str, Tuple[int, int], Tuple[str, str]] + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param items: A list of names or IDs of the items to modify. :type items: List[Union[int, str]] @@ -4639,14 +4648,14 @@ def remove_items_category( def set_annotation_statuses( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], annotation_status: NotEmptyStr, items: Optional[List[NotEmptyStr]] = None, ): """Sets annotation statuses of items. - :param project: project name or folder path (e.g., “project1/folder1”). - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param annotation_status: The desired status to set for the annotation. This status should match one of the predefined statuses available in the project workflow. @@ -4656,7 +4665,7 @@ def set_annotation_statuses( :type items: list of strs """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.items.set_annotation_statuses( project=project, folder=folder, @@ -4669,7 +4678,7 @@ def set_annotation_statuses( def download_annotations( self, - project: Union[NotEmptyStr, dict], + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], path: Union[str, Path] = None, items: Optional[List[NotEmptyStr]] = None, recursive: bool = False, @@ -4678,8 +4687,8 @@ def download_annotations( ): """Downloads annotation JSON files of the selected items to the local directory. - :param project: project name or folder path (e.g., “project1/folder1”). - :type project: str + :param project: Project and folder as a tuple, folder is optional. (e.g., "project1/folder1", (project_id, folder_id)) + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param path: local directory path where the annotations will be downloaded. If none, the current directory is used. @@ -4709,24 +4718,26 @@ def download_annotations( :type data_spec: str, optional + :return: local path of the downloaded annotations folder. + :rtype: str + Example Usage of Multimodal Projects:: from superannotate import SAClient - sa = SAClient() + sa_client = SAClient() # Call the get_annotations function - response = sa.download_annotations( - project="project1/folder1", + response = sa_client.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 + """ project_name, folder_name = extract_project_folder(project) project, folder = self.controller.get_project_folder( @@ -4946,15 +4957,17 @@ def delete_custom_fields( return response.data def upload_custom_values( - self, project: NotEmptyStr, items: conlist(Dict[str, dict], min_items=1) + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + items: conlist(Dict[str, dict], min_items=1), ): """ Attach custom metadata to items. SAClient.get_item_metadata(), SAClient.search_items(), SAClient.query() methods will return the item metadata and custom metadata. - :param project: project name or folder path (e.g., “project1/folder1”) - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param items: list of name-data pairs. The key of each dict indicates an existing item name and the value represents the custom metadata dict. @@ -5010,10 +5023,7 @@ 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) response = self.controller.custom_fields.upload_values( project=project, folder=folder, items=items ) @@ -5022,13 +5032,16 @@ def upload_custom_values( return response.data def delete_custom_values( - self, project: NotEmptyStr, items: conlist(Dict[str, List[str]], min_items=1) + self, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], + items: conlist(Dict[str, List[str]], min_items=1), ): """ Remove custom data from items - :param project: project name or folder path (e.g., “project1/folder1”) - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] + :param items: list of name-custom data dicts. The key of each dict element indicates an existing item in the project root or folder. @@ -5049,10 +5062,7 @@ 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) response = self.controller.custom_fields.delete_values( project=project, folder=folder, items=items ) @@ -5144,14 +5154,14 @@ def add_items_to_subset( def set_approval_statuses( self, - project: NotEmptyStr, + project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], approval_status: Optional[APPROVAL_STATUS], items: Optional[List[NotEmptyStr]] = None, ): """Sets annotation statuses of items - :param project: project name or folder path (e.g., “project1/folder1”). - :type project: str + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param approval_status: approval status to set. \n Available statuses are:: @@ -5164,7 +5174,7 @@ def set_approval_statuses( :param items: item names to set the mentioned status for. If None, all the items in the project will be used. :type items: list of strs """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) response = self.controller.items.set_approval_statuses( project=project, folder=folder, diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 1e3ff5880..044d8367e 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -16,8 +16,10 @@ from lib.core.entities import TeamEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.enums import CustomFieldType +from lib.core.enums import WMUserStateEnum from lib.core.exceptions import AppException from lib.core.exceptions import AppValidationException +from lib.core.jsx_conditions import EmptyQuery from lib.core.jsx_conditions import Filter from lib.core.jsx_conditions import OperatorEnum from lib.core.response import Response @@ -168,7 +170,11 @@ def execute(self): raise AppException("Workflow not fund.") project.workflow = project_workflow if self._include_contributors: - project.contributors = project.users + project.contributors = self._service_provider.work_management.list_users( + EmptyQuery(), + project_id=project.id, + parent_entity=CustomFieldEntityEnum.PROJECT, + ).data else: project.users = [] if self._include_custom_fields: @@ -848,13 +854,14 @@ def execute(self): if self.is_valid(): team_users = set() project_users = {user.user_id for user in self._project.users} - for user in self._team.users: - if user.user_role == constants.UserRole.CONTRIBUTOR.value: + users = self._service_provider.work_management.list_users(EmptyQuery()).data + pending_invitations = [] + for user in users: + if user.state == WMUserStateEnum.Pending.value: + pending_invitations.append(user) + elif user.role == constants.UserRole.CONTRIBUTOR.value: team_users.add(user.email) - # collecting pending team users which is not admin - for user in self._team.pending_invitations: - if user["user_role"] == constants.UserRole.CONTRIBUTOR.value: - team_users.add(user["email"]) + # collecting pending project users which is not admin for user in self._project.unverified_users: project_users.add(user["email"]) @@ -917,12 +924,16 @@ def __init__( def execute(self): if self.is_valid(): - team_users = {user.email for user in self._team.users} + all_users = self._service_provider.work_management.list_users( + EmptyQuery(), parent_entity=CustomFieldEntityEnum.TEAM + ).data # collecting pending team users - team_users.update( - {user["email"] for user in self._team.pending_invitations} - ) - + team_user_emails = [] + team_users, pending_invitations = [], [] + for user in all_users: + team_user_emails.append(user.email) + if user.state == WMUserStateEnum.Pending.value: + pending_invitations.append(user.email) emails = set(self._emails) to_skip = list(emails.intersection(team_users)) @@ -933,12 +944,15 @@ def execute(self): f"Found {len(to_skip)}/{len(self._emails)} existing members of the team." ) if to_add: + # REMINDER UserRole.VIEWER is the contributor for the teams + team_role = ( + constants.UserRole.ADMIN.value + if self._set_admin + else constants.UserRole.CONTRIBUTOR.value + ) response = self._service_provider.invite_contributors( team_id=self._team.id, - # REMINDER UserRole.VIEWER is the contributor for the teams - team_role=constants.UserRole.ADMIN.value - if self._set_admin - else constants.UserRole.CONTRIBUTOR.value, + team_role=team_role, # noqa emails=to_add, ) invited, failed = ( diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 74aca75da..c7cfb3c3b 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -1660,8 +1660,10 @@ def get_project_folder_by_path( project_name, folder_name = extract_project_folder(path) return self.get_project_folder((project_name, folder_name)) - def get_project(self, name: str) -> ProjectEntity: - project = self.projects.get_by_name(name).data + def get_project(self, name_or_id: Union[int, str]) -> ProjectEntity: + if isinstance(name_or_id, int): + return self.get_project_by_id(name_or_id).data + project = self.projects.get_by_name(name_or_id).data if not project: raise AppException("Project not found.") return project @@ -1851,17 +1853,15 @@ def get_exports(self, project_name: str, return_metadata: bool): def download_image( self, - project_name: str, + project: ProjectEntity, image_name: str, download_path: str, - folder_name: str = None, + folder: FolderEntity = None, image_variant: str = None, include_annotations: bool = None, include_fuse: bool = None, include_overlay: bool = None, ): - project = self.get_project(project_name) - folder = self.get_folder(project, folder_name) image = self._get_image(project, image_name, folder) use_case = usecases.DownloadImageUseCase( @@ -1977,11 +1977,8 @@ def upload_videos( return use_case.execute() def get_annotations_per_frame( - self, project_name: str, folder_name: str, video_name: str, fps: int + self, project: ProjectEntity, folder: FolderEntity, video_name: str, fps: int ): - project = self.get_project(project_name) - folder = self.get_folder(project, folder_name) - use_case = usecases.GetVideoAnnotationsPerFrame( config=self._config, reporter=self.get_default_reporter(), @@ -1994,10 +1991,12 @@ def get_annotations_per_frame( return use_case.execute() def query_entities( - self, project_name: str, folder_name: str, query: str = None, subset: str = None + self, + project: ProjectEntity, + folder: FolderEntity, + query: str = None, + subset: str = None, ) -> List[BaseItemEntity]: - project = self.get_project(project_name) - folder = self.get_folder(project, folder_name) use_case = usecases.QueryEntitiesUseCase( reporter=self.get_default_reporter(), @@ -2030,8 +2029,11 @@ def query_items_count(self, project_name: str, query: str = None) -> int: return response.data["count"] def get_project_folder( - self, path: Union[str, Tuple[int, int], Tuple[str, str]] + self, path: Union[str, int, Tuple[int, int], Tuple[str, str]] ) -> Tuple[ProjectEntity, Optional[FolderEntity]]: + if isinstance(path, int): + project = self.get_project_by_id(path).data + return project, self.get_folder(project, None) if isinstance(path, str): project_name, folder_name = extract_project_folder(path) project = self.get_project(project_name) @@ -2047,7 +2049,6 @@ def get_project_folder( 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( diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index c50b0d3ae..e8c255333 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -168,7 +168,10 @@ def _get_item_service_url(client: HttpClient): def get_team(self, team_id: int) -> TeamResponse: return self.client.request( - f"{self.URL_TEAM}/{team_id}", "get", content_type=TeamResponse + f"{self.URL_TEAM}/{team_id}", + "get", + content_type=TeamResponse, + params={"include_users": False}, ) def get_user(self, team_id: int) -> UserResponse: diff --git a/src/superannotate/lib/infrastructure/services/project.py b/src/superannotate/lib/infrastructure/services/project.py index 112d7ebde..c6900f5e4 100644 --- a/src/superannotate/lib/infrastructure/services/project.py +++ b/src/superannotate/lib/infrastructure/services/project.py @@ -25,7 +25,7 @@ class ProjectService(BaseProjectService): URL_EDITOR_TEMPLATE = "/project/{project_id}/custom-editor-template" def get_by_id(self, project_id: int): - params = {} + params = {"include_users": False} result = self.client.request( self.URL_GET_BY_ID.format(project_id=project_id), "get", diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index c3934d833..8e5043c3b 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -58,6 +58,42 @@ def extract_project_folder(user_input: Union[str, dict]) -> Tuple[str, Optional[ raise PathError("Invalid project path") +def extract_project_folder_inputs(user_input: Union[str, dict, tuple, int]) -> dict: + if isinstance(user_input, int): + return {"project_name": user_input, "project_value_type": "id"} + if isinstance(user_input, tuple): + if isinstance(user_input[0], int): + return { + "project_name": user_input[0], + "folder_name": user_input[1], + "project_value_type": "id tuple", + } + else: + return { + "project_name": user_input[0], + "folder_name": user_input[1], + "project_value_type": "name tuple", + } + if isinstance(user_input, str): + project_name, folder_name = split_project_path(user_input) + return { + "project_name": project_name, + "folder_name": folder_name, + "project_value_type": "name", + } + if isinstance(user_input, dict): + project_path = user_input.get("name") + if not project_path: + raise PathError("Invalid project path") + project_name, folder_name = split_project_path(project_path) + return { + "project_name": project_name, + "folder_name": folder_name, + "project_value_type": "dict", + } + raise PathError("Invalid project path") + + def async_retry_on_generator( exceptions: Tuple[Type[Exception]], retries: int = 3, diff --git a/tests/integration/test_team_metadata.py b/tests/integration/test_team_metadata.py index 95711f31b..63237541b 100644 --- a/tests/integration/test_team_metadata.py +++ b/tests/integration/test_team_metadata.py @@ -14,15 +14,5 @@ class TestTeam(BaseTestCase): def test_team_metadata(self): metadata = sa.get_team_metadata() self.assertTrue( - all([x in metadata for x in ["id", "users", "name", "description", "type"]]) + all([x in metadata for x in ["id", "name", "description", "type"]]) ) - - for user in metadata["users"]: - self.assertTrue( - all( - [ - x in user - for x in ["id", "email", "first_name", "last_name", "user_role"] - ] - ) - ) 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 6cd3e3f7f..4ea9d5132 100644 --- a/tests/integration/work_management/test_pause_resume_user_activity.py +++ b/tests/integration/work_management/test_pause_resume_user_activity.py @@ -53,7 +53,9 @@ def test_pause_and_resume_user_activity(self): [i["name"] for i in self.ATTACHMENT_LIST], scapegoat["email"], ) + import time + time.sleep(6) with self.assertLogs("sa", level="INFO") as cm: sa.resume_user_activity(pk=scapegoat["email"], projects=[self.PROJECT_NAME]) assert ( @@ -61,7 +63,9 @@ def test_pause_and_resume_user_activity(self): == f"INFO:sa:User with email {scapegoat['email']} has been successfully unblocked" f" from the specified projects: {[self.PROJECT_NAME]}." ) + import time + time.sleep(4) sa.assign_items( self.PROJECT_NAME, [i["name"] for i in self.ATTACHMENT_LIST], From f4efbcf673f158cea67dc02e8649029397b17481 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 19 Nov 2025 16:15:51 +0400 Subject: [PATCH 02/13] Remove contributor entity --- .../lib/app/interface/sdk_interface.py | 6 ++-- .../lib/core/entities/__init__.py | 4 +-- .../lib/core/entities/project.py | 15 ++-------- .../lib/core/usecases/projects.py | 28 +++++++++++-------- .../lib/infrastructure/controller.py | 8 ++---- 5 files changed, 27 insertions(+), 34 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 03cbae36e..94d216528 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3525,9 +3525,9 @@ def add_contributors_to_project( """ project = self.controller.projects.get_by_name(project).data contributors = [ - entities.ContributorEntity( - user_id=email, - user_role=self.controller.service_provider.get_role_id(project, role), + entities.WMProjectUserEntity( + email=email, + role=self.controller.service_provider.get_role_id(project, role), ) for email in emails ] diff --git a/src/superannotate/lib/core/entities/__init__.py b/src/superannotate/lib/core/entities/__init__.py index 12adc442f..9a31c1346 100644 --- a/src/superannotate/lib/core/entities/__init__.py +++ b/src/superannotate/lib/core/entities/__init__.py @@ -15,7 +15,6 @@ from lib.core.entities.multimodal_form import FormModel from lib.core.entities.multimodal_form import generate_classes_from_form from lib.core.entities.project import AttachmentEntity -from lib.core.entities.project import ContributorEntity from lib.core.entities.project import CustomFieldEntity from lib.core.entities.project import ProjectEntity from lib.core.entities.project import SettingEntity @@ -25,6 +24,7 @@ from lib.core.entities.project import WorkflowEntity from lib.core.entities.project_entities import BaseEntity from lib.core.entities.project_entities import S3FileEntity +from lib.core.entities.work_managament import WMProjectUserEntity __all__ = [ # base @@ -47,7 +47,7 @@ "ProjectEntity", "WorkflowEntity", "CategoryEntity", - "ContributorEntity", + "WMProjectUserEntity", "ConfigEntity", "StepEntity", "FolderEntity", diff --git a/src/superannotate/lib/core/entities/project.py b/src/superannotate/lib/core/entities/project.py index 7c367bdd6..da1ed9bb8 100644 --- a/src/superannotate/lib/core/entities/project.py +++ b/src/superannotate/lib/core/entities/project.py @@ -7,6 +7,7 @@ from lib.core.entities.base import BaseModel from lib.core.entities.classes import AnnotationClassEntity +from lib.core.entities.work_managament import WMProjectUserEntity from lib.core.enums import BaseTitledEnum from lib.core.enums import ProjectStatus from lib.core.enums import ProjectType @@ -78,16 +79,6 @@ def __copy__(self): return SettingEntity(attribute=self.attribute, value=self.value) -class ContributorEntity(BaseModel): - first_name: Optional[str] - last_name: Optional[str] - user_id: str - user_role: Union[int, str] - - class Config: - extra = Extra.ignore - - class WorkflowEntity(TimedBaseModel): id: Optional[int] name: Optional[str] @@ -118,9 +109,9 @@ class ProjectEntity(TimedBaseModel): workflow: Optional[WorkflowEntity] sync_status: Optional[int] upload_state: Optional[int] - users: Optional[List[ContributorEntity]] = [] + users: Optional[List[WMProjectUserEntity]] = [] unverified_users: Optional[List[Any]] = [] - contributors: List[ContributorEntity] = [] + contributors: List[WMProjectUserEntity] = [] settings: List[SettingEntity] = [] classes: List[AnnotationClassEntity] = [] item_count: Optional[int] = Field(None, alias="imageCount") diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 044d8367e..005e4c081 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -8,12 +8,12 @@ from lib.core.conditions import Condition from lib.core.conditions import CONDITION_EQ as EQ from lib.core.entities import AnnotationClassEntity -from lib.core.entities import ContributorEntity from lib.core.entities import FormModel from lib.core.entities import generate_classes_from_form from lib.core.entities import ProjectEntity from lib.core.entities import SettingEntity from lib.core.entities import TeamEntity +from lib.core.entities import WMProjectUserEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.enums import CustomFieldType from lib.core.enums import WMUserStateEnum @@ -830,7 +830,7 @@ def __init__( self, team: TeamEntity, project: ProjectEntity, - contributors: List[ContributorEntity], + contributors: List[WMProjectUserEntity], service_provider: BaseServiceProvider, ): super().__init__() @@ -842,7 +842,7 @@ def __init__( def validate_emails(self): email_entity_map = {} for c in self._contributors: - email_entity_map[c.user_id] = c + email_entity_map[c.email] = c len_unique, len_provided = len(email_entity_map), len(self._contributors) if len_unique < len_provided: logger.info( @@ -853,7 +853,13 @@ def validate_emails(self): def execute(self): if self.is_valid(): team_users = set() - project_users = {user.user_id for user in self._project.users} + project_users = self._service_provider.work_management.list_users( + EmptyQuery(), + include_custom_fields=True, + parent_entity=CustomFieldEntityEnum.PROJECT, + project_id=self._project.id, + ).data + project_emails = {user.email for user in project_users} users = self._service_provider.work_management.list_users(EmptyQuery()).data pending_invitations = [] for user in users: @@ -864,16 +870,16 @@ def execute(self): # collecting pending project users which is not admin for user in self._project.unverified_users: - project_users.add(user["email"]) + project_emails.add(user["email"]) role_email_map = defaultdict(list) to_skip = [] to_add = [] for contributor in self._contributors: - role_email_map[contributor.user_role].append(contributor.user_id) - for role, emails in role_email_map.items(): - role_id = self._service_provider.get_role_id(self._project, role) - _to_add = list(team_users.intersection(emails) - project_users) + role_email_map[contributor.role].append(contributor.email) + for role_id, emails in role_email_map.items(): + role_name = self._service_provider.get_role_name(self._project, role_id) + _to_add = list(team_users.intersection(emails) - project_emails) to_add.extend(_to_add) to_skip.extend(list(set(emails).difference(_to_add))) if _to_add: @@ -893,7 +899,7 @@ def execute(self): if response and not response.data.get("invalidUsers"): logger.info( f"Added {len(_to_add)}/{len(emails)} " - f"contributors to the project {self._project.name} with the {role} role." + f"contributors to the project {self._project.name} with the {role_name} role." ) if to_skip: @@ -902,7 +908,7 @@ def execute(self): "contributors that are out of the team scope or already have access to the project." ) self._response.data = to_add, to_skip - return self._response + return self._response class InviteContributorsToTeam(BaseUserBasedUseCase): diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index c7cfb3c3b..18ca1c68d 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -21,7 +21,6 @@ from lib.core.entities import AttachmentEntity from lib.core.entities import BaseItemEntity from lib.core.entities import ConfigEntity -from lib.core.entities import ContributorEntity from lib.core.entities import CustomFieldEntity from lib.core.entities import FolderEntity from lib.core.entities import ImageEntity @@ -30,6 +29,7 @@ from lib.core.entities import SettingEntity from lib.core.entities import TeamEntity from lib.core.entities import UserEntity +from lib.core.entities import WMProjectUserEntity from lib.core.entities.classes import AnnotationClassEntity from lib.core.entities.filters import ItemFilters from lib.core.entities.filters import ProjectFilters @@ -592,13 +592,9 @@ def add_contributors( self, team: TeamEntity, project: ProjectEntity, - contributors: List[ContributorEntity], + contributors: List[WMProjectUserEntity], ): project = self.get_metadata(project, include_contributors=True).data - for contributor in contributors: - contributor.user_role = self.service_provider.get_role_name( - project, contributor.user_role - ) use_case = usecases.AddContributorsToProject( team=team, project=project, From b2e6d47655ea7a0817342c87b2ee1345d7bafc66 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 19 Nov 2025 16:50:03 +0400 Subject: [PATCH 03/13] Update project fields --- src/superannotate/lib/app/interface/sdk_interface.py | 4 +++- src/superannotate/lib/app/serializers.py | 3 +-- src/superannotate/lib/core/entities/project.py | 1 - src/superannotate/lib/core/usecases/projects.py | 4 ---- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 94d216528..236aa0797 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -4385,6 +4385,7 @@ def attach_items( unique_attachments = parse_obj_as(List[AttachmentEntity], unique_attachments) uploaded, fails, duplicated = [], [], [] _unique_attachments = [] + project, folder = self.controller.get_project_folder(project) if any(i.integration for i in unique_attachments): integtation_item_map = { @@ -4412,8 +4413,9 @@ def attach_items( _unique_attachments = unique_attachments if _unique_attachments: + path = project.name + (f"/{folder.name}" if folder.name != "root" else "") logger.info( - f"Attaching {len(_unique_attachments)} file(s) to project {project}." + f"Attaching {len(_unique_attachments)} file(s) to project {path}." ) response = self.controller.items.attach( diff --git a/src/superannotate/lib/app/serializers.py b/src/superannotate/lib/app/serializers.py index 59ff0eb31..28a289de0 100644 --- a/src/superannotate/lib/app/serializers.py +++ b/src/superannotate/lib/app/serializers.py @@ -107,7 +107,6 @@ def serialize( to_exclude = { "sync_status": True, - "unverified_users": True, "classes": { "__all__": {"attribute_groups": {"__all__": {"is_multiselect"}}} }, @@ -143,7 +142,7 @@ def serialize( exclude_unset=False, ): - to_exclude = {"sync_status": True, "unverified_users": True, "classes": True} + to_exclude = {"sync_status": True, "classes": True} if exclude: for field in exclude: to_exclude[field] = True diff --git a/src/superannotate/lib/core/entities/project.py b/src/superannotate/lib/core/entities/project.py index da1ed9bb8..a039ee1aa 100644 --- a/src/superannotate/lib/core/entities/project.py +++ b/src/superannotate/lib/core/entities/project.py @@ -110,7 +110,6 @@ class ProjectEntity(TimedBaseModel): sync_status: Optional[int] upload_state: Optional[int] users: Optional[List[WMProjectUserEntity]] = [] - unverified_users: Optional[List[Any]] = [] contributors: List[WMProjectUserEntity] = [] settings: List[SettingEntity] = [] classes: List[AnnotationClassEntity] = [] diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 005e4c081..2b7facf72 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -868,10 +868,6 @@ def execute(self): elif user.role == constants.UserRole.CONTRIBUTOR.value: team_users.add(user.email) - # collecting pending project users which is not admin - for user in self._project.unverified_users: - project_emails.add(user["email"]) - role_email_map = defaultdict(list) to_skip = [] to_add = [] From d7e5cd5a08ffa97e079690c9cb83edfd4a5e5ea4 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 24 Nov 2025 11:50:31 +0400 Subject: [PATCH 04/13] Update clone_project --- requirements.txt | 2 +- src/superannotate/lib/app/interface/sdk_interface.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ee471b63b..d8a23ef1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pydantic>=1.10,!=2.0.* +pydantic>=1.10,<3,!=2.0.* aiohttp~=3.8 boto3~=1.26 opencv-python-headless~=4.7 diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 236aa0797..6a7c28a28 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1334,6 +1334,7 @@ def clone_project( self.controller.projects.add_contributors( self.controller.team, new_project, project.contributors ) + new_project.users = project.contributors if copy_annotation_classes: logger.info( f"Cloning annotation classes from {from_project} to {project_name}." From 767e2832666afdba939a1f7609610329bb8f7184 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 24 Nov 2025 12:00:10 +0400 Subject: [PATCH 05/13] Add test to for add_contributors_to_project (pending users) --- pytest.ini | 2 +- src/superannotate/lib/core/usecases/projects.py | 9 ++++----- tests/integration/work_management/test_list_users.py | 9 +++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pytest.ini b/pytest.ini index c0f66b58e..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 6 --dist loadscope +;addopts = -n 6 --dist loadscope diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 2b7facf72..f188c26c0 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -860,12 +860,11 @@ def execute(self): project_id=self._project.id, ).data project_emails = {user.email for user in project_users} - users = self._service_provider.work_management.list_users(EmptyQuery()).data - pending_invitations = [] + users = self._service_provider.work_management.list_users( + EmptyQuery(), parent_entity=CustomFieldEntityEnum.TEAM + ).data for user in users: - if user.state == WMUserStateEnum.Pending.value: - pending_invitations.append(user) - elif user.role == constants.UserRole.CONTRIBUTOR.value: + if user.role == constants.UserRole.CONTRIBUTOR.value: team_users.add(user.email) role_email_map = defaultdict(list) diff --git a/tests/integration/work_management/test_list_users.py b/tests/integration/work_management/test_list_users.py index 53c7d3213..0b14e95d3 100644 --- a/tests/integration/work_management/test_list_users.py +++ b/tests/integration/work_management/test_list_users.py @@ -1,3 +1,4 @@ +import pytest from superannotate import SAClient from tests.integration.base import BaseTestCase @@ -22,6 +23,14 @@ def setUp(self): self.PROJECT_NAME, [scapegoat["email"]], "Annotator" ) + @pytest.mark.skip(reason="For not send real email") + def test_pending_users(self): + test_email = "test@superannotate.com" + sa.invite_contributors_to_team(emails=[test_email]) + sa.add_contributors_to_project(self.PROJECT_NAME, [test_email], "Annotator") + project = sa.get_project_metadata(self.PROJECT_NAME, include_contributors=True) + assert project['contributors'][1]["state"] == "Pending" + def test_list_users_by_project_name(self): project_users = sa.list_users(project=self.PROJECT_NAME) assert len(project_users) == 1 From 5ed4313342853ff99197e52c71e3ea5d61a9e8e3 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 24 Nov 2025 16:31:10 +0400 Subject: [PATCH 06/13] Drop project.users field --- .../lib/app/interface/sdk_interface.py | 33 ++++++++----------- .../lib/core/entities/project.py | 3 +- .../lib/core/usecases/projects.py | 3 +- .../projects/test_clone_project.py | 8 ----- .../work_management/test_list_users.py | 2 +- 5 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 6a7c28a28..1ad338d23 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1334,7 +1334,7 @@ def clone_project( self.controller.projects.add_contributors( self.controller.team, new_project, project.contributors ) - new_project.users = project.contributors + new_project.contributors = project.contributors if copy_annotation_classes: logger.info( f"Cloning annotation classes from {from_project} to {project_name}." @@ -1354,15 +1354,12 @@ def clone_project( if response.errors: raise AppException(response.errors) - data = ProjectSerializer(response.data).serialize() - if data.get("users"): - for contributor in data["users"]: - contributor[ - "user_role" - ] = self.controller.service_provider.get_role_name( - new_project, contributor["user_role"] - ) - return data + project = ProjectSerializer(response.data).serialize() + for contributor in project["contributors"]: + contributor["role"] = self.controller.service_provider.get_role_name( + project, contributor["role"] + ) + return project def create_categories( self, project: Union[NotEmptyStr, int], categories: List[NotEmptyStr] @@ -1753,7 +1750,6 @@ def get_project_metadata( "raw_config": {"roles": ["Annotator", "QA"], ...} }, "upload_state": "INITIAL", - "users": [], "contributors": [], "settings": [], "classes": [], @@ -1782,15 +1778,12 @@ def get_project_metadata( ) if response.errors: raise AppException(response.errors) - data = ProjectSerializer(response.data).serialize() - if data.get("users"): - for contributor in data["users"]: - contributor[ - "user_role" - ] = self.controller.service_provider.get_role_name( - response.data, contributor["user_role"] - ) - return data + project = ProjectSerializer(response.data).serialize() + for contributor in project["contributors"]: + contributor["role"] = self.controller.service_provider.get_role_name( + project, contributor["role"] + ) + return project def get_project_settings(self, project: Union[NotEmptyStr, dict]): """Gets project's settings. diff --git a/src/superannotate/lib/core/entities/project.py b/src/superannotate/lib/core/entities/project.py index a039ee1aa..2e3eb7a42 100644 --- a/src/superannotate/lib/core/entities/project.py +++ b/src/superannotate/lib/core/entities/project.py @@ -109,7 +109,6 @@ class ProjectEntity(TimedBaseModel): workflow: Optional[WorkflowEntity] sync_status: Optional[int] upload_state: Optional[int] - users: Optional[List[WMProjectUserEntity]] = [] contributors: List[WMProjectUserEntity] = [] settings: List[SettingEntity] = [] classes: List[AnnotationClassEntity] = [] @@ -138,7 +137,7 @@ def __copy__(self): instructions_link=self.instructions_link, status=self.status, folder_id=self.folder_id, - users=self.users, + contributors=self.contributors, settings=[s.__copy__() for s in self.settings], upload_state=self.upload_state, workflow_id=self.workflow_id, diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index f188c26c0..99a993688 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -175,8 +175,7 @@ def execute(self): project_id=project.id, parent_entity=CustomFieldEntityEnum.PROJECT, ).data - 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( diff --git a/tests/integration/projects/test_clone_project.py b/tests/integration/projects/test_clone_project.py index 7358429a5..de73cbabc 100644 --- a/tests/integration/projects/test_clone_project.py +++ b/tests/integration/projects/test_clone_project.py @@ -234,11 +234,3 @@ def test_clone_video_project_frame_mode_off(self): for s in new_settings: if s["attribute"] == "FrameMode": assert not s["value"] - - -# -# -# def test_(): -# sa.delete_project("tod") -# sa.get_project_metadata('tttp', include_contributors=True) -# sa.clone_project('tod', 'tttp', copy_contributors=True) diff --git a/tests/integration/work_management/test_list_users.py b/tests/integration/work_management/test_list_users.py index 0b14e95d3..baebc52ce 100644 --- a/tests/integration/work_management/test_list_users.py +++ b/tests/integration/work_management/test_list_users.py @@ -29,7 +29,7 @@ def test_pending_users(self): sa.invite_contributors_to_team(emails=[test_email]) sa.add_contributors_to_project(self.PROJECT_NAME, [test_email], "Annotator") project = sa.get_project_metadata(self.PROJECT_NAME, include_contributors=True) - assert project['contributors'][1]["state"] == "Pending" + assert project["contributors"][1]["state"] == "Pending" def test_list_users_by_project_name(self): project_users = sa.list_users(project=self.PROJECT_NAME) From 99fdf2a34b33f3deb2e429ab79748977ce00f747 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 26 Nov 2025 11:09:14 +0400 Subject: [PATCH 07/13] Add remove_users/remove_users_from_project methods --- docs/source/api_reference/api_project.rst | 1 + docs/source/api_reference/api_team.rst | 1 + .../lib/app/interface/sdk_interface.py | 67 ++++++++++++++++++- .../lib/core/serviceproviders.py | 8 +++ .../lib/infrastructure/controller.py | 8 +++ .../lib/infrastructure/serviceprovider.py | 20 ++++++ 6 files changed, 104 insertions(+), 1 deletion(-) diff --git a/docs/source/api_reference/api_project.rst b/docs/source/api_reference/api_project.rst index 23f0cdac5..e9384102e 100644 --- a/docs/source/api_reference/api_project.rst +++ b/docs/source/api_reference/api_project.rst @@ -30,3 +30,4 @@ Projects .. automethod:: superannotate.SAClient.create_categories .. automethod:: superannotate.SAClient.list_categories .. automethod:: superannotate.SAClient.remove_categories +.. automethod:: superannotate.SAClient.remove_users_from_project diff --git a/docs/source/api_reference/api_team.rst b/docs/source/api_reference/api_team.rst index 2217365e1..6178dcf75 100644 --- a/docs/source/api_reference/api_team.rst +++ b/docs/source/api_reference/api_team.rst @@ -11,6 +11,7 @@ Team .. automethod:: superannotate.SAClient.get_user_metadata .. automethod:: superannotate.SAClient.set_user_custom_field .. automethod:: superannotate.SAClient.list_users +.. automethod:: superannotate.SAClient.remove_users .. automethod:: superannotate.SAClient.pause_user_activity .. automethod:: superannotate.SAClient.resume_user_activity .. automethod:: superannotate.SAClient.get_user_scores diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 1ad338d23..0859f125c 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -75,7 +75,6 @@ from lib.core.jsx_conditions import EmptyQuery from lib.core.entities.items import ProjectCategoryEntity - logger = logging.getLogger("sa") NotEmptyStr = constr(strict=True, min_length=1) @@ -5337,3 +5336,69 @@ def list_workflows(self): EmptyQuery() ) return BaseSerializer.serialize_iterable(workflows.data) + + def remove_users(self, users: Union[List[int], List[str]]): + """ + Allows removing users from the team. + :param users: A list of emails or IDs of the users. + :type users: Union[List[int], List[str]] + + :rtype: None: + + Request Example: + :: + + SAClient.remove_users(member=["example@gmail.com","example1@gmail.com"]) + + """ + success = 0 + if users: + if isinstance(users[0], int): + users = self.controller.work_management.list_users(id__in=users) + user_emails = [user.email for user in users] + else: + user_emails = users + if user_emails: + success, _ = self.controller.work_management.remove_users(user_emails) + logger.info( + f"Successfully removed {success} user(s) out of the {len(users)} provided." + ) + + def remove_users_from_project( + self, project: Union[NotEmptyStr, int], users: Union[List[int], List[str]] + ): + """ + Allows removing users from the team. + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :param users: A list of emails or IDs of the users. + :type users: Union[List[int], List[str]] + + :rtype: None: + + Request Example: + :: + + SAClient.remove_users_from_project(project="Test Project", users=["example@gmail.com","example1@gmail.com"]) + + """ + project = self.controller.get_project(project) + + success = 0 + if users: + if isinstance(users[0], int): + users = self.controller.work_management.list_users( + project=project, id__in=users + ) + user_emails = [user.email for user in users] + else: + user_emails = users + if user_emails: + success, _ = self.controller.work_management.remove_users_from_project( + project, user_emails + ) + logger.info( + f"Successfully removed {success} users(s) out of the {len(users)} provided from the project {project.name}." + ) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index a2fcdd175..0c87ff8c5 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -931,3 +931,11 @@ def get_custom_fields_templates( parent: CustomFieldEntityEnum, ): raise NotImplementedError + + @abstractmethod + def remove_users(self, emails: List[str]): + raise NotImplementedError + + @abstractmethod + def remove_users_from_project(self, project_id: int, emails: List[str]): + raise NotImplementedError diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 18ca1c68d..bd2c3e1a0 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -177,6 +177,14 @@ def set_custom_field_value( context=_context, ) + def remove_users(self, user_emails: List[str]): + data = self.service_provider.remove_users(user_emails) + return data.get("success", 0), data.get("error", 0) + + def remove_users_from_project(self, project: ProjectEntity, user_emails: List[str]): + data = self.service_provider.remove_users_from_project(project.id, user_emails) + return len(data.get("succeeded", [])), len(data.get("failed", [])) + def list_users( self, include: List[Literal["custom_fields", "categories"]] = None, diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index e8c255333..20bb51908 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -41,6 +41,8 @@ class ServiceProvider(BaseServiceProvider): URL_INVITE_CONTRIBUTORS = "api/v1/team/{}/inviteUsers" URL_ANNOTATION_UPLOAD_PATH_TOKEN = "images/getAnnotationsPathsAndTokens" URL_CREATE_WORKFLOW = "api/v1/workflows/submit" + URL_REMOVE_USERS_FROM_TEAM = "team/{team_id}/members/bulk" + URL_REMOVE_USERS_FROM_PROJECT = "project/{project_id}/share/bulk" def __init__(self, client: HttpClient): self.enum_mapping = {"approval_status": ApprovalStatus.get_mapping()} @@ -339,3 +341,21 @@ def create_custom_workflow(self, org_id: str, data: dict): }, data=data, ) + + def remove_users(self, emails: List[str]): + response = self.client.request( + url=self.URL_REMOVE_USERS_FROM_TEAM.format(team_id=self.client.team_id), + method="delete", + data={"data": [{"email": email} for email in emails]}, + ) + response.raise_for_status() + return response.data + + def remove_users_from_project(self, project_id: int, emails: List[str]): + response = self.client.request( + url=self.URL_REMOVE_USERS_FROM_PROJECT.format(project_id=project_id), + method="delete", + data={"user_id": emails}, + ) + response.raise_for_status() + return response.data From 406ce7b88bca4466e98df2aeee40b7bd0a68946f Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 26 Nov 2025 11:11:18 +0400 Subject: [PATCH 08/13] Update docs --- .../lib/app/interface/sdk_interface.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 0859f125c..d16111cdc 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -2893,7 +2893,7 @@ def set_project_steps( Request Example for General Annotation Project: :: - sa.set_project_steps( + sa_client.set_project_steps( project="Medical Annotations", steps=[ { @@ -2915,7 +2915,7 @@ def set_project_steps( Request Example for Keypoint Annotation Project: :: - sa.set_project_steps( + sa_client.set_project_steps( project="Pose Estimation Project", steps=[ { @@ -5340,6 +5340,7 @@ def list_workflows(self): def remove_users(self, users: Union[List[int], List[str]]): """ Allows removing users from the team. + :param users: A list of emails or IDs of the users. :type users: Union[List[int], List[str]] @@ -5354,8 +5355,8 @@ def remove_users(self, users: Union[List[int], List[str]]): success = 0 if users: if isinstance(users[0], int): - users = self.controller.work_management.list_users(id__in=users) - user_emails = [user.email for user in users] + users_data = self.controller.work_management.list_users(id__in=users) + user_emails = [user.email for user in users_data] else: user_emails = users if user_emails: @@ -5389,10 +5390,10 @@ def remove_users_from_project( success = 0 if users: if isinstance(users[0], int): - users = self.controller.work_management.list_users( + users_data = self.controller.work_management.list_users( project=project, id__in=users ) - user_emails = [user.email for user in users] + user_emails = [user.email for user in users_data] else: user_emails = users if user_emails: From 24e6b96fd9454500362542503d9999e1607ac411 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 1 Dec 2025 12:05:46 +0400 Subject: [PATCH 09/13] Fix clone project --- 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 d16111cdc..1cfa559c5 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1353,12 +1353,12 @@ def clone_project( if response.errors: raise AppException(response.errors) - project = ProjectSerializer(response.data).serialize() - for contributor in project["contributors"]: + project_data = ProjectSerializer(response.data).serialize() + for contributor in project_data["contributors"]: contributor["role"] = self.controller.service_provider.get_role_name( project, contributor["role"] ) - return project + return project_data def create_categories( self, project: Union[NotEmptyStr, int], categories: List[NotEmptyStr] From 42ddc32f395c6169e64291c2d889bd17849723f8 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 1 Dec 2025 14:31:43 +0400 Subject: [PATCH 10/13] Fix clone project --- src/superannotate/lib/app/interface/sdk_interface.py | 6 +++--- tests/integration/work_management/test_list_users.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 1cfa559c5..7c796e2e6 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1765,9 +1765,9 @@ def get_project_metadata( } """ project_name, _ = extract_project_folder(project) - project = self.controller.get_project(project_name) + project_entity = self.controller.get_project(project_name) response = self.controller.projects.get_metadata( - project, + project_entity, include_annotation_classes, include_settings, include_workflow, @@ -1780,7 +1780,7 @@ def get_project_metadata( project = ProjectSerializer(response.data).serialize() for contributor in project["contributors"]: contributor["role"] = self.controller.service_provider.get_role_name( - project, contributor["role"] + project_entity, contributor["role"] ) return project diff --git a/tests/integration/work_management/test_list_users.py b/tests/integration/work_management/test_list_users.py index baebc52ce..d8d928390 100644 --- a/tests/integration/work_management/test_list_users.py +++ b/tests/integration/work_management/test_list_users.py @@ -1,4 +1,6 @@ import pytest +from pygments.lexer import include + from superannotate import SAClient from tests.integration.base import BaseTestCase @@ -25,10 +27,12 @@ def setUp(self): @pytest.mark.skip(reason="For not send real email") def test_pending_users(self): - test_email = "test@superannotate.com" + test_email = "test1@superannotate.com" sa.invite_contributors_to_team(emails=[test_email]) sa.add_contributors_to_project(self.PROJECT_NAME, [test_email], "Annotator") - project = sa.get_project_metadata(self.PROJECT_NAME, include_contributors=True) + sa.clone_project("narek test4", self.PROJECT_NAME, copy_contributors=True) + project = sa.get_project_metadata("narek test4", include_contributors=True) + assert project["contributors"][1]["state"] == "Pending" def test_list_users_by_project_name(self): From 886e7065dce29fedb83ff48aee731b21638de842 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 1 Dec 2025 16:14:08 +0400 Subject: [PATCH 11/13] Update project create --- src/superannotate/lib/app/interface/sdk_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 7c796e2e6..a6dacb51e 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1247,7 +1247,7 @@ def create_project( project_response = self.controller.projects.create(project_entity) project_response.raise_for_status() project = project_response.data - if form: + if form and ProjectType(project_type) == ProjectType.MULTIMODAL: form_response = self.controller.projects.attach_form(project, form) try: form_response.raise_for_status() From 04a0837d90211c5275e2d3ba1cc14936a2dee70e Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 2 Dec 2025 15:03:38 +0400 Subject: [PATCH 12/13] Update changelog --- CHANGELOG.rst | 15 +++++++++++++++ src/superannotate/__init__.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f19be4b24..66908805e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,21 @@ History All release highlights of this project will be documented in this file. +4.5.0 - December 4, 2025 +________________________ + +**Added** + + - ``SAClient.remove_users`` Removes users from the team by their email or ID. + - ``SAClient.remove_users_from_project`` Removes users from a specific project by name or ID. + +**Updated** + + - ``SAClient.get_team_metadata`` Removed users and pending_users keys from the response. + - ``SAClient.clone_project`` Removed users key from the response. + - ``SAClient.get_project_metadata`` Removed users key from the response. + + 4.4.39 - November 13, 2025 __________________________ diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index a549ca579..3ca2c8d87 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.40dev1" +__version__ = "4.5.0dev1" os.environ.update({"sa_version": __version__}) From a5d9fc7c712d5e387b77256fa794b0c3b2fbac85 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 2 Dec 2025 15:08:10 +0400 Subject: [PATCH 13/13] Update docs --- docs/source/userguide/quickstart.rst | 12 +++++------ docs/source/userguide/setup_project.rst | 28 ++++++++++++------------- docs/source/userguide/utilities.rst | 14 ++++++------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index 144557f56..0827ddda5 100644 --- a/docs/source/userguide/quickstart.rst +++ b/docs/source/userguide/quickstart.rst @@ -101,7 +101,7 @@ To create a new "Vector" project with name "Example Project 1" and description project = "Example Project 1" - sa.create_project(project, "test", "Vector") + sa_client.create_project(project, "test", "Vector") ---------- @@ -115,7 +115,7 @@ To upload all images with extensions "jpg" or "png" from the .. code-block:: python - sa.upload_images_from_folder_to_project(project, "") + sa_client.upload_images_from_folder_to_project(project, "") See the full argument options for :py:func:`upload_images_from_folder_to_project` :ref:`here `. @@ -144,19 +144,19 @@ To download the image one can use: image = "example_image1.jpg" - sa.download_image(project, image, "") + sa_client.download_image(project, image, "") To download image annotations: .. code-block:: python - sa.download_image_annotations(project, image, "") + sa_client.download_image_annotations(project, image, "") Upload back to the platform with: .. code-block:: python - sa.upload_image_annotations(project, image, "") + sa_client.upload_image_annotations(project, image, "") --------- @@ -168,4 +168,4 @@ A team contributor can be invited to the team with: .. code-block:: python - sa.invite_contributors_to_team(emails=["admin@superannotate.com"], admin=False) + sa_client.invite_contributors_to_team(emails=["admin@superannotate.com"], admin=False) diff --git a/docs/source/userguide/setup_project.rst b/docs/source/userguide/setup_project.rst index 6c2884262..e8af0e3e6 100644 --- a/docs/source/userguide/setup_project.rst +++ b/docs/source/userguide/setup_project.rst @@ -64,7 +64,7 @@ Form Editor. "environments": [] } - response = sa.create_project( + response = sa_client.create_project( project_name="My Multimodal Project", project_description="Example multimodal project created via SDK", project_type="Multimodal", @@ -76,7 +76,7 @@ After creating the project, you can create folders and generate items: .. code-block:: python # Create a new folder in the project - sa.create_folder( + sa_client.create_folder( project="My Multimodal Project", folder_name="First Folder" ) @@ -84,7 +84,7 @@ After creating the project, you can create folders and generate items: # Generate multiple items in the specific project and folder # If there are no items in the folder, it will generate a blank item # otherwise, it will generate items based on the Custom Form - sa.generate_items( + sa_client.generate_items( project="My Multimodal Project/First Folder", count=10, name="My Item" @@ -98,7 +98,7 @@ To upload annotations to these items: # list of annotation dicts ] - sa.upload_annotations( + sa_client.upload_annotations( project="My Multimodal Project/First Folder", annotations=annotations, keep_status=True, @@ -115,7 +115,7 @@ To create a new "Vector" project with name "Example Project 1" and description project = "Example Project 1" - sa.create_project(project, "test", "Vector") + sa_client.create_project(project, "test", "Vector") Uploading images to project @@ -127,7 +127,7 @@ To upload all images with extensions "jpg" or "png" from the .. code-block:: python - sa.upload_images_from_folder_to_project(project, "") + sa_client.upload_images_from_folder_to_project(project, "") See the full argument options for :py:func:`upload_images_from_folder_to_project` :ref:`here `. @@ -150,7 +150,7 @@ To create a new folder "folder1" in the project "Example Project 1": .. code-block:: python - sa.create_folder(project, "folder1") + sa_client.create_folder(project, "folder1") After that point almost all SDK functions that use project name as argument can point to that folder with slash after the project name, e.g., @@ -162,7 +162,7 @@ point to that folder with slash after the project name, e.g., .. code-block:: python - sa.upload_images_from_folder_to_project(project + "/folder1", "") + sa_client.upload_images_from_folder_to_project(project + "/folder1", "") Working with annotation classes =============================== @@ -171,7 +171,7 @@ An annotation class for a project can be created with SDK's: .. code-block:: python - sa.create_annotation_class(project, "Large car", color="#FFFFAA") + sa_client.create_annotation_class(project, "Large car", color="#FFFFAA") To create annotation classes in bulk with SuperAnnotate export format @@ -181,7 +181,7 @@ https://superannotate.readthedocs.io/en/stable/userguide/setup_project.html#work .. code-block:: python - sa.create_annotation_classes_from_classes_json(project, "") + sa_client.create_annotation_classes_from_classes_json(project, "") All of the annotation classes of a project are downloaded (as :file:`classes/classes.json`) with @@ -190,7 +190,7 @@ can also be downloaded separately with: .. code-block:: python - sa.download_annotation_classes_json(project, "") + sa_client.download_annotation_classes_json(project, "") The :file:`classes.json` file will be downloaded to :file:`""` folder. @@ -245,7 +245,7 @@ you are uploading to should contain annotation class with that name. .. code-block:: python - sa.upload_annotations_from_folder_to_project(project, "") + sa_client.upload_annotations_from_folder_to_project(project, "") This will try uploading to the project all the JSON files in the folder that have :file:`".json"` postfix. @@ -262,13 +262,13 @@ To export the project annotations we need to prepare the export first: .. code-block:: python - export = sa.prepare_export(project, include_fuse=True) + export = sa_client.prepare_export(project, include_fuse=True) We can download the prepared export with: .. code-block:: python - sa.download_export(project, export, "", extract_zip_contents=True) + sa_client.download_export(project, export, "", extract_zip_contents=True) :ref:`download_export ` will wait until the export is finished preparing and download it to the specified folder. diff --git a/docs/source/userguide/utilities.rst b/docs/source/userguide/utilities.rst index 74c70985d..40349cdbd 100644 --- a/docs/source/userguide/utilities.rst +++ b/docs/source/userguide/utilities.rst @@ -20,7 +20,7 @@ to convert them to other annotation formats: .. code-block:: python - sa.export_annotation("", "", "", "", + sa_client.export_annotation("", "", "", "", "", "") .. note:: @@ -197,10 +197,10 @@ Example Python Script: csv_to_jsonl("annotations.csv", "annotations.jsonl") # Upload to SuperAnnotate - sa = SAClient() + sa_client = SAClient() annotations = [json.loads(line) for line in Path("annotations.jsonl").open("r", encoding="utf-8")] - response = sa.upload_annotations( + response = sa_client.upload_annotations( project="project1/folder1", annotations=annotations, keep_status=True, @@ -214,7 +214,7 @@ Fetching Annotations and Converting to JSONL/CSV Steps to Retrieve and Convert Annotations: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -1. Fetch **annotations from SuperAnnotate** using `sa.get_annotations()`. +1. Fetch **annotations from SuperAnnotate** using `sa_client.get_annotations()`. 2. Convert the **annotation list into JSONL format**. 3. Convert the **JSONL data into CSV** for external use. @@ -230,8 +230,8 @@ Python Script to Convert Annotations to JSONL: jsonl_file.write('\n') # Fetch annotations from SuperAnnotate - sa = SAClient() - annotations = sa.get_annotations("project", data_spec="multimodal") + sa_client = SAClient() + annotations = sa_client.get_annotations("project", data_spec="multimodal") # Convert to JSONL convert_annotations_to_jsonl(annotations, "fetched_annotations.jsonl") @@ -284,7 +284,7 @@ SuperAnnotate format annotations: .. code-block:: python - df = sa.aggregate_annotations_as_df("") + df = sa_client.aggregate_annotations_as_df("") The created DataFrame will have columns specified at :ref:`aggregate_annotations_as_df `.