diff --git a/.github/workflows/check_global_api.yml b/.github/workflows/check_global_api.yml index cdbb600cf..3119ae88d 100644 --- a/.github/workflows/check_global_api.yml +++ b/.github/workflows/check_global_api.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.11' - name: Install dependencies run: | diff --git a/automated_api.py b/automated_api.py index 9565717bd..766cd4bc3 100644 --- a/automated_api.py +++ b/automated_api.py @@ -154,6 +154,7 @@ def _get_typehint(annotation, api_globals): str(annotation) .replace("NoneType", "None") ) + full_path_regex = re.compile( r"(?P(?P[a-zA-Z0-9_\.]+))" ) @@ -181,6 +182,17 @@ def _get_typehint(annotation, api_globals): name = name.split(".")[-1] typehint = typehint.replace(groups["full"], name) + if "Literal" in typehint: + for match in re.finditer( + r"(?PLiteral\[(?P[^\]]*)\])", typehint + ): + full_content = match.group("fullcontent") + content = match.group("content") + items = [f'"{i.strip()}"' for i in content.split(",")] + new_content = ", ".join(items) + new_full_content = full_content.replace(content, new_content) + typehint = typehint.replace(full_content, new_full_content) + try: # Test if typehint is valid for known '_api' content exec(f"_: {typehint} = None", api_globals) diff --git a/ayon_api/__init__.py b/ayon_api/__init__.py index c964c24ef..a125e8993 100644 --- a/ayon_api/__init__.py +++ b/ayon_api/__init__.py @@ -69,6 +69,14 @@ patch, get, delete, + get_server_config, + set_server_config, + get_server_config_overrides, + get_server_config_value, + download_server_config_file, + download_server_config_file_to_stream, + upload_server_config_file, + upload_server_config_file_from_stream, download_file_to_stream, download_file, upload_project_file, @@ -116,6 +124,14 @@ create_activity, update_activity, delete_activity, + get_raw_activity_categories, + get_activity_categories, + create_activity_reaction, + delete_activity_reaction, + suggest_entity_mention, + get_raw_entity_watchers, + get_entity_watchers, + set_entity_watchers, send_activities_batch_operations, get_bundles, create_bundle, @@ -151,6 +167,7 @@ reset_attributes_cache, set_attributes_cache_timeout, set_attribute_config, + delete_attribute_config, remove_attribute_config, get_attributes_for_type, get_attributes_fields_for_type, @@ -168,6 +185,13 @@ create_project, update_project, delete_project, + get_raw_project_folders, + get_project_folders, + create_project_folder, + update_project_folder, + set_project_folders_order, + assign_projects_to_project_folder, + delete_project_folder, get_project_root_overrides, get_project_roots_by_site, get_project_root_overrides_by_site_id, @@ -356,6 +380,14 @@ "patch", "get", "delete", + "get_server_config", + "set_server_config", + "get_server_config_overrides", + "get_server_config_value", + "download_server_config_file", + "download_server_config_file_to_stream", + "upload_server_config_file", + "upload_server_config_file_from_stream", "download_file_to_stream", "download_file", "upload_project_file", @@ -403,6 +435,14 @@ "create_activity", "update_activity", "delete_activity", + "get_raw_activity_categories", + "get_activity_categories", + "create_activity_reaction", + "delete_activity_reaction", + "suggest_entity_mention", + "get_raw_entity_watchers", + "get_entity_watchers", + "set_entity_watchers", "send_activities_batch_operations", "get_bundles", "create_bundle", @@ -438,6 +478,7 @@ "reset_attributes_cache", "set_attributes_cache_timeout", "set_attribute_config", + "delete_attribute_config", "remove_attribute_config", "get_attributes_for_type", "get_attributes_fields_for_type", @@ -455,6 +496,13 @@ "create_project", "update_project", "delete_project", + "get_raw_project_folders", + "get_project_folders", + "create_project_folder", + "update_project_folder", + "set_project_folders_order", + "assign_projects_to_project_folder", + "delete_project_folder", "get_project_root_overrides", "get_project_roots_by_site", "get_project_root_overrides_by_site_id", diff --git a/ayon_api/_api.py b/ayon_api/_api.py index 98e794203..5c843360f 100644 --- a/ayon_api/_api.py +++ b/ayon_api/_api.py @@ -41,7 +41,7 @@ ) if typing.TYPE_CHECKING: - from typing import Union + from typing import Union, Literal from .typing import ( ServerVersion, ActivityType, @@ -926,6 +926,177 @@ def delete( ) +def get_server_config(): + con = get_server_api_connection() + return con.get_server_config() + + +def set_server_config( + studio_name: str | None = None, + customization: dict[str, Any] | None = None, + authentication: dict[str, Any] | None = None, + project_options: dict[str, Any] | None = None, + changelog: dict[str, Any] | None = None, +) -> None: + con = get_server_api_connection() + return con.set_server_config( + studio_name=studio_name, + customization=customization, + authentication=authentication, + project_options=project_options, + changelog=changelog, + ) + + +def get_server_config_overrides(): + con = get_server_api_connection() + return con.get_server_config_overrides() + + +def get_server_config_value( + key: str, +): + con = get_server_api_connection() + return con.get_server_config_value( + key=key, + ) + + +def download_server_config_file( + file_type: Literal["login_background", "studio_logo"], + filepath: str, + *, + chunk_size: Optional[int] = None, + progress: Optional[TransferProgress] = None, +) -> TransferProgress: + """Download server config file. + + Validate if server has config file available first. Method crashes + if the file is not available. + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + filepath (str): Target filepath. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + """ + con = get_server_api_connection() + return con.download_server_config_file( + file_type=file_type, + filepath=filepath, + chunk_size=chunk_size, + progress=progress, + ) + + +def download_server_config_file_to_stream( + file_type: Literal["login_background", "studio_logo"], + stream: StreamType, + *, + chunk_size: Optional[int] = None, + progress: Optional[TransferProgress] = None, +) -> TransferProgress: + """Download server config file to byte stream. + + Validate if server has config file available first. Method crashes + if the file is not available. + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + stream (StreamType): Stream where downloaded content is stored. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + """ + con = get_server_api_connection() + return con.download_server_config_file_to_stream( + file_type=file_type, + stream=stream, + chunk_size=chunk_size, + progress=progress, + ) + + +def upload_server_config_file( + file_type: Literal["login_background", "studio_logo"], + filepath: str, + *, + content_type: str | None = None, + filename: str | None = None, + chunk_size: int | None = None, + progress: TransferProgress | None = None, +) -> requests.Response: + """Upload server config file from byte stream. + + TODO create filename using file_type and extension from content_type + if filename is not specified + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + filepath (str): Filepath used to store the file. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + Returns: + requests.Response: Response from upload. + + """ + con = get_server_api_connection() + return con.upload_server_config_file( + file_type=file_type, + filepath=filepath, + content_type=content_type, + filename=filename, + chunk_size=chunk_size, + progress=progress, + ) + + +def upload_server_config_file_from_stream( + file_type: Literal["login_background", "studio_logo"], + stream: StreamType, + filename: str, + *, + content_type: str | None = None, + chunk_size: int | None = None, + progress: TransferProgress | None = None, +) -> requests.Response: + """Upload server config file from byte stream. + + TODO create filename using file_type and extension from content_type + if filename is not specified + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + stream (StreamType): Stream where downloaded content is stored. + filename (str): Filename used to store the file. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + Returns: + requests.Response: Response from upload. + + """ + con = get_server_api_connection() + return con.upload_server_config_file_from_stream( + file_type=file_type, + stream=stream, + filename=filename, + content_type=content_type, + chunk_size=chunk_size, + progress=progress, + ) + + def download_file_to_stream( endpoint: str, stream: StreamType, @@ -2370,6 +2541,144 @@ def delete_activity( ) +def get_raw_activity_categories( + project_name: str, +) -> dict[str, Any]: + """Get activity categories available on server (raw response). + + Args: + project_name (str): Project name to get categories for. + + Returns: + list[str]: Available activity categories. + + """ + con = get_server_api_connection() + return con.get_raw_activity_categories( + project_name=project_name, + ) + + +def get_activity_categories( + project_name: str, +) -> list[str]: + """Get activity categories available on server. + + Args: + project_name (str): Project name to get categories for. + + Returns: + list[str]: Available activity categories. + + """ + con = get_server_api_connection() + return con.get_activity_categories( + project_name=project_name, + ) + + +def create_activity_reaction( + project_name: str, + activity_id: str, + reaction: str, +) -> None: + """React to activity. + """ + con = get_server_api_connection() + return con.create_activity_reaction( + project_name=project_name, + activity_id=activity_id, + reaction=reaction, + ) + + +def delete_activity_reaction( + project_name: str, + activity_id: str, + reaction: str, +) -> None: + con = get_server_api_connection() + return con.delete_activity_reaction( + project_name=project_name, + activity_id=activity_id, + reaction=reaction, + ) + + +def suggest_entity_mention( + project_name: str, + entity_id: str, + entity_type: Literal["folder", "task", "version"], +) -> dict[str, dict[str, Any]]: + """Suggest entities for mention in activity body. + + At this moment does not change data only returns suggestions. + + Args: + project_name (str): Project name to search in. + entity_id (str): Entity id. + entity_type (str): Entity type of the entity. + + Returns: + list[dict[str, Any]]: List of suggested entities with + their details. + + """ + con = get_server_api_connection() + return con.suggest_entity_mention( + project_name=project_name, + entity_id=entity_id, + entity_type=entity_type, + ) + + +def get_raw_entity_watchers( + project_name: str, + entity_id: str, + entity_type: str, +) -> dict[str, Any]: + """Get entity watchers (raw response). + """ + con = get_server_api_connection() + return con.get_raw_entity_watchers( + project_name=project_name, + entity_id=entity_id, + entity_type=entity_type, + ) + + +def get_entity_watchers( + project_name: str, + entity_id: str, + entity_type: str, +) -> list[str]: + """List watchers of an entity. + """ + con = get_server_api_connection() + return con.get_entity_watchers( + project_name=project_name, + entity_id=entity_id, + entity_type=entity_type, + ) + + +def set_entity_watchers( + project_name: str, + entity_id: str, + entity_type: str, + watchers: list[str], +): + """Change watchers of an entity. + """ + con = get_server_api_connection() + return con.set_entity_watchers( + project_name=project_name, + entity_id=entity_id, + entity_type=entity_type, + watchers=watchers, + ) + + def send_activities_batch_operations( project_name: str, operations: list[dict[str, Any]], @@ -3622,13 +3931,30 @@ def set_attribute_config( ) -def remove_attribute_config( +def delete_attribute_config( attribute_name: str, ) -> None: """Remove attribute from server. This can't be un-done, please use carefully. + Args: + attribute_name (str): Name of attribute to remove. + + """ + con = get_server_api_connection() + return con.delete_attribute_config( + attribute_name=attribute_name, + ) + + +def remove_attribute_config( + attribute_name: str, +) -> None: + """Remove attribute from server. + + DEPRECATED: Use 'delete_attribute_config' instead. + Args: attribute_name (str): Name of attribute to remove. @@ -3803,6 +4129,7 @@ def get_rest_project( def get_rest_projects( active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, ) -> Generator[ProjectDict, None, None]: """Query available project entities. @@ -3813,6 +4140,7 @@ def get_rest_projects( are returned if 'None' is passed. library (Optional[bool]): Filter standard/library projects. Both are returned if 'None' is passed. + include_skeleton (bool): Include skeleton projects. Returns: Generator[ProjectDict, None, None]: Available projects. @@ -3822,12 +4150,14 @@ def get_rest_projects( return con.get_rest_projects( active=active, library=library, + include_skeleton=include_skeleton, ) def get_rest_projects_list( active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, ) -> list[ProjectListDict]: """Receive available projects. @@ -3838,6 +4168,7 @@ def get_rest_projects_list( are returned if 'None' is passed. library (Optional[bool]): Filter standard/library projects. Both are returned if 'None' is passed. + include_skeleton (bool): Include skeleton projects. Returns: list[ProjectListDict]: List of available projects. @@ -3847,12 +4178,14 @@ def get_rest_projects_list( return con.get_rest_projects_list( active=active, library=library, + include_skeleton=include_skeleton, ) def get_project_names( active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, ) -> list[str]: """Receive available project names. @@ -3863,6 +4196,7 @@ def get_project_names( are returned if 'None' is passed. library (Optional[bool]): Filter standard/library projects. Both are returned if 'None' is passed. + include_skeleton (bool): Include skeleton projects. Returns: list[str]: List of available project names. @@ -3872,12 +4206,14 @@ def get_project_names( return con.get_project_names( active=active, library=library, + include_skeleton=include_skeleton, ) def get_projects( active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, fields: Optional[Iterable[str]] = None, own_attributes: bool = False, ) -> Generator[ProjectDict, None, None]: @@ -3888,6 +4224,7 @@ def get_projects( Filter is disabled when 'None' is passed. library (Optional[bool]): Filter library projects. Filter is disabled when 'None' is passed. + include_skeleton (bool): Include skeleton projects. fields (Optional[Iterable[str]]): fields to be queried for project. own_attributes (Optional[bool]): Attribute values that are @@ -3901,6 +4238,7 @@ def get_projects( return con.get_projects( active=active, library=library, + include_skeleton=include_skeleton, fields=fields, own_attributes=own_attributes, ) @@ -3938,6 +4276,8 @@ def create_project( project_code: str, library_project: bool = False, preset_name: Optional[str] = None, + data: dict[str, Any] | None = None, + skeleton: bool = False, ) -> ProjectDict: """Create project using AYON settings. @@ -3957,6 +4297,8 @@ def create_project( library_project (Optional[bool]): Project is library project. preset_name (Optional[str]): Name of anatomy preset. Default is used if not passed. + data (dict[str, Any]): Project data. + skeleton (bool): Project is skeleton project. Raises: ValueError: When project name already exists. @@ -3971,6 +4313,8 @@ def create_project( project_code=project_code, library_project=library_project, preset_name=preset_name, + data=data, + skeleton=skeleton, ) @@ -4049,6 +4393,83 @@ def delete_project( ) +def get_raw_project_folders() -> dict[str, Any]: + """Get project folders (raw data). + """ + con = get_server_api_connection() + return con.get_raw_project_folders() + + +def get_project_folders() -> list[dict[str, Any]]: + con = get_server_api_connection() + return con.get_project_folders() + + +def create_project_folder( + label: str, + parent_id: str | None = None, + data: dict[str, Any] | None = None, +) -> str: + """Create project folder. + """ + con = get_server_api_connection() + return con.create_project_folder( + label=label, + parent_id=parent_id, + data=data, + ) + + +def update_project_folder( + folder_id: str, + label: str | None = None, + parent_id: str | None = None, + data: dict[str, Any] | None = None, +) -> None: + con = get_server_api_connection() + return con.update_project_folder( + folder_id=folder_id, + label=label, + parent_id=parent_id, + data=data, + ) + + +def set_project_folders_order( + folder_ids: list[str], +) -> None: + """Set project folders order. + """ + con = get_server_api_connection() + return con.set_project_folders_order( + folder_ids=folder_ids, + ) + + +def assign_projects_to_project_folder( + folder_id: str, + project_names: list[str], +) -> None: + """Assign project folder to project. + """ + con = get_server_api_connection() + return con.assign_projects_to_project_folder( + folder_id=folder_id, + project_names=project_names, + ) + + +def delete_project_folder( + folder_id: str, +): + """Delete project folder. + """ + con = get_server_api_connection() + return con.delete_project_folder( + folder_id=folder_id, + ) + + def get_project_root_overrides( project_name: str, ) -> dict[str, dict[str, str]]: diff --git a/ayon_api/_api_helpers/activities.py b/ayon_api/_api_helpers/activities.py index f7ae3d4f7..2428e3b54 100644 --- a/ayon_api/_api_helpers/activities.py +++ b/ayon_api/_api_helpers/activities.py @@ -2,7 +2,7 @@ import json import typing -from typing import Optional, Iterable, Generator, Any +from typing import Optional, Iterable, Generator, Any, Literal from ayon_api.utils import ( SortOrder, @@ -255,6 +255,117 @@ def delete_activity(self, project_name: str, activity_id: str) -> None: ) response.raise_for_status() + def get_raw_activity_categories(self, project_name: str) -> dict[str, Any]: + """Get activity categories available on server (raw response). + + Args: + project_name (str): Project name to get categories for. + + Returns: + list[str]: Available activity categories. + + """ + response = self.get(f"projects/{project_name}/activityCategories") + response.raise_for_status() + return response.data + + def get_activity_categories(self, project_name: str) -> list[str]: + """Get activity categories available on server. + + Args: + project_name (str): Project name to get categories for. + + Returns: + list[str]: Available activity categories. + + """ + + data = self.get_raw_activity_categories(project_name) + return data["categories"] + + def create_activity_reaction( + self, + project_name: str, + activity_id: str, + reaction: str, + ) -> None: + """React to activity.""" + response = self.post( + f"projects/{project_name}/activities/{activity_id}/reactions", + reaction=reaction, + ) + response.raise_for_status() + + def delete_activity_reaction( + self, project_name: str, activity_id: str, reaction: str + ) -> None: + response = self.delete( + f"projects/{project_name}/activities/{activity_id}" + f"/reactions/{reaction}" + ) + response.raise_for_status() + + def suggest_entity_mention( + self, + project_name: str, + entity_id: str, + entity_type: Literal["folder", "task", "version"], + ) -> dict[str, dict[str, Any]]: + """Suggest entities for mention in activity body. + + At this moment does not change data only returns suggestions. + + Args: + project_name (str): Project name to search in. + entity_id (str): Entity id. + entity_type (str): Entity type of the entity. + + Returns: + list[dict[str, Any]]: List of suggested entities with + their details. + + """ + response = self.post( + f"projects/{project_name}/suggest", + entity_id=entity_id, + entity_type=entity_type, + ) + response.raise_for_status() + return response.data + + def get_raw_entity_watchers( + self, project_name: str, entity_id: str, entity_type: str + ) -> dict[str, Any]: + """Get entity watchers (raw response).""" + response = self.get( + f"projects/{project_name}/{entity_type}/{entity_id}/watchers" + ) + response.raise_for_status() + return response.data + + def get_entity_watchers( + self, project_name: str, entity_id: str, entity_type: str + ) -> list[str]: + """List watchers of an entity.""" + data = self.get_raw_entity_watchers( + project_name, entity_id, entity_type + ) + return data["watchers"] + + def set_entity_watchers( + self, + project_name: str, + entity_id: str, + entity_type: str, + watchers: list[str], + ): + """Change watchers of an entity.""" + response = self.post( + f"projects/{project_name}/{entity_type}/{entity_id}/watchers", + watchers=watchers, + ) + response.raise_for_status() + def send_activities_batch_operations( self, project_name: str, diff --git a/ayon_api/_api_helpers/attributes.py b/ayon_api/_api_helpers/attributes.py index c9deb6356..dbd2544b9 100644 --- a/ayon_api/_api_helpers/attributes.py +++ b/ayon_api/_api_helpers/attributes.py @@ -139,7 +139,7 @@ def set_attribute_config( self.reset_attributes_schema() - def remove_attribute_config(self, attribute_name: str) -> None: + def delete_attribute_config(self, attribute_name: str) -> None: """Remove attribute from server. This can't be un-done, please use carefully. @@ -156,6 +156,17 @@ def remove_attribute_config(self, attribute_name: str) -> None: self.reset_attributes_schema() + def remove_attribute_config(self, attribute_name: str) -> None: + """Remove attribute from server. + + DEPRECATED: Use 'delete_attribute_config' instead. + + Args: + attribute_name (str): Name of attribute to remove. + + """ + return self.delete_attribute_config(attribute_name) + def get_attributes_for_type( self, entity_type: AttributeScope ) -> dict[str, AttributeSchemaDataDict]: diff --git a/ayon_api/_api_helpers/projects.py b/ayon_api/_api_helpers/projects.py index 58857248e..0cadfeef8 100644 --- a/ayon_api/_api_helpers/projects.py +++ b/ayon_api/_api_helpers/projects.py @@ -174,6 +174,7 @@ def get_rest_projects( self, active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, ) -> Generator[ProjectDict, None, None]: """Query available project entities. @@ -184,12 +185,17 @@ def get_rest_projects( are returned if 'None' is passed. library (Optional[bool]): Filter standard/library projects. Both are returned if 'None' is passed. + include_skeleton (bool): Include skeleton projects. Returns: Generator[ProjectDict, None, None]: Available projects. """ - for project_name in self.get_project_names(active, library): + for project_name in self.get_project_names( + active=active, + library=library, + include_skeleton=include_skeleton, + ): project = self.get_rest_project(project_name) if project: yield project @@ -198,6 +204,7 @@ def get_rest_projects_list( self, active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, ) -> list[ProjectListDict]: """Receive available projects. @@ -208,6 +215,7 @@ def get_rest_projects_list( are returned if 'None' is passed. library (Optional[bool]): Filter standard/library projects. Both are returned if 'None' is passed. + include_skeleton (bool): Include skeleton projects. Returns: list[ProjectListDict]: List of available projects. @@ -219,10 +227,14 @@ def get_rest_projects_list( if library is not None: library = "true" if library else "false" - query = prepare_query_string({ + query_data = { "active": active, "library": library, - }) + } + if include_skeleton: + query_data["skeleton"] = "true" + + query = prepare_query_string(query_data) response = self.get(f"projects{query}") response.raise_for_status() data = response.data @@ -232,6 +244,7 @@ def get_project_names( self, active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, ) -> list[str]: """Receive available project names. @@ -242,6 +255,7 @@ def get_project_names( are returned if 'None' is passed. library (Optional[bool]): Filter standard/library projects. Both are returned if 'None' is passed. + include_skeleton (bool): Include skeleton projects. Returns: list[str]: List of available project names. @@ -249,13 +263,18 @@ def get_project_names( """ return [ project["name"] - for project in self.get_rest_projects_list(active, library) + for project in self.get_rest_projects_list( + active=active, + library=library, + include_skeleton=include_skeleton, + ) ] def get_projects( self, active: Optional[bool] = True, library: Optional[bool] = None, + include_skeleton: bool = False, fields: Optional[Iterable[str]] = None, own_attributes: bool = False, ) -> Generator[ProjectDict, None, None]: @@ -266,6 +285,7 @@ def get_projects( Filter is disabled when 'None' is passed. library (Optional[bool]): Filter library projects. Filter is disabled when 'None' is passed. + include_skeleton (bool): Include skeleton projects. fields (Optional[Iterable[str]]): fields to be queried for project. own_attributes (Optional[bool]): Attribute values that are @@ -280,7 +300,11 @@ def get_projects( graphql_fields, fetch_type = self._get_project_graphql_fields(fields) if fetch_type == ProjectFetchType.RESTList: - yield from self.get_rest_projects_list(active, library) + yield from self.get_rest_projects_list( + active=active, + library=library, + include_skeleton=include_skeleton, + ) return projects_by_name = {} @@ -288,6 +312,7 @@ def get_projects( projects = list(self._get_graphql_projects( active, library, + include_skeleton=include_skeleton, fields=graphql_fields, own_attributes=own_attributes, )) @@ -296,7 +321,11 @@ def get_projects( return projects_by_name = {p["name"]: p for p in projects} - for project in self.get_rest_projects(active=active, library=library): + for project in self.get_rest_projects( + active=active, + library=library, + include_skeleton=include_skeleton, + ): if own_attributes: fill_own_attribs(project) @@ -356,6 +385,8 @@ def create_project( project_code: str, library_project: bool = False, preset_name: Optional[str] = None, + data: dict[str, Any] | None = None, + skeleton: bool = False, ) -> ProjectDict: """Create project using AYON settings. @@ -375,6 +406,8 @@ def create_project( library_project (Optional[bool]): Project is library project. preset_name (Optional[str]): Name of anatomy preset. Default is used if not passed. + data (dict[str, Any]): Project data. + skeleton (bool): Project is skeleton project. Raises: ValueError: When project name already exists. @@ -395,12 +428,19 @@ def create_project( preset = self.get_project_anatomy_preset(preset_name) + if data is None: + data = {} + + if skeleton: + data["skeleton"] = True + result = self.post( "projects", name=project_name, code=project_code, anatomy=preset, - library=library_project + library=library_project, + data=data, ) if result.status != 201: @@ -498,6 +538,73 @@ def delete_project(self, project_name: str): f"Failed to delete project \"{project_name}\". {detail}" ) + def get_raw_project_folders(self) -> dict[str, Any]: + """Get project folders (raw data).""" + response = self.get("projectFolders") + response.raise_for_status() + return response.data + + def get_project_folders(self) -> list[dict[str, Any]]: + data = self.get_raw_project_folders() + return data["folders"] + + def create_project_folder( + self, + label: str, + parent_id: str | None = None, + data: dict[str, Any] | None = None, + ) -> str: + """Create project folder.""" + kwargs = {} + if parent_id is not None: + kwargs["parentId"] = parent_id + if data: + kwargs["data"] = data + + response = self.post("projectFolders", label=label, **kwargs) + response.raise_for_status() + return response.data["id"] + + def update_project_folder( + self, + folder_id: str, + label: str | None = None, + parent_id: str | None = None, + data: dict[str, Any] | None = None, + ) -> None: + body = { + key: value + for key, value in ( + ("label", label), + ("parentId", parent_id), + ("data", data), + ) + if value is not None + } + response = self.patch(f"projectFolders/{folder_id}", **body) + response.raise_for_status() + + def set_project_folders_order(self, folder_ids: list[str]) -> None: + """Set project folders order.""" + response = self.post("projectFolders/order", order=folder_ids) + response.raise_for_status() + + def assign_projects_to_project_folder( + self, folder_id: str, project_names: list[str], + ) -> None: + """Assign project folder to project.""" + response = self.post( + "projectFolders/assign", + folderId=folder_id, + projectNames=project_names, + ) + response.raise_for_status() + + def delete_project_folder(self, folder_id: str): + """Delete project folder.""" + response = self.delete(f"projectFolders/{folder_id}") + response.raise_for_status() + def get_project_root_overrides( self, project_name: str ) -> dict[str, dict[str, str]]: @@ -792,8 +899,9 @@ def _fill_project_entity_data(self, project: dict[str, Any]) -> None: def _get_graphql_projects( self, - active: Optional[bool], - library: Optional[bool], + active: bool | None, + library: bool | None, + include_skeleton: bool, fields: set[str], own_attributes: bool, project_name: Optional[str] = None @@ -810,6 +918,9 @@ def _get_graphql_projects( if project_name is not None: query.set_variable_value("projectName", project_name) + if include_skeleton: + query.set_variable_value("skeleton", True) + attributes = {} if "allAttrib" in fields: attributes = self.get_attributes_for_type("project") diff --git a/ayon_api/graphql_queries.py b/ayon_api/graphql_queries.py index 100c0aa9d..d376d0d43 100644 --- a/ayon_api/graphql_queries.py +++ b/ayon_api/graphql_queries.py @@ -91,8 +91,10 @@ def project_graphql_query(fields): def projects_graphql_query(fields): query = GraphQlQuery("ProjectsQuery") project_name_var = query.add_variable("projectName", "String!") + skeleton_var = query.add_variable("skeleton", "Boolean!") projects_field = query.add_field_with_edges("projects") projects_field.set_filter("name", project_name_var) + projects_field.set_filter("includeSkeleton", skeleton_var) nested_fields = fields_to_dict(fields) diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index 7ad2f5ba0..7b60d68a7 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -16,7 +16,7 @@ import uuid from contextlib import contextmanager import typing -from typing import Optional, Iterable, Generator, Any, Union +from typing import Optional, Iterable, Generator, Any, Union, Literal import requests @@ -1361,6 +1361,176 @@ def get(self, entrypoint: str, **kwargs): def delete(self, entrypoint: str, **kwargs): return self.raw_delete(entrypoint, params=kwargs) + def get_server_config(self): + response = self.get("config") + response.raise_for_status() + return response.data + + def set_server_config( + self, + studio_name: str | None = None, + customization: dict[str, Any] | None = None, + authentication: dict[str, Any] | None = None, + project_options: dict[str, Any] | None = None, + changelog: dict[str, Any] | None = None, + ) -> None: + body = { + key: value + for key, value in ( + ("studio_name", studio_name), + ("customization", customization), + ("authentication", authentication), + ("project_options", project_options), + ("changelog", changelog), + ) + if value is not None + } + response = self.post("config", **body) + response.raise_for_status() + + def get_server_config_overrides(self): + response = self.get("config/overrides") + response.raise_for_status() + return response.data + + def get_server_config_value(self, key: str): + response = self.get(f"config/value/{key}") + response.raise_for_status() + return response.data + + def download_server_config_file( + self, + file_type: Literal["login_background", "studio_logo"], + filepath: str, + *, + chunk_size: Optional[int] = None, + progress: Optional[TransferProgress] = None, + ) -> TransferProgress: + """Download server config file. + + Validate if server has config file available first. Method crashes + if the file is not available. + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + filepath (str): Target filepath. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + """ + return self.download_file( + f"api/config/files/{file_type}", + filepath, + chunk_size=chunk_size, + progress=progress, + ) + + def download_server_config_file_to_stream( + self, + file_type: Literal["login_background", "studio_logo"], + stream: StreamType, + *, + chunk_size: Optional[int] = None, + progress: Optional[TransferProgress] = None, + ) -> TransferProgress: + """Download server config file to byte stream. + + Validate if server has config file available first. Method crashes + if the file is not available. + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + stream (StreamType): Stream where downloaded content is stored. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + """ + return self.download_file_to_stream( + f"api/config/files/{file_type}", + stream, + chunk_size=chunk_size, + progress=progress, + ) + + def upload_server_config_file( + self, + file_type: Literal["login_background", "studio_logo"], + filepath: str, + *, + content_type: str | None = None, + filename: str | None = None, + chunk_size: int | None = None, + progress: TransferProgress | None = None, + ) -> requests.Response: + """Upload server config file from byte stream. + + TODO create filename using file_type and extension from content_type + if filename is not specified + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + filepath (str): Filepath used to store the file. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + Returns: + requests.Response: Response from upload. + + """ + if not filename: + filename = os.path.basename(filepath) + return self.upload_file( + f"api/config/files/{file_type}", + filepath, + filename=filename, + content_type=content_type, + chunk_size=chunk_size, + progress=progress, + ) + + def upload_server_config_file_from_stream( + self, + file_type: Literal["login_background", "studio_logo"], + stream: StreamType, + filename: str, + *, + content_type: str | None = None, + chunk_size: int | None = None, + progress: TransferProgress | None = None, + ) -> requests.Response: + """Upload server config file from byte stream. + + TODO create filename using file_type and extension from content_type + if filename is not specified + + Args: + file_type (Literal["login_background", "studio_logo"]): File to + download. + stream (StreamType): Stream where downloaded content is stored. + filename (str): Filename used to store the file. + chunk_size (int | None): Size of chunks used for download. + progress (TransferProgress | None): Object to track download + progress. + + Returns: + requests.Response: Response from upload. + + """ + return self.upload_file_from_stream( + f"api/config/files/{file_type}", + stream, + filename=filename, + content_type=content_type, + chunk_size=chunk_size, + progress=progress, + ) + def _endpoint_to_url( self, endpoint: str,