diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 91b0d576..66908805 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,9 +6,24 @@ History All release highlights of this project will be documented in this file. -4.4.39 - November 13, 2025 +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 +__________________________ + **Updated** - ``SAClient.get_item_by_id`` now supports an optional include parameter to fetch additional fields like custom_metadata and categories. diff --git a/docs/source/api_reference/api_metadata.rst b/docs/source/api_reference/api_metadata.rst index f3670d31..f6c2110b 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/api_reference/api_project.rst b/docs/source/api_reference/api_project.rst index 23f0cdac..e9384102 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 2217365e..6178dcf7 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/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index 144557f5..0827ddda 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 9315e50c..e8af0e3e 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_client.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_client.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_client.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_client.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": @@ -13,11 +115,11 @@ 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 ---------------------------- +=========================== To upload all images with extensions "jpg" or "png" from the @@ -25,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 `. @@ -42,13 +144,13 @@ See the full argument options for Creating a folder in a project -______________________________ +============================== 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., @@ -60,16 +162,16 @@ 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 -_______________________________ +=============================== 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 @@ -79,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 @@ -88,13 +190,13 @@ 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. Working with annotations -________________________ +======================== The SuperAnnotate format annotation JSONs have the general form: @@ -143,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. @@ -154,19 +256,19 @@ already be present in the project for the upload to work. Exporting projects -__________________ +================== 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 74c70985..40349cdb 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 `. diff --git a/requirements.txt b/requirements.txt index ee471b63..d8a23ef1 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/__init__.py b/src/superannotate/__init__.py index 2605a8d0..3ca2c8d8 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.39" +__version__ = "4.5.0dev1" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index eb14a733..65ba5410 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -20,7 +20,7 @@ from lib.core.pydantic_v1 import ErrorWrapper from lib.core.pydantic_v1 import ValidationError from lib.infrastructure.controller import Controller -from lib.infrastructure.utils import extract_project_folder +from lib.infrastructure.utils import extract_project_folder_inputs from lib.infrastructure.validators import wrap_error from mixpanel import Mixpanel @@ -176,9 +176,7 @@ def default_parser(function_name: str, kwargs: dict) -> 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 806d8946..a6dacb51 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) @@ -761,7 +760,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 +770,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 +835,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 +844,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] @@ -1248,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() @@ -1334,6 +1333,7 @@ def clone_project( self.controller.projects.add_contributors( self.controller.team, new_project, project.contributors ) + new_project.contributors = project.contributors if copy_annotation_classes: logger.info( f"Cloning annotation classes from {from_project} to {project_name}." @@ -1353,15 +1353,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_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_data def create_categories( self, project: Union[NotEmptyStr, int], categories: List[NotEmptyStr] @@ -1752,7 +1749,6 @@ def get_project_metadata( "raw_config": {"roles": ["Annotator", "QA"], ...} }, "upload_state": "INITIAL", - "users": [], "contributors": [], "settings": [], "classes": [], @@ -1769,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, @@ -1781,15 +1777,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_entity, contributor["role"] + ) + return project def get_project_settings(self, project: Union[NotEmptyStr, dict]): """Gets project's settings. @@ -1922,7 +1915,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 +2016,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 +2039,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 +2060,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 +2080,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 +2089,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 +2299,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 +2319,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 +2607,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 +2615,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 +2701,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 +2714,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 @@ -2887,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=[ { @@ -2909,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=[ { @@ -2944,7 +2950,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 +2960,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 +2985,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 +3003,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 +3011,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 +3056,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 +3073,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 +3088,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 +3105,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 +3152,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 +3168,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 +3178,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 +3199,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 +3207,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 +3445,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 ) @@ -3512,9 +3518,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 ] @@ -3550,15 +3556,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 +3583,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 +3595,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 +3607,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 +3628,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 +3653,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 +3780,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 +3800,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 +3809,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 +3857,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 +3877,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 +3887,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 +3949,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 +4308,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 +4359,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 +4378,8 @@ 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 @@ -4401,12 +4406,11 @@ 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}." - ) - project, folder = self.controller.get_project_folder( - (project_name, folder_name) + f"Attaching {len(_unique_attachments)} file(s) to project {path}." ) + response = self.controller.items.attach( project=project, folder=folder, @@ -4425,7 +4429,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 +4438,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 +4576,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 +4584,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 +4614,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 +4643,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 +4660,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 +4673,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 +4682,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 +4713,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 +4952,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 +5018,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 +5027,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 +5057,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 +5149,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 +5169,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, @@ -5331,3 +5336,70 @@ 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_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: + 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_data = self.controller.work_management.list_users( + project=project, id__in=users + ) + user_emails = [user.email for user in users_data] + 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/app/serializers.py b/src/superannotate/lib/app/serializers.py index 59ff0eb3..28a289de 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/__init__.py b/src/superannotate/lib/core/entities/__init__.py index 12adc442..9a31c134 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 7c367bdd..2e3eb7a4 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,7 @@ class ProjectEntity(TimedBaseModel): workflow: Optional[WorkflowEntity] sync_status: Optional[int] upload_state: Optional[int] - users: Optional[List[ContributorEntity]] = [] - 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") @@ -148,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/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index a2fcdd17..0c87ff8c 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/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 1e3ff588..99a99368 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -8,16 +8,18 @@ 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 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,9 +170,12 @@ def execute(self): raise AppException("Workflow not fund.") project.workflow = project_workflow if self._include_contributors: - project.contributors = project.users - else: - project.users = [] + project.contributors = self._service_provider.work_management.list_users( + EmptyQuery(), + project_id=project.id, + parent_entity=CustomFieldEntityEnum.PROJECT, + ).data + if self._include_custom_fields: context = {"team_id": self._project.team_id} custom_fields_names = self._service_provider.list_custom_field_names( @@ -824,7 +829,7 @@ def __init__( self, team: TeamEntity, project: ProjectEntity, - contributors: List[ContributorEntity], + contributors: List[WMProjectUserEntity], service_provider: BaseServiceProvider, ): super().__init__() @@ -836,7 +841,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( @@ -847,26 +852,28 @@ 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} - for user in self._team.users: - if user.user_role == constants.UserRole.CONTRIBUTOR.value: + 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(), parent_entity=CustomFieldEntityEnum.TEAM + ).data + for user in users: + if 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"]) 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: @@ -886,7 +893,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: @@ -895,7 +902,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): @@ -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 74aca75d..bd2c3e1a 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 @@ -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, @@ -592,13 +600,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, @@ -1660,8 +1664,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 +1857,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 +1981,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 +1995,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 +2033,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 +2053,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 c50b0d3a..20bb5190 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()} @@ -168,7 +170,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: @@ -336,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 diff --git a/src/superannotate/lib/infrastructure/services/project.py b/src/superannotate/lib/infrastructure/services/project.py index 112d7ebd..c6900f5e 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 c3934d83..8e5043c3 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/projects/test_clone_project.py b/tests/integration/projects/test_clone_project.py index 7358429a..de73cbab 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/test_team_metadata.py b/tests/integration/test_team_metadata.py index 95711f31..63237541 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_list_users.py b/tests/integration/work_management/test_list_users.py index 53c7d321..d8d92839 100644 --- a/tests/integration/work_management/test_list_users.py +++ b/tests/integration/work_management/test_list_users.py @@ -1,3 +1,6 @@ +import pytest +from pygments.lexer import include + from superannotate import SAClient from tests.integration.base import BaseTestCase @@ -22,6 +25,16 @@ 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 = "test1@superannotate.com" + sa.invite_contributors_to_team(emails=[test_email]) + sa.add_contributors_to_project(self.PROJECT_NAME, [test_email], "Annotator") + 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): project_users = sa.list_users(project=self.PROJECT_NAME) assert len(project_users) == 1 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 6cd3e3f7..4ea9d513 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],