diff --git a/automated_api.py b/automated_api.py index ee5e661d6..a764663c2 100644 --- a/automated_api.py +++ b/automated_api.py @@ -114,25 +114,40 @@ def prepare_docstring(func): return f'"""{docstring}{line_char}\n"""' -def _get_typehint(param, api_globals): - if param.annotation is inspect.Parameter.empty: - return None - - an = param.annotation - if inspect.isclass(an): - return an.__name__ +def _get_typehint(annotation, api_globals): + if inspect.isclass(annotation): + return annotation.__name__ + + typehint = ( + str(annotation) + .replace("typing.", "") + .replace("NoneType", "None") + ) + forwardref_regex = re.compile( + r"(?PForwardRef\('(?P[a-zA-Z0-9]+)'\))" + ) + for item in forwardref_regex.finditer(str(typehint)): + groups = item.groupdict() + name = groups["name"] + typehint = typehint.replace(groups["full"], f'"{name}"') - typehint = str(an).replace("typing.", "") try: # Test if typehint is valid for known '_api' content exec(f"_: {typehint} = None", api_globals) except NameError: + print("Unknown typehint:", typehint) typehint = f'"{typehint}"' return typehint +def _get_param_typehint(param, api_globals): + if param.annotation is inspect.Parameter.empty: + return None + return _get_typehint(param.annotation, api_globals) + + def _add_typehint(param_name, param, api_globals): - typehint = _get_typehint(param, api_globals) + typehint = _get_param_typehint(param, api_globals) if not typehint: return param_name return f"{param_name}: {typehint}" @@ -154,7 +169,7 @@ def _kw_default_to_str(param_name, param, api_globals): raise TypeError("Unknown default value type") else: default = repr(default) - typehint = _get_typehint(param, api_globals) + typehint = _get_param_typehint(param, api_globals) if typehint: return f"{param_name}: {typehint} = {default}" return f"{param_name}={default}" @@ -216,7 +231,8 @@ def sig_params_to_str(sig, param_names, api_globals, indent=0): func_params_str = f"(\n{lines_str}\n{base_indent_str})" if sig.return_annotation is not inspect.Signature.empty: - func_params_str += f" -> {sig.return_annotation}" + return_typehint = _get_typehint(sig.return_annotation, api_globals) + func_params_str += f" -> {return_typehint}" body_params_str = "()" if body_params: diff --git a/ayon_api/__init__.py b/ayon_api/__init__.py index 747768117..e6214b0dc 100644 --- a/ayon_api/__init__.py +++ b/ayon_api/__init__.py @@ -72,6 +72,11 @@ dispatch_event, delete_event, enroll_event_job, + get_activities, + get_activity_by_id, + create_activity, + update_activity, + delete_activity, download_file_to_stream, download_file, upload_file_from_stream, @@ -234,6 +239,7 @@ get_representations_links, get_representation_links, send_batch_operations, + send_activities_batch_operations, ) @@ -309,6 +315,11 @@ "dispatch_event", "delete_event", "enroll_event_job", + "get_activities", + "get_activity_by_id", + "create_activity", + "update_activity", + "delete_activity", "download_file_to_stream", "download_file", "upload_file_from_stream", @@ -471,4 +482,5 @@ "get_representations_links", "get_representation_links", "send_batch_operations", + "send_activities_batch_operations", ) diff --git a/ayon_api/_api.py b/ayon_api/_api.py index ac3c2d0b5..b1af5f6cd 100644 --- a/ayon_api/_api.py +++ b/ayon_api/_api.py @@ -11,7 +11,8 @@ import os import socket -from typing import Optional +import typing +from typing import Optional, List, Dict, Iterable, Generator, Any from .constants import ( SERVER_URL_ENV_KEY, @@ -24,6 +25,9 @@ get_default_settings_variant as _get_default_settings_variant, ) +if typing.TYPE_CHECKING: + from ._typing import ActivityType, ActivityReferenceType + class GlobalServerAPI(ServerAPI): """Extended server api which also handles storing tokens and url. @@ -1121,6 +1125,176 @@ def enroll_event_job( ) +def get_activities( + project_name: str, + activity_ids: Optional[Iterable[str]] = None, + activity_types: Optional[Iterable["ActivityType"]] = None, + entity_ids: Optional[Iterable[str]] = None, + entity_names: Optional[Iterable[str]] = None, + entity_type: Optional[str] = None, + changed_after: Optional[str] = None, + changed_before: Optional[str] = None, + reference_types: Optional[Iterable["ActivityReferenceType"]] = None, + fields: Optional[Iterable[str]] = None, +) -> Generator[Dict[str, Any], None, None]: + """Get activities from server with filtering options. + + Args: + project_name (str): Project on which activities happened. + activity_ids (Optional[Iterable[str]]): Activity ids. + activity_types (Optional[Iterable[ActivityType]]): Activity types. + entity_ids (Optional[Iterable[str]]): Entity ids. + entity_names (Optional[Iterable[str]]): Entity names. + entity_type (Optional[str]): Entity type. + changed_after (Optional[str]): Return only activities changed + after given iso datetime string. + changed_before (Optional[str]): Return only activities changed + before given iso datetime string. + reference_types (Optional[Iterable[ActivityReferenceType]]): + Reference types filter. Defaults to `['origin']`. + fields (Optional[Iterable[str]]): Fields that should be received + for each activity. + + Returns: + Generator[dict[str, Any]]: Available activities matching filters. + + """ + con = get_server_api_connection() + return con.get_activities( + project_name=project_name, + activity_ids=activity_ids, + activity_types=activity_types, + entity_ids=entity_ids, + entity_names=entity_names, + entity_type=entity_type, + changed_after=changed_after, + changed_before=changed_before, + reference_types=reference_types, + fields=fields, + ) + + +def get_activity_by_id( + project_name: str, + activity_id: str, + reference_types: Optional[Iterable["ActivityReferenceType"]] = None, + fields: Optional[Iterable[str]] = None, +) -> Optional[Dict[str, Any]]: + """Get activity by id. + + Args: + project_name (str): Project on which activity happened. + activity_id (str): Activity id. + fields (Optional[Iterable[str]]): Fields that should be received + for each activity. + + Returns: + Optional[Dict[str, Any]]: Activity data or None if activity is not + found. + + """ + con = get_server_api_connection() + return con.get_activity_by_id( + project_name=project_name, + activity_id=activity_id, + reference_types=reference_types, + fields=fields, + ) + + +def create_activity( + project_name: str, + entity_id: str, + entity_type: str, + activity_type: "ActivityType", + activity_id: Optional[str] = None, + body: Optional[str] = None, + file_ids: Optional[List[str]] = None, + timestamp: Optional[str] = None, + data: Optional[Dict[str, Any]] = None, +) -> str: + """Create activity on a project. + + Args: + project_name (str): Project on which activity happened. + entity_id (str): Entity id. + entity_type (str): Entity type. + activity_type (ActivityType): Activity type. + activity_id (Optional[str]): Activity id. + body (Optional[str]): Activity body. + file_ids (Optional[List[str]]): List of file ids attached + to activity. + timestamp (Optional[str]): Activity timestamp. + data (Optional[Dict[str, Any]]): Additional data. + + Returns: + str: Activity id. + + """ + con = get_server_api_connection() + return con.create_activity( + project_name=project_name, + entity_id=entity_id, + entity_type=entity_type, + activity_type=activity_type, + activity_id=activity_id, + body=body, + file_ids=file_ids, + timestamp=timestamp, + data=data, + ) + + +def update_activity( + project_name: str, + activity_id: str, + body: Optional[str] = None, + file_ids: Optional[List[str]] = None, + append_file_ids: Optional[bool] = False, + data: Optional[Dict[str, Any]] = None, +): + """Update activity by id. + + Args: + project_name (str): Project on which activity happened. + activity_id (str): Activity id. + body (str): Activity body. + file_ids (Optional[List[str]]): List of file ids attached + to activity. + append_file_ids (Optional[bool]): Append file ids to existing + list of file ids. + data (Optional[Dict[str, Any]]): Update data in activity. + + """ + con = get_server_api_connection() + return con.update_activity( + project_name=project_name, + activity_id=activity_id, + body=body, + file_ids=file_ids, + append_file_ids=append_file_ids, + data=data, + ) + + +def delete_activity( + project_name: str, + activity_id: str, +): + """Delete activity by id. + + Args: + project_name (str): Project on which activity happened. + activity_id (str): Activity id to remove. + + """ + con = get_server_api_connection() + return con.delete_activity( + project_name=project_name, + activity_id=activity_id, + ) + + def download_file_to_stream( endpoint, stream, @@ -6396,3 +6570,43 @@ def send_batch_operations( can_fail=can_fail, raise_on_fail=raise_on_fail, ) + + +def send_activities_batch_operations( + project_name, + operations, + can_fail=False, + raise_on_fail=True, +): + """Post multiple CRUD activities operations to server. + + When multiple changes should be made on server side this is the best + way to go. It is possible to pass multiple operations to process on a + server side and do the changes in a transaction. + + Args: + project_name (str): On which project should be operations + processed. + operations (list[dict[str, Any]]): Operations to be processed. + can_fail (Optional[bool]): Server will try to process all + operations even if one of them fails. + raise_on_fail (Optional[bool]): Raise exception if an operation + fails. You can handle failed operations on your own + when set to 'False'. + + Raises: + ValueError: Operations can't be converted to json string. + FailedOperations: When output does not contain server operations + or 'raise_on_fail' is enabled and any operation fails. + + Returns: + list[dict[str, Any]]: Operations result with process details. + + """ + con = get_server_api_connection() + return con.send_activities_batch_operations( + project_name=project_name, + operations=operations, + can_fail=can_fail, + raise_on_fail=raise_on_fail, + ) diff --git a/ayon_api/_typing.py b/ayon_api/_typing.py new file mode 100644 index 000000000..566f598ab --- /dev/null +++ b/ayon_api/_typing.py @@ -0,0 +1,19 @@ +from typing import Literal + +ActivityType = Literal[ + "comment", + "watch", + "reviewable", + "status.change", + "assignee.add", + "assignee.remove", + "version.publish" +] + +ActivityReferenceType = Literal[ + "origin", + "mention", + "author", + "relation", + "watching", +] diff --git a/ayon_api/constants.py b/ayon_api/constants.py index 346620c77..4d5700ce9 100644 --- a/ayon_api/constants.py +++ b/ayon_api/constants.py @@ -185,3 +185,13 @@ "description", "author", } + +DEFAULT_ACTIVITY_FIELDS = { + "activityId", + "activityType", + "activityData", + "body", + "entityId", + "entityType", + "author.name", +} diff --git a/ayon_api/graphql_queries.py b/ayon_api/graphql_queries.py index 8363d1ece..999c9e5c2 100644 --- a/ayon_api/graphql_queries.py +++ b/ayon_api/graphql_queries.py @@ -636,3 +636,47 @@ def users_graphql_query(fields): for k, v in value.items(): query_queue.append((k, v, field)) return query + + +def activities_graphql_query(fields): + query = GraphQlQuery("Activities") + project_name_var = query.add_variable("projectName", "String!") + activity_ids_var = query.add_variable("activityIds", "[String!]") + activity_types_var = query.add_variable("activityTypes", "[String!]") + entity_ids_var = query.add_variable("entityIds", "[String!]") + entity_names_var = query.add_variable("entityNames", "[String!]") + entity_type_var = query.add_variable("entityType", "String!") + changed_after_var = query.add_variable("changedAfter", "String!") + changed_before_var = query.add_variable("changedBefore", "String!") + reference_types_var = query.add_variable("referenceTypes", "[String!]") + + project_field = query.add_field("project") + project_field.set_filter("name", project_name_var) + + activities_field = project_field.add_field_with_edges("activities") + activities_field.set_filter("activityIds", activity_ids_var) + activities_field.set_filter("activityTypes", activity_types_var) + activities_field.set_filter("entityIds", entity_ids_var) + activities_field.set_filter("entityNames", entity_names_var) + activities_field.set_filter("entityType", entity_type_var) + activities_field.set_filter("changedAfter", changed_after_var) + activities_field.set_filter("changedBefore", changed_before_var) + activities_field.set_filter("referenceTypes", reference_types_var) + + nested_fields = fields_to_dict(set(fields)) + + query_queue = collections.deque() + for key, value in nested_fields.items(): + query_queue.append((key, value, activities_field)) + + while query_queue: + item = query_queue.popleft() + key, value, parent = item + field = parent.add_field(key) + if value is FIELD_VALUE: + continue + + for k, v in value.items(): + query_queue.append((k, v, field)) + + return query diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index d82f1ad3e..f130e065f 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -16,7 +16,8 @@ import warnings import itertools from contextlib import contextmanager -from typing import Optional +import typing +from typing import Optional, Iterable, Generator, Dict, List, Any try: from http import HTTPStatus @@ -49,6 +50,7 @@ REPRESENTATION_FILES_FIELDS, DEFAULT_WORKFILE_INFO_FIELDS, DEFAULT_EVENT_FIELDS, + DEFAULT_ACTIVITY_FIELDS, DEFAULT_USER_FIELDS, DEFAULT_LINK_FIELDS, ) @@ -68,6 +70,7 @@ workfiles_info_graphql_query, events_graphql_query, users_graphql_query, + activities_graphql_query, ) from .exceptions import ( FailedOperations, @@ -95,6 +98,9 @@ get_media_mime_type, ) +if typing.TYPE_CHECKING: + from ._typing import ActivityType, ActivityReferenceType + PatternType = type(re.compile("")) JSONDecodeError = getattr(json, "JSONDecodeError", ValueError) # This should be collected from server schema @@ -1782,6 +1788,229 @@ def enroll_event_job( return response.data + def get_activities( + self, + project_name: str, + activity_ids: Optional[Iterable[str]] = None, + activity_types: Optional[Iterable["ActivityType"]] = None, + entity_ids: Optional[Iterable[str]] = None, + entity_names: Optional[Iterable[str]] = None, + entity_type: Optional[str] = None, + changed_after: Optional[str] = None, + changed_before: Optional[str] = None, + reference_types: Optional[Iterable["ActivityReferenceType"]] = None, + fields: Optional[Iterable[str]] = None, + ) -> Generator[Dict[str, Any], None, None]: + """Get activities from server with filtering options. + + Args: + project_name (str): Project on which activities happened. + activity_ids (Optional[Iterable[str]]): Activity ids. + activity_types (Optional[Iterable[ActivityType]]): Activity types. + entity_ids (Optional[Iterable[str]]): Entity ids. + entity_names (Optional[Iterable[str]]): Entity names. + entity_type (Optional[str]): Entity type. + changed_after (Optional[str]): Return only activities changed + after given iso datetime string. + changed_before (Optional[str]): Return only activities changed + before given iso datetime string. + reference_types (Optional[Iterable[ActivityReferenceType]]): + Reference types filter. Defaults to `['origin']`. + fields (Optional[Iterable[str]]): Fields that should be received + for each activity. + + Returns: + Generator[dict[str, Any]]: Available activities matching filters. + + """ + if not project_name: + return + filters = { + "projectName": project_name, + } + if reference_types is None: + reference_types = {"origin"} + + if not _prepare_list_filters( + filters, + ("activityIds", activity_ids), + ("activityTypes", activity_types), + ("entityIds", entity_ids), + ("entityNames", entity_names), + ("referenceTypes", reference_types), + ): + return + + for filter_key, filter_value in ( + ("entityType", entity_type), + ("changedAfter", changed_after), + ("changedBefore", changed_before), + ): + if filter_value is not None: + filters[filter_key] = filter_value + + if not fields: + fields = self.get_default_fields_for_type("activity") + + query = activities_graphql_query(set(fields)) + for attr, filter_value in filters.items(): + query.set_variable_value(attr, filter_value) + + for parsed_data in query.continuous_query(self): + for activity in parsed_data["project"]["activities"]: + activity_data = activity.get("activityData") + if isinstance(activity_data, str): + activity["activityData"] = json.loads(activity_data) + yield activity + + def get_activity_by_id( + self, + project_name: str, + activity_id: str, + reference_types: Optional[Iterable["ActivityReferenceType"]] = None, + fields: Optional[Iterable[str]] = None, + ) -> Optional[Dict[str, Any]]: + """Get activity by id. + + Args: + project_name (str): Project on which activity happened. + activity_id (str): Activity id. + fields (Optional[Iterable[str]]): Fields that should be received + for each activity. + + Returns: + Optional[Dict[str, Any]]: Activity data or None if activity is not + found. + + """ + for activity in self.get_activities( + project_name=project_name, + activity_ids={activity_id}, + reference_types=reference_types, + fields=fields, + ): + return activity + return None + + def create_activity( + self, + project_name: str, + entity_id: str, + entity_type: str, + activity_type: "ActivityType", + activity_id: Optional[str] = None, + body: Optional[str] = None, + file_ids: Optional[List[str]] = None, + timestamp: Optional[str] = None, + data: Optional[Dict[str, Any]] = None, + ) -> str: + """Create activity on a project. + + Args: + project_name (str): Project on which activity happened. + entity_id (str): Entity id. + entity_type (str): Entity type. + activity_type (ActivityType): Activity type. + activity_id (Optional[str]): Activity id. + body (Optional[str]): Activity body. + file_ids (Optional[List[str]]): List of file ids attached + to activity. + timestamp (Optional[str]): Activity timestamp. + data (Optional[Dict[str, Any]]): Additional data. + + Returns: + str: Activity id. + + """ + post_data = { + "activityType": activity_type, + } + for key, value in ( + ("id", activity_id), + ("body", body), + ("files", file_ids), + ("timestamp", timestamp), + ("data", data), + ): + if value is not None: + post_data[key] = value + + response = self.post( + f"projects/{project_name}/{entity_type}/{entity_id}/activities", + **post_data + ) + response.raise_for_status() + return response.data["id"] + + def update_activity( + self, + project_name: str, + activity_id: str, + body: Optional[str] = None, + file_ids: Optional[List[str]] = None, + append_file_ids: Optional[bool] = False, + data: Optional[Dict[str, Any]] = None, + ): + """Update activity by id. + + Args: + project_name (str): Project on which activity happened. + activity_id (str): Activity id. + body (str): Activity body. + file_ids (Optional[List[str]]): List of file ids attached + to activity. + append_file_ids (Optional[bool]): Append file ids to existing + list of file ids. + data (Optional[Dict[str, Any]]): Update data in activity. + + """ + update_data = {} + major, minor, patch, _, _ = self.server_version_tuple + new_patch_model = (major, minor, patch) > (1, 5, 6) + if body is None and not new_patch_model: + raise ValueError( + "Update without 'body' is supported" + " after server version 1.5.6." + ) + + if body is not None: + update_data["body"] = body + + if file_ids is not None: + update_data["files"] = file_ids + if new_patch_model: + update_data["appendFiles"] = append_file_ids + elif append_file_ids: + raise ValueError( + "Append file ids is supported after server version 1.5.6." + ) + + if data is not None: + if not new_patch_model: + raise ValueError( + "Update of data is supported after server version 1.5.6." + ) + update_data["data"] = data + + response = self.patch( + f"projects/{project_name}/activities/{activity_id}", + **update_data + ) + response.raise_for_status() + + def delete_activity(self, project_name: str, activity_id: str): + """Delete activity by id. + + Args: + project_name (str): Project on which activity happened. + activity_id (str): Activity id to remove. + + """ + response = self.delete( + f"projects/{project_name}/activities/{activity_id}" + ) + response.raise_for_status() + def _endpoint_to_url( self, endpoint: str, @@ -2359,6 +2588,9 @@ def get_default_fields_for_type(self, entity_type): if entity_type == "event": return set(DEFAULT_EVENT_FIELDS) + if entity_type == "activity": + return set(DEFAULT_ACTIVITY_FIELDS) + if entity_type == "project": entity_type_defaults = set(DEFAULT_PROJECT_FIELDS) if not self.graphql_allows_data_in_query: @@ -8322,6 +8554,59 @@ def send_batch_operations( list[dict[str, Any]]: Operations result with process details. """ + return self._send_batch_operations( + f"projects/{project_name}/operations", + operations, + can_fail, + raise_on_fail, + ) + + def send_activities_batch_operations( + self, + project_name, + operations, + can_fail=False, + raise_on_fail=True + ): + """Post multiple CRUD activities operations to server. + + When multiple changes should be made on server side this is the best + way to go. It is possible to pass multiple operations to process on a + server side and do the changes in a transaction. + + Args: + project_name (str): On which project should be operations + processed. + operations (list[dict[str, Any]]): Operations to be processed. + can_fail (Optional[bool]): Server will try to process all + operations even if one of them fails. + raise_on_fail (Optional[bool]): Raise exception if an operation + fails. You can handle failed operations on your own + when set to 'False'. + + Raises: + ValueError: Operations can't be converted to json string. + FailedOperations: When output does not contain server operations + or 'raise_on_fail' is enabled and any operation fails. + + Returns: + list[dict[str, Any]]: Operations result with process details. + + """ + return self._send_batch_operations( + f"projects/{project_name}/operations/activities", + operations, + can_fail, + raise_on_fail, + ) + + def _send_batch_operations( + self, + uri: str, + operations: List[Dict[str, Any]], + can_fail: bool, + raise_on_fail: bool + ): if not operations: return [] @@ -8354,7 +8639,7 @@ def send_batch_operations( return [] result = self.post( - "projects/{}/operations".format(project_name), + uri, operations=operations_body, canFail=can_fail )