From ad8ffd376c1eaba78216b60e042ebbdb2aa0217e Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 19 Nov 2025 11:37:02 +0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 = []