diff --git a/automated_api.py b/automated_api.py index c5289fe4b..761f15b51 100644 --- a/automated_api.py +++ b/automated_api.py @@ -349,6 +349,10 @@ def prepare_api_functions(api_globals): ActionsAPI, LinksAPI, ListsAPI, + UsersAPI, + ServicesAPI, + SystemAPI, + ReviewablesAPI, ) functions = [] @@ -371,6 +375,10 @@ def prepare_api_functions(api_globals): _items.extend(LinksAPI.__dict__.items()) _items.extend(ListsAPI.__dict__.items()) _items.extend(ThumbnailsAPI.__dict__.items()) + _items.extend(UsersAPI.__dict__.items()) + _items.extend(ServicesAPI.__dict__.items()) + _items.extend(SystemAPI.__dict__.items()) + _items.extend(ReviewablesAPI.__dict__.items()) processed = set() for attr_name, attr in _items: diff --git a/ayon_api/__init__.py b/ayon_api/__init__.py index 7d700ce99..cd11f40b3 100644 --- a/ayon_api/__init__.py +++ b/ayon_api/__init__.py @@ -159,12 +159,28 @@ get_addons_studio_settings, get_addons_project_settings, get_addons_settings, + set_addon_studio_settings, + set_addon_project_settings, + get_addon_studio_overrides, + modify_addon_studio_overrides, + delete_addon_studio_overrides, + get_addon_project_overrides, + modify_addon_project_overrides, + delete_addon_project_overrides, + get_addon_studio_raw_overrides, + set_addon_studio_raw_overrides, + get_addon_project_raw_overrides, + set_addon_project_raw_overrides, + set_addon_site_settings, + migrate_addon_settings, + migrate_settings_by_bundle, get_event, get_events, update_event, dispatch_event, create_event, delete_event, + delete_events, enroll_event_job, get_attributes_schema, reset_attributes_schema, @@ -180,6 +196,12 @@ get_project_anatomy_preset, get_built_in_anatomy_preset, get_build_in_anatomy_preset, + get_anatomy_schema, + save_project_anatomy_preset, + rename_project_anatomy_preset, + delete_project_anatomy_preset, + set_primary_anatomy_preset, + unset_primary_anatomy_preset, get_rest_project, get_rest_projects, get_rest_projects_list, @@ -189,6 +211,17 @@ create_project, update_project, delete_project, + rename_project, + get_project_anatomy, + set_project_anatomy, + get_project_stats, + get_project_tags, + get_project_bundle, + set_project_bundle, + unset_project_bundle, + get_project_entity_uris, + resolve_uris, + get_project_file_info, get_raw_project_folders, get_project_folders, create_project_folder, @@ -199,6 +232,7 @@ get_project_root_overrides, get_project_roots_by_site, get_project_root_overrides_by_site_id, + set_project_root_overrides, get_project_roots_for_site, get_project_roots_by_site_id, get_project_roots_by_platform, @@ -211,6 +245,8 @@ get_folder_by_path, get_folder_by_name, get_folder_ids_with_products, + search_folders, + get_entity_groups, create_folder, update_folder, delete_folder, @@ -224,6 +260,7 @@ create_task, update_task, delete_task, + assign_users_to_task, get_rest_product, get_products, get_product_by_id, @@ -310,6 +347,8 @@ update_entity_list_folder, delete_entity_list_folder, set_entity_list_folders_order, + materialize_entity_list, + set_entity_list_items_order, get_thumbnail_by_id, get_thumbnail, get_folder_thumbnail, @@ -320,6 +359,58 @@ create_thumbnail_with_stream, update_thumbnail, update_thumbnail_from_stream, + set_entity_thumbnail, + set_entity_thumbnail_from_stream, + get_project_thumbnail, + set_project_thumbnail, + set_project_thumbnail_from_stream, + create_user, + update_user, + delete_user, + rename_user, + change_password, + set_user_access_groups, + get_user_sessions, + delete_user_session, + get_user_api_keys, + create_user_api_key, + update_user_api_key, + delete_user_api_key, + get_user_permissions, + get_access_groups, + get_access_group_schema, + get_access_group, + save_access_group, + delete_access_group, + set_projects_access, + get_teams, + update_teams, + save_team, + delete_team, + save_team_member, + delete_team_member, + get_project_users, + set_project_user_access_groups, + get_services, + spawn_service, + update_service, + delete_service, + get_hosts, + get_sites, + delete_site, + get_restart_required, + set_restart_required, + get_server_config_schema, + update_server_config, + get_enums, + get_enum, + get_reviewables, + get_version_reviewables, + get_product_reviewables, + get_folder_reviewables, + get_task_reviewables, + sort_version_reviewables, + update_reviewable, ) @@ -482,12 +573,28 @@ "get_addons_studio_settings", "get_addons_project_settings", "get_addons_settings", + "set_addon_studio_settings", + "set_addon_project_settings", + "get_addon_studio_overrides", + "modify_addon_studio_overrides", + "delete_addon_studio_overrides", + "get_addon_project_overrides", + "modify_addon_project_overrides", + "delete_addon_project_overrides", + "get_addon_studio_raw_overrides", + "set_addon_studio_raw_overrides", + "get_addon_project_raw_overrides", + "set_addon_project_raw_overrides", + "set_addon_site_settings", + "migrate_addon_settings", + "migrate_settings_by_bundle", "get_event", "get_events", "update_event", "dispatch_event", "create_event", "delete_event", + "delete_events", "enroll_event_job", "get_attributes_schema", "reset_attributes_schema", @@ -503,6 +610,12 @@ "get_project_anatomy_preset", "get_built_in_anatomy_preset", "get_build_in_anatomy_preset", + "get_anatomy_schema", + "save_project_anatomy_preset", + "rename_project_anatomy_preset", + "delete_project_anatomy_preset", + "set_primary_anatomy_preset", + "unset_primary_anatomy_preset", "get_rest_project", "get_rest_projects", "get_rest_projects_list", @@ -512,6 +625,17 @@ "create_project", "update_project", "delete_project", + "rename_project", + "get_project_anatomy", + "set_project_anatomy", + "get_project_stats", + "get_project_tags", + "get_project_bundle", + "set_project_bundle", + "unset_project_bundle", + "get_project_entity_uris", + "resolve_uris", + "get_project_file_info", "get_raw_project_folders", "get_project_folders", "create_project_folder", @@ -522,6 +646,7 @@ "get_project_root_overrides", "get_project_roots_by_site", "get_project_root_overrides_by_site_id", + "set_project_root_overrides", "get_project_roots_for_site", "get_project_roots_by_site_id", "get_project_roots_by_platform", @@ -534,6 +659,8 @@ "get_folder_by_path", "get_folder_by_name", "get_folder_ids_with_products", + "search_folders", + "get_entity_groups", "create_folder", "update_folder", "delete_folder", @@ -547,6 +674,7 @@ "create_task", "update_task", "delete_task", + "assign_users_to_task", "get_rest_product", "get_products", "get_product_by_id", @@ -633,6 +761,8 @@ "update_entity_list_folder", "delete_entity_list_folder", "set_entity_list_folders_order", + "materialize_entity_list", + "set_entity_list_items_order", "get_thumbnail_by_id", "get_thumbnail", "get_folder_thumbnail", @@ -643,4 +773,56 @@ "create_thumbnail_with_stream", "update_thumbnail", "update_thumbnail_from_stream", + "set_entity_thumbnail", + "set_entity_thumbnail_from_stream", + "get_project_thumbnail", + "set_project_thumbnail", + "set_project_thumbnail_from_stream", + "create_user", + "update_user", + "delete_user", + "rename_user", + "change_password", + "set_user_access_groups", + "get_user_sessions", + "delete_user_session", + "get_user_api_keys", + "create_user_api_key", + "update_user_api_key", + "delete_user_api_key", + "get_user_permissions", + "get_access_groups", + "get_access_group_schema", + "get_access_group", + "save_access_group", + "delete_access_group", + "set_projects_access", + "get_teams", + "update_teams", + "save_team", + "delete_team", + "save_team_member", + "delete_team_member", + "get_project_users", + "set_project_user_access_groups", + "get_services", + "spawn_service", + "update_service", + "delete_service", + "get_hosts", + "get_sites", + "delete_site", + "get_restart_required", + "set_restart_required", + "get_server_config_schema", + "update_server_config", + "get_enums", + "get_enum", + "get_reviewables", + "get_version_reviewables", + "get_product_reviewables", + "get_folder_reviewables", + "get_task_reviewables", + "sort_version_reviewables", + "update_reviewable", ) diff --git a/ayon_api/_api.py b/ayon_api/_api.py index 444f085af..7c0bf357e 100644 --- a/ayon_api/_api.py +++ b/ayon_api/_api.py @@ -53,6 +53,7 @@ EntityListEntityType, EntityListItemMode, EntityListScope, + ReviewablesEntityType, BackgroundOperationTask, LinkDirection, CreateLinkData, @@ -3219,6 +3220,8 @@ def get_addon_settings_schema( addon_name: str, addon_version: str, project_name: Optional[str] = None, + variant: Optional[str] = None, + site_id: Optional[str] = None, ) -> dict[str, Any]: """Sudio/Project settings schema of an addon. @@ -3230,6 +3233,10 @@ def get_addon_settings_schema( addon_version (str): Version of addon. project_name (Optional[str]): Schema for specific project or default studio schemas. + variant (Optional[str]): Settings variant used as context for + the schema. Server uses 'production' if not passed. + site_id (Optional[str]): Site id used as context for project + schema. Ignored if 'project_name' is not passed. Returns: dict[str, Any]: Schema of studio/project settings. @@ -3240,6 +3247,8 @@ def get_addon_settings_schema( addon_name=addon_name, addon_version=addon_version, project_name=project_name, + variant=variant, + site_id=site_id, ) @@ -3614,1558 +3623,4364 @@ def get_addons_settings( ) -def get_event( - event_id: str, -) -> Optional[dict[str, Any]]: - """Query full event data by id. +def set_addon_studio_settings( + addon_name: str, + addon_version: str, + settings: dict[str, Any], + variant: Optional[str] = None, +) -> None: + """Set studio settings of an addon. - Events received using event server do not contain full information. To - get the full event information is required to receive it explicitly. + Server calculates studio overrides from passed settings values. + Explicit (un)pinning of fields can be done with + '__pinned_fields__' and '__unpinned_fields__' keys in settings. Args: - event_id (str): Event id. - - Returns: - dict[str, Any]: Full event data. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + settings (dict[str, Any]): Full settings values. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. """ con = get_server_api_connection() - return con.get_event( - event_id=event_id, + return con.set_addon_studio_settings( + addon_name=addon_name, + addon_version=addon_version, + settings=settings, + variant=variant, ) -def get_events( - topics: Optional[Iterable[str]] = None, - event_ids: Optional[Iterable[str]] = None, - project_names: Optional[Iterable[str]] = None, - statuses: Optional[Iterable[EventStatus]] = None, - users: Optional[Iterable[str]] = None, - text_filter: Optional[str] = None, - include_logs: Optional[bool] = None, - has_children: Optional[bool] = None, - newer_than: Optional[str] = None, - older_than: Optional[str] = None, - fields: Optional[Iterable[str]] = None, - limit: Optional[int] = None, - order: Optional[SortOrder] = None, - first: Optional[int] = None, - last: Optional[int] = None, - states: Optional[Iterable[str]] = None, -) -> Generator[dict[str, Any], None, None]: - """Get events from server with filtering options. +def set_addon_project_settings( + addon_name: str, + addon_version: str, + project_name: str, + settings: dict[str, Any], + variant: Optional[str] = None, + site_id: Optional[str] = None, +) -> None: + """Set project settings of an addon. - Notes: - Not all event happen on a project. + Server calculates project overrides from passed settings values. + If 'site_id' is passed then project site overrides of current user + are set instead. Args: - topics (Optional[Iterable[str]]): Name of topics. - event_ids (Optional[Iterable[str]]): Event ids. - project_names (Optional[Iterable[str]]): Project on which - event happened. - statuses (Optional[Iterable[EventStatus]]): Filtering by statuses. - users (Optional[Iterable[str]]): Filtering by users - who created/triggered an event. - text_filter (Optional[str]): Filtering by text in event payload. - include_logs (Optional[bool]): Query also log events. - has_children (Optional[bool]): Event is with/without children - events. If 'None' then all events are returned, default. - newer_than (Optional[str]): Return only events newer than given - iso datetime string. - older_than (Optional[str]): Return only events older than given - iso datetime string. - fields (Optional[Iterable[str]]): Fields that should be received - for each event. - limit (Optional[int]): Limit number of events to be fetched. - order (Optional[SortOrder]): Order events in ascending - or descending order. It is recommended to set 'limit' - when used descending. - first (Optional[int]): Get first n events. - last (Optional[int]): Get last n events. - states (Optional[Iterable[str]]): DEPRECATED Filtering by states. - Use 'statuses' instead. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + settings (dict[str, Any]): Full settings values. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id for which are site overrides + set. Project overrides are set if not passed. + + """ + con = get_server_api_connection() + return con.set_addon_project_settings( + addon_name=addon_name, + addon_version=addon_version, + project_name=project_name, + settings=settings, + variant=variant, + site_id=site_id, + ) + + +def get_addon_studio_overrides( + addon_name: str, + addon_version: str, + variant: Optional[str] = None, + as_version: Optional[str] = None, +) -> dict[str, Any]: + """Get studio overrides of an addon. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + as_version (Optional[str]): Use overrides of different addon + version. Returns: - Generator[dict[str, Any]]: Available events matching filters. + dict[str, Any]: Overrides information by settings path. """ con = get_server_api_connection() - return con.get_events( - topics=topics, - event_ids=event_ids, - project_names=project_names, - statuses=statuses, - users=users, - text_filter=text_filter, - include_logs=include_logs, - has_children=has_children, - newer_than=newer_than, - older_than=older_than, - fields=fields, - limit=limit, - order=order, - first=first, - last=last, - states=states, + return con.get_addon_studio_overrides( + addon_name=addon_name, + addon_version=addon_version, + variant=variant, + as_version=as_version, ) -def update_event( - event_id: str, - sender: Optional[str] = None, - project_name: Optional[str] = None, - username: Optional[str] = None, - status: Optional[EventStatus] = None, - description: Optional[str] = None, - summary: Optional[dict[str, Any]] = None, - payload: Optional[dict[str, Any]] = None, - progress: Optional[int] = None, - retries: Optional[int] = None, +def modify_addon_studio_overrides( + addon_name: str, + addon_version: str, + action: Literal["delete", "pin"], + path: list[str], + variant: Optional[str] = None, ) -> None: - """Update event data. + """Modify studio override of an addon on a settings path. Args: - event_id (str): Event id. - sender (Optional[str]): New sender of event. - project_name (Optional[str]): New project name. - username (Optional[str]): New username. - status (Optional[EventStatus]): New event status. Enum: "pending", - "in_progress", "finished", "failed", "aborted", "restarted" - description (Optional[str]): New description. - summary (Optional[dict[str, Any]]): New summary. - payload (Optional[dict[str, Any]]): New payload. - progress (Optional[int]): New progress. Range [0-100]. - retries (Optional[int]): New retries. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + action (Literal["delete", "pin"]): Remove override or pin + current value as override. + path (list[str]): Path to settings key. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. """ con = get_server_api_connection() - return con.update_event( - event_id=event_id, - sender=sender, - project_name=project_name, - username=username, - status=status, - description=description, - summary=summary, - payload=payload, - progress=progress, - retries=retries, + return con.modify_addon_studio_overrides( + addon_name=addon_name, + addon_version=addon_version, + action=action, + path=path, + variant=variant, ) -def dispatch_event( - topic: str, - sender: Optional[str] = None, - event_hash: Optional[str] = None, - project_name: Optional[str] = None, - username: Optional[str] = None, - depends_on: Optional[str] = None, - description: Optional[str] = None, - summary: Optional[dict[str, Any]] = None, - payload: Optional[dict[str, Any]] = None, - finished: bool = True, - store: bool = True, - dependencies: Optional[list[str]] = None, -) -> RestApiResponse: - """Dispatch event to server. +def delete_addon_studio_overrides( + addon_name: str, + addon_version: str, + variant: Optional[str] = None, +) -> None: + """Delete all studio overrides of an addon. Args: - topic (str): Event topic used for filtering of listeners. - sender (Optional[str]): Sender of event. - event_hash (Optional[str]): Event hash. - project_name (Optional[str]): Project name. - depends_on (Optional[str]): Add dependency to another event. - username (Optional[str]): Username which triggered event. - description (Optional[str]): Description of event. - summary (Optional[dict[str, Any]]): Summary of event that can - be used for simple filtering on listeners. - payload (Optional[dict[str, Any]]): Full payload of event data with - all details. - finished (bool): Mark event as finished on dispatch. - store (bool): Store event in event queue for possible - future processing otherwise is event send only - to active listeners. - dependencies (Optional[list[str]]): Deprecated. - List of event id dependencies. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + + """ + con = get_server_api_connection() + return con.delete_addon_studio_overrides( + addon_name=addon_name, + addon_version=addon_version, + variant=variant, + ) + + +def get_addon_project_overrides( + addon_name: str, + addon_version: str, + project_name: str, + variant: Optional[str] = None, + site_id: Optional[str] = None, + as_version: Optional[str] = None, +) -> dict[str, Any]: + """Get project overrides of an addon. + + Output contains studio and project overrides, and site overrides + if 'site_id' is passed. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to include site overrides. + as_version (Optional[str]): Use overrides of different addon + version. Returns: - RestApiResponse: Response from server. + dict[str, Any]: Overrides information by settings path. """ con = get_server_api_connection() - return con.dispatch_event( - topic=topic, - sender=sender, - event_hash=event_hash, + return con.get_addon_project_overrides( + addon_name=addon_name, + addon_version=addon_version, project_name=project_name, - username=username, - depends_on=depends_on, - description=description, - summary=summary, - payload=payload, - finished=finished, - store=store, - dependencies=dependencies, + variant=variant, + site_id=site_id, + as_version=as_version, ) -def create_event( - topic: str, - sender: Optional[str] = None, - event_hash: Optional[str] = None, - project_name: Optional[str] = None, - username: Optional[str] = None, - depends_on: Optional[str] = None, - description: Optional[str] = None, - summary: Optional[dict[str, Any]] = None, - payload: Optional[dict[str, Any]] = None, - finished: bool = True, - store: bool = True, - dependencies: Optional[list[str]] = None, -) -> str: - """Dispatch event to server. - - Args: - topic (str): Event topic used for filtering of listeners. - sender (Optional[str]): Sender of event. - event_hash (Optional[str]): Event hash. - project_name (Optional[str]): Project name. - depends_on (Optional[str]): Add dependency to another event. - username (Optional[str]): Username which triggered event. - description (Optional[str]): Description of event. - summary (Optional[dict[str, Any]]): Summary of event that can - be used for simple filtering on listeners. - payload (Optional[dict[str, Any]]): Full payload of event data with - all details. - finished (bool): Mark event as finished on dispatch. - store (bool): Store event in event queue for possible - future processing otherwise is event send only - to active listeners. - dependencies (Optional[list[str]]): Deprecated. - List of event id dependencies. +def modify_addon_project_overrides( + addon_name: str, + addon_version: str, + project_name: str, + action: Literal["delete", "pin"], + path: list[str], + variant: Optional[str] = None, + site_id: Optional[str] = None, +) -> None: + """Modify project override of an addon on a settings path. - Returns: - str: Event id. + Project site override of current user is modified if 'site_id' + is passed. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + action (Literal["delete", "pin"]): Remove override or pin + current value as override. + path (list[str]): Path to settings key. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to modify site overrides. """ con = get_server_api_connection() - return con.create_event( - topic=topic, - sender=sender, - event_hash=event_hash, + return con.modify_addon_project_overrides( + addon_name=addon_name, + addon_version=addon_version, project_name=project_name, - username=username, - depends_on=depends_on, - description=description, - summary=summary, - payload=payload, - finished=finished, - store=store, - dependencies=dependencies, + action=action, + path=path, + variant=variant, + site_id=site_id, ) -def delete_event( - event_id: str, +def delete_addon_project_overrides( + addon_name: str, + addon_version: str, + project_name: str, + variant: Optional[str] = None, + site_id: Optional[str] = None, ) -> None: - """Delete event by id. + """Delete all project overrides of an addon. - Supported since AYON server 1.6.0. + Project site overrides of current user are deleted if 'site_id' + is passed. Args: - event_id (str): Event id. - - Returns: - RestApiResponse: Response from server. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to delete site overrides. """ con = get_server_api_connection() - return con.delete_event( - event_id=event_id, + return con.delete_addon_project_overrides( + addon_name=addon_name, + addon_version=addon_version, + project_name=project_name, + variant=variant, + site_id=site_id, ) -def enroll_event_job( - source_topic: Union[str, list[str]], - target_topic: str, - sender: str, - description: Optional[str] = None, - sequential: Optional[bool] = None, - events_filter: Optional[EventFilter] = None, - max_retries: Optional[int] = None, - ignore_older_than: Optional[str] = None, - ignore_sender_types: Optional[str] = None, -) -> Optional[EnrollEventData]: - """Enroll job based on events. - - Enroll will find first unprocessed event with 'source_topic' and will - create new event with 'target_topic' for it and return the new event - data. - - Use 'sequential' to control that only single target event is created - at same time. Creation of new target events is blocked while there is - at least one unfinished event with target topic, when set to 'True'. - This helps when order of events matter and more than one process using - the same target is running at the same time. - - Make sure the new event has updated status to '"finished"' status - when you're done with logic - - Target topic should not clash with other processes/services. - - Created target event have 'dependsOn' key where is id of source topic. +def get_addon_studio_raw_overrides( + addon_name: str, + addon_version: str, + variant: Optional[str] = None, +) -> dict[str, Any]: + """Get raw studio overrides of an addon. - Use-case: - - Service 1 is creating events with topic 'my.leech' - - Service 2 process 'my.leech' and uses target topic 'my.process' - - this service can run on 1-n machines - - all events must be processed in a sequence by their creation - time and only one event can be processed at a time - - in this case 'sequential' should be set to 'True' so only - one machine is actually processing events, but if one goes - down there are other that can take place - - Service 3 process 'my.leech' and uses target topic 'my.discover' - - this service can run on 1-n machines - - order of events is not important - - 'sequential' should be 'False' + Raw overrides are stored data without any processing. Only admins + can access them. Args: - source_topic (Union[str, list[str]]): Source topic to enroll with - wildcards '*', or explicit list of topics. - target_topic (str): Topic of dependent event. - sender (str): Identifier of sender (e.g. service name or username). - description (Optional[str]): Human readable text shown - in target event. - sequential (Optional[bool]): The source topic must be processed - in sequence. - events_filter (Optional[dict[str, Any]]): Filtering conditions - to filter the source event. For more technical specifications - look to server backed 'ayon_server.sqlfilter.Filter'. - TODO: Add example of filters. - max_retries (Optional[int]): How many times can be event retried. - Default value is based on server (3 at the time of this PR). - ignore_older_than (Optional[int]): Ignore events older than - given number in days. - ignore_sender_types (Optional[list[str]]): Ignore events triggered - by given sender types. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. Returns: - Optional[EnrollEventData]: None if there is no event matching - filters. Created event with 'target_topic'. + dict[str, Any]: Raw overrides data. """ con = get_server_api_connection() - return con.enroll_event_job( - source_topic=source_topic, - target_topic=target_topic, - sender=sender, - description=description, - sequential=sequential, - events_filter=events_filter, - max_retries=max_retries, - ignore_older_than=ignore_older_than, - ignore_sender_types=ignore_sender_types, + return con.get_addon_studio_raw_overrides( + addon_name=addon_name, + addon_version=addon_version, + variant=variant, ) -def get_attributes_schema( - use_cache: bool = True, -) -> AttributesSchemaDict: - con = get_server_api_connection() - return con.get_attributes_schema( - use_cache=use_cache, - ) - +def set_addon_studio_raw_overrides( + addon_name: str, + addon_version: str, + overrides: dict[str, Any], + variant: Optional[str] = None, +) -> None: + """Set raw studio overrides of an addon. -def reset_attributes_schema() -> None: - """Reset attributes schema cache. + Warnings: + Data are stored without any validation. Only admins can use + this. Use with caution. - DEPRECATED: - Use 'reset_attributes_cache' instead. + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + overrides (dict[str, Any]): Raw overrides data. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. """ con = get_server_api_connection() - return con.reset_attributes_schema() + return con.set_addon_studio_raw_overrides( + addon_name=addon_name, + addon_version=addon_version, + overrides=overrides, + variant=variant, + ) -def reset_attributes_cache() -> None: - con = get_server_api_connection() - return con.reset_attributes_cache() +def get_addon_project_raw_overrides( + addon_name: str, + addon_version: str, + project_name: str, + variant: Optional[str] = None, + site_id: Optional[str] = None, +) -> dict[str, Any]: + """Get raw project overrides of an addon. + Raw overrides are stored data without any processing. Project + overrides are accessible only by admins, project site overrides + of current user are returned if 'site_id' is passed. -def set_attributes_cache_timeout( - timeout: int, -) -> None: - con = get_server_api_connection() - return con.set_attributes_cache_timeout( - timeout=timeout, - ) + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to get site overrides. + Returns: + dict[str, Any]: Raw overrides data. -def set_attribute_config( - attribute_name: str, - data: AttributeSchemaDataDict, - scope: list[AttributeScope], - position: Optional[int] = None, - builtin: bool = False, -) -> None: + """ con = get_server_api_connection() - return con.set_attribute_config( - attribute_name=attribute_name, - data=data, - scope=scope, - position=position, - builtin=builtin, + return con.get_addon_project_raw_overrides( + addon_name=addon_name, + addon_version=addon_version, + project_name=project_name, + variant=variant, + site_id=site_id, ) -def delete_attribute_config( - attribute_name: str, +def set_addon_project_raw_overrides( + addon_name: str, + addon_version: str, + project_name: str, + overrides: dict[str, Any], + variant: Optional[str] = None, + site_id: Optional[str] = None, ) -> None: - """Remove attribute from server. + """Set raw project overrides of an addon. - This can't be un-done, please use carefully. + Project site overrides of current user are set if 'site_id' + is passed. + + Warnings: + Data are stored without any validation. Use with caution. Args: - attribute_name (str): Name of attribute to remove. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + overrides (dict[str, Any]): Raw overrides data. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to set site overrides. """ con = get_server_api_connection() - return con.delete_attribute_config( - attribute_name=attribute_name, + return con.set_addon_project_raw_overrides( + addon_name=addon_name, + addon_version=addon_version, + project_name=project_name, + overrides=overrides, + variant=variant, + site_id=site_id, ) -def remove_attribute_config( - attribute_name: str, +def set_addon_site_settings( + addon_name: str, + addon_version: str, + settings: dict[str, Any], + site_id: Optional[str] = None, ) -> None: - """Remove attribute from server. - - DEPRECATED: Use 'delete_attribute_config' instead. + """Set site settings of an addon for current user. Args: - attribute_name (str): Name of attribute to remove. + addon_name (str): Name of addon. + addon_version (str): Version of addon. + settings (dict[str, Any]): Site settings values. + site_id (Optional[str]): Site id for which are settings set. + Using 'site_id' attribute if not passed. + + Raises: + ValueError: If site id is not available. """ con = get_server_api_connection() - return con.remove_attribute_config( - attribute_name=attribute_name, + return con.set_addon_site_settings( + addon_name=addon_name, + addon_version=addon_version, + settings=settings, + site_id=site_id, ) -def get_attributes_for_type( - entity_type: AttributeScope, -) -> dict[str, AttributeSchemaDataDict]: - """Get attribute schemas available for an entity type. - - Example:: - - ``` - # Example attribute schema - { - # Common - "type": "integer", - "title": "Clip Out", - "description": null, - "example": 1, - "default": 1, - # These can be filled based on value of 'type' - "gt": null, - "ge": null, - "lt": null, - "le": null, - "minLength": null, - "maxLength": null, - "minItems": null, - "maxItems": null, - "regex": null, - "enum": null - } - ``` +def migrate_addon_settings( + addon_name: str, + source_version: str, + target_version: str, + source_variant: Optional[str] = None, + target_variant: Optional[str] = None, + with_projects: Optional[bool] = None, +) -> dict[str, Any]: + """Migrate settings of an addon between addon versions. Args: - entity_type (str): Entity type for which should be attributes - received. + addon_name (str): Name of addon. + source_version (str): Source addon version. + target_version (str): Target addon version. + source_variant (Optional[str]): Source settings variant. Server + uses 'production' if not passed. + target_variant (Optional[str]): Target settings variant. Server + uses 'production' if not passed. + with_projects (Optional[bool]): Migrate also project settings. + Server uses 'True' if not passed. Returns: - dict[str, dict[str, Any]]: Attribute schemas that are available - for entered entity type. + dict[str, Any]: Server response with 'events' created during + the migration. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.16.8. """ con = get_server_api_connection() - return con.get_attributes_for_type( - entity_type=entity_type, + return con.migrate_addon_settings( + addon_name=addon_name, + source_version=source_version, + target_version=target_version, + source_variant=source_variant, + target_variant=target_variant, + with_projects=with_projects, ) -def get_attributes_fields_for_type( - entity_type: AttributeScope, -) -> set[str]: - """Prepare attribute fields for entity type. +def migrate_settings_by_bundle( + source_bundle: str, + target_bundle: str, + source_variant: str, + target_variant: str, + with_projects: bool = True, +) -> None: + """Migrate settings of addons from source to target bundle. - DEPRECATED: Field 'attrib' is marked as deprecated and should not be - used for GraphQL queries. + Settings of addons present in both bundles are migrated. - Returns: - set[str]: Attributes fields for entity type. + Args: + source_bundle (str): Source bundle name. + target_bundle (str): Target bundle name. + source_variant (str): Source settings variant. + target_variant (str): Target settings variant. + with_projects (bool): Migrate also project settings. """ con = get_server_api_connection() - return con.get_attributes_fields_for_type( - entity_type=entity_type, + return con.migrate_settings_by_bundle( + source_bundle=source_bundle, + target_bundle=target_bundle, + source_variant=source_variant, + target_variant=target_variant, + with_projects=with_projects, ) -def get_project_anatomy_presets() -> list[AnatomyPresetDict]: - """Anatomy presets available on server. +def get_event( + event_id: str, +) -> Optional[dict[str, Any]]: + """Query full event data by id. - Content has basic information about presets. Example output:: + Events received using event server do not contain full information. To + get the full event information is required to receive it explicitly. - [ - { - "name": "netflix_VFX", - "primary": false, - "version": "1.0.0" - }, - { - ... - }, - ... - ] + Args: + event_id (str): Event id. Returns: - list[dict[str, str]]: Anatomy presets available on server. + dict[str, Any]: Full event data. """ con = get_server_api_connection() - return con.get_project_anatomy_presets() + return con.get_event( + event_id=event_id, + ) -def get_default_anatomy_preset_name() -> str: - """Name of default anatomy preset. +def get_events( + topics: Optional[Iterable[str]] = None, + event_ids: Optional[Iterable[str]] = None, + project_names: Optional[Iterable[str]] = None, + statuses: Optional[Iterable[EventStatus]] = None, + users: Optional[Iterable[str]] = None, + text_filter: Optional[str] = None, + include_logs: Optional[bool] = None, + has_children: Optional[bool] = None, + newer_than: Optional[str] = None, + older_than: Optional[str] = None, + fields: Optional[Iterable[str]] = None, + limit: Optional[int] = None, + order: Optional[SortOrder] = None, + first: Optional[int] = None, + last: Optional[int] = None, + states: Optional[Iterable[str]] = None, +) -> Generator[dict[str, Any], None, None]: + """Get events from server with filtering options. - Primary preset is used as default preset. But when primary preset is - not set a built-in is used instead. Built-in preset is named '_'. + Notes: + Not all event happen on a project. + + Args: + topics (Optional[Iterable[str]]): Name of topics. + event_ids (Optional[Iterable[str]]): Event ids. + project_names (Optional[Iterable[str]]): Project on which + event happened. + statuses (Optional[Iterable[EventStatus]]): Filtering by statuses. + users (Optional[Iterable[str]]): Filtering by users + who created/triggered an event. + text_filter (Optional[str]): Filtering by text in event payload. + include_logs (Optional[bool]): Query also log events. + has_children (Optional[bool]): Event is with/without children + events. If 'None' then all events are returned, default. + newer_than (Optional[str]): Return only events newer than given + iso datetime string. + older_than (Optional[str]): Return only events older than given + iso datetime string. + fields (Optional[Iterable[str]]): Fields that should be received + for each event. + limit (Optional[int]): Limit number of events to be fetched. + order (Optional[SortOrder]): Order events in ascending + or descending order. It is recommended to set 'limit' + when used descending. + first (Optional[int]): Get first n events. + last (Optional[int]): Get last n events. + states (Optional[Iterable[str]]): DEPRECATED Filtering by states. + Use 'statuses' instead. Returns: - str: Name of preset that can be used by - 'get_project_anatomy_preset'. + Generator[dict[str, Any]]: Available events matching filters. """ con = get_server_api_connection() - return con.get_default_anatomy_preset_name() - + return con.get_events( + topics=topics, + event_ids=event_ids, + project_names=project_names, + statuses=statuses, + users=users, + text_filter=text_filter, + include_logs=include_logs, + has_children=has_children, + newer_than=newer_than, + older_than=older_than, + fields=fields, + limit=limit, + order=order, + first=first, + last=last, + states=states, + ) -def get_project_anatomy_preset( - preset_name: Optional[str] = None, -) -> AnatomyPresetDict: - """Anatomy preset values by name. - Get anatomy preset values by preset name. Primary preset is returned - if preset name is set to 'None'. +def update_event( + event_id: str, + sender: Optional[str] = None, + project_name: Optional[str] = None, + username: Optional[str] = None, + status: Optional[EventStatus] = None, + description: Optional[str] = None, + summary: Optional[dict[str, Any]] = None, + payload: Optional[dict[str, Any]] = None, + progress: Optional[int] = None, + retries: Optional[int] = None, +) -> None: + """Update event data. Args: - preset_name (Optional[str]): Preset name. - - Returns: - AnatomyPresetDict: Anatomy preset values. + event_id (str): Event id. + sender (Optional[str]): New sender of event. + project_name (Optional[str]): New project name. + username (Optional[str]): New username. + status (Optional[EventStatus]): New event status. Enum: "pending", + "in_progress", "finished", "failed", "aborted", "restarted" + description (Optional[str]): New description. + summary (Optional[dict[str, Any]]): New summary. + payload (Optional[dict[str, Any]]): New payload. + progress (Optional[int]): New progress. Range [0-100]. + retries (Optional[int]): New retries. """ con = get_server_api_connection() - return con.get_project_anatomy_preset( - preset_name=preset_name, + return con.update_event( + event_id=event_id, + sender=sender, + project_name=project_name, + username=username, + status=status, + description=description, + summary=summary, + payload=payload, + progress=progress, + retries=retries, ) -def get_built_in_anatomy_preset() -> AnatomyPresetDict: - """Get built-in anatomy preset. +def dispatch_event( + topic: str, + sender: Optional[str] = None, + event_hash: Optional[str] = None, + project_name: Optional[str] = None, + username: Optional[str] = None, + depends_on: Optional[str] = None, + description: Optional[str] = None, + summary: Optional[dict[str, Any]] = None, + payload: Optional[dict[str, Any]] = None, + finished: bool = True, + store: bool = True, + dependencies: Optional[list[str]] = None, +) -> RestApiResponse: + """Dispatch event to server. + + Args: + topic (str): Event topic used for filtering of listeners. + sender (Optional[str]): Sender of event. + event_hash (Optional[str]): Event hash. + project_name (Optional[str]): Project name. + depends_on (Optional[str]): Add dependency to another event. + username (Optional[str]): Username which triggered event. + description (Optional[str]): Description of event. + summary (Optional[dict[str, Any]]): Summary of event that can + be used for simple filtering on listeners. + payload (Optional[dict[str, Any]]): Full payload of event data with + all details. + finished (bool): Mark event as finished on dispatch. + store (bool): Store event in event queue for possible + future processing otherwise is event send only + to active listeners. + dependencies (Optional[list[str]]): Deprecated. + List of event id dependencies. Returns: - AnatomyPresetDict: Built-in anatomy preset. + RestApiResponse: Response from server. """ con = get_server_api_connection() - return con.get_built_in_anatomy_preset() + return con.dispatch_event( + topic=topic, + sender=sender, + event_hash=event_hash, + project_name=project_name, + username=username, + depends_on=depends_on, + description=description, + summary=summary, + payload=payload, + finished=finished, + store=store, + dependencies=dependencies, + ) -def get_build_in_anatomy_preset() -> AnatomyPresetDict: - con = get_server_api_connection() - return con.get_build_in_anatomy_preset() - - -def get_rest_project( - project_name: str, -) -> Optional[ProjectDict]: - """Query project by name. - - This call returns project with anatomy data. +def create_event( + topic: str, + sender: Optional[str] = None, + event_hash: Optional[str] = None, + project_name: Optional[str] = None, + username: Optional[str] = None, + depends_on: Optional[str] = None, + description: Optional[str] = None, + summary: Optional[dict[str, Any]] = None, + payload: Optional[dict[str, Any]] = None, + finished: bool = True, + store: bool = True, + dependencies: Optional[list[str]] = None, +) -> str: + """Dispatch event to server. Args: - project_name (str): Name of project. + topic (str): Event topic used for filtering of listeners. + sender (Optional[str]): Sender of event. + event_hash (Optional[str]): Event hash. + project_name (Optional[str]): Project name. + depends_on (Optional[str]): Add dependency to another event. + username (Optional[str]): Username which triggered event. + description (Optional[str]): Description of event. + summary (Optional[dict[str, Any]]): Summary of event that can + be used for simple filtering on listeners. + payload (Optional[dict[str, Any]]): Full payload of event data with + all details. + finished (bool): Mark event as finished on dispatch. + store (bool): Store event in event queue for possible + future processing otherwise is event send only + to active listeners. + dependencies (Optional[list[str]]): Deprecated. + List of event id dependencies. Returns: - Optional[ProjectDict]: Project entity data or 'None' if - project was not found. + str: Event id. """ con = get_server_api_connection() - return con.get_rest_project( + return con.create_event( + topic=topic, + sender=sender, + event_hash=event_hash, project_name=project_name, + username=username, + depends_on=depends_on, + description=description, + summary=summary, + payload=payload, + finished=finished, + store=store, + dependencies=dependencies, ) -def get_rest_projects( - active: Optional[bool] = True, - library: Optional[bool] = None, - include_skeleton: bool = False, -) -> Generator[ProjectDict, None, None]: - """Query available project entities. +def delete_event( + event_id: str, +) -> None: + """Delete event by id. - User must be logged in. + Supported since AYON server 1.6.0. Args: - active (Optional[bool]): Filter active/inactive projects. Both - 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. + event_id (str): Event id. Returns: - Generator[ProjectDict, None, None]: Available projects. + RestApiResponse: Response from server. """ con = get_server_api_connection() - return con.get_rest_projects( - active=active, - library=library, - include_skeleton=include_skeleton, + return con.delete_event( + event_id=event_id, ) -def get_rest_projects_list( - active: Optional[bool] = True, - library: Optional[bool] = None, - include_skeleton: bool = False, -) -> list[ProjectListDict]: - """Receive available projects. +def delete_events( + events_filter: EventFilter, +) -> None: + """Delete events matching filter. - User must be logged in. + Requires administrator privileges. + + Example filter:: + + { + "conditions": [ + { + "key": "topic", + "value": "my.topic", + "operator": "eq", + } + ], + "operator": "and", + } Args: - active (Optional[bool]): Filter active/inactive projects. Both - 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. + events_filter (EventFilter): Filtering conditions to find events + to delete. For more technical specifications look to server + backed 'ayon_server.sqlfilter.QueryFilter'. - Returns: - list[ProjectListDict]: List of available projects. + Raises: + ValueError: Filter does not contain any conditions. Server would + delete all events. """ con = get_server_api_connection() - return con.get_rest_projects_list( - active=active, - library=library, - include_skeleton=include_skeleton, + return con.delete_events( + events_filter=events_filter, ) -def get_project_names( - active: Optional[bool] = True, - library: Optional[bool] = None, - include_skeleton: bool = False, -) -> list[str]: - """Receive available project names. +def enroll_event_job( + source_topic: Union[str, list[str]], + target_topic: str, + sender: str, + description: Optional[str] = None, + sequential: Optional[bool] = None, + events_filter: Optional[EventFilter] = None, + max_retries: Optional[int] = None, + ignore_older_than: Optional[str] = None, + ignore_sender_types: Optional[str] = None, +) -> Optional[EnrollEventData]: + """Enroll job based on events. - User must be logged in. + Enroll will find first unprocessed event with 'source_topic' and will + create new event with 'target_topic' for it and return the new event + data. - Args: - active (Optional[bool]): Filter active/inactive projects. Both - 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. + Use 'sequential' to control that only single target event is created + at same time. Creation of new target events is blocked while there is + at least one unfinished event with target topic, when set to 'True'. + This helps when order of events matter and more than one process using + the same target is running at the same time. - Returns: - list[str]: List of available project names. + Make sure the new event has updated status to '"finished"' status + when you're done with logic - """ - con = get_server_api_connection() - return con.get_project_names( - active=active, - library=library, - include_skeleton=include_skeleton, - ) + Target topic should not clash with other processes/services. + Created target event have 'dependsOn' key where is id of source topic. -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]: - """Get projects. + Use-case: + - Service 1 is creating events with topic 'my.leech' + - Service 2 process 'my.leech' and uses target topic 'my.process' + - this service can run on 1-n machines + - all events must be processed in a sequence by their creation + time and only one event can be processed at a time + - in this case 'sequential' should be set to 'True' so only + one machine is actually processing events, but if one goes + down there are other that can take place + - Service 3 process 'my.leech' and uses target topic 'my.discover' + - this service can run on 1-n machines + - order of events is not important + - 'sequential' should be 'False' Args: - active (Optional[bool]): Filter active or inactive 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 - not explicitly set on entity will have 'None' value. + source_topic (Union[str, list[str]]): Source topic to enroll with + wildcards '*', or explicit list of topics. + target_topic (str): Topic of dependent event. + sender (str): Identifier of sender (e.g. service name or username). + description (Optional[str]): Human readable text shown + in target event. + sequential (Optional[bool]): The source topic must be processed + in sequence. + events_filter (Optional[dict[str, Any]]): Filtering conditions + to filter the source event. For more technical specifications + look to server backed 'ayon_server.sqlfilter.Filter'. + TODO: Add example of filters. + max_retries (Optional[int]): How many times can be event retried. + Default value is based on server (3 at the time of this PR). + ignore_older_than (Optional[int]): Ignore events older than + given number in days. + ignore_sender_types (Optional[list[str]]): Ignore events triggered + by given sender types. Returns: - Generator[ProjectDict, None, None]: Queried projects. + Optional[EnrollEventData]: None if there is no event matching + filters. Created event with 'target_topic'. """ con = get_server_api_connection() - return con.get_projects( - active=active, - library=library, - include_skeleton=include_skeleton, - fields=fields, - own_attributes=own_attributes, + return con.enroll_event_job( + source_topic=source_topic, + target_topic=target_topic, + sender=sender, + description=description, + sequential=sequential, + events_filter=events_filter, + max_retries=max_retries, + ignore_older_than=ignore_older_than, + ignore_sender_types=ignore_sender_types, ) -def get_project( - project_name: str, - fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Optional[ProjectDict]: - """Get project. +def get_attributes_schema( + use_cache: bool = True, +) -> AttributesSchemaDict: + con = get_server_api_connection() + return con.get_attributes_schema( + use_cache=use_cache, + ) - Args: - project_name (str): Name of project. - fields (Optional[Iterable[str]]): fields to be queried - for project. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. - Returns: - Optional[ProjectDict]: Project entity data or None - if project was not found. +def reset_attributes_schema() -> None: + """Reset attributes schema cache. + + DEPRECATED: + Use 'reset_attributes_cache' instead. """ con = get_server_api_connection() - return con.get_project( - project_name=project_name, - fields=fields, - own_attributes=own_attributes, - ) - - -def create_project( - project_name: str, - 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. - - This project creation function is not validating project entity on - creation. It is because project entity is created blindly with only - minimum required information about project which is name and code. + return con.reset_attributes_schema() - Entered project name must be unique and project must not exist yet. - Note: - This function is here to be OP v4 ready but in v3 has more logic - to do. That's why inner imports are in the body. +def reset_attributes_cache() -> None: + con = get_server_api_connection() + return con.reset_attributes_cache() - Args: - project_name (str): New project name. Should be unique. - project_code (str): Project's code should be unique too. - 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. +def set_attributes_cache_timeout( + timeout: int, +) -> None: + con = get_server_api_connection() + return con.set_attributes_cache_timeout( + timeout=timeout, + ) - Returns: - ProjectDict: Created project entity. - """ +def set_attribute_config( + attribute_name: str, + data: AttributeSchemaDataDict, + scope: list[AttributeScope], + position: Optional[int] = None, + builtin: bool = False, +) -> None: con = get_server_api_connection() - return con.create_project( - project_name=project_name, - project_code=project_code, - library_project=library_project, - preset_name=preset_name, + return con.set_attribute_config( + attribute_name=attribute_name, data=data, - skeleton=skeleton, + scope=scope, + position=position, + builtin=builtin, ) -def update_project( - project_name: str, - library: Optional[bool] = None, - folder_types: Optional[list[dict[str, Any]]] = None, - task_types: Optional[list[dict[str, Any]]] = None, - link_types: Optional[list[dict[str, Any]]] = None, - statuses: Optional[list[dict[str, Any]]] = None, - tags: Optional[list[dict[str, Any]]] = None, - config: Optional[dict[str, Any]] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - active: Optional[bool] = None, - project_code: Optional[str] = None, - **changes, +def delete_attribute_config( + attribute_name: str, ) -> None: - """Update project entity on server. + """Remove attribute from server. + + This can't be un-done, please use carefully. Args: - project_name (str): Name of project. - library (Optional[bool]): Change library state. - folder_types (Optional[list[dict[str, Any]]]): Folder type - definitions. - task_types (Optional[list[dict[str, Any]]]): Task type - definitions. - link_types (Optional[list[dict[str, Any]]]): Link type - definitions. - statuses (Optional[list[dict[str, Any]]]): Status definitions. - tags (Optional[list[dict[str, Any]]]): List of tags available to - set on entities. - config (Optional[dict[str, Any]]): Project anatomy config - with templates and roots. - attrib (Optional[dict[str, Any]]): Project attributes to change. - data (Optional[dict[str, Any]]): Custom data of a project. This - value will 100% override project data. - active (Optional[bool]): Change active state of a project. - project_code (Optional[str]): Change project code. Not recommended - during production. - **changes: Other changed keys based on Rest API documentation. + attribute_name (str): Name of attribute to remove. """ con = get_server_api_connection() - return con.update_project( - project_name=project_name, - library=library, - folder_types=folder_types, - task_types=task_types, - link_types=link_types, - statuses=statuses, - tags=tags, - config=config, - attrib=attrib, - data=data, - active=active, - project_code=project_code, - **changes, + return con.delete_attribute_config( + attribute_name=attribute_name, ) -def delete_project( - project_name: str, -): - """Delete project from server. +def remove_attribute_config( + attribute_name: str, +) -> None: + """Remove attribute from server. - This will completely remove project from server without any step back. + DEPRECATED: Use 'delete_attribute_config' instead. Args: - project_name (str): Project name that will be removed. + attribute_name (str): Name of attribute to remove. """ con = get_server_api_connection() - return con.delete_project( - project_name=project_name, + return con.remove_attribute_config( + attribute_name=attribute_name, ) -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_attributes_for_type( + entity_type: AttributeScope, +) -> dict[str, AttributeSchemaDataDict]: + """Get attribute schemas available for an entity type. -def get_project_folders() -> list[dict[str, Any]]: - con = get_server_api_connection() - return con.get_project_folders() + Example:: + ``` + # Example attribute schema + { + # Common + "type": "integer", + "title": "Clip Out", + "description": null, + "example": 1, + "default": 1, + # These can be filled based on value of 'type' + "gt": null, + "ge": null, + "lt": null, + "le": null, + "minLength": null, + "maxLength": null, + "minItems": null, + "maxItems": null, + "regex": null, + "enum": null + } + ``` -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, - ) + Args: + entity_type (str): Entity type for which should be attributes + received. + Returns: + dict[str, dict[str, Any]]: Attribute schemas that are available + for entered entity type. -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, + return con.get_attributes_for_type( + entity_type=entity_type, ) -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 get_attributes_fields_for_type( + entity_type: AttributeScope, +) -> set[str]: + """Prepare attribute fields for entity type. -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, - ) + DEPRECATED: Field 'attrib' is marked as deprecated and should not be + used for GraphQL queries. + Returns: + set[str]: Attributes fields for entity type. -def delete_project_folder( - folder_id: str, -): - """Delete project folder.""" + """ con = get_server_api_connection() - return con.delete_project_folder( - folder_id=folder_id, + return con.get_attributes_fields_for_type( + entity_type=entity_type, ) -def get_project_root_overrides( - project_name: str, -) -> dict[str, dict[str, str]]: - """Root overrides per site name. - - Method is based on logged user and can't be received for any other - user on server. - - Output will contain only roots per site id used by logged user. +def get_project_anatomy_presets() -> list[AnatomyPresetDict]: + """Anatomy presets available on server. - Args: - project_name (str): Name of project. + Content has basic information about presets. Example output:: - Returns: - dict[str, dict[str, str]]: Root values by root name by site id. + [ + { + "name": "netflix_VFX", + "primary": false, + "version": "1.0.0" + }, + { + ... + }, + ... + ] + + Returns: + list[dict[str, str]]: Anatomy presets available on server. """ con = get_server_api_connection() - return con.get_project_root_overrides( - project_name=project_name, - ) + return con.get_project_anatomy_presets() -def get_project_roots_by_site( - project_name: str, -) -> dict[str, dict[str, str]]: - """Root overrides per site name. +def get_default_anatomy_preset_name() -> str: + """Name of default anatomy preset. - Method is based on logged user and can't be received for any other - user on server. + Primary preset is used as default preset. But when primary preset is + not set a built-in is used instead. Built-in preset is named '_'. - Output will contain only roots per site id used by logged user. + Returns: + str: Name of preset that can be used by + 'get_project_anatomy_preset'. - Deprecated: - Use 'get_project_root_overrides' instead. Function - deprecated since 1.0.6 + """ + con = get_server_api_connection() + return con.get_default_anatomy_preset_name() + + +def get_project_anatomy_preset( + preset_name: Optional[str] = None, +) -> AnatomyPresetDict: + """Anatomy preset values by name. + + Get anatomy preset values by preset name. Primary preset is returned + if preset name is set to 'None'. Args: - project_name (str): Name of project. + preset_name (Optional[str]): Preset name. Returns: - dict[str, dict[str, str]]: Root values by root name by site id. + AnatomyPresetDict: Anatomy preset values. """ con = get_server_api_connection() - return con.get_project_roots_by_site( - project_name=project_name, + return con.get_project_anatomy_preset( + preset_name=preset_name, ) -def get_project_root_overrides_by_site_id( - project_name: str, - site_id: Optional[str] = None, -) -> dict[str, str]: - """Root overrides for site. - - If site id is not passed a site set in current api object is used - instead. - - Args: - project_name (str): Name of project. - site_id (Optional[str]): Site id for which want to receive - site overrides. +def get_built_in_anatomy_preset() -> AnatomyPresetDict: + """Get built-in anatomy preset. Returns: - dict[str, str]: Root values by root name or None if - site does not have overrides. + AnatomyPresetDict: Built-in anatomy preset. """ con = get_server_api_connection() - return con.get_project_root_overrides_by_site_id( - project_name=project_name, - site_id=site_id, - ) + return con.get_built_in_anatomy_preset() -def get_project_roots_for_site( - project_name: str, - site_id: Optional[str] = None, -) -> dict[str, str]: - """Root overrides for site. +def get_build_in_anatomy_preset() -> AnatomyPresetDict: + con = get_server_api_connection() + return con.get_build_in_anatomy_preset() - If site id is not passed a site set in current api object is used - instead. - Deprecated: - Use 'get_project_root_overrides_by_site_id' instead. Function - deprecated since 1.0.6 - Args: - project_name (str): Name of project. - site_id (Optional[str]): Site id for which want to receive - site overrides. +def get_anatomy_schema() -> dict[str, Any]: + """Get anatomy JSON schema. + + The schema is used to display the anatomy preset editor form. Returns: - dict[str, str]: Root values by root name, root name is not - available if it does not have overrides. + dict[str, Any]: Anatomy JSON schema. """ con = get_server_api_connection() - return con.get_project_roots_for_site( - project_name=project_name, - site_id=site_id, - ) + return con.get_anatomy_schema() -def get_project_roots_by_site_id( - project_name: str, - site_id: Optional[str] = None, -) -> dict[str, str]: - """Root values for a site. +def save_project_anatomy_preset( + preset_name: str, + preset: AnatomyPresetDict, +) -> None: + """Create or update anatomy preset. - If site id is not passed a site set in current api object is used - instead. If site id is not available, default roots are returned - for current platform. + Built-in preset '__builtin__' and primary preset reference + '__primary__' cannot be updated. Args: - project_name (str): Name of project. - site_id (Optional[str]): Site id for which want to receive - root values. - - Returns: - dict[str, str]: Root values. + preset_name (str): Anatomy preset name. + preset (AnatomyPresetDict): Anatomy preset values. """ con = get_server_api_connection() - return con.get_project_roots_by_site_id( - project_name=project_name, - site_id=site_id, + return con.save_project_anatomy_preset( + preset_name=preset_name, + preset=preset, ) -def get_project_roots_by_platform( - project_name: str, - platform_name: Optional[str] = None, -) -> dict[str, str]: - """Root values for a site. - - If platform name is not passed current platform name is used instead. - - This function does return root values without site overrides. It is - possible to use the function to receive default root values. +def rename_project_anatomy_preset( + preset_name: str, + new_preset_name: str, +) -> None: + """Rename anatomy preset. Args: - project_name (str): Name of project. - platform_name (Optional[Literal["windows", "linux", "darwin"]]): - Platform name for which want to receive root values. Current - platform name is used if not passed. - - Returns: - dict[str, str]: Root values. + preset_name (str): Current anatomy preset name. + new_preset_name (str): New anatomy preset name. """ con = get_server_api_connection() - return con.get_project_roots_by_platform( - project_name=project_name, - platform_name=platform_name, + return con.rename_project_anatomy_preset( + preset_name=preset_name, + new_preset_name=new_preset_name, ) -def get_rest_folder( - project_name: str, - folder_id: str, -) -> Optional[FolderDict]: +def delete_project_anatomy_preset( + preset_name: str, +) -> None: + """Delete anatomy preset. + + Args: + preset_name (str): Anatomy preset name. + + """ con = get_server_api_connection() - return con.get_rest_folder( - project_name=project_name, - folder_id=folder_id, + return con.delete_project_anatomy_preset( + preset_name=preset_name, ) -def get_rest_folders( - project_name: str, - include_attrib: bool = False, -) -> list[FlatFolderDict]: - """Get simplified flat list of all project folders. +def set_primary_anatomy_preset( + preset_name: str, +) -> None: + """Set anatomy preset as primary. - Get all project folders in single REST call. This can be faster than - using 'get_folders' method which is using GraphQl, but does not - allow any filtering, and set of fields is defined - by server backend. + Primary preset is used as default preset for new projects. Use + preset name '_' to unset any primary preset (built-in preset + is used as default). - Example:: + Args: + preset_name (str): Anatomy preset name. - [ - { - "id": "112233445566", - "parentId": "112233445567", - "path": "/root/parent/child", - "parents": ["root", "parent"], - "name": "child", - "label": "Child", - "folderType": "Folder", - "hasTasks": False, - "hasChildren": False, - "taskNames": [ - "Compositing", - ], - "status": "In Progress", - "attrib": {}, - "ownAttrib": [], - "updatedAt": "2023-06-12T15:37:02.420260", - }, - ... - ] + """ + con = get_server_api_connection() + return con.set_primary_anatomy_preset( + preset_name=preset_name, + ) - Args: - project_name (str): Project name. - include_attrib (Optional[bool]): Include attribute values - in output. Slower to query. - Returns: - list[FlatFolderDict]: List of folder entities. +def unset_primary_anatomy_preset( + preset_name: str, +) -> None: + """Unset primary flag of anatomy preset. + + Args: + preset_name (str): Anatomy preset name. """ con = get_server_api_connection() - return con.get_rest_folders( - project_name=project_name, - include_attrib=include_attrib, + return con.unset_primary_anatomy_preset( + preset_name=preset_name, ) -def get_folders_hierarchy( +def get_rest_project( project_name: str, - search_string: Optional[str] = None, - folder_types: Optional[Iterable[str]] = None, -) -> ProjectHierarchyDict: - """Get project hierarchy. - - All folders in project in hierarchy data structure. +) -> Optional[ProjectDict]: + """Query project by name. - Example output: - { - "hierarchy": [ - { - "id": "...", - "name": "...", - "label": "...", - "status": "...", - "folderType": "...", - "hasTasks": False, - "taskNames": [], - "parents": [], - "parentId": None, - "children": [...children folders...] - }, - ... - ] - } + This call returns project with anatomy data. Args: - project_name (str): Project where to look for folders. - search_string (Optional[str]): Search string to filter folders. - folder_types (Optional[Iterable[str]]): Folder types to filter. + project_name (str): Name of project. Returns: - dict[str, Any]: Response data from server. + Optional[ProjectDict]: Project entity data or 'None' if + project was not found. """ con = get_server_api_connection() - return con.get_folders_hierarchy( + return con.get_rest_project( project_name=project_name, - search_string=search_string, - folder_types=folder_types, ) -def get_folders_rest( - project_name: str, - include_attrib: bool = False, -) -> list[FlatFolderDict]: - """Get simplified flat list of all project folders. - - Get all project folders in single REST call. This can be faster than - using 'get_folders' method which is using GraphQl, but does not - allow any filtering, and set of fields is defined - by server backend. +def get_rest_projects( + active: Optional[bool] = True, + library: Optional[bool] = None, + include_skeleton: bool = False, +) -> Generator[ProjectDict, None, None]: + """Query available project entities. + + User must be logged in. + + Args: + active (Optional[bool]): Filter active/inactive projects. Both + 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. + + """ + con = get_server_api_connection() + 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. + + User must be logged in. + + Args: + active (Optional[bool]): Filter active/inactive projects. Both + 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. + + """ + con = get_server_api_connection() + 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. + + User must be logged in. + + Args: + active (Optional[bool]): Filter active/inactive projects. Both + 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. + + """ + con = get_server_api_connection() + 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]: + """Get projects. + + Args: + active (Optional[bool]): Filter active or inactive 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 + not explicitly set on entity will have 'None' value. + + Returns: + Generator[ProjectDict, None, None]: Queried projects. + + """ + con = get_server_api_connection() + return con.get_projects( + active=active, + library=library, + include_skeleton=include_skeleton, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_project( + project_name: str, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Optional[ProjectDict]: + """Get project. + + Args: + project_name (str): Name of project. + fields (Optional[Iterable[str]]): fields to be queried + for project. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Optional[ProjectDict]: Project entity data or None + if project was not found. + + """ + con = get_server_api_connection() + return con.get_project( + project_name=project_name, + fields=fields, + own_attributes=own_attributes, + ) + + +def create_project( + project_name: str, + 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. + + This project creation function is not validating project entity on + creation. It is because project entity is created blindly with only + minimum required information about project which is name and code. + + Entered project name must be unique and project must not exist yet. + + Note: + This function is here to be OP v4 ready but in v3 has more logic + to do. That's why inner imports are in the body. + + Args: + project_name (str): New project name. Should be unique. + project_code (str): Project's code should be unique too. + 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. + + Returns: + ProjectDict: Created project entity. + + """ + con = get_server_api_connection() + return con.create_project( + project_name=project_name, + project_code=project_code, + library_project=library_project, + preset_name=preset_name, + data=data, + skeleton=skeleton, + ) + + +def update_project( + project_name: str, + library: Optional[bool] = None, + folder_types: Optional[list[dict[str, Any]]] = None, + task_types: Optional[list[dict[str, Any]]] = None, + link_types: Optional[list[dict[str, Any]]] = None, + statuses: Optional[list[dict[str, Any]]] = None, + tags: Optional[list[dict[str, Any]]] = None, + config: Optional[dict[str, Any]] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + active: Optional[bool] = None, + project_code: Optional[str] = None, + **changes, +) -> None: + """Update project entity on server. + + Args: + project_name (str): Name of project. + library (Optional[bool]): Change library state. + folder_types (Optional[list[dict[str, Any]]]): Folder type + definitions. + task_types (Optional[list[dict[str, Any]]]): Task type + definitions. + link_types (Optional[list[dict[str, Any]]]): Link type + definitions. + statuses (Optional[list[dict[str, Any]]]): Status definitions. + tags (Optional[list[dict[str, Any]]]): List of tags available to + set on entities. + config (Optional[dict[str, Any]]): Project anatomy config + with templates and roots. + attrib (Optional[dict[str, Any]]): Project attributes to change. + data (Optional[dict[str, Any]]): Custom data of a project. This + value will 100% override project data. + active (Optional[bool]): Change active state of a project. + project_code (Optional[str]): Change project code. Not recommended + during production. + **changes: Other changed keys based on Rest API documentation. + + """ + con = get_server_api_connection() + return con.update_project( + project_name=project_name, + library=library, + folder_types=folder_types, + task_types=task_types, + link_types=link_types, + statuses=statuses, + tags=tags, + config=config, + attrib=attrib, + data=data, + active=active, + project_code=project_code, + **changes, + ) + + +def delete_project( + project_name: str, +): + """Delete project from server. + + This will completely remove project from server without any step back. + + Args: + project_name (str): Project name that will be removed. + + """ + con = get_server_api_connection() + return con.delete_project( + project_name=project_name, + ) + + +def rename_project( + project_name: str, + new_project_name: str, + new_project_code: Optional[str] = None, +) -> None: + """Rename project. + + Args: + project_name (str): Current project name. + new_project_name (str): New project name. + new_project_code (Optional[str]): New project code. Project code + is unchanged if not passed. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.0. + + """ + con = get_server_api_connection() + return con.rename_project( + project_name=project_name, + new_project_name=new_project_name, + new_project_code=new_project_code, + ) + + +def get_project_anatomy( + project_name: str, +) -> AnatomyPresetDict: + """Get project anatomy. + + Args: + project_name (str): Project name. + + Returns: + AnatomyPresetDict: Project anatomy in anatomy preset format. + + """ + con = get_server_api_connection() + return con.get_project_anatomy( + project_name=project_name, + ) + + +def set_project_anatomy( + project_name: str, + anatomy: AnatomyPresetDict, +) -> None: + """Set project anatomy. + + Args: + project_name (str): Project name. + anatomy (AnatomyPresetDict): Anatomy data in anatomy + preset format. + + """ + con = get_server_api_connection() + return con.set_project_anatomy( + project_name=project_name, + anatomy=anatomy, + ) + + +def get_project_stats( + project_name: str, +) -> dict[str, Any]: + """Get project statistics. + + Example output:: + + { + "counts": { + "folders": 10, + "products": 20, + "versions": 30, + "representations": 40, + "tasks": 50 + } + } + + Args: + project_name (str): Project name. + + Returns: + dict[str, Any]: Project statistics. + + """ + con = get_server_api_connection() + return con.get_project_stats( + project_name=project_name, + ) + + +def get_project_tags( + project_name: str, +) -> dict[str, list[str]]: + """Get tags used on entities in project. + + Example output:: + + { + "folders": ["tag1"], + "tasks": [], + "products": [], + "versions": ["tag2"], + "representations": [], + "workfiles": [] + } + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Used tags by entity type (plural). + + """ + con = get_server_api_connection() + return con.get_project_tags( + project_name=project_name, + ) + + +def get_project_bundle( + project_name: str, + variant: str = 'production', +) -> dict[str, Any]: + """Get project bundle information. + + Information contains addon versions used by project, installer + version, dependency packages and available options for each. + + Args: + project_name (str): Project name. + variant (str): Bundle variant 'production' or 'staging'. + + Returns: + dict[str, Any]: Project bundle information. + + """ + con = get_server_api_connection() + return con.get_project_bundle( + project_name=project_name, + variant=variant, + ) + + +def set_project_bundle( + project_name: str, + addons: dict[str, Optional[str]], + installer_version: Optional[str] = None, + dependency_packages: Optional[dict[str, Optional[str]]] = None, + variant: str = 'production', +) -> None: + """Set (freeze) project bundle. + + Args: + project_name (str): Project name. + addons (dict[str, Optional[str]]): Addon versions by addon name. + Use 'None' to disable an addon. + installer_version (Optional[str]): Installer version. + dependency_packages (Optional[dict[str, Optional[str]]]): + Dependency package names by platform name. + variant (str): Bundle variant 'production' or 'staging'. + + """ + con = get_server_api_connection() + return con.set_project_bundle( + project_name=project_name, + addons=addons, + installer_version=installer_version, + dependency_packages=dependency_packages, + variant=variant, + ) + + +def unset_project_bundle( + project_name: str, + variant: str = 'production', +) -> None: + """Unset (unfreeze) project bundle. + + Args: + project_name (str): Project name. + variant (str): Bundle variant 'production' or 'staging'. + + """ + con = get_server_api_connection() + return con.unset_project_bundle( + project_name=project_name, + variant=variant, + ) + + +def get_project_entity_uris( + project_name: str, + entity_type: str, + entity_ids: Iterable[str], +) -> list[dict[str, str]]: + """Get AYON URIs for project entities. + + Example output:: + + [ + { + "id": "0254c370005811ee9a740242ac130004", + "uri": "ayon+entity://demo/assets/hero" + }, + ... + ] + + Args: + project_name (str): Project name. + entity_type (str): Entity type, e.g. 'folder', 'task', + 'product', 'version', 'representation' or 'workfile'. + entity_ids (Iterable[str]): Entity ids. + + Returns: + list[dict[str, str]]: Entity URIs with entity ids. + + """ + con = get_server_api_connection() + return con.get_project_entity_uris( + project_name=project_name, + entity_type=entity_type, + entity_ids=entity_ids, + ) + + +def resolve_uris( + uris: Iterable[str], + resolve_roots: bool = False, + path_only: bool = False, + site_id: Optional[str] = None, +) -> list[dict[str, Any]]: + """Resolve AYON URIs to entities. + + Each URI starts with 'ayon://{project_name}/{path}' with optional + query arguments 'product', 'version', 'representation' or + 'task', 'workfile'. + + Example output:: + + [ + { + "uri": "ayon://demo/assets/hero?product=modelMain", + "entities": [ + { + "projectName": "demo", + "folderId": "0254c370005811ee9a740242ac130004", + "productId": "0255ce50005811ee9a740242ac130004", + "target": "product" + } + ] + }, + ... + ] + + Args: + uris (Iterable[str]): AYON URIs to resolve. + resolve_roots (bool): Resolve roots in representation paths. + Requires site id. + path_only (bool): Return only file paths of entities. + site_id (Optional[str]): Site id used to resolve roots. Site id + set on connection is used if not passed. + + Returns: + list[dict[str, Any]]: Resolved URIs. + + """ + con = get_server_api_connection() + return con.resolve_uris( + uris=uris, + resolve_roots=resolve_roots, + path_only=path_only, + site_id=site_id, + ) + + +def get_project_file_info( + project_name: str, + file_id: str, +) -> dict[str, Any]: + """Get information about project file. + + Example output:: + + { + "size": 1024, + "filename": "image.png", + "contentType": "image/png", + "mediaInfo": None + } + + Args: + project_name (str): Project name. + file_id (str): File id. + + Returns: + dict[str, Any]: File information. + + """ + con = get_server_api_connection() + return con.get_project_file_info( + project_name=project_name, + file_id=file_id, + ) + + +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]]: + """Root overrides per site name. + + Method is based on logged user and can't be received for any other + user on server. + + Output will contain only roots per site id used by logged user. + + Args: + project_name (str): Name of project. + + Returns: + dict[str, dict[str, str]]: Root values by root name by site id. + + """ + con = get_server_api_connection() + return con.get_project_root_overrides( + project_name=project_name, + ) + + +def get_project_roots_by_site( + project_name: str, +) -> dict[str, dict[str, str]]: + """Root overrides per site name. + + Method is based on logged user and can't be received for any other + user on server. + + Output will contain only roots per site id used by logged user. + + Deprecated: + Use 'get_project_root_overrides' instead. Function + deprecated since 1.0.6 + + Args: + project_name (str): Name of project. + + Returns: + dict[str, dict[str, str]]: Root values by root name by site id. + + """ + con = get_server_api_connection() + return con.get_project_roots_by_site( + project_name=project_name, + ) + + +def get_project_root_overrides_by_site_id( + project_name: str, + site_id: Optional[str] = None, +) -> dict[str, str]: + """Root overrides for site. + + If site id is not passed a site set in current api object is used + instead. + + Args: + project_name (str): Name of project. + site_id (Optional[str]): Site id for which want to receive + site overrides. + + Returns: + dict[str, str]: Root values by root name or None if + site does not have overrides. + + """ + con = get_server_api_connection() + return con.get_project_root_overrides_by_site_id( + project_name=project_name, + site_id=site_id, + ) + + +def set_project_root_overrides( + project_name: str, + site_id: str, + roots: dict[str, Optional[str]], +) -> None: + """Set root overrides of logged user for a site. + + Roots defined in project anatomy with empty value are removed, + and all overrides for the site are removed if no root value + is left. + + Args: + project_name (str): Project name. + site_id (str): Site id for which overrides are set. + roots (dict[str, Optional[str]]): Root values by root name. + + """ + con = get_server_api_connection() + return con.set_project_root_overrides( + project_name=project_name, + site_id=site_id, + roots=roots, + ) + + +def get_project_roots_for_site( + project_name: str, + site_id: Optional[str] = None, +) -> dict[str, str]: + """Root overrides for site. + + If site id is not passed a site set in current api object is used + instead. + + Deprecated: + Use 'get_project_root_overrides_by_site_id' instead. Function + deprecated since 1.0.6 + Args: + project_name (str): Name of project. + site_id (Optional[str]): Site id for which want to receive + site overrides. + + Returns: + dict[str, str]: Root values by root name, root name is not + available if it does not have overrides. + + """ + con = get_server_api_connection() + return con.get_project_roots_for_site( + project_name=project_name, + site_id=site_id, + ) + + +def get_project_roots_by_site_id( + project_name: str, + site_id: Optional[str] = None, +) -> dict[str, str]: + """Root values for a site. + + If site id is not passed a site set in current api object is used + instead. If site id is not available, default roots are returned + for current platform. + + Args: + project_name (str): Name of project. + site_id (Optional[str]): Site id for which want to receive + root values. + + Returns: + dict[str, str]: Root values. + + """ + con = get_server_api_connection() + return con.get_project_roots_by_site_id( + project_name=project_name, + site_id=site_id, + ) + + +def get_project_roots_by_platform( + project_name: str, + platform_name: Optional[str] = None, +) -> dict[str, str]: + """Root values for a site. + + If platform name is not passed current platform name is used instead. + + This function does return root values without site overrides. It is + possible to use the function to receive default root values. + + Args: + project_name (str): Name of project. + platform_name (Optional[Literal["windows", "linux", "darwin"]]): + Platform name for which want to receive root values. Current + platform name is used if not passed. + + Returns: + dict[str, str]: Root values. + + """ + con = get_server_api_connection() + return con.get_project_roots_by_platform( + project_name=project_name, + platform_name=platform_name, + ) + + +def get_rest_folder( + project_name: str, + folder_id: str, +) -> Optional[FolderDict]: + con = get_server_api_connection() + return con.get_rest_folder( + project_name=project_name, + folder_id=folder_id, + ) + + +def get_rest_folders( + project_name: str, + include_attrib: bool = False, +) -> list[FlatFolderDict]: + """Get simplified flat list of all project folders. + + Get all project folders in single REST call. This can be faster than + using 'get_folders' method which is using GraphQl, but does not + allow any filtering, and set of fields is defined + by server backend. + + Example:: + + [ + { + "id": "112233445566", + "parentId": "112233445567", + "path": "/root/parent/child", + "parents": ["root", "parent"], + "name": "child", + "label": "Child", + "folderType": "Folder", + "hasTasks": False, + "hasChildren": False, + "taskNames": [ + "Compositing", + ], + "status": "In Progress", + "attrib": {}, + "ownAttrib": [], + "updatedAt": "2023-06-12T15:37:02.420260", + }, + ... + ] + + Args: + project_name (str): Project name. + include_attrib (Optional[bool]): Include attribute values + in output. Slower to query. + + Returns: + list[FlatFolderDict]: List of folder entities. + + """ + con = get_server_api_connection() + return con.get_rest_folders( + project_name=project_name, + include_attrib=include_attrib, + ) + + +def get_folders_hierarchy( + project_name: str, + search_string: Optional[str] = None, + folder_types: Optional[Iterable[str]] = None, +) -> ProjectHierarchyDict: + """Get project hierarchy. + + All folders in project in hierarchy data structure. + + Example output: + { + "hierarchy": [ + { + "id": "...", + "name": "...", + "label": "...", + "status": "...", + "folderType": "...", + "hasTasks": False, + "taskNames": [], + "parents": [], + "parentId": None, + "children": [...children folders...] + }, + ... + ] + } + + Args: + project_name (str): Project where to look for folders. + search_string (Optional[str]): Search string to filter folders. + folder_types (Optional[Iterable[str]]): Folder types to filter. + + Returns: + dict[str, Any]: Response data from server. + + """ + con = get_server_api_connection() + return con.get_folders_hierarchy( + project_name=project_name, + search_string=search_string, + folder_types=folder_types, + ) + + +def get_folders_rest( + project_name: str, + include_attrib: bool = False, +) -> list[FlatFolderDict]: + """Get simplified flat list of all project folders. + + Get all project folders in single REST call. This can be faster than + using 'get_folders' method which is using GraphQl, but does not + allow any filtering, and set of fields is defined + by server backend. + + Example:: + + [ + { + "id": "112233445566", + "parentId": "112233445567", + "path": "/root/parent/child", + "parents": ["root", "parent"], + "name": "child", + "label": "Child", + "folderType": "Folder", + "hasTasks": False, + "hasChildren": False, + "taskNames": [ + "Compositing", + ], + "status": "In Progress", + "attrib": {}, + "ownAttrib": [], + "updatedAt": "2023-06-12T15:37:02.420260", + }, + ... + ] + + Deprecated: + Use 'get_rest_folders' instead. Function was renamed to match + other rest functions, like 'get_rest_folder', + 'get_rest_project' etc. . + Will be removed in '1.0.7' or '1.1.0'. + + Args: + project_name (str): Project name. + include_attrib (Optional[bool]): Include attribute values + in output. Slower to query. + + Returns: + list[FlatFolderDict]: List of folder entities. + + """ + con = get_server_api_connection() + return con.get_folders_rest( + project_name=project_name, + include_attrib=include_attrib, + ) + + +def get_folders( + project_name: str, + folder_ids: Optional[Iterable[str]] = None, + folder_paths: Optional[Iterable[str]] = None, + folder_names: Optional[Iterable[str]] = None, + folder_types: Optional[Iterable[str]] = None, + parent_ids: Optional[Iterable[str]] = None, + folder_path_regex: Optional[str] = None, + has_products: Optional[bool] = None, + has_tasks: Optional[bool] = None, + has_children: Optional[bool] = None, + statuses: Optional[Iterable[str]] = None, + assignees_all: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + active: Optional[bool] = True, + has_links: Optional[bool] = None, + filters: Optional[AdvancedFilterDict] = None, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Generator[FolderDict, None, None]: + """Query folders from server. + + Todos: + Folder name won't be unique identifier, so we should add + folder path filtering. + + Notes: + Filter 'active' don't have direct filter in GraphQl. + + Args: + project_name (str): Name of project. + folder_ids (Optional[Iterable[str]]): Folder ids to filter. + folder_paths (Optional[Iterable[str]]): Folder paths used + for filtering. + folder_names (Optional[Iterable[str]]): Folder names used + for filtering. + folder_types (Optional[Iterable[str]]): Folder types used + for filtering. + parent_ids (Optional[Iterable[str]]): Ids of folder parents. + Use 'None' if folder is direct child of project. + folder_path_regex (Optional[str]): Folder path regex used + for filtering. + has_products (Optional[bool]): Filter folders with/without + products. Ignored when None, default behavior. + has_tasks (Optional[bool]): Filter folders with/without + tasks. Ignored when None, default behavior. + has_children (Optional[bool]): Filter folders with/without + children. Ignored when None, default behavior. + statuses (Optional[Iterable[str]]): Folder statuses used + for filtering. + assignees_all (Optional[Iterable[str]]): Filter by assigness + on children tasks. Task must have all of passed assignees. + tags (Optional[Iterable[str]]): Folder tags used + for filtering. + active (Optional[bool]): Filter active/inactive folders. + Both are returned if is set to None. + has_links (Optional[Literal[IN, OUT, ANY]]): Filter + representations with IN/OUT/ANY links. + filters (Optional[AdvancedFilterDict]): Advanced filtering options. + fields (Optional[Iterable[str]]): Fields to be queried for + folder. All possible folder fields are returned + if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Generator[FolderDict, None, None]: Queried folder entities. + + """ + con = get_server_api_connection() + return con.get_folders( + project_name=project_name, + folder_ids=folder_ids, + folder_paths=folder_paths, + folder_names=folder_names, + folder_types=folder_types, + parent_ids=parent_ids, + folder_path_regex=folder_path_regex, + has_products=has_products, + has_tasks=has_tasks, + has_children=has_children, + statuses=statuses, + assignees_all=assignees_all, + tags=tags, + active=active, + has_links=has_links, + filters=filters, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_folder_by_id( + project_name: str, + folder_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Optional[FolderDict]: + """Query folder entity by id. + + Args: + project_name (str): Name of project where to look for queried + entities. + folder_id (str): Folder id. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Optional[FolderDict]: Folder entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_folder_by_id( + project_name=project_name, + folder_id=folder_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_folder_by_path( + project_name: str, + folder_path: str, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Optional[FolderDict]: + """Query folder entity by path. + + Folder path is a path to folder with all parent names joined by slash. + + Args: + project_name (str): Name of project where to look for queried + entities. + folder_path (str): Folder path. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Optional[FolderDict]: Folder entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_folder_by_path( + project_name=project_name, + folder_path=folder_path, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_folder_by_name( + project_name: str, + folder_name: str, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Optional[FolderDict]: + """Query folder entity by path. + + Warnings: + Folder name is not a unique identifier of a folder. Function is + kept for OpenPype 3 compatibility. + + Args: + project_name (str): Name of project where to look for queried + entities. + folder_name (str): Folder name. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Optional[FolderDict]: Folder entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_folder_by_name( + project_name=project_name, + folder_name=folder_name, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_folder_ids_with_products( + project_name: str, + folder_ids: Optional[Iterable[str]] = None, +) -> set[str]: + """Find folders which have at least one product. + + Folders that have at least one product should be immutable, so they + should not change path -> change of name or name of any parent + is not possible. + + Args: + project_name (str): Name of project. + folder_ids (Optional[Iterable[str]]): Limit folder ids filtering + to a set of folders. If set to None all folders on project are + checked. + + Returns: + set[str]: Folder ids that have at least one product. + + """ + con = get_server_api_connection() + return con.get_folder_ids_with_products( + project_name=project_name, + folder_ids=folder_ids, + ) + + +def search_folders( + project_name: str, + search: Optional[str] = None, + folder_search: Optional[str] = None, + folder_filter: Optional[AdvancedFilterDict] = None, + task_search: Optional[str] = None, + task_filter: Optional[AdvancedFilterDict] = None, +) -> list[str]: + """Search folders using text search and filters. + + Returns ids of folders matching the folder conditions, or containing + tasks matching the task conditions. + + Args: + project_name (str): Project name. + search (Optional[str]): Fulltext search applied to both + folders and tasks. + folder_search (Optional[str]): Fulltext search applied + to folders. + folder_filter (Optional[AdvancedFilterDict]): Filter applied + to folders. + task_search (Optional[str]): Fulltext search applied to tasks. + task_filter (Optional[AdvancedFilterDict]): Filter applied + to tasks. + + Returns: + list[str]: Matching folder ids. + + Raises: + UnsupportedServerVersion: 'search' is used with server version + lower than 1.15.4. + + """ + con = get_server_api_connection() + return con.search_folders( + project_name=project_name, + search=search, + folder_search=folder_search, + folder_filter=folder_filter, + task_search=task_search, + task_filter=task_filter, + ) + + +def get_entity_groups( + project_name: str, + entity_type: str, + grouping_key: str, + empty: bool = False, +) -> dict[str, Any]: + """Get groups of entities based on grouping key. + + Example output:: + + { + "groups": [ + {"value": "john.doe", "count": 42, ...}, + ... + ], + "key": "assignees", + "entityType": "task" + } + + Args: + project_name (str): Project name. + entity_type (str): Entity type, e.g. 'folder' or 'task'. + grouping_key (str): Grouping key, e.g. 'status', 'tags', + 'taskType', 'folderType', 'productType', 'productBaseType', + 'assignees' (tasks only) or 'attrib.{attribute name}'. + empty (bool): Include empty groups. + + Returns: + dict[str, Any]: Entity groups data. + + """ + con = get_server_api_connection() + return con.get_entity_groups( + project_name=project_name, + entity_type=entity_type, + grouping_key=grouping_key, + empty=empty, + ) + + +def create_folder( + project_name: str, + name: str, + folder_type: Optional[str] = None, + parent_id: Optional[str] = None, + label: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[Iterable[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + thumbnail_id: Optional[str] = None, + folder_id: Optional[str] = None, +) -> str: + """Create new folder. + + Args: + project_name (str): Project name. + name (str): Folder name. + folder_type (Optional[str]): Folder type. + parent_id (Optional[str]): Parent folder id. Parent is project + if is ``None``. + label (Optional[str]): Label of folder. + attrib (Optional[dict[str, Any]]): Folder attributes. + data (Optional[dict[str, Any]]): Folder data. + tags (Optional[Iterable[str]]): Folder tags. + status (Optional[str]): Folder status. + active (Optional[bool]): Folder active state. + thumbnail_id (Optional[str]): Folder thumbnail id. + folder_id (Optional[str]): Folder id. If not passed new id is + generated. + + Returns: + str: Entity id. + + """ + con = get_server_api_connection() + return con.create_folder( + project_name=project_name, + name=name, + folder_type=folder_type, + parent_id=parent_id, + label=label, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + thumbnail_id=thumbnail_id, + folder_id=folder_id, + ) + + +def update_folder( + project_name: str, + folder_id: str, + name: Optional[str] = None, + folder_type: Optional[str] = None, + parent_id: Optional[str] = NOT_SET, + label: Optional[str] = NOT_SET, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[Iterable[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + thumbnail_id: Optional[str] = NOT_SET, +) -> None: + """Update folder entity on server. + + Do not pass ``parent_id``, ``label`` amd ``thumbnail_id`` if you don't + want to change their values. Value ``None`` would unset + their value. + + Update of ``data`` will override existing value on folder entity. + + Update of ``attrib`` does change only passed attributes. If you want + to unset value, use ``None``. + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + name (Optional[str]): New name. + folder_type (Optional[str]): New folder type. + parent_id (Optional[str]): New parent folder id. + label (Optional[str]): New label. + attrib (Optional[dict[str, Any]]): New attributes. + data (Optional[dict[str, Any]]): New data. + tags (Optional[Iterable[str]]): New tags. + status (Optional[str]): New status. + active (Optional[bool]): New active state. + thumbnail_id (Optional[str]): New thumbnail id. + + """ + con = get_server_api_connection() + return con.update_folder( + project_name=project_name, + folder_id=folder_id, + name=name, + folder_type=folder_type, + parent_id=parent_id, + label=label, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + thumbnail_id=thumbnail_id, + ) + + +def delete_folder( + project_name: str, + folder_id: str, + force: bool = False, +) -> None: + """Delete folder. + + Args: + project_name (str): Project name. + folder_id (str): Folder id to delete. + force (Optional[bool]): Folder delete folder with all children + folder, products, versions and representations. + + """ + con = get_server_api_connection() + return con.delete_folder( + project_name=project_name, + folder_id=folder_id, + force=force, + ) + + +def get_rest_task( + project_name: str, + task_id: str, +) -> Optional[TaskDict]: + con = get_server_api_connection() + return con.get_rest_task( + project_name=project_name, + task_id=task_id, + ) + + +def get_tasks( + project_name: str, + task_ids: Optional[Iterable[str]] = None, + task_names: Optional[Iterable[str]] = None, + task_types: Optional[Iterable[str]] = None, + folder_ids: Optional[Iterable[str]] = None, + assignees: Optional[Iterable[str]] = None, + assignees_all: Optional[Iterable[str]] = None, + statuses: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + active: Optional[bool] = True, + filters: Optional[AdvancedFilterDict] = None, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Generator[TaskDict, None, None]: + """Query task entities from server. + + Args: + project_name (str): Name of project. + task_ids (Iterable[str]): Task ids to filter. + task_names (Iterable[str]): Task names used for filtering. + task_types (Iterable[str]): Task types used for filtering. + folder_ids (Iterable[str]): Ids of task parents. Use 'None' + if folder is direct child of project. + assignees (Optional[Iterable[str]]): Task assignees used for + filtering. All tasks with any of passed assignees are + returned. + assignees_all (Optional[Iterable[str]]): Task assignees used + for filtering. Task must have all of passed assignees to be + returned. + statuses (Optional[Iterable[str]]): Task statuses used for + filtering. + tags (Optional[Iterable[str]]): Task tags used for + filtering. + active (Optional[bool]): Filter active/inactive tasks. + Both are returned if is set to None. + filters (Optional[AdvancedFilterDict]): Advanced filtering options. + fields (Optional[Iterable[str]]): Fields to be queried for + folder. All possible folder fields are returned + if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Generator[TaskDict, None, None]: Queried task entities. + + """ + con = get_server_api_connection() + return con.get_tasks( + project_name=project_name, + task_ids=task_ids, + task_names=task_names, + task_types=task_types, + folder_ids=folder_ids, + assignees=assignees, + assignees_all=assignees_all, + statuses=statuses, + tags=tags, + active=active, + filters=filters, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_task_by_name( + project_name: str, + folder_id: str, + task_name: str, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Optional[TaskDict]: + """Query task entity by name and folder id. + + Args: + project_name (str): Name of project where to look for queried + entities. + folder_id (str): Folder id. + task_name (str): Task name + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Optional[TaskDict]: Task entity data or None if was not found. + + """ + con = get_server_api_connection() + return con.get_task_by_name( + project_name=project_name, + folder_id=folder_id, + task_name=task_name, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_task_by_id( + project_name: str, + task_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Optional[TaskDict]: + """Query task entity by id. + + Args: + project_name (str): Name of project where to look for queried + entities. + task_id (str): Task id. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Optional[TaskDict]: Task entity data or None if was not found. + + """ + con = get_server_api_connection() + return con.get_task_by_id( + project_name=project_name, + task_id=task_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_tasks_by_folder_paths( + project_name: str, + folder_paths: Iterable[str], + task_names: Optional[Iterable[str]] = None, + task_types: Optional[Iterable[str]] = None, + assignees: Optional[Iterable[str]] = None, + assignees_all: Optional[Iterable[str]] = None, + statuses: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + active: Optional[bool] = True, + filters: Optional[AdvancedFilterDict] = None, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> dict[str, list[TaskDict]]: + """Query task entities from server by folder paths. + + Args: + project_name (str): Name of project. + folder_paths (list[str]): Folder paths. + task_names (Iterable[str]): Task names used for filtering. + task_types (Iterable[str]): Task types used for filtering. + assignees (Optional[Iterable[str]]): Task assignees used for + filtering. All tasks with any of passed assignees are + returned. + assignees_all (Optional[Iterable[str]]): Task assignees used + for filtering. Task must have all of passed assignees to be + returned. + statuses (Optional[Iterable[str]]): Task statuses used for + filtering. + tags (Optional[Iterable[str]]): Task tags used for + filtering. + active (Optional[bool]): Filter active/inactive tasks. + Both are returned if is set to None. + filters (Optional[AdvancedFilterDict]): Advanced filtering options. + fields (Optional[Iterable[str]]): Fields to be queried for + folder. All possible folder fields are returned + if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + dict[str, list[TaskDict]]: Task entities by + folder path. + + """ + con = get_server_api_connection() + return con.get_tasks_by_folder_paths( + project_name=project_name, + folder_paths=folder_paths, + task_names=task_names, + task_types=task_types, + assignees=assignees, + assignees_all=assignees_all, + statuses=statuses, + tags=tags, + active=active, + filters=filters, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_tasks_by_folder_path( + project_name: str, + folder_path: str, + task_names: Optional[Iterable[str]] = None, + task_types: Optional[Iterable[str]] = None, + assignees: Optional[Iterable[str]] = None, + assignees_all: Optional[Iterable[str]] = None, + statuses: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + active: Optional[bool] = True, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> list[TaskDict]: + """Query task entities from server by folder path. + + Args: + project_name (str): Name of project. + folder_path (str): Folder path. + task_names (Iterable[str]): Task names used for filtering. + task_types (Iterable[str]): Task types used for filtering. + assignees (Optional[Iterable[str]]): Task assignees used for + filtering. All tasks with any of passed assignees are + returned. + assignees_all (Optional[Iterable[str]]): Task assignees used + for filtering. Task must have all of passed assignees to be + returned. + statuses (Optional[Iterable[str]]): Task statuses used for + filtering. + tags (Optional[Iterable[str]]): Task tags used for + filtering. + active (Optional[bool]): Filter active/inactive tasks. + Both are returned if is set to None. + fields (Optional[Iterable[str]]): Fields to be queried for + folder. All possible folder fields are returned + if 'None' is passed. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + """ + con = get_server_api_connection() + return con.get_tasks_by_folder_path( + project_name=project_name, + folder_path=folder_path, + task_names=task_names, + task_types=task_types, + assignees=assignees, + assignees_all=assignees_all, + statuses=statuses, + tags=tags, + active=active, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_task_by_folder_path( + project_name: str, + folder_path: str, + task_name: str, + fields: Optional[Iterable[str]] = None, + own_attributes: bool = False, +) -> Optional[TaskDict]: + """Query task entity by folder path and task name. + + Args: + project_name (str): Project name. + folder_path (str): Folder path. + task_name (str): Task name. + fields (Optional[Iterable[str]]): Task fields that should + be returned. + own_attributes (Optional[bool]): Attribute values that are + not explicitly set on entity will have 'None' value. + + Returns: + Optional[TaskDict]: Task entity data or None if was not found. + + """ + con = get_server_api_connection() + return con.get_task_by_folder_path( + project_name=project_name, + folder_path=folder_path, + task_name=task_name, + fields=fields, + own_attributes=own_attributes, + ) + + +def create_task( + project_name: str, + name: str, + task_type: str, + folder_id: str, + label: Optional[str] = None, + assignees: Optional[Iterable[str]] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[list[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + thumbnail_id: Optional[str] = None, + task_id: Optional[str] = None, +) -> str: + """Create new task. + + Args: + project_name (str): Project name. + name (str): Folder name. + task_type (str): Task type. + folder_id (str): Parent folder id. + label (Optional[str]): Label of folder. + assignees (Optional[Iterable[str]]): Task assignees. + attrib (Optional[dict[str, Any]]): Task attributes. + data (Optional[dict[str, Any]]): Task data. + tags (Optional[Iterable[str]]): Task tags. + status (Optional[str]): Task status. + active (Optional[bool]): Task active state. + thumbnail_id (Optional[str]): Task thumbnail id. + task_id (Optional[str]): Task id. If not passed new id is + generated. + + Returns: + str: Task id. + + """ + con = get_server_api_connection() + return con.create_task( + project_name=project_name, + name=name, + task_type=task_type, + folder_id=folder_id, + label=label, + assignees=assignees, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + thumbnail_id=thumbnail_id, + task_id=task_id, + ) + + +def update_task( + project_name: str, + task_id: str, + name: Optional[str] = None, + task_type: Optional[str] = None, + folder_id: Optional[str] = None, + label: Optional[str] = NOT_SET, + assignees: Optional[list[str]] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[list[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + thumbnail_id: Optional[str] = NOT_SET, +) -> None: + """Update task entity on server. + + Do not pass ``label`` amd ``thumbnail_id`` if you don't + want to change their values. Value ``None`` would unset + their value. + + Update of ``data`` will override existing value on folder entity. + + Update of ``attrib`` does change only passed attributes. If you want + to unset value, use ``None``. + + Args: + project_name (str): Project name. + task_id (str): Task id. + name (Optional[str]): New name. + task_type (Optional[str]): New task type. + folder_id (Optional[str]): New folder id. + label (Optional[Optional[str]]): New label. + assignees (Optional[str]): New assignees. + attrib (Optional[dict[str, Any]]): New attributes. + data (Optional[dict[str, Any]]): New data. + tags (Optional[Iterable[str]]): New tags. + status (Optional[str]): New status. + active (Optional[bool]): New active state. + thumbnail_id (Optional[str]): New thumbnail id. + + """ + con = get_server_api_connection() + return con.update_task( + project_name=project_name, + task_id=task_id, + name=name, + task_type=task_type, + folder_id=folder_id, + label=label, + assignees=assignees, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + thumbnail_id=thumbnail_id, + ) + + +def delete_task( + project_name: str, + task_id: str, +) -> None: + """Delete task. + + Args: + project_name (str): Project name. + task_id (str): Task id to delete. + + """ + con = get_server_api_connection() + return con.delete_task( + project_name=project_name, + task_id=task_id, + ) + + +def assign_users_to_task( + project_name: str, + task_id: str, + users: Iterable[str], + mode: Literal["add", "remove", "set"] = 'add', +) -> None: + """Change users assigned to a task. + + Notes: + Users without manager role can only assign or unassign + themselves. + + Args: + project_name (str): Project name. + task_id (str): Task id. + users (Iterable[str]): User names. + mode (Literal["add", "remove", "set"]): How passed users are + applied. 'add' adds users to current assignees, 'remove' + removes them and 'set' replaces assignees with passed users. + + """ + con = get_server_api_connection() + return con.assign_users_to_task( + project_name=project_name, + task_id=task_id, + users=users, + mode=mode, + ) + + +def get_rest_product( + project_name: str, + product_id: str, +) -> Optional[ProductDict]: + con = get_server_api_connection() + return con.get_rest_product( + project_name=project_name, + product_id=product_id, + ) + + +def get_products( + project_name: str, + product_ids: Optional[Iterable[str]] = None, + product_names: Optional[Iterable[str]] = None, + folder_ids: Optional[Iterable[str]] = None, + product_types: Optional[Iterable[str]] = None, + product_base_types: Optional[Iterable[str]] = None, + product_name_regex: Optional[str] = None, + product_path_regex: Optional[str] = None, + names_by_folder_ids: Optional[dict[str, Iterable[str]]] = None, + statuses: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + active: Optional[bool] = True, + filters: Optional[AdvancedFilterDict] = None, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Generator[ProductDict, None, None]: + """Query products from server. + + Todos: + Separate 'name_by_folder_ids' filtering to separated method. It + cannot be combined with some other filters. + + Args: + project_name (str): Name of project. + product_ids (Optional[Iterable[str]]): Task ids to filter. + product_names (Optional[Iterable[str]]): Task names used for + filtering. + folder_ids (Optional[Iterable[str]]): Ids of task parents. + Use 'None' if folder is direct child of project. + product_types (Optional[Iterable[str]]): Product types used for + filtering. + product_base_types (Optional[Iterable[str]]): Product base types + used for filtering. + product_name_regex (Optional[str]): Filter products by name regex. + product_path_regex (Optional[str]): Filter products by path regex. + Path starts with folder path and ends with product name. + names_by_folder_ids (Optional[dict[str, Iterable[str]]]): Product + name filtering by folder id. + statuses (Optional[Iterable[str]]): Product statuses used + for filtering. + tags (Optional[Iterable[str]]): Product tags used + for filtering. + active (Optional[bool]): Filter active/inactive products. + Both are returned if is set to None. + filters (Optional[AdvancedFilterDict]): Advanced filtering options. + fields (Optional[Iterable[str]]): Fields to be queried for + folder. All possible folder fields are returned + if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + products. + + Returns: + Generator[ProductDict, None, None]: Queried product entities. + + """ + con = get_server_api_connection() + return con.get_products( + project_name=project_name, + product_ids=product_ids, + product_names=product_names, + folder_ids=folder_ids, + product_types=product_types, + product_base_types=product_base_types, + product_name_regex=product_name_regex, + product_path_regex=product_path_regex, + names_by_folder_ids=names_by_folder_ids, + statuses=statuses, + tags=tags, + active=active, + filters=filters, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_product_by_id( + project_name: str, + product_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[ProductDict]: + """Query product entity by id. + + Args: + project_name (str): Name of project where to look for queried + entities. + product_id (str): Product id. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + products. + + Returns: + Optional[ProductDict]: Product entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_product_by_id( + project_name=project_name, + product_id=product_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_product_by_name( + project_name: str, + product_name: str, + folder_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[ProductDict]: + """Query product entity by name and folder id. + + Args: + project_name (str): Name of project where to look for queried + entities. + product_name (str): Product name. + folder_id (str): Folder id (Folder is a parent of products). + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + products. + + Returns: + Optional[ProductDict]: Product entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_product_by_name( + project_name=project_name, + product_name=product_name, + folder_id=folder_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_product_types( + fields: Optional[Iterable[str]] = None, +) -> list[ProductTypeDict]: + """Types of products. + + This is server wide information. Product types have 'name', 'icon' and + 'color'. + + Args: + fields (Optional[Iterable[str]]): Product types fields to query. + + Returns: + list[ProductTypeDict]: Product types information. + + """ + con = get_server_api_connection() + return con.get_product_types( + fields=fields, + ) + + +def get_project_product_types( + project_name: str, + fields: Optional[Iterable[str]] = None, +) -> list[ProductTypeDict]: + """DEPRECATED Types of products available in a project. + + Filter only product types available in a project. + + Args: + project_name (str): Name of the project where to look for + product types. + fields (Optional[Iterable[str]]): Product types fields to query. + + Returns: + list[ProductTypeDict]: Product types information. + + """ + con = get_server_api_connection() + return con.get_project_product_types( + project_name=project_name, + fields=fields, + ) + + +def get_product_type_names( + project_name: Optional[str] = None, + product_ids: Optional[Iterable[str]] = None, +) -> set[str]: + """DEPRECATED Product type names. + + Warnings: + This function will be probably removed. Matters if 'products_id' + filter has real use-case. + + Args: + project_name (Optional[str]): Name of project where to look for + queried entities. + product_ids (Optional[Iterable[str]]): Product ids filter. Can be + used only with 'project_name'. + + Returns: + set[str]: Product type names. + + """ + con = get_server_api_connection() + return con.get_product_type_names( + project_name=project_name, + product_ids=product_ids, + ) + + +def create_product( + project_name: str, + name: str, + product_type: str, + folder_id: str, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[Iterable[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + product_base_type: Optional[str] = None, + product_id: Optional[str] = None, +) -> str: + """Create new product. + + Args: + project_name (str): Project name. + name (str): Product name. + product_type (str): Product type. + folder_id (str): Parent folder id. + attrib (Optional[dict[str, Any]]): Product attributes. + data (Optional[dict[str, Any]]): Product data. + tags (Optional[Iterable[str]]): Product tags. + status (Optional[str]): Product status. + active (Optional[bool]): Product active state. + product_base_type (Optional[str]): Product base type. + product_id (Optional[str]): Product id. If not passed new id is + generated. + + Returns: + str: Product id. + + """ + con = get_server_api_connection() + return con.create_product( + project_name=project_name, + name=name, + product_type=product_type, + folder_id=folder_id, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + product_base_type=product_base_type, + product_id=product_id, + ) + + +def update_product( + project_name: str, + product_id: str, + name: Optional[str] = None, + folder_id: Optional[str] = None, + product_type: Optional[str] = None, + product_base_type: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[Iterable[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, +) -> None: + """Update product entity on server. + + Update of ``data`` will override existing value on folder entity. + + Update of ``attrib`` does change only passed attributes. If you want + to unset value, use ``None``. + + Args: + project_name (str): Project name. + product_id (str): Product id. + name (Optional[str]): New product name. + folder_id (Optional[str]): New product id. + product_type (Optional[str]): New product type. + product_base_type (Optional[str]): New product base type. + attrib (Optional[dict[str, Any]]): New product attributes. + data (Optional[dict[str, Any]]): New product data. + tags (Optional[Iterable[str]]): New product tags. + status (Optional[str]): New product status. + active (Optional[bool]): New product active state. + + """ + con = get_server_api_connection() + return con.update_product( + project_name=project_name, + product_id=product_id, + name=name, + folder_id=folder_id, + product_type=product_type, + product_base_type=product_base_type, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + ) + + +def delete_product( + project_name: str, + product_id: str, +) -> None: + """Delete product. + + Args: + project_name (str): Project name. + product_id (str): Product id to delete. + + """ + con = get_server_api_connection() + return con.delete_product( + project_name=project_name, + product_id=product_id, + ) + + +def get_rest_version( + project_name: str, + version_id: str, +) -> Optional[VersionDict]: + con = get_server_api_connection() + return con.get_rest_version( + project_name=project_name, + version_id=version_id, + ) + + +def get_versions( + project_name: str, + version_ids: Optional[Iterable[str]] = None, + product_ids: Optional[Iterable[str]] = None, + task_ids: Optional[Iterable[str]] = None, + versions: Optional[Iterable[str]] = None, + hero: bool = True, + standard: bool = True, + latest: Optional[bool] = None, + statuses: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, + active: Optional[bool] = True, + filters: Optional[AdvancedFilterDict] = None, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Generator[VersionDict, None, None]: + """Get version entities based on passed filters from server. + + Args: + project_name (str): Name of project where to look for versions. + version_ids (Optional[Iterable[str]]): Version ids used for + version filtering. + product_ids (Optional[Iterable[str]]): Product ids used for + version filtering. + task_ids (Optional[Iterable[str]]): Task ids used for + version filtering. + versions (Optional[Iterable[int]]): Versions we're interested in. + hero (Optional[bool]): Skip hero versions when set to False. + standard (Optional[bool]): Skip standard (non-hero) when + set to False. + latest (Optional[bool]): Return only latest version of standard + versions. This can be combined only with 'standard' attribute + set to True. + statuses (Optional[Iterable[str]]): Representation statuses used + for filtering. + tags (Optional[Iterable[str]]): Representation tags used + for filtering. + active (Optional[bool]): Receive active/inactive entities. + Both are returned when 'None' is passed. + filters (Optional[AdvancedFilterDict]): Advanced filtering options. + fields (Optional[Iterable[str]]): Fields to be queried + for version. All possible folder fields are returned + if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + Generator[VersionDict, None, None]: Queried version entities. + + """ + con = get_server_api_connection() + return con.get_versions( + project_name=project_name, + version_ids=version_ids, + product_ids=product_ids, + task_ids=task_ids, + versions=versions, + hero=hero, + standard=standard, + latest=latest, + statuses=statuses, + tags=tags, + active=active, + filters=filters, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_version_by_id( + project_name: str, + version_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[VersionDict]: + """Query version entity by id. + + Args: + project_name (str): Name of project where to look for queried + entities. + version_id (str): Version id. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + Optional[VersionDict]: Version entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_version_by_id( + project_name=project_name, + version_id=version_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_version_by_name( + project_name: str, + version: int, + product_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[VersionDict]: + """Query version entity by version and product id. + + Args: + project_name (str): Name of project where to look for queried + entities. + version (int): Version of version entity. + product_id (str): Product id. Product is a parent of version. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + Optional[VersionDict]: Version entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_version_by_name( + project_name=project_name, + version=version, + product_id=product_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_hero_version_by_id( + project_name: str, + version_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[VersionDict]: + """Query hero version entity by id. + + Args: + project_name (str): Name of project where to look for queried + entities. + version_id (int): Hero version id. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + Optional[VersionDict]: Version entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_hero_version_by_id( + project_name=project_name, + version_id=version_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_hero_version_by_product_id( + project_name: str, + product_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[VersionDict]: + """Query hero version entity by product id. + + Only one hero version is available on a product. + + Args: + project_name (str): Name of project where to look for queried + entities. + product_id (int): Product id. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + Optional[VersionDict]: Version entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_hero_version_by_product_id( + project_name=project_name, + product_id=product_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_hero_versions( + project_name: str, + product_ids: Optional[Iterable[str]] = None, + version_ids: Optional[Iterable[str]] = None, + active: Optional[bool] = True, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Generator[VersionDict, None, None]: + """Query hero versions by multiple filters. + + Only one hero version is available on a product. + + Args: + project_name (str): Name of project where to look for queried + entities. + product_ids (Optional[Iterable[str]]): Product ids. + version_ids (Optional[Iterable[str]]): Version ids. + active (Optional[bool]): Receive active/inactive entities. + Both are returned when 'None' is passed. + fields (Optional[Iterable[str]]): Fields that should be returned. + All fields are returned if 'None' is passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + Optional[VersionDict]: Version entity data or None + if was not found. + + """ + con = get_server_api_connection() + return con.get_hero_versions( + project_name=project_name, + product_ids=product_ids, + version_ids=version_ids, + active=active, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_last_versions( + project_name: str, + product_ids: Iterable[str], + active: Optional[bool] = True, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> dict[str, Optional[VersionDict]]: + """Query last version entities by product ids. + + Args: + project_name (str): Project where to look for representation. + product_ids (Iterable[str]): Product ids. + active (Optional[bool]): Receive active/inactive entities. + Both are returned when 'None' is passed. + fields (Optional[Iterable[str]]): fields to be queried + for representations. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + dict[str, Optional[VersionDict]]: Last versions by product id. + + """ + con = get_server_api_connection() + return con.get_last_versions( + project_name=project_name, + product_ids=product_ids, + active=active, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_last_version_by_product_id( + project_name: str, + product_id: str, + active: Optional[bool] = True, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[VersionDict]: + """Query last version entity by product id. + + Args: + project_name (str): Project where to look for representation. + product_id (str): Product id. + active (Optional[bool]): Receive active/inactive entities. + Both are returned when 'None' is passed. + fields (Optional[Iterable[str]]): fields to be queried + for representations. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + versions. + + Returns: + Optional[VersionDict]: Queried version entity or None. + + """ + con = get_server_api_connection() + return con.get_last_version_by_product_id( + project_name=project_name, + product_id=product_id, + active=active, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_last_version_by_product_name( + project_name: str, + product_name: str, + folder_id: str, + active: Optional[bool] = True, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[VersionDict]: + """Query last version entity by product name and folder id. + + Args: + project_name (str): Project where to look for representation. + product_name (str): Product name. + folder_id (str): Folder id. + active (Optional[bool]): Receive active/inactive entities. + Both are returned when 'None' is passed. + fields (Optional[Iterable[str]]): fields to be queried + for representations. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + representations. + + Returns: + Optional[VersionDict]: Queried version entity or None. + + """ + con = get_server_api_connection() + return con.get_last_version_by_product_name( + project_name=project_name, + product_name=product_name, + folder_id=folder_id, + active=active, + fields=fields, + own_attributes=own_attributes, + ) + + +def version_is_latest( + project_name: str, + version_id: str, +) -> bool: + """Is version latest from a product. + + Args: + project_name (str): Project where to look for representation. + version_id (str): Version id. + + Returns: + bool: Version is latest or not. + + """ + con = get_server_api_connection() + return con.version_is_latest( + project_name=project_name, + version_id=version_id, + ) + + +def create_version( + project_name: str, + version: int, + product_id: str, + task_id: Optional[str] = None, + author: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[Iterable[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + thumbnail_id: Optional[str] = None, + version_id: Optional[str] = None, +) -> str: + """Create new version. - Example:: + Args: + project_name (str): Project name. + version (int): Version. + product_id (str): Parent product id. + task_id (Optional[str]): Parent task id. + author (Optional[str]): Version author. + attrib (Optional[dict[str, Any]]): Version attributes. + data (Optional[dict[str, Any]]): Version data. + tags (Optional[Iterable[str]]): Version tags. + status (Optional[str]): Version status. + active (Optional[bool]): Version active state. + thumbnail_id (Optional[str]): Version thumbnail id. + version_id (Optional[str]): Version id. If not passed new id is + generated. - [ - { - "id": "112233445566", - "parentId": "112233445567", - "path": "/root/parent/child", - "parents": ["root", "parent"], - "name": "child", - "label": "Child", - "folderType": "Folder", - "hasTasks": False, - "hasChildren": False, - "taskNames": [ - "Compositing", - ], - "status": "In Progress", - "attrib": {}, - "ownAttrib": [], - "updatedAt": "2023-06-12T15:37:02.420260", - }, - ... - ] + Returns: + str: Version id. - Deprecated: - Use 'get_rest_folders' instead. Function was renamed to match - other rest functions, like 'get_rest_folder', - 'get_rest_project' etc. . - Will be removed in '1.0.7' or '1.1.0'. + """ + con = get_server_api_connection() + return con.create_version( + project_name=project_name, + version=version, + product_id=product_id, + task_id=task_id, + author=author, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + thumbnail_id=thumbnail_id, + version_id=version_id, + ) + + +def update_version( + project_name: str, + version_id: str, + version: Optional[int] = None, + product_id: Optional[str] = None, + task_id: Optional[str] = NOT_SET, + author: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[Iterable[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + thumbnail_id: Optional[str] = NOT_SET, +) -> None: + """Update version entity on server. + + Do not pass ``task_id`` amd ``thumbnail_id`` if you don't + want to change their values. Value ``None`` would unset + their value. + + Update of ``data`` will override existing value on folder entity. + + Update of ``attrib`` does change only passed attributes. If you want + to unset value, use ``None``. Args: project_name (str): Project name. - include_attrib (Optional[bool]): Include attribute values - in output. Slower to query. + version_id (str): Version id. + version (Optional[int]): New version. + product_id (Optional[str]): New product id. + task_id (Optional[str]): New task id. + author (Optional[str]): New author username. + attrib (Optional[dict[str, Any]]): New attributes. + data (Optional[dict[str, Any]]): New data. + tags (Optional[Iterable[str]]): New tags. + status (Optional[str]): New status. + active (Optional[bool]): New active state. + thumbnail_id (Optional[str]): New thumbnail id. - Returns: - list[FlatFolderDict]: List of folder entities. + """ + con = get_server_api_connection() + return con.update_version( + project_name=project_name, + version_id=version_id, + version=version, + product_id=product_id, + task_id=task_id, + author=author, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + thumbnail_id=thumbnail_id, + ) + + +def delete_version( + project_name: str, + version_id: str, +) -> None: + """Delete version. + + Args: + project_name (str): Project name. + version_id (str): Version id to delete. """ con = get_server_api_connection() - return con.get_folders_rest( + return con.delete_version( project_name=project_name, - include_attrib=include_attrib, + version_id=version_id, ) -def get_folders( +def get_rest_representation( project_name: str, - folder_ids: Optional[Iterable[str]] = None, - folder_paths: Optional[Iterable[str]] = None, - folder_names: Optional[Iterable[str]] = None, - folder_types: Optional[Iterable[str]] = None, - parent_ids: Optional[Iterable[str]] = None, - folder_path_regex: Optional[str] = None, - has_products: Optional[bool] = None, - has_tasks: Optional[bool] = None, - has_children: Optional[bool] = None, + representation_id: str, +) -> Optional[RepresentationDict]: + con = get_server_api_connection() + return con.get_rest_representation( + project_name=project_name, + representation_id=representation_id, + ) + + +def get_representations( + project_name: str, + representation_ids: Optional[Iterable[str]] = None, + representation_names: Optional[Iterable[str]] = None, + version_ids: Optional[Iterable[str]] = None, + names_by_version_ids: Optional[dict[str, Iterable[str]]] = None, statuses: Optional[Iterable[str]] = None, - assignees_all: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, active: Optional[bool] = True, - has_links: Optional[bool] = None, + has_links: Optional[str] = None, filters: Optional[AdvancedFilterDict] = None, fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Generator[FolderDict, None, None]: - """Query folders from server. + own_attributes=_PLACEHOLDER, +) -> Generator[RepresentationDict, None, None]: + """Get representation entities based on passed filters from server. - Todos: - Folder name won't be unique identifier, so we should add - folder path filtering. + .. todo:: - Notes: - Filter 'active' don't have direct filter in GraphQl. + Add separated function for 'names_by_version_ids' filtering. + Because can't be combined with others. Args: - project_name (str): Name of project. - folder_ids (Optional[Iterable[str]]): Folder ids to filter. - folder_paths (Optional[Iterable[str]]): Folder paths used - for filtering. - folder_names (Optional[Iterable[str]]): Folder names used - for filtering. - folder_types (Optional[Iterable[str]]): Folder types used - for filtering. - parent_ids (Optional[Iterable[str]]): Ids of folder parents. - Use 'None' if folder is direct child of project. - folder_path_regex (Optional[str]): Folder path regex used - for filtering. - has_products (Optional[bool]): Filter folders with/without - products. Ignored when None, default behavior. - has_tasks (Optional[bool]): Filter folders with/without - tasks. Ignored when None, default behavior. - has_children (Optional[bool]): Filter folders with/without - children. Ignored when None, default behavior. - statuses (Optional[Iterable[str]]): Folder statuses used + project_name (str): Name of project where to look for versions. + representation_ids (Optional[Iterable[str]]): Representation ids + used for representation filtering. + representation_names (Optional[Iterable[str]]): Representation + names used for representation filtering. + version_ids (Optional[Iterable[str]]): Version ids used for + representation filtering. Versions are parents of + representations. + names_by_version_ids (Optional[dict[str, Iterable[str]]]): Find + representations by names and version ids. This filter + discards all other filters. + statuses (Optional[Iterable[str]]): Representation statuses used for filtering. - assignees_all (Optional[Iterable[str]]): Filter by assigness - on children tasks. Task must have all of passed assignees. - tags (Optional[Iterable[str]]): Folder tags used + tags (Optional[Iterable[str]]): Representation tags used for filtering. - active (Optional[bool]): Filter active/inactive folders. - Both are returned if is set to None. + active (Optional[bool]): Receive active/inactive entities. + Both are returned when 'None' is passed. has_links (Optional[Literal[IN, OUT, ANY]]): Filter representations with IN/OUT/ANY links. filters (Optional[AdvancedFilterDict]): Advanced filtering options. fields (Optional[Iterable[str]]): Fields to be queried for - folder. All possible folder fields are returned - if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. + representation. All possible fields are returned if 'None' is + passed. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + representations. + + Returns: + Generator[RepresentationDict, None, None]: Queried + representation entities. + + """ + con = get_server_api_connection() + return con.get_representations( + project_name=project_name, + representation_ids=representation_ids, + representation_names=representation_names, + version_ids=version_ids, + names_by_version_ids=names_by_version_ids, + statuses=statuses, + tags=tags, + active=active, + has_links=has_links, + filters=filters, + fields=fields, + own_attributes=own_attributes, + ) + + +def get_representation_by_id( + project_name: str, + representation_id: str, + fields: Optional[Iterable[str]] = None, + own_attributes=_PLACEHOLDER, +) -> Optional[RepresentationDict]: + """Query representation entity from server based on id filter. + + Args: + project_name (str): Project where to look for representation. + representation_id (str): Id of representation. + fields (Optional[Iterable[str]]): fields to be queried + for representations. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + representations. Returns: - Generator[FolderDict, None, None]: Queried folder entities. + Optional[RepresentationDict]: Queried representation + entity or None. """ con = get_server_api_connection() - return con.get_folders( + return con.get_representation_by_id( project_name=project_name, - folder_ids=folder_ids, - folder_paths=folder_paths, - folder_names=folder_names, - folder_types=folder_types, - parent_ids=parent_ids, - folder_path_regex=folder_path_regex, - has_products=has_products, - has_tasks=has_tasks, - has_children=has_children, - statuses=statuses, - assignees_all=assignees_all, - tags=tags, - active=active, - has_links=has_links, - filters=filters, + representation_id=representation_id, fields=fields, own_attributes=own_attributes, ) -def get_folder_by_id( +def get_representation_by_name( project_name: str, - folder_id: str, + representation_name: str, + version_id: str, fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Optional[FolderDict]: - """Query folder entity by id. + own_attributes=_PLACEHOLDER, +) -> Optional[RepresentationDict]: + """Query representation entity by name and version id. Args: - project_name (str): Name of project where to look for queried - entities. - folder_id (str): Folder id. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. + project_name (str): Project where to look for representation. + representation_name (str): Representation name. + version_id (str): Version id. + fields (Optional[Iterable[str]]): fields to be queried + for representations. + own_attributes (Optional[bool]): DEPRECATED: Not supported for + representations. Returns: - Optional[FolderDict]: Folder entity data or None - if was not found. + Optional[RepresentationDict]: Queried representation entity + or None. """ con = get_server_api_connection() - return con.get_folder_by_id( + return con.get_representation_by_name( project_name=project_name, - folder_id=folder_id, + representation_name=representation_name, + version_id=version_id, fields=fields, own_attributes=own_attributes, ) -def get_folder_by_path( +def get_representations_hierarchy( project_name: str, - folder_path: str, - fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Optional[FolderDict]: - """Query folder entity by path. + representation_ids: Iterable[str], + project_fields: Optional[Iterable[str]] = None, + folder_fields: Optional[Iterable[str]] = None, + task_fields: Optional[Iterable[str]] = None, + product_fields: Optional[Iterable[str]] = None, + version_fields: Optional[Iterable[str]] = None, + representation_fields: Optional[Iterable[str]] = None, +) -> dict[str, RepresentationHierarchy]: + """Find representation with parents by representation id. - Folder path is a path to folder with all parent names joined by slash. + Representation entity with parent entities up to project. + + Default fields are used when any fields are set to `None`. But it is + possible to pass in empty iterable (list, set, tuple) to skip + entity. Args: - project_name (str): Name of project where to look for queried - entities. - folder_path (str): Folder path. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. + project_name (str): Project where to look for entities. + representation_ids (Iterable[str]): Representation ids. + project_fields (Optional[Iterable[str]]): Project fields. + folder_fields (Optional[Iterable[str]]): Folder fields. + task_fields (Optional[Iterable[str]]): Task fields. + product_fields (Optional[Iterable[str]]): Product fields. + version_fields (Optional[Iterable[str]]): Version fields. + representation_fields (Optional[Iterable[str]]): Representation + fields. Returns: - Optional[FolderDict]: Folder entity data or None - if was not found. + dict[str, RepresentationHierarchy]: Parent entities by + representation id. """ con = get_server_api_connection() - return con.get_folder_by_path( + return con.get_representations_hierarchy( project_name=project_name, - folder_path=folder_path, - fields=fields, - own_attributes=own_attributes, + representation_ids=representation_ids, + project_fields=project_fields, + folder_fields=folder_fields, + task_fields=task_fields, + product_fields=product_fields, + version_fields=version_fields, + representation_fields=representation_fields, ) -def get_folder_by_name( +def get_representation_hierarchy( project_name: str, - folder_name: str, - fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Optional[FolderDict]: - """Query folder entity by path. + representation_id: str, + project_fields: Optional[Iterable[str]] = None, + folder_fields: Optional[Iterable[str]] = None, + task_fields: Optional[Iterable[str]] = None, + product_fields: Optional[Iterable[str]] = None, + version_fields: Optional[Iterable[str]] = None, + representation_fields: Optional[Iterable[str]] = None, +) -> Optional[RepresentationHierarchy]: + """Find representation parents by representation id. - Warnings: - Folder name is not a unique identifier of a folder. Function is - kept for OpenPype 3 compatibility. + Representation parent entities up to project. Args: - project_name (str): Name of project where to look for queried - entities. - folder_name (str): Folder name. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. + project_name (str): Project where to look for entities. + representation_id (str): Representation id. + project_fields (Optional[Iterable[str]]): Project fields. + folder_fields (Optional[Iterable[str]]): Folder fields. + task_fields (Optional[Iterable[str]]): Task fields. + product_fields (Optional[Iterable[str]]): Product fields. + version_fields (Optional[Iterable[str]]): Version fields. + representation_fields (Optional[Iterable[str]]): Representation + fields. Returns: - Optional[FolderDict]: Folder entity data or None - if was not found. + RepresentationHierarchy: Representation hierarchy entities. """ con = get_server_api_connection() - return con.get_folder_by_name( + return con.get_representation_hierarchy( project_name=project_name, - folder_name=folder_name, - fields=fields, - own_attributes=own_attributes, + representation_id=representation_id, + project_fields=project_fields, + folder_fields=folder_fields, + task_fields=task_fields, + product_fields=product_fields, + version_fields=version_fields, + representation_fields=representation_fields, ) -def get_folder_ids_with_products( +def get_representations_parents( project_name: str, - folder_ids: Optional[Iterable[str]] = None, -) -> set[str]: - """Find folders which have at least one product. + representation_ids: Iterable[str], + project_fields: Optional[Iterable[str]] = None, + folder_fields: Optional[Iterable[str]] = None, + product_fields: Optional[Iterable[str]] = None, + version_fields: Optional[Iterable[str]] = None, +) -> dict[str, RepresentationParents]: + """Find representations parents by representation id. - Folders that have at least one product should be immutable, so they - should not change path -> change of name or name of any parent - is not possible. + Representation parent entities up to project. + + Args: + project_name (str): Project where to look for entities. + representation_ids (Iterable[str]): Representation ids. + project_fields (Optional[Iterable[str]]): Project fields. + folder_fields (Optional[Iterable[str]]): Folder fields. + product_fields (Optional[Iterable[str]]): Product fields. + version_fields (Optional[Iterable[str]]): Version fields. + + Returns: + dict[str, RepresentationParents]: Parent entities by + representation id. + + """ + con = get_server_api_connection() + return con.get_representations_parents( + project_name=project_name, + representation_ids=representation_ids, + project_fields=project_fields, + folder_fields=folder_fields, + product_fields=product_fields, + version_fields=version_fields, + ) + + +def get_representation_parents( + project_name: str, + representation_id: str, + project_fields: Optional[Iterable[str]] = None, + folder_fields: Optional[Iterable[str]] = None, + product_fields: Optional[Iterable[str]] = None, + version_fields: Optional[Iterable[str]] = None, +) -> Optional[RepresentationParents]: + """Find representation parents by representation id. + + Representation parent entities up to project. + + Args: + project_name (str): Project where to look for entities. + representation_id (str): Representation id. + project_fields (Optional[Iterable[str]]): Project fields. + folder_fields (Optional[Iterable[str]]): Folder fields. + product_fields (Optional[Iterable[str]]): Product fields. + version_fields (Optional[Iterable[str]]): Version fields. + + Returns: + RepresentationParents: Representation parent entities. + + """ + con = get_server_api_connection() + return con.get_representation_parents( + project_name=project_name, + representation_id=representation_id, + project_fields=project_fields, + folder_fields=folder_fields, + product_fields=product_fields, + version_fields=version_fields, + ) + + +def get_repre_ids_by_context_filters( + project_name: str, + context_filters: Optional[dict[str, Iterable[str]]], + representation_names: Optional[Iterable[str]] = None, + version_ids: Optional[Iterable[str]] = None, +) -> list[str]: + """Find representation ids which match passed context filters. + + Each representation has context integrated on representation entity in + database. The context may contain project, folder, task name or + product name, product type and many more. This implementation gives + option to quickly filter representation based on representation data + in database. + + Context filters have defined structure. To define filter of nested + subfield use dot '.' as delimiter (For example 'task.name'). + Filter values can be regex filters. String or ``re.Pattern`` can + be used. Args: - project_name (str): Name of project. - folder_ids (Optional[Iterable[str]]): Limit folder ids filtering - to a set of folders. If set to None all folders on project are - checked. + project_name (str): Project where to look for representations. + context_filters (dict[str, list[str]]): Filters of context fields. + representation_names (Optional[Iterable[str]]): Representation + names, can be used as additional filter for representations + by their names. + version_ids (Optional[Iterable[str]]): Version ids, can be used + as additional filter for representations by their parent ids. Returns: - set[str]: Folder ids that have at least one product. + list[str]: Representation ids that match passed filters. + + Example: + The function returns just representation ids so if entities are + required for funtionality they must be queried afterwards by + their ids. + >>> from ayon_api import get_repre_ids_by_context_filters + >>> from ayon_api import get_representations + >>> project_name = "testProject" + >>> filters = { + ... "task.name": ["[aA]nimation"], + ... "product": [".*[Mm]ain"] + ... } + >>> repre_ids = get_repre_ids_by_context_filters( + ... project_name, filters) + >>> repres = get_representations(project_name, repre_ids) """ con = get_server_api_connection() - return con.get_folder_ids_with_products( + return con.get_repre_ids_by_context_filters( project_name=project_name, - folder_ids=folder_ids, + context_filters=context_filters, + representation_names=representation_names, + version_ids=version_ids, ) -def create_folder( +def create_representation( project_name: str, name: str, - folder_type: Optional[str] = None, - parent_id: Optional[str] = None, - label: Optional[str] = None, + version_id: str, + files: Optional[list[dict[str, Any]]] = None, attrib: Optional[dict[str, Any]] = None, data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, + traits: Optional[dict[str, Any]] = None, + tags: Optional[list[str]] = None, status: Optional[str] = None, active: Optional[bool] = None, - thumbnail_id: Optional[str] = None, - folder_id: Optional[str] = None, + representation_id: Optional[str] = None, ) -> str: - """Create new folder. + """Create new representation. Args: project_name (str): Project name. - name (str): Folder name. - folder_type (Optional[str]): Folder type. - parent_id (Optional[str]): Parent folder id. Parent is project - if is ``None``. - label (Optional[str]): Label of folder. - attrib (Optional[dict[str, Any]]): Folder attributes. - data (Optional[dict[str, Any]]): Folder data. - tags (Optional[Iterable[str]]): Folder tags. - status (Optional[str]): Folder status. - active (Optional[bool]): Folder active state. - thumbnail_id (Optional[str]): Folder thumbnail id. - folder_id (Optional[str]): Folder id. If not passed new id is - generated. + name (str): Representation name. + version_id (str): Parent version id. + files (Optional[list[dict]]): Representation files information. + attrib (Optional[dict[str, Any]]): Representation attributes. + data (Optional[dict[str, Any]]): Representation data. + traits (Optional[dict[str, Any]]): Representation traits + serialized data as dict. + tags (Optional[Iterable[str]]): Representation tags. + status (Optional[str]): Representation status. + active (Optional[bool]): Representation active state. + representation_id (Optional[str]): Representation id. If not + passed new id is generated. Returns: - str: Entity id. + str: Representation id. """ con = get_server_api_connection() - return con.create_folder( + return con.create_representation( project_name=project_name, name=name, - folder_type=folder_type, - parent_id=parent_id, - label=label, + version_id=version_id, + files=files, attrib=attrib, data=data, + traits=traits, tags=tags, status=status, active=active, - thumbnail_id=thumbnail_id, - folder_id=folder_id, + representation_id=representation_id, ) -def update_folder( +def update_representation( project_name: str, - folder_id: str, + representation_id: str, name: Optional[str] = None, - folder_type: Optional[str] = None, - parent_id: Optional[str] = NOT_SET, - label: Optional[str] = NOT_SET, + version_id: Optional[str] = None, + files: Optional[list[dict[str, Any]]] = None, attrib: Optional[dict[str, Any]] = None, data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, + traits: Optional[dict[str, Any]] = None, + tags: Optional[list[str]] = None, status: Optional[str] = None, active: Optional[bool] = None, - thumbnail_id: Optional[str] = NOT_SET, ) -> None: - """Update folder entity on server. - - Do not pass ``parent_id``, ``label`` amd ``thumbnail_id`` if you don't - want to change their values. Value ``None`` would unset - their value. + """Update representation entity on server. Update of ``data`` will override existing value on folder entity. @@ -5174,3674 +7989,3270 @@ def update_folder( Args: project_name (str): Project name. - folder_id (str): Folder id. + representation_id (str): Representation id. name (Optional[str]): New name. - folder_type (Optional[str]): New folder type. - parent_id (Optional[str]): New parent folder id. - label (Optional[str]): New label. + version_id (Optional[str]): New version id. + files (Optional[list[dict]]): New files + information. attrib (Optional[dict[str, Any]]): New attributes. data (Optional[dict[str, Any]]): New data. + traits (Optional[dict[str, Any]]): New traits. tags (Optional[Iterable[str]]): New tags. status (Optional[str]): New status. active (Optional[bool]): New active state. - thumbnail_id (Optional[str]): New thumbnail id. - - """ - con = get_server_api_connection() - return con.update_folder( - project_name=project_name, - folder_id=folder_id, - name=name, - folder_type=folder_type, - parent_id=parent_id, - label=label, - attrib=attrib, - data=data, - tags=tags, - status=status, - active=active, - thumbnail_id=thumbnail_id, - ) - - -def delete_folder( - project_name: str, - folder_id: str, - force: bool = False, -) -> None: - """Delete folder. - - Args: - project_name (str): Project name. - folder_id (str): Folder id to delete. - force (Optional[bool]): Folder delete folder with all children - folder, products, versions and representations. - - """ - con = get_server_api_connection() - return con.delete_folder( - project_name=project_name, - folder_id=folder_id, - force=force, - ) - - -def get_rest_task( - project_name: str, - task_id: str, -) -> Optional[TaskDict]: - con = get_server_api_connection() - return con.get_rest_task( - project_name=project_name, - task_id=task_id, - ) - - -def get_tasks( - project_name: str, - task_ids: Optional[Iterable[str]] = None, - task_names: Optional[Iterable[str]] = None, - task_types: Optional[Iterable[str]] = None, - folder_ids: Optional[Iterable[str]] = None, - assignees: Optional[Iterable[str]] = None, - assignees_all: Optional[Iterable[str]] = None, - statuses: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - active: Optional[bool] = True, - filters: Optional[AdvancedFilterDict] = None, - fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Generator[TaskDict, None, None]: - """Query task entities from server. - - Args: - project_name (str): Name of project. - task_ids (Iterable[str]): Task ids to filter. - task_names (Iterable[str]): Task names used for filtering. - task_types (Iterable[str]): Task types used for filtering. - folder_ids (Iterable[str]): Ids of task parents. Use 'None' - if folder is direct child of project. - assignees (Optional[Iterable[str]]): Task assignees used for - filtering. All tasks with any of passed assignees are - returned. - assignees_all (Optional[Iterable[str]]): Task assignees used - for filtering. Task must have all of passed assignees to be - returned. - statuses (Optional[Iterable[str]]): Task statuses used for - filtering. - tags (Optional[Iterable[str]]): Task tags used for - filtering. - active (Optional[bool]): Filter active/inactive tasks. - Both are returned if is set to None. - filters (Optional[AdvancedFilterDict]): Advanced filtering options. - fields (Optional[Iterable[str]]): Fields to be queried for - folder. All possible folder fields are returned - if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. - - Returns: - Generator[TaskDict, None, None]: Queried task entities. - - """ - con = get_server_api_connection() - return con.get_tasks( - project_name=project_name, - task_ids=task_ids, - task_names=task_names, - task_types=task_types, - folder_ids=folder_ids, - assignees=assignees, - assignees_all=assignees_all, - statuses=statuses, - tags=tags, - active=active, - filters=filters, - fields=fields, - own_attributes=own_attributes, - ) - - -def get_task_by_name( - project_name: str, - folder_id: str, - task_name: str, - fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Optional[TaskDict]: - """Query task entity by name and folder id. - - Args: - project_name (str): Name of project where to look for queried - entities. - folder_id (str): Folder id. - task_name (str): Task name - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. - - Returns: - Optional[TaskDict]: Task entity data or None if was not found. """ con = get_server_api_connection() - return con.get_task_by_name( + return con.update_representation( project_name=project_name, - folder_id=folder_id, - task_name=task_name, - fields=fields, - own_attributes=own_attributes, + representation_id=representation_id, + name=name, + version_id=version_id, + files=files, + attrib=attrib, + data=data, + traits=traits, + tags=tags, + status=status, + active=active, ) -def get_task_by_id( +def delete_representation( project_name: str, - task_id: str, - fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Optional[TaskDict]: - """Query task entity by id. + representation_id: str, +) -> None: + """Delete representation. Args: - project_name (str): Name of project where to look for queried - entities. - task_id (str): Task id. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. - - Returns: - Optional[TaskDict]: Task entity data or None if was not found. + project_name (str): Project name. + representation_id (str): Representation id to delete. """ con = get_server_api_connection() - return con.get_task_by_id( + return con.delete_representation( project_name=project_name, - task_id=task_id, - fields=fields, - own_attributes=own_attributes, + representation_id=representation_id, ) -def get_tasks_by_folder_paths( +def get_workfile_entities( project_name: str, - folder_paths: Iterable[str], - task_names: Optional[Iterable[str]] = None, - task_types: Optional[Iterable[str]] = None, - assignees: Optional[Iterable[str]] = None, - assignees_all: Optional[Iterable[str]] = None, + *, + workfile_ids: Optional[Iterable[str]] = None, + task_ids: Optional[Iterable[str]] = None, + paths: Optional[Iterable[str]] = None, + path_regex: Optional[str] = None, statuses: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, - active: Optional[bool] = True, - filters: Optional[AdvancedFilterDict] = None, + has_links: Optional[str] = None, fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> dict[str, list[TaskDict]]: - """Query task entities from server by folder paths. +) -> Generator[WorkfileInfoDict, None, None]: + """Workfile info entities by passed filters. Args: - project_name (str): Name of project. - folder_paths (list[str]): Folder paths. - task_names (Iterable[str]): Task names used for filtering. - task_types (Iterable[str]): Task types used for filtering. - assignees (Optional[Iterable[str]]): Task assignees used for - filtering. All tasks with any of passed assignees are - returned. - assignees_all (Optional[Iterable[str]]): Task assignees used - for filtering. Task must have all of passed assignees to be - returned. - statuses (Optional[Iterable[str]]): Task statuses used for - filtering. - tags (Optional[Iterable[str]]): Task tags used for - filtering. - active (Optional[bool]): Filter active/inactive tasks. - Both are returned if is set to None. - filters (Optional[AdvancedFilterDict]): Advanced filtering options. + project_name (str): Project under which the entity is located. + workfile_ids (Optional[Iterable[str]]): Workfile ids. + task_ids (Optional[Iterable[str]]): Task ids. + paths (Optional[Iterable[str]]): Rootless workfiles paths. + path_regex (Optional[str]): Regex filter for workfile path. + statuses (Optional[Iterable[str]]): Workfile info statuses used + for filtering. + tags (Optional[Iterable[str]]): Workfile info tags used + for filtering. + has_links (Optional[Literal[IN, OUT, ANY]]): Filter + representations with IN/OUT/ANY links. fields (Optional[Iterable[str]]): Fields to be queried for - folder. All possible folder fields are returned - if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. + representation. All possible fields are returned if 'None' is + passed. Returns: - dict[str, list[TaskDict]]: Task entities by - folder path. + Generator[WorkfileInfoDict, None, None]: Queried workfile info + entites. """ con = get_server_api_connection() - return con.get_tasks_by_folder_paths( + return con.get_workfile_entities( project_name=project_name, - folder_paths=folder_paths, - task_names=task_names, - task_types=task_types, - assignees=assignees, - assignees_all=assignees_all, + workfile_ids=workfile_ids, + task_ids=task_ids, + paths=paths, + path_regex=path_regex, statuses=statuses, tags=tags, - active=active, - filters=filters, + has_links=has_links, fields=fields, - own_attributes=own_attributes, ) -def get_tasks_by_folder_path( +def get_workfile_entity( project_name: str, - folder_path: str, - task_names: Optional[Iterable[str]] = None, - task_types: Optional[Iterable[str]] = None, - assignees: Optional[Iterable[str]] = None, - assignees_all: Optional[Iterable[str]] = None, - statuses: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - active: Optional[bool] = True, + task_id: str, + path: str, + *, fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> list[TaskDict]: - """Query task entities from server by folder path. +) -> Optional[WorkfileInfoDict]: + """Workfile info entity by task id and workfile path. Args: - project_name (str): Name of project. - folder_path (str): Folder path. - task_names (Iterable[str]): Task names used for filtering. - task_types (Iterable[str]): Task types used for filtering. - assignees (Optional[Iterable[str]]): Task assignees used for - filtering. All tasks with any of passed assignees are - returned. - assignees_all (Optional[Iterable[str]]): Task assignees used - for filtering. Task must have all of passed assignees to be - returned. - statuses (Optional[Iterable[str]]): Task statuses used for - filtering. - tags (Optional[Iterable[str]]): Task tags used for - filtering. - active (Optional[bool]): Filter active/inactive tasks. - Both are returned if is set to None. + project_name (str): Project under which the entity is located. + task_id (str): Task id. + path (str): Rootless workfile path. fields (Optional[Iterable[str]]): Fields to be queried for - folder. All possible folder fields are returned - if 'None' is passed. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. + representation. All possible fields are returned if 'None' is + passed. + + Returns: + Optional[WorkfileInfoDict]: Workfile info entity or None. """ con = get_server_api_connection() - return con.get_tasks_by_folder_path( + return con.get_workfile_entity( project_name=project_name, - folder_path=folder_path, - task_names=task_names, - task_types=task_types, - assignees=assignees, - assignees_all=assignees_all, - statuses=statuses, - tags=tags, - active=active, + task_id=task_id, + path=path, fields=fields, - own_attributes=own_attributes, ) -def get_task_by_folder_path( +def get_workfile_entity_by_id( project_name: str, - folder_path: str, - task_name: str, + workfile_id: str, + *, fields: Optional[Iterable[str]] = None, - own_attributes: bool = False, -) -> Optional[TaskDict]: - """Query task entity by folder path and task name. +) -> Optional[WorkfileInfoDict]: + """Workfile info entity by id. Args: - project_name (str): Project name. - folder_path (str): Folder path. - task_name (str): Task name. - fields (Optional[Iterable[str]]): Task fields that should - be returned. - own_attributes (Optional[bool]): Attribute values that are - not explicitly set on entity will have 'None' value. + project_name (str): Project under which the entity is located. + workfile_id (str): Workfile info id. + fields (Optional[Iterable[str]]): Fields to be queried for + representation. All possible fields are returned if 'None' is + passed. Returns: - Optional[TaskDict]: Task entity data or None if was not found. + Optional[WorkfileInfoDict]: Workfile info entity or None. """ con = get_server_api_connection() - return con.get_task_by_folder_path( + return con.get_workfile_entity_by_id( project_name=project_name, - folder_path=folder_path, - task_name=task_name, + workfile_id=workfile_id, fields=fields, - own_attributes=own_attributes, ) -def create_task( +def create_workfile_entity( project_name: str, - name: str, - task_type: str, - folder_id: str, - label: Optional[str] = None, - assignees: Optional[Iterable[str]] = None, + path: str, + task_id: str, + *, + thumbnail_id: Optional[str] = None, attrib: Optional[dict[str, Any]] = None, data: Optional[dict[str, Any]] = None, tags: Optional[list[str]] = None, status: Optional[str] = None, active: Optional[bool] = None, - thumbnail_id: Optional[str] = None, - task_id: Optional[str] = None, + workfile_id: Optional[str] = None, ) -> str: - """Create new task. + """Create new workfile. Args: project_name (str): Project name. - name (str): Folder name. - task_type (str): Task type. - folder_id (str): Parent folder id. - label (Optional[str]): Label of folder. - assignees (Optional[Iterable[str]]): Task assignees. - attrib (Optional[dict[str, Any]]): Task attributes. - data (Optional[dict[str, Any]]): Task data. - tags (Optional[Iterable[str]]): Task tags. - status (Optional[str]): Task status. - active (Optional[bool]): Task active state. - thumbnail_id (Optional[str]): Task thumbnail id. - task_id (Optional[str]): Task id. If not passed new id is - generated. + path (str): Representation name. + task_id (str): Parent task id. + thumbnail_id (Optional[str]): Thumbnail id. + attrib (Optional[dict[str, Any]]): Representation attributes. + data (Optional[dict[str, Any]]): Representation data. + tags (Optional[Iterable[str]]): Representation tags. + status (Optional[str]): Representation status. + active (Optional[bool]): Representation active state. + workfile_id (Optional[str]): Workfile info id. If not + passed new id is generated. Returns: - str: Task id. + str: Workfile info id. """ con = get_server_api_connection() - return con.create_task( - project_name=project_name, - name=name, - task_type=task_type, - folder_id=folder_id, - label=label, - assignees=assignees, + return con.create_workfile_entity( + project_name=project_name, + path=path, + task_id=task_id, + thumbnail_id=thumbnail_id, attrib=attrib, data=data, tags=tags, status=status, active=active, - thumbnail_id=thumbnail_id, - task_id=task_id, + workfile_id=workfile_id, ) -def update_task( +def update_workfile_entity( project_name: str, - task_id: str, - name: Optional[str] = None, - task_type: Optional[str] = None, - folder_id: Optional[str] = None, - label: Optional[str] = NOT_SET, - assignees: Optional[list[str]] = None, + workfile_id: str, + *, + path: Optional[str] = None, + task_id: Optional[str] = None, attrib: Optional[dict[str, Any]] = None, data: Optional[dict[str, Any]] = None, - tags: Optional[list[str]] = None, + tags: Optional[Iterable[str]] = None, status: Optional[str] = None, active: Optional[bool] = None, thumbnail_id: Optional[str] = NOT_SET, + created_by: Optional[str] = None, + updated_by: Optional[str] = None, ) -> None: - """Update task entity on server. - - Do not pass ``label`` amd ``thumbnail_id`` if you don't - want to change their values. Value ``None`` would unset - their value. - - Update of ``data`` will override existing value on folder entity. + """Update workfile entity on server. Update of ``attrib`` does change only passed attributes. If you want to unset value, use ``None``. Args: project_name (str): Project name. - task_id (str): Task id. - name (Optional[str]): New name. - task_type (Optional[str]): New task type. - folder_id (Optional[str]): New folder id. - label (Optional[Optional[str]]): New label. - assignees (Optional[str]): New assignees. + workfile_id (str): Workfile id. + path (Optional[str]): New rootless workfile path.. + task_id (Optional[str]): New parent task id. attrib (Optional[dict[str, Any]]): New attributes. data (Optional[dict[str, Any]]): New data. tags (Optional[Iterable[str]]): New tags. status (Optional[str]): New status. active (Optional[bool]): New active state. thumbnail_id (Optional[str]): New thumbnail id. + created_by (Optional[str]): New created by username. + updated_by (Optional[str]): New updated by username. """ con = get_server_api_connection() - return con.update_task( + return con.update_workfile_entity( project_name=project_name, + workfile_id=workfile_id, + path=path, task_id=task_id, - name=name, - task_type=task_type, - folder_id=folder_id, - label=label, - assignees=assignees, attrib=attrib, data=data, tags=tags, status=status, active=active, thumbnail_id=thumbnail_id, + created_by=created_by, + updated_by=updated_by, ) -def delete_task( +def delete_workfile_entity( project_name: str, - task_id: str, + workfile_id: str, ) -> None: - """Delete task. + """Delete workfile entity on server. Args: project_name (str): Project name. - task_id (str): Task id to delete. + workfile_id (str): Workfile id to delete. """ con = get_server_api_connection() - return con.delete_task( - project_name=project_name, - task_id=task_id, - ) - - -def get_rest_product( - project_name: str, - product_id: str, -) -> Optional[ProductDict]: - con = get_server_api_connection() - return con.get_rest_product( + return con.delete_workfile_entity( project_name=project_name, - product_id=product_id, + workfile_id=workfile_id, ) -def get_products( +def get_workfiles_info( project_name: str, - product_ids: Optional[Iterable[str]] = None, - product_names: Optional[Iterable[str]] = None, - folder_ids: Optional[Iterable[str]] = None, - product_types: Optional[Iterable[str]] = None, - product_base_types: Optional[Iterable[str]] = None, - product_name_regex: Optional[str] = None, - product_path_regex: Optional[str] = None, - names_by_folder_ids: Optional[dict[str, Iterable[str]]] = None, + workfile_ids: Optional[Iterable[str]] = None, + task_ids: Optional[Iterable[str]] = None, + paths: Optional[Iterable[str]] = None, + path_regex: Optional[str] = None, statuses: Optional[Iterable[str]] = None, tags: Optional[Iterable[str]] = None, - active: Optional[bool] = True, - filters: Optional[AdvancedFilterDict] = None, + has_links: Optional[str] = None, fields: Optional[Iterable[str]] = None, own_attributes=_PLACEHOLDER, -) -> Generator[ProductDict, None, None]: - """Query products from server. - - Todos: - Separate 'name_by_folder_ids' filtering to separated method. It - cannot be combined with some other filters. +) -> Generator[WorkfileInfoDict, None, None]: + """DEPRECATED Workfile info entities by passed filters. Args: - project_name (str): Name of project. - product_ids (Optional[Iterable[str]]): Task ids to filter. - product_names (Optional[Iterable[str]]): Task names used for - filtering. - folder_ids (Optional[Iterable[str]]): Ids of task parents. - Use 'None' if folder is direct child of project. - product_types (Optional[Iterable[str]]): Product types used for - filtering. - product_base_types (Optional[Iterable[str]]): Product base types - used for filtering. - product_name_regex (Optional[str]): Filter products by name regex. - product_path_regex (Optional[str]): Filter products by path regex. - Path starts with folder path and ends with product name. - names_by_folder_ids (Optional[dict[str, Iterable[str]]]): Product - name filtering by folder id. - statuses (Optional[Iterable[str]]): Product statuses used + project_name (str): Project under which the entity is located. + workfile_ids (Optional[Iterable[str]]): Workfile ids. + task_ids (Optional[Iterable[str]]): Task ids. + paths (Optional[Iterable[str]]): Rootless workfiles paths. + path_regex (Optional[str]): Regex filter for workfile path. + statuses (Optional[Iterable[str]]): Workfile info statuses used for filtering. - tags (Optional[Iterable[str]]): Product tags used + tags (Optional[Iterable[str]]): Workfile info tags used for filtering. - active (Optional[bool]): Filter active/inactive products. - Both are returned if is set to None. - filters (Optional[AdvancedFilterDict]): Advanced filtering options. + has_links (Optional[Literal[IN, OUT, ANY]]): Filter + representations with IN/OUT/ANY links. fields (Optional[Iterable[str]]): Fields to be queried for - folder. All possible folder fields are returned - if 'None' is passed. + representation. All possible fields are returned if 'None' is + passed. own_attributes (Optional[bool]): DEPRECATED: Not supported for - products. + workfiles. Returns: - Generator[ProductDict, None, None]: Queried product entities. + Generator[WorkfileInfoDict, None, None]: Queried workfile info + entites. """ con = get_server_api_connection() - return con.get_products( + return con.get_workfiles_info( project_name=project_name, - product_ids=product_ids, - product_names=product_names, - folder_ids=folder_ids, - product_types=product_types, - product_base_types=product_base_types, - product_name_regex=product_name_regex, - product_path_regex=product_path_regex, - names_by_folder_ids=names_by_folder_ids, + workfile_ids=workfile_ids, + task_ids=task_ids, + paths=paths, + path_regex=path_regex, statuses=statuses, tags=tags, - active=active, - filters=filters, + has_links=has_links, fields=fields, own_attributes=own_attributes, ) -def get_product_by_id( +def get_workfile_info( project_name: str, - product_id: str, + task_id: str, + path: str, fields: Optional[Iterable[str]] = None, own_attributes=_PLACEHOLDER, -) -> Optional[ProductDict]: - """Query product entity by id. +) -> Optional[WorkfileInfoDict]: + """DEPRECATED Workfile info entity by task id and workfile path. Args: - project_name (str): Name of project where to look for queried - entities. - product_id (str): Product id. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. + project_name (str): Project under which the entity is located. + task_id (str): Task id. + path (str): Rootless workfile path. + fields (Optional[Iterable[str]]): Fields to be queried for + representation. All possible fields are returned if 'None' is + passed. own_attributes (Optional[bool]): DEPRECATED: Not supported for - products. + workfiles. Returns: - Optional[ProductDict]: Product entity data or None - if was not found. + Optional[WorkfileInfoDict]: Workfile info entity or None. """ con = get_server_api_connection() - return con.get_product_by_id( + return con.get_workfile_info( project_name=project_name, - product_id=product_id, + task_id=task_id, + path=path, fields=fields, own_attributes=own_attributes, ) -def get_product_by_name( +def get_workfile_info_by_id( project_name: str, - product_name: str, - folder_id: str, + workfile_id: str, fields: Optional[Iterable[str]] = None, own_attributes=_PLACEHOLDER, -) -> Optional[ProductDict]: - """Query product entity by name and folder id. +) -> Optional[WorkfileInfoDict]: + """DEPRECATED Workfile info entity by id. Args: - project_name (str): Name of project where to look for queried - entities. - product_name (str): Product name. - folder_id (str): Folder id (Folder is a parent of products). - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. + project_name (str): Project under which the entity is located. + workfile_id (str): Workfile info id. + fields (Optional[Iterable[str]]): Fields to be queried for + representation. All possible fields are returned if 'None' is + passed. own_attributes (Optional[bool]): DEPRECATED: Not supported for - products. + workfiles. Returns: - Optional[ProductDict]: Product entity data or None - if was not found. + Optional[WorkfileInfoDict]: Workfile info entity or None. """ con = get_server_api_connection() - return con.get_product_by_name( + return con.get_workfile_info_by_id( + project_name=project_name, + workfile_id=workfile_id, + fields=fields, + own_attributes=own_attributes, + ) + + +def update_workfile_info( + project_name: str, + workfile_id: str, + path: Optional[str] = None, + task_id: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[Iterable[str]] = None, + status: Optional[str] = None, + active: Optional[bool] = None, + thumbnail_id: Optional[str] = NOT_SET, + created_by: Optional[str] = None, + updated_by: Optional[str] = None, +) -> None: + """DEPRECATED Update workfile entity on server. + + Update of ``attrib`` does change only passed attributes. If you want + to unset value, use ``None``. + + Args: + project_name (str): Project name. + workfile_id (str): Workfile id. + path (Optional[str]): New rootless workfile path.. + task_id (Optional[str]): New parent task id. + attrib (Optional[dict[str, Any]]): New attributes. + data (Optional[dict[str, Any]]): New data. + tags (Optional[Iterable[str]]): New tags. + status (Optional[str]): New status. + active (Optional[bool]): New active state. + thumbnail_id (Optional[str]): New thumbnail id. + created_by (Optional[str]): New created by username. + updated_by (Optional[str]): New updated by username. + + """ + con = get_server_api_connection() + return con.update_workfile_info( project_name=project_name, - product_name=product_name, - folder_id=folder_id, - fields=fields, - own_attributes=own_attributes, + workfile_id=workfile_id, + path=path, + task_id=task_id, + attrib=attrib, + data=data, + tags=tags, + status=status, + active=active, + thumbnail_id=thumbnail_id, + created_by=created_by, + updated_by=updated_by, ) -def get_product_types( - fields: Optional[Iterable[str]] = None, -) -> list[ProductTypeDict]: - """Types of products. - - This is server wide information. Product types have 'name', 'icon' and - 'color'. +def delete_workfile_info( + project_name: str, + workfile_id: str, +) -> None: + """DEPRECATED Delete workfile entity on server. Args: - fields (Optional[Iterable[str]]): Product types fields to query. - - Returns: - list[ProductTypeDict]: Product types information. + project_name (str): Project name. + workfile_id (str): Workfile id to delete. """ con = get_server_api_connection() - return con.get_product_types( - fields=fields, + return con.delete_workfile_info( + project_name=project_name, + workfile_id=workfile_id, ) -def get_project_product_types( - project_name: str, - fields: Optional[Iterable[str]] = None, -) -> list[ProductTypeDict]: - """DEPRECATED Types of products available in a project. - - Filter only product types available in a project. +def get_full_link_type_name( + link_type_name: str, + input_type: str, + output_type: str, +) -> str: + """Calculate full link type name used for query from server. Args: - project_name (str): Name of the project where to look for - product types. - fields (Optional[Iterable[str]]): Product types fields to query. + link_type_name (str): Type of link. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. Returns: - list[ProductTypeDict]: Product types information. + str: Full name of link type used for query from server. """ con = get_server_api_connection() - return con.get_project_product_types( - project_name=project_name, - fields=fields, + return con.get_full_link_type_name( + link_type_name=link_type_name, + input_type=input_type, + output_type=output_type, ) -def get_product_type_names( - project_name: Optional[str] = None, - product_ids: Optional[Iterable[str]] = None, -) -> set[str]: - """DEPRECATED Product type names. +def get_link_types( + project_name: str, +) -> list[dict[str, Any]]: + """All link types available on a project. - Warnings: - This function will be probably removed. Matters if 'products_id' - filter has real use-case. + Example output: + [ + { + "name": "reference|folder|folder", + "link_type": "reference", + "input_type": "folder", + "output_type": "folder", + "data": {} + } + ] Args: - project_name (Optional[str]): Name of project where to look for - queried entities. - product_ids (Optional[Iterable[str]]): Product ids filter. Can be - used only with 'project_name'. + project_name (str): Name of project where to look for link types. Returns: - set[str]: Product type names. + list[dict[str, Any]]: Link types available on project. """ con = get_server_api_connection() - return con.get_product_type_names( + return con.get_link_types( project_name=project_name, - product_ids=product_ids, ) -def create_product( +def get_link_type( project_name: str, - name: str, - product_type: str, - folder_id: str, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, - product_base_type: Optional[str] = None, - product_id: Optional[str] = None, -) -> str: - """Create new product. + link_type_name: str, + input_type: str, + output_type: str, +) -> Optional[dict[str, Any]]: + """Get link type data. + + There is not dedicated REST endpoint to get single link type, + so method 'get_link_types' is used. + + Example output: + { + "name": "reference|folder|folder", + "link_type": "reference", + "input_type": "folder", + "output_type": "folder", + "data": {} + } Args: - project_name (str): Project name. - name (str): Product name. - product_type (str): Product type. - folder_id (str): Parent folder id. - attrib (Optional[dict[str, Any]]): Product attributes. - data (Optional[dict[str, Any]]): Product data. - tags (Optional[Iterable[str]]): Product tags. - status (Optional[str]): Product status. - active (Optional[bool]): Product active state. - product_base_type (Optional[str]): Product base type. - product_id (Optional[str]): Product id. If not passed new id is - generated. + project_name (str): Project where link type is available. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. Returns: - str: Product id. + Optional[dict[str, Any]]: Link type information. """ con = get_server_api_connection() - return con.create_product( + return con.get_link_type( project_name=project_name, - name=name, - product_type=product_type, - folder_id=folder_id, - attrib=attrib, - data=data, - tags=tags, - status=status, - active=active, - product_base_type=product_base_type, - product_id=product_id, + link_type_name=link_type_name, + input_type=input_type, + output_type=output_type, ) -def update_product( +def create_link_type( project_name: str, - product_id: str, - name: Optional[str] = None, - folder_id: Optional[str] = None, - product_type: Optional[str] = None, - product_base_type: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, + link_type_name: str, + input_type: str, + output_type: str, data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, ) -> None: - """Update product entity on server. - - Update of ``data`` will override existing value on folder entity. + """Create or update link type on server. - Update of ``attrib`` does change only passed attributes. If you want - to unset value, use ``None``. + Warning: + Because PUT is used for creation it is also used for update. Args: - project_name (str): Project name. - product_id (str): Product id. - name (Optional[str]): New product name. - folder_id (Optional[str]): New product id. - product_type (Optional[str]): New product type. - product_base_type (Optional[str]): New product base type. - attrib (Optional[dict[str, Any]]): New product attributes. - data (Optional[dict[str, Any]]): New product data. - tags (Optional[Iterable[str]]): New product tags. - status (Optional[str]): New product status. - active (Optional[bool]): New product active state. + project_name (str): Project where link type is created. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. + data (Optional[dict[str, Any]]): Additional data related to link. + + Raises: + HTTPRequestError: Server error happened. """ con = get_server_api_connection() - return con.update_product( + return con.create_link_type( project_name=project_name, - product_id=product_id, - name=name, - folder_id=folder_id, - product_type=product_type, - product_base_type=product_base_type, - attrib=attrib, + link_type_name=link_type_name, + input_type=input_type, + output_type=output_type, data=data, - tags=tags, - status=status, - active=active, ) -def delete_product( +def delete_link_type( project_name: str, - product_id: str, + link_type_name: str, + input_type: str, + output_type: str, ) -> None: - """Delete product. + """Remove link type from project. Args: - project_name (str): Project name. - product_id (str): Product id to delete. + project_name (str): Project where link type is created. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. + + Raises: + HTTPRequestError: Server error happened. """ con = get_server_api_connection() - return con.delete_product( + return con.delete_link_type( project_name=project_name, - product_id=product_id, + link_type_name=link_type_name, + input_type=input_type, + output_type=output_type, ) -def get_rest_version( +def make_sure_link_type_exists( project_name: str, - version_id: str, -) -> Optional[VersionDict]: + link_type_name: str, + input_type: str, + output_type: str, + data: Optional[dict[str, Any]] = None, +) -> None: + """Make sure link type exists on a project. + + Args: + project_name (str): Name of project. + link_type_name (str): Name of link type. + input_type (str): Input entity type of link. + output_type (str): Output entity type of link. + data (Optional[dict[str, Any]]): Link type related data. + + """ con = get_server_api_connection() - return con.get_rest_version( + return con.make_sure_link_type_exists( project_name=project_name, - version_id=version_id, + link_type_name=link_type_name, + input_type=input_type, + output_type=output_type, + data=data, ) -def get_versions( +def create_link( project_name: str, - version_ids: Optional[Iterable[str]] = None, - product_ids: Optional[Iterable[str]] = None, - task_ids: Optional[Iterable[str]] = None, - versions: Optional[Iterable[str]] = None, - hero: bool = True, - standard: bool = True, - latest: Optional[bool] = None, - statuses: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - active: Optional[bool] = True, - filters: Optional[AdvancedFilterDict] = None, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Generator[VersionDict, None, None]: - """Get version entities based on passed filters from server. + link_type_name: str, + input_id: str, + input_type: str, + output_id: str, + output_type: str, + link_name: Optional[str] = None, + data: Optional[dict[str, Any]] = None, +) -> CreateLinkResponseData: + """Create link between 2 entities. + + Link has a type which must already exists on a project. + + Example output:: + + { + "id": "59a212c0d2e211eda0e20242ac120002" + } Args: - project_name (str): Name of project where to look for versions. - version_ids (Optional[Iterable[str]]): Version ids used for - version filtering. - product_ids (Optional[Iterable[str]]): Product ids used for - version filtering. - task_ids (Optional[Iterable[str]]): Task ids used for - version filtering. - versions (Optional[Iterable[int]]): Versions we're interested in. - hero (Optional[bool]): Skip hero versions when set to False. - standard (Optional[bool]): Skip standard (non-hero) when - set to False. - latest (Optional[bool]): Return only latest version of standard - versions. This can be combined only with 'standard' attribute - set to True. - statuses (Optional[Iterable[str]]): Representation statuses used - for filtering. - tags (Optional[Iterable[str]]): Representation tags used - for filtering. - active (Optional[bool]): Receive active/inactive entities. - Both are returned when 'None' is passed. - filters (Optional[AdvancedFilterDict]): Advanced filtering options. - fields (Optional[Iterable[str]]): Fields to be queried - for version. All possible folder fields are returned - if 'None' is passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where the link is created. + link_type_name (str): Type of link. + input_id (str): Input entity id. + input_type (str): Entity type of input entity. + output_id (str): Output entity id. + output_type (str): Entity type of output entity. + link_name (Optional[str]): Name of link. + data (Optional[dict[str, Any]]): Additional data to be stored + with the link. Returns: - Generator[VersionDict, None, None]: Queried version entities. + CreateLinkResponseData: Information about link. + + Raises: + HTTPRequestError: Server error happened. """ con = get_server_api_connection() - return con.get_versions( + return con.create_link( project_name=project_name, - version_ids=version_ids, - product_ids=product_ids, - task_ids=task_ids, - versions=versions, - hero=hero, - standard=standard, - latest=latest, - statuses=statuses, - tags=tags, - active=active, - filters=filters, - fields=fields, - own_attributes=own_attributes, + link_type_name=link_type_name, + input_id=input_id, + input_type=input_type, + output_id=output_id, + output_type=output_type, + link_name=link_name, + data=data, ) -def get_version_by_id( +def create_links( project_name: str, - version_id: str, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[VersionDict]: - """Query version entity by id. + links: list[CreateLinkData], +) -> list[CreateLinksResponseItem]: + """Create multiple links in a single request. + + Example of link data:: + [ + { + "input": "59a212c0d2e211eda0e20242ac120001", + "output": "59a212c0d2e211eda0e20242ac120002", + "linkType": "reference|folder|folder", + "name": "my_link", + "data": {"key": "value"} + } + ] Args: - project_name (str): Name of project where to look for queried - entities. - version_id (str): Version id. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where links are created. + links (list[CreateLinkData]): List of link data. Returns: - Optional[VersionDict]: Version entity data or None - if was not found. + list[CreateLinksResponseItem]: Information about created links. + + Raises: + ValueError: Link data is invalid. """ con = get_server_api_connection() - return con.get_version_by_id( + return con.create_links( project_name=project_name, - version_id=version_id, - fields=fields, - own_attributes=own_attributes, + links=links, ) -def get_version_by_name( +def delete_link( project_name: str, - version: int, - product_id: str, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[VersionDict]: - """Query version entity by version and product id. + link_id: str, +) -> None: + """Remove link by id. Args: - project_name (str): Name of project where to look for queried - entities. - version (int): Version of version entity. - product_id (str): Product id. Product is a parent of version. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where link exists. + link_id (str): Id of link. - Returns: - Optional[VersionDict]: Version entity data or None - if was not found. + Raises: + HTTPRequestError: Server error happened. """ con = get_server_api_connection() - return con.get_version_by_name( + return con.delete_link( project_name=project_name, - version=version, - product_id=product_id, - fields=fields, - own_attributes=own_attributes, + link_id=link_id, ) -def get_hero_version_by_id( +def get_entities_links( project_name: str, - version_id: str, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[VersionDict]: - """Query hero version entity by id. + entity_type: str, + entity_ids: Optional[Iterable[str]] = None, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, + link_names: Optional[Iterable[str]] = None, + link_name_regex: Optional[str] = None, +) -> dict[str, list[dict[str, Any]]]: + """Helper method to get links from server for entity types. + + .. highlight:: text + .. code-block:: text + + Example output: + { + "59a212c0d2e211eda0e20242ac120001": [ + { + "id": "59a212c0d2e211eda0e20242ac120002", + "linkType": "reference", + "description": "reference link between folders", + "projectName": "my_project", + "author": "frantadmin", + "entityId": "b1df109676db11ed8e8c6c9466b19aa8", + "entityType": "folder", + "direction": "out" + }, + ... + ], + ... + } Args: - project_name (str): Name of project where to look for queried - entities. - version_id (int): Hero version id. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where links are. + entity_type (Literal["folder", "task", "product", + "version", "representations"]): Entity type. + entity_ids (Optional[Iterable[str]]): Ids of entities for which + links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. + link_names (Optional[Iterable[str]]): Link name filters. + link_name_regex (Optional[str]): Regex filter for link name. Returns: - Optional[VersionDict]: Version entity data or None - if was not found. + dict[str, list[dict[str, Any]]]: Link info by entity ids. """ con = get_server_api_connection() - return con.get_hero_version_by_id( + return con.get_entities_links( project_name=project_name, - version_id=version_id, - fields=fields, - own_attributes=own_attributes, - ) - - -def get_hero_version_by_product_id( - project_name: str, - product_id: str, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[VersionDict]: - """Query hero version entity by product id. + entity_type=entity_type, + entity_ids=entity_ids, + link_types=link_types, + link_direction=link_direction, + link_names=link_names, + link_name_regex=link_name_regex, + ) - Only one hero version is available on a product. + +def get_folders_links( + project_name: str, + folder_ids: Optional[Iterable[str]] = None, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> dict[str, list[dict[str, Any]]]: + """Query folders links from server. Args: - project_name (str): Name of project where to look for queried - entities. - product_id (int): Product id. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where links are. + folder_ids (Optional[Iterable[str]]): Ids of folders for which + links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. Returns: - Optional[VersionDict]: Version entity data or None - if was not found. + dict[str, list[dict[str, Any]]]: Link info by folder ids. """ con = get_server_api_connection() - return con.get_hero_version_by_product_id( + return con.get_folders_links( project_name=project_name, - product_id=product_id, - fields=fields, - own_attributes=own_attributes, + folder_ids=folder_ids, + link_types=link_types, + link_direction=link_direction, ) -def get_hero_versions( +def get_folder_links( project_name: str, - product_ids: Optional[Iterable[str]] = None, - version_ids: Optional[Iterable[str]] = None, - active: Optional[bool] = True, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Generator[VersionDict, None, None]: - """Query hero versions by multiple filters. - - Only one hero version is available on a product. + folder_id: str, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> list[dict[str, Any]]: + """Query folder links from server. Args: - project_name (str): Name of project where to look for queried - entities. - product_ids (Optional[Iterable[str]]): Product ids. - version_ids (Optional[Iterable[str]]): Version ids. - active (Optional[bool]): Receive active/inactive entities. - Both are returned when 'None' is passed. - fields (Optional[Iterable[str]]): Fields that should be returned. - All fields are returned if 'None' is passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where links are. + folder_id (str): Folder id for which links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. Returns: - Optional[VersionDict]: Version entity data or None - if was not found. + list[dict[str, Any]]: Link info of folder. """ con = get_server_api_connection() - return con.get_hero_versions( + return con.get_folder_links( project_name=project_name, - product_ids=product_ids, - version_ids=version_ids, - active=active, - fields=fields, - own_attributes=own_attributes, + folder_id=folder_id, + link_types=link_types, + link_direction=link_direction, ) -def get_last_versions( +def get_tasks_links( project_name: str, - product_ids: Iterable[str], - active: Optional[bool] = True, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> dict[str, Optional[VersionDict]]: - """Query last version entities by product ids. + task_ids: Optional[Iterable[str]] = None, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> dict[str, list[dict[str, Any]]]: + """Query tasks links from server. Args: - project_name (str): Project where to look for representation. - product_ids (Iterable[str]): Product ids. - active (Optional[bool]): Receive active/inactive entities. - Both are returned when 'None' is passed. - fields (Optional[Iterable[str]]): fields to be queried - for representations. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where links are. + task_ids (Optional[Iterable[str]]): Ids of tasks for which + links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. Returns: - dict[str, Optional[VersionDict]]: Last versions by product id. + dict[str, list[dict[str, Any]]]: Link info by task ids. """ con = get_server_api_connection() - return con.get_last_versions( + return con.get_tasks_links( project_name=project_name, - product_ids=product_ids, - active=active, - fields=fields, - own_attributes=own_attributes, + task_ids=task_ids, + link_types=link_types, + link_direction=link_direction, ) -def get_last_version_by_product_id( +def get_task_links( project_name: str, - product_id: str, - active: Optional[bool] = True, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[VersionDict]: - """Query last version entity by product id. + task_id: str, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> list[dict[str, Any]]: + """Query task links from server. Args: - project_name (str): Project where to look for representation. - product_id (str): Product id. - active (Optional[bool]): Receive active/inactive entities. - Both are returned when 'None' is passed. - fields (Optional[Iterable[str]]): fields to be queried - for representations. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - versions. + project_name (str): Project where links are. + task_id (str): Task id for which links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. Returns: - Optional[VersionDict]: Queried version entity or None. + list[dict[str, Any]]: Link info of task. """ con = get_server_api_connection() - return con.get_last_version_by_product_id( + return con.get_task_links( project_name=project_name, - product_id=product_id, - active=active, - fields=fields, - own_attributes=own_attributes, + task_id=task_id, + link_types=link_types, + link_direction=link_direction, ) -def get_last_version_by_product_name( +def get_products_links( project_name: str, - product_name: str, - folder_id: str, - active: Optional[bool] = True, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[VersionDict]: - """Query last version entity by product name and folder id. + product_ids: Optional[Iterable[str]] = None, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> dict[str, list[dict[str, Any]]]: + """Query products links from server. Args: - project_name (str): Project where to look for representation. - product_name (str): Product name. - folder_id (str): Folder id. - active (Optional[bool]): Receive active/inactive entities. - Both are returned when 'None' is passed. - fields (Optional[Iterable[str]]): fields to be queried - for representations. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - representations. + project_name (str): Project where links are. + product_ids (Optional[Iterable[str]]): Ids of products for which + links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. Returns: - Optional[VersionDict]: Queried version entity or None. + dict[str, list[dict[str, Any]]]: Link info by product ids. """ con = get_server_api_connection() - return con.get_last_version_by_product_name( + return con.get_products_links( project_name=project_name, - product_name=product_name, - folder_id=folder_id, - active=active, - fields=fields, - own_attributes=own_attributes, + product_ids=product_ids, + link_types=link_types, + link_direction=link_direction, ) -def version_is_latest( +def get_product_links( project_name: str, - version_id: str, -) -> bool: - """Is version latest from a product. + product_id: str, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> list[dict[str, Any]]: + """Query product links from server. Args: - project_name (str): Project where to look for representation. - version_id (str): Version id. + project_name (str): Project where links are. + product_id (str): Product id for which links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. Returns: - bool: Version is latest or not. + list[dict[str, Any]]: Link info of product. """ con = get_server_api_connection() - return con.version_is_latest( + return con.get_product_links( project_name=project_name, - version_id=version_id, + product_id=product_id, + link_types=link_types, + link_direction=link_direction, ) -def create_version( +def get_versions_links( project_name: str, - version: int, - product_id: str, - task_id: Optional[str] = None, - author: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, - thumbnail_id: Optional[str] = None, - version_id: Optional[str] = None, -) -> str: - """Create new version. - - Args: - project_name (str): Project name. - version (int): Version. - product_id (str): Parent product id. - task_id (Optional[str]): Parent task id. - author (Optional[str]): Version author. - attrib (Optional[dict[str, Any]]): Version attributes. - data (Optional[dict[str, Any]]): Version data. - tags (Optional[Iterable[str]]): Version tags. - status (Optional[str]): Version status. - active (Optional[bool]): Version active state. - thumbnail_id (Optional[str]): Version thumbnail id. - version_id (Optional[str]): Version id. If not passed new id is - generated. + version_ids: Optional[Iterable[str]] = None, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> dict[str, list[dict[str, Any]]]: + """Query versions links from server. + + Args: + project_name (str): Project where links are. + version_ids (Optional[Iterable[str]]): Ids of versions for which + links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. Returns: - str: Version id. + dict[str, list[dict[str, Any]]]: Link info by version ids. """ con = get_server_api_connection() - return con.create_version( + return con.get_versions_links( project_name=project_name, - version=version, - product_id=product_id, - task_id=task_id, - author=author, - attrib=attrib, - data=data, - tags=tags, - status=status, - active=active, - thumbnail_id=thumbnail_id, - version_id=version_id, + version_ids=version_ids, + link_types=link_types, + link_direction=link_direction, ) -def update_version( +def get_version_links( project_name: str, version_id: str, - version: Optional[int] = None, - product_id: Optional[str] = None, - task_id: Optional[str] = NOT_SET, - author: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, - thumbnail_id: Optional[str] = NOT_SET, -) -> None: - """Update version entity on server. - - Do not pass ``task_id`` amd ``thumbnail_id`` if you don't - want to change their values. Value ``None`` would unset - their value. - - Update of ``data`` will override existing value on folder entity. - - Update of ``attrib`` does change only passed attributes. If you want - to unset value, use ``None``. + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> list[dict[str, Any]]: + """Query version links from server. Args: - project_name (str): Project name. - version_id (str): Version id. - version (Optional[int]): New version. - product_id (Optional[str]): New product id. - task_id (Optional[str]): New task id. - author (Optional[str]): New author username. - attrib (Optional[dict[str, Any]]): New attributes. - data (Optional[dict[str, Any]]): New data. - tags (Optional[Iterable[str]]): New tags. - status (Optional[str]): New status. - active (Optional[bool]): New active state. - thumbnail_id (Optional[str]): New thumbnail id. + project_name (str): Project where links are. + version_id (str): Version id for which links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. + + Returns: + list[dict[str, Any]]: Link info of version. """ con = get_server_api_connection() - return con.update_version( + return con.get_version_links( project_name=project_name, version_id=version_id, - version=version, - product_id=product_id, - task_id=task_id, - author=author, - attrib=attrib, - data=data, - tags=tags, - status=status, - active=active, - thumbnail_id=thumbnail_id, + link_types=link_types, + link_direction=link_direction, ) -def delete_version( +def get_representations_links( project_name: str, - version_id: str, -) -> None: - """Delete version. + representation_ids: Optional[Iterable[str]] = None, + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> dict[str, list[dict[str, Any]]]: + """Query representations links from server. Args: - project_name (str): Project name. - version_id (str): Version id to delete. + project_name (str): Project where links are. + representation_ids (Optional[Iterable[str]]): Ids of + representations for which links should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. + + Returns: + dict[str, list[dict[str, Any]]]: Link info by representation ids. """ con = get_server_api_connection() - return con.delete_version( + return con.get_representations_links( project_name=project_name, - version_id=version_id, + representation_ids=representation_ids, + link_types=link_types, + link_direction=link_direction, ) -def get_rest_representation( +def get_representation_links( project_name: str, representation_id: str, -) -> Optional[RepresentationDict]: + link_types: Optional[Iterable[str]] = None, + link_direction: Optional[LinkDirection] = None, +) -> list[dict[str, Any]]: + """Query representation links from server. + + Args: + project_name (str): Project where links are. + representation_id (str): Representation id for which links + should be received. + link_types (Optional[Iterable[str]]): Link type filters. + link_direction (Optional[Literal["in", "out"]]): Link direction + filter. + + Returns: + list[dict[str, Any]]: Link info of representation. + + """ con = get_server_api_connection() - return con.get_rest_representation( + return con.get_representation_links( project_name=project_name, representation_id=representation_id, + link_types=link_types, + link_direction=link_direction, ) -def get_representations( +def get_entity_lists( project_name: str, - representation_ids: Optional[Iterable[str]] = None, - representation_names: Optional[Iterable[str]] = None, - version_ids: Optional[Iterable[str]] = None, - names_by_version_ids: Optional[dict[str, Iterable[str]]] = None, - statuses: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - active: Optional[bool] = True, - has_links: Optional[str] = None, - filters: Optional[AdvancedFilterDict] = None, + *, + list_ids: Optional[Iterable[str]] = None, + active: Optional[bool] = None, fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Generator[RepresentationDict, None, None]: - """Get representation entities based on passed filters from server. +) -> Generator[dict[str, Any], None, None]: + """Fetch entity lists from AYON server. - .. todo:: + Warnings: + You can't get list items for lists with different 'entityType' in + one call. - Add separated function for 'names_by_version_ids' filtering. - Because can't be combined with others. + Notes: + To get list items, you have to pass 'items' field or + 'items.{sub-fields you want}' to 'fields' argument. Args: - project_name (str): Name of project where to look for versions. - representation_ids (Optional[Iterable[str]]): Representation ids - used for representation filtering. - representation_names (Optional[Iterable[str]]): Representation - names used for representation filtering. - version_ids (Optional[Iterable[str]]): Version ids used for - representation filtering. Versions are parents of - representations. - names_by_version_ids (Optional[dict[str, Iterable[str]]]): Find - representations by names and version ids. This filter - discards all other filters. - statuses (Optional[Iterable[str]]): Representation statuses used - for filtering. - tags (Optional[Iterable[str]]): Representation tags used - for filtering. - active (Optional[bool]): Receive active/inactive entities. - Both are returned when 'None' is passed. - has_links (Optional[Literal[IN, OUT, ANY]]): Filter - representations with IN/OUT/ANY links. - filters (Optional[AdvancedFilterDict]): Advanced filtering options. - fields (Optional[Iterable[str]]): Fields to be queried for - representation. All possible fields are returned if 'None' is - passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - representations. + project_name (str): Project name where entity lists are. + list_ids (Optional[Iterable[str]]): List of entity list ids to + fetch. + active (Optional[bool]): Filter by active state of entity lists. + fields (Optional[Iterable[str]]): Fields to fetch from server. Returns: - Generator[RepresentationDict, None, None]: Queried - representation entities. + Generator[dict[str, Any], None, None]: Entity list entities + matching defined filters. """ con = get_server_api_connection() - return con.get_representations( + return con.get_entity_lists( project_name=project_name, - representation_ids=representation_ids, - representation_names=representation_names, - version_ids=version_ids, - names_by_version_ids=names_by_version_ids, - statuses=statuses, - tags=tags, + list_ids=list_ids, active=active, - has_links=has_links, - filters=filters, fields=fields, - own_attributes=own_attributes, ) -def get_representation_by_id( +def get_entity_list_rest( project_name: str, - representation_id: str, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[RepresentationDict]: - """Query representation entity from server based on id filter. + list_id: str, +) -> Optional[dict[str, Any]]: + """Get entity list by id using REST API. Args: - project_name (str): Project where to look for representation. - representation_id (str): Id of representation. - fields (Optional[Iterable[str]]): fields to be queried - for representations. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - representations. + project_name (str): Project name. + list_id (str): Entity list id. Returns: - Optional[RepresentationDict]: Queried representation - entity or None. + Optional[dict[str, Any]]: Entity list data or None if not found. """ con = get_server_api_connection() - return con.get_representation_by_id( + return con.get_entity_list_rest( project_name=project_name, - representation_id=representation_id, - fields=fields, - own_attributes=own_attributes, + list_id=list_id, ) -def get_representation_by_name( +def get_entity_list_by_id( project_name: str, - representation_name: str, - version_id: str, + list_id: str, fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[RepresentationDict]: - """Query representation entity by name and version id. - - Args: - project_name (str): Project where to look for representation. - representation_name (str): Representation name. - version_id (str): Version id. - fields (Optional[Iterable[str]]): fields to be queried - for representations. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - representations. +) -> Optional[dict[str, Any]]: + """Get entity list by id using GraphQl. + + Args: + project_name (str): Project name. + list_id (str): Entity list id. + fields (Optional[Iterable[str]]): Fields to fetch from server. Returns: - Optional[RepresentationDict]: Queried representation entity - or None. + Optional[dict[str, Any]]: Entity list data or None if not found. """ con = get_server_api_connection() - return con.get_representation_by_name( + return con.get_entity_list_by_id( project_name=project_name, - representation_name=representation_name, - version_id=version_id, + list_id=list_id, fields=fields, - own_attributes=own_attributes, ) -def get_representations_hierarchy( +def create_entity_list( project_name: str, - representation_ids: Iterable[str], - project_fields: Optional[Iterable[str]] = None, - folder_fields: Optional[Iterable[str]] = None, - task_fields: Optional[Iterable[str]] = None, - product_fields: Optional[Iterable[str]] = None, - version_fields: Optional[Iterable[str]] = None, - representation_fields: Optional[Iterable[str]] = None, -) -> dict[str, RepresentationHierarchy]: - """Find representation with parents by representation id. - - Representation entity with parent entities up to project. - - Default fields are used when any fields are set to `None`. But it is - possible to pass in empty iterable (list, set, tuple) to skip - entity. + entity_type: EntityListEntityType, + label: str, + *, + list_type: Optional[str] = None, + access: Optional[dict[str, Any]] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[list[str]] = None, + template: Optional[dict[str, Any]] = None, + entity_list_folder_id: Optional[str] = None, + owner: Optional[str] = None, + active: Optional[bool] = None, + items: Optional[list[dict[str, Any]]] = None, + list_id: Optional[str] = None, +) -> str: + """Create entity list. Args: - project_name (str): Project where to look for entities. - representation_ids (Iterable[str]): Representation ids. - project_fields (Optional[Iterable[str]]): Project fields. - folder_fields (Optional[Iterable[str]]): Folder fields. - task_fields (Optional[Iterable[str]]): Task fields. - product_fields (Optional[Iterable[str]]): Product fields. - version_fields (Optional[Iterable[str]]): Version fields. - representation_fields (Optional[Iterable[str]]): Representation - fields. - - Returns: - dict[str, RepresentationHierarchy]: Parent entities by - representation id. + project_name (str): Project name where entity list lives. + entity_type (EntityListEntityType): Which entity types can be + used in list. + label (str): Entity list label. + list_type (Optional[str]): Entity list type. + access (Optional[dict[str, Any]]): Access control for entity list. + attrib (Optional[dict[str, Any]]): Attribute values of + entity list. + data (Optional[dict[str, Any]]): Custom data of entity list. + tags (Optional[list[str]]): Entity list tags. + template (Optional[dict[str, Any]]): Dynamic list template. + entity_list_folder_id (Optional[str]): Entity list folder id. + owner (Optional[str]): New owner of the list. + active (Optional[bool]): Change active state of entity list. + items (Optional[list[dict[str, Any]]]): Initial items in + entity list. + list_id (Optional[str]): Entity list id. """ con = get_server_api_connection() - return con.get_representations_hierarchy( + return con.create_entity_list( project_name=project_name, - representation_ids=representation_ids, - project_fields=project_fields, - folder_fields=folder_fields, - task_fields=task_fields, - product_fields=product_fields, - version_fields=version_fields, - representation_fields=representation_fields, + entity_type=entity_type, + label=label, + list_type=list_type, + access=access, + attrib=attrib, + data=data, + tags=tags, + template=template, + entity_list_folder_id=entity_list_folder_id, + owner=owner, + active=active, + items=items, + list_id=list_id, ) -def get_representation_hierarchy( +def update_entity_list( project_name: str, - representation_id: str, - project_fields: Optional[Iterable[str]] = None, - folder_fields: Optional[Iterable[str]] = None, - task_fields: Optional[Iterable[str]] = None, - product_fields: Optional[Iterable[str]] = None, - version_fields: Optional[Iterable[str]] = None, - representation_fields: Optional[Iterable[str]] = None, -) -> Optional[RepresentationHierarchy]: - """Find representation parents by representation id. - - Representation parent entities up to project. + list_id: str, + *, + label: Optional[str] = None, + access: Optional[dict[str, Any]] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[list[str]] = None, + entity_list_folder_id: str | None | type[NOT_SET] = NOT_SET, + owner: Optional[str] = None, + active: Optional[bool] = None, +) -> None: + """Update entity list. Args: - project_name (str): Project where to look for entities. - representation_id (str): Representation id. - project_fields (Optional[Iterable[str]]): Project fields. - folder_fields (Optional[Iterable[str]]): Folder fields. - task_fields (Optional[Iterable[str]]): Task fields. - product_fields (Optional[Iterable[str]]): Product fields. - version_fields (Optional[Iterable[str]]): Version fields. - representation_fields (Optional[Iterable[str]]): Representation - fields. - - Returns: - RepresentationHierarchy: Representation hierarchy entities. + project_name (str): Project name where entity list lives. + list_id (str): Entity list id that will be updated. + label (Optional[str]): New label of entity list. + access (Optional[dict[str, Any]]): Access control for entity list. + attrib (Optional[dict[str, Any]]): Attribute values of + entity list. + data (Optional[dict[str, Any]]): Custom data of entity list. + tags (Optional[list[str]]): Entity list tags. + entity_list_folder_id (str | None | type[NOT_SET]): New entity + list folder id. Use ``None`` to move entity list to root. + Use 'NOT_SET' to keep current folder. + owner (Optional[str]): New owner of the list. + active (Optional[bool]): Change active state of entity list. """ con = get_server_api_connection() - return con.get_representation_hierarchy( + return con.update_entity_list( project_name=project_name, - representation_id=representation_id, - project_fields=project_fields, - folder_fields=folder_fields, - task_fields=task_fields, - product_fields=product_fields, - version_fields=version_fields, - representation_fields=representation_fields, + list_id=list_id, + label=label, + access=access, + attrib=attrib, + data=data, + tags=tags, + entity_list_folder_id=entity_list_folder_id, + owner=owner, + active=active, ) -def get_representations_parents( +def delete_entity_list( project_name: str, - representation_ids: Iterable[str], - project_fields: Optional[Iterable[str]] = None, - folder_fields: Optional[Iterable[str]] = None, - product_fields: Optional[Iterable[str]] = None, - version_fields: Optional[Iterable[str]] = None, -) -> dict[str, RepresentationParents]: - """Find representations parents by representation id. - - Representation parent entities up to project. + list_id: str, +) -> None: + """Delete entity list from project. Args: - project_name (str): Project where to look for entities. - representation_ids (Iterable[str]): Representation ids. - project_fields (Optional[Iterable[str]]): Project fields. - folder_fields (Optional[Iterable[str]]): Folder fields. - product_fields (Optional[Iterable[str]]): Product fields. - version_fields (Optional[Iterable[str]]): Version fields. - - Returns: - dict[str, RepresentationParents]: Parent entities by - representation id. + project_name (str): Project name. + list_id (str): Entity list id that will be removed. """ con = get_server_api_connection() - return con.get_representations_parents( + return con.delete_entity_list( project_name=project_name, - representation_ids=representation_ids, - project_fields=project_fields, - folder_fields=folder_fields, - product_fields=product_fields, - version_fields=version_fields, + list_id=list_id, ) -def get_representation_parents( +def get_entity_list_attribute_definitions( project_name: str, - representation_id: str, - project_fields: Optional[Iterable[str]] = None, - folder_fields: Optional[Iterable[str]] = None, - product_fields: Optional[Iterable[str]] = None, - version_fields: Optional[Iterable[str]] = None, -) -> Optional[RepresentationParents]: - """Find representation parents by representation id. - - Representation parent entities up to project. + list_id: str, +) -> list[EntityListAttributeDefinitionDict]: + """Get attribute definitioins on entity list. Args: - project_name (str): Project where to look for entities. - representation_id (str): Representation id. - project_fields (Optional[Iterable[str]]): Project fields. - folder_fields (Optional[Iterable[str]]): Folder fields. - product_fields (Optional[Iterable[str]]): Product fields. - version_fields (Optional[Iterable[str]]): Version fields. + project_name (str): Project name. + list_id (str): Entity list id. Returns: - RepresentationParents: Representation parent entities. + list[EntityListAttributeDefinitionDict]: List of attribute + definitions. """ con = get_server_api_connection() - return con.get_representation_parents( + return con.get_entity_list_attribute_definitions( project_name=project_name, - representation_id=representation_id, - project_fields=project_fields, - folder_fields=folder_fields, - product_fields=product_fields, - version_fields=version_fields, + list_id=list_id, ) -def get_repre_ids_by_context_filters( - project_name: str, - context_filters: Optional[dict[str, Iterable[str]]], - representation_names: Optional[Iterable[str]] = None, - version_ids: Optional[Iterable[str]] = None, -) -> list[str]: - """Find representation ids which match passed context filters. - - Each representation has context integrated on representation entity in - database. The context may contain project, folder, task name or - product name, product type and many more. This implementation gives - option to quickly filter representation based on representation data - in database. - - Context filters have defined structure. To define filter of nested - subfield use dot '.' as delimiter (For example 'task.name'). - Filter values can be regex filters. String or ``re.Pattern`` can - be used. - - Args: - project_name (str): Project where to look for representations. - context_filters (dict[str, list[str]]): Filters of context fields. - representation_names (Optional[Iterable[str]]): Representation - names, can be used as additional filter for representations - by their names. - version_ids (Optional[Iterable[str]]): Version ids, can be used - as additional filter for representations by their parent ids. - - Returns: - list[str]: Representation ids that match passed filters. +def set_entity_list_attribute_definitions( + project_name: str, + list_id: str, + attribute_definitions: list[EntityListAttributeDefinitionDict], +) -> None: + """Set attribute definitioins on entity list. - Example: - The function returns just representation ids so if entities are - required for funtionality they must be queried afterwards by - their ids. - >>> from ayon_api import get_repre_ids_by_context_filters - >>> from ayon_api import get_representations - >>> project_name = "testProject" - >>> filters = { - ... "task.name": ["[aA]nimation"], - ... "product": [".*[Mm]ain"] - ... } - >>> repre_ids = get_repre_ids_by_context_filters( - ... project_name, filters) - >>> repres = get_representations(project_name, repre_ids) + Args: + project_name (str): Project name. + list_id (str): Entity list id. + attribute_definitions (list[EntityListAttributeDefinitionDict]): + List of attribute definitions. """ con = get_server_api_connection() - return con.get_repre_ids_by_context_filters( + return con.set_entity_list_attribute_definitions( project_name=project_name, - context_filters=context_filters, - representation_names=representation_names, - version_ids=version_ids, + list_id=list_id, + attribute_definitions=attribute_definitions, ) -def create_representation( +def create_entity_list_item( project_name: str, - name: str, - version_id: str, - files: Optional[list[dict[str, Any]]] = None, + list_id: str, + entity_id: str, + *, + position: Optional[int] = None, + label: Optional[str] = None, attrib: Optional[dict[str, Any]] = None, data: Optional[dict[str, Any]] = None, - traits: Optional[dict[str, Any]] = None, tags: Optional[list[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, - representation_id: Optional[str] = None, + item_id: Optional[str] = None, ) -> str: - """Create new representation. + """Create entity list item. Args: - project_name (str): Project name. - name (str): Representation name. - version_id (str): Parent version id. - files (Optional[list[dict]]): Representation files information. - attrib (Optional[dict[str, Any]]): Representation attributes. - data (Optional[dict[str, Any]]): Representation data. - traits (Optional[dict[str, Any]]): Representation traits - serialized data as dict. - tags (Optional[Iterable[str]]): Representation tags. - status (Optional[str]): Representation status. - active (Optional[bool]): Representation active state. - representation_id (Optional[str]): Representation id. If not - passed new id is generated. + project_name (str): Project name where entity list lives. + list_id (str): Entity list id where item will be added. + entity_id (str): Id of entity added to the list. + position (Optional[int]): Position of item in entity list. + label (Optional[str]): Label of item in entity list. + attrib (Optional[dict[str, Any]]): Item attribute values. + data (Optional[dict[str, Any]]): Item data. + tags (Optional[list[str]]): Tags of item in entity list. + item_id (Optional[str]): Id of item that will be created. Returns: - str: Representation id. + str: Item id. """ con = get_server_api_connection() - return con.create_representation( + return con.create_entity_list_item( project_name=project_name, - name=name, - version_id=version_id, - files=files, + list_id=list_id, + entity_id=entity_id, + position=position, + label=label, attrib=attrib, data=data, - traits=traits, tags=tags, - status=status, - active=active, - representation_id=representation_id, + item_id=item_id, ) -def update_representation( +def update_entity_list_items( project_name: str, - representation_id: str, - name: Optional[str] = None, - version_id: Optional[str] = None, - files: Optional[list[dict[str, Any]]] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - traits: Optional[dict[str, Any]] = None, - tags: Optional[list[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, + list_id: str, + items: list[dict[str, Any]], + mode: EntityListItemMode, ) -> None: - """Update representation entity on server. - - Update of ``data`` will override existing value on folder entity. - - Update of ``attrib`` does change only passed attributes. If you want - to unset value, use ``None``. + """Update items in entity list. Args: - project_name (str): Project name. - representation_id (str): Representation id. - name (Optional[str]): New name. - version_id (Optional[str]): New version id. - files (Optional[list[dict]]): New files - information. - attrib (Optional[dict[str, Any]]): New attributes. - data (Optional[dict[str, Any]]): New data. - traits (Optional[dict[str, Any]]): New traits. - tags (Optional[Iterable[str]]): New tags. - status (Optional[str]): New status. - active (Optional[bool]): New active state. + project_name (str): Project name where entity list live. + list_id (str): Entity list id. + items (list[dict[str, Any]]): Entity list items. + mode (EntityListItemMode): Mode of items update. """ con = get_server_api_connection() - return con.update_representation( + return con.update_entity_list_items( project_name=project_name, - representation_id=representation_id, - name=name, - version_id=version_id, - files=files, - attrib=attrib, - data=data, - traits=traits, - tags=tags, - status=status, - active=active, + list_id=list_id, + items=items, + mode=mode, ) -def delete_representation( +def update_entity_list_item( project_name: str, - representation_id: str, + list_id: str, + item_id: str, + *, + new_list_id: Optional[str] = None, + position: Optional[int] = None, + label: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + tags: Optional[list[str]] = None, ) -> None: - """Delete representation. + """Update item in entity list. Args: - project_name (str): Project name. - representation_id (str): Representation id to delete. + project_name (str): Project name where entity list live. + list_id (str): Entity list id where item lives. + item_id (str): Item id that will be removed from entity list. + new_list_id (Optional[str]): New entity list id where item will be + added. + position (Optional[int]): Position of item in entity list. + label (Optional[str]): Label of item in entity list. + attrib (Optional[dict[str, Any]]): Attributes of item in entity + list. + data (Optional[dict[str, Any]]): Custom data of item in + entity list. + tags (Optional[list[str]]): Tags of item in entity list. """ con = get_server_api_connection() - return con.delete_representation( + return con.update_entity_list_item( project_name=project_name, - representation_id=representation_id, + list_id=list_id, + item_id=item_id, + new_list_id=new_list_id, + position=position, + label=label, + attrib=attrib, + data=data, + tags=tags, ) -def get_workfile_entities( +def delete_entity_list_item( project_name: str, - *, - workfile_ids: Optional[Iterable[str]] = None, - task_ids: Optional[Iterable[str]] = None, - paths: Optional[Iterable[str]] = None, - path_regex: Optional[str] = None, - statuses: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - has_links: Optional[str] = None, - fields: Optional[Iterable[str]] = None, -) -> Generator[WorkfileInfoDict, None, None]: - """Workfile info entities by passed filters. + list_id: str, + item_id: str, +) -> None: + """Delete item from entity list. Args: - project_name (str): Project under which the entity is located. - workfile_ids (Optional[Iterable[str]]): Workfile ids. - task_ids (Optional[Iterable[str]]): Task ids. - paths (Optional[Iterable[str]]): Rootless workfiles paths. - path_regex (Optional[str]): Regex filter for workfile path. - statuses (Optional[Iterable[str]]): Workfile info statuses used - for filtering. - tags (Optional[Iterable[str]]): Workfile info tags used - for filtering. - has_links (Optional[Literal[IN, OUT, ANY]]): Filter - representations with IN/OUT/ANY links. - fields (Optional[Iterable[str]]): Fields to be queried for - representation. All possible fields are returned if 'None' is - passed. - - Returns: - Generator[WorkfileInfoDict, None, None]: Queried workfile info - entites. + project_name (str): Project name where entity list live. + list_id (str): Entity list id from which item will be removed. + item_id (str): Item id that will be removed from entity list. """ con = get_server_api_connection() - return con.get_workfile_entities( + return con.delete_entity_list_item( project_name=project_name, - workfile_ids=workfile_ids, - task_ids=task_ids, - paths=paths, - path_regex=path_regex, - statuses=statuses, - tags=tags, - has_links=has_links, - fields=fields, + list_id=list_id, + item_id=item_id, ) -def get_workfile_entity( +def get_entity_list_entities( project_name: str, - task_id: str, - path: str, - *, - fields: Optional[Iterable[str]] = None, -) -> Optional[WorkfileInfoDict]: - """Workfile info entity by task id and workfile path. + entity_list_id: str, +) -> dict[str, Any]: + """Get entity list items using REST API. Args: - project_name (str): Project under which the entity is located. - task_id (str): Task id. - path (str): Rootless workfile path. - fields (Optional[Iterable[str]]): Fields to be queried for - representation. All possible fields are returned if 'None' is - passed. + project_name (str): Project name. + entity_list_id (str): Entity list id. Returns: - Optional[WorkfileInfoDict]: Workfile info entity or None. + dict[str, Any]: Information about entities on the list. """ con = get_server_api_connection() - return con.get_workfile_entity( + return con.get_entity_list_entities( project_name=project_name, - task_id=task_id, - path=path, - fields=fields, + entity_list_id=entity_list_id, ) -def get_workfile_entity_by_id( +def get_entity_list_folders_raw( project_name: str, - workfile_id: str, - *, - fields: Optional[Iterable[str]] = None, -) -> Optional[WorkfileInfoDict]: - """Workfile info entity by id. +) -> dict[str, Any]: + """Get entity list folders. Args: - project_name (str): Project under which the entity is located. - workfile_id (str): Workfile info id. - fields (Optional[Iterable[str]]): Fields to be queried for - representation. All possible fields are returned if 'None' is - passed. + project_name (str): Project name. Returns: - Optional[WorkfileInfoDict]: Workfile info entity or None. + dict[str, Any]: Raw output of entity list folders output. At this + moment contains only "folders" key with list of folders, + but it can be extended in the future. """ con = get_server_api_connection() - return con.get_workfile_entity_by_id( + return con.get_entity_list_folders_raw( project_name=project_name, - workfile_id=workfile_id, - fields=fields, ) -def create_workfile_entity( +def get_entity_list_folders( project_name: str, - path: str, - task_id: str, - *, - thumbnail_id: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[list[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, - workfile_id: Optional[str] = None, -) -> str: - """Create new workfile. - - Args: - project_name (str): Project name. - path (str): Representation name. - task_id (str): Parent task id. - thumbnail_id (Optional[str]): Thumbnail id. - attrib (Optional[dict[str, Any]]): Representation attributes. - data (Optional[dict[str, Any]]): Representation data. - tags (Optional[Iterable[str]]): Representation tags. - status (Optional[str]): Representation status. - active (Optional[bool]): Representation active state. - workfile_id (Optional[str]): Workfile info id. If not - passed new id is generated. +) -> list[dict[str, Any]]: + """Get entity list folders. Returns: - str: Workfile info id. + list[dict[str, Any]]: List of entity list folders. """ con = get_server_api_connection() - return con.create_workfile_entity( + return con.get_entity_list_folders( project_name=project_name, - path=path, - task_id=task_id, - thumbnail_id=thumbnail_id, - attrib=attrib, - data=data, - tags=tags, - status=status, - active=active, - workfile_id=workfile_id, ) -def update_workfile_entity( +def create_entity_list_folder( project_name: str, - workfile_id: str, + label: str, *, - path: Optional[str] = None, - task_id: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, - thumbnail_id: Optional[str] = NOT_SET, - created_by: Optional[str] = None, - updated_by: Optional[str] = None, -) -> None: - """Update workfile entity on server. - - Update of ``attrib`` does change only passed attributes. If you want - to unset value, use ``None``. + parent_id: str | None = None, + color: str | None = None, + icon: str | None = None, + scope: list[EntityListScope] | None = None, + data: dict[str, Any] | None = None, + access: dict[str, Any] | None = None, + entity_list_folder_id: str | None = None, +) -> str: + """Create entity list folder. Args: project_name (str): Project name. - workfile_id (str): Workfile id. - path (Optional[str]): New rootless workfile path.. - task_id (Optional[str]): New parent task id. - attrib (Optional[dict[str, Any]]): New attributes. - data (Optional[dict[str, Any]]): New data. - tags (Optional[Iterable[str]]): New tags. - status (Optional[str]): New status. - active (Optional[bool]): New active state. - thumbnail_id (Optional[str]): New thumbnail id. - created_by (Optional[str]): New created by username. - updated_by (Optional[str]): New updated by username. + label (str): Folder label. + parent_id (str | None): Parent folder id. If None, the folder will + be created in root. + color (str | None): Folder color. + icon (str | None): Folder icon. + scope (list[EntityListScope] | None): Folder scope. Empty list can + be used to scope folder for all views. + data (dict[str, Any] | None): Custom data of entity list folder. + access (dict[str, Any] | None): Access control for + entity list folder. + entity_list_folder_id (str | None): Id of folder that will be + created. If None, a new id will be generated. + + Returns: + str: Created entity list folder id. """ con = get_server_api_connection() - return con.update_workfile_entity( + return con.create_entity_list_folder( project_name=project_name, - workfile_id=workfile_id, - path=path, - task_id=task_id, - attrib=attrib, + label=label, + parent_id=parent_id, + color=color, + icon=icon, + scope=scope, data=data, - tags=tags, - status=status, - active=active, - thumbnail_id=thumbnail_id, - created_by=created_by, - updated_by=updated_by, + access=access, + entity_list_folder_id=entity_list_folder_id, ) -def delete_workfile_entity( +def update_entity_list_folder( project_name: str, - workfile_id: str, + entity_list_folder_id: str, + *, + label: str | None = None, + parent_id: str | None | type[NOT_SET] = NOT_SET, + color: str | None = None, + icon: str | None = None, + scope: list[EntityListScope] | None = None, + data: dict[str, Any] | None = None, + access: dict[str, Any] | None = None, ) -> None: - """Delete workfile entity on server. + """Update entity list folder. Args: project_name (str): Project name. - workfile_id (str): Workfile id to delete. + entity_list_folder_id (str): Folder id that will be updated. + label (str | None): New label of entity list folder. + parent_id (str | None | type[NOT_SET]): New parent id of entity + list folder. If None, the folder will be moved to root. + color (str | None): New color of entity list folder. + icon (str | None): New icon of entity list folder. + scope (list[EntityListScope] | None): New scope of entity list + folder. Empty list can be used to scope folder for all views. + data (dict[str, Any] | None): Custom data of entity list folder. + access (dict[str, Any] | None): Access control for + entity list folder. """ con = get_server_api_connection() - return con.delete_workfile_entity( + return con.update_entity_list_folder( project_name=project_name, - workfile_id=workfile_id, + entity_list_folder_id=entity_list_folder_id, + label=label, + parent_id=parent_id, + color=color, + icon=icon, + scope=scope, + data=data, + access=access, ) -def get_workfiles_info( +def delete_entity_list_folder( project_name: str, - workfile_ids: Optional[Iterable[str]] = None, - task_ids: Optional[Iterable[str]] = None, - paths: Optional[Iterable[str]] = None, - path_regex: Optional[str] = None, - statuses: Optional[Iterable[str]] = None, - tags: Optional[Iterable[str]] = None, - has_links: Optional[str] = None, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Generator[WorkfileInfoDict, None, None]: - """DEPRECATED Workfile info entities by passed filters. - - Args: - project_name (str): Project under which the entity is located. - workfile_ids (Optional[Iterable[str]]): Workfile ids. - task_ids (Optional[Iterable[str]]): Task ids. - paths (Optional[Iterable[str]]): Rootless workfiles paths. - path_regex (Optional[str]): Regex filter for workfile path. - statuses (Optional[Iterable[str]]): Workfile info statuses used - for filtering. - tags (Optional[Iterable[str]]): Workfile info tags used - for filtering. - has_links (Optional[Literal[IN, OUT, ANY]]): Filter - representations with IN/OUT/ANY links. - fields (Optional[Iterable[str]]): Fields to be queried for - representation. All possible fields are returned if 'None' is - passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - workfiles. - - Returns: - Generator[WorkfileInfoDict, None, None]: Queried workfile info - entites. - - """ + entity_list_folder_id: str, +) -> None: + """Delete entity list folder.""" con = get_server_api_connection() - return con.get_workfiles_info( + return con.delete_entity_list_folder( project_name=project_name, - workfile_ids=workfile_ids, - task_ids=task_ids, - paths=paths, - path_regex=path_regex, - statuses=statuses, - tags=tags, - has_links=has_links, - fields=fields, - own_attributes=own_attributes, + entity_list_folder_id=entity_list_folder_id, ) -def get_workfile_info( - project_name: str, - task_id: str, - path: str, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[WorkfileInfoDict]: - """DEPRECATED Workfile info entity by task id and workfile path. - - Args: - project_name (str): Project under which the entity is located. - task_id (str): Task id. - path (str): Rootless workfile path. - fields (Optional[Iterable[str]]): Fields to be queried for - representation. All possible fields are returned if 'None' is - passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - workfiles. +def set_entity_list_folders_order( + project_name: str, + order: list[str], +) -> None: + """Change order of entity list folders. - Returns: - Optional[WorkfileInfoDict]: Workfile info entity or None. + Args: + project_name (str): Project name. + order (list[str]): List of folder ids in desired order. """ con = get_server_api_connection() - return con.get_workfile_info( + return con.set_entity_list_folders_order( project_name=project_name, - task_id=task_id, - path=path, - fields=fields, - own_attributes=own_attributes, + order=order, ) -def get_workfile_info_by_id( +def materialize_entity_list( project_name: str, - workfile_id: str, - fields: Optional[Iterable[str]] = None, - own_attributes=_PLACEHOLDER, -) -> Optional[WorkfileInfoDict]: - """DEPRECATED Workfile info entity by id. + list_id: str, +) -> dict[str, Any]: + """Materialize entity list. Args: - project_name (str): Project under which the entity is located. - workfile_id (str): Workfile info id. - fields (Optional[Iterable[str]]): Fields to be queried for - representation. All possible fields are returned if 'None' is - passed. - own_attributes (Optional[bool]): DEPRECATED: Not supported for - workfiles. + project_name (str): Project name. + list_id (str): Entity list id. Returns: - Optional[WorkfileInfoDict]: Workfile info entity or None. + dict[str, Any]: Summary of materialized entity list. """ con = get_server_api_connection() - return con.get_workfile_info_by_id( + return con.materialize_entity_list( project_name=project_name, - workfile_id=workfile_id, - fields=fields, - own_attributes=own_attributes, + list_id=list_id, ) -def update_workfile_info( +def set_entity_list_items_order( project_name: str, - workfile_id: str, - path: Optional[str] = None, - task_id: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[Iterable[str]] = None, - status: Optional[str] = None, - active: Optional[bool] = None, - thumbnail_id: Optional[str] = NOT_SET, - created_by: Optional[str] = None, - updated_by: Optional[str] = None, + list_id: str, + order: list[str], ) -> None: - """DEPRECATED Update workfile entity on server. - - Update of ``attrib`` does change only passed attributes. If you want - to unset value, use ``None``. + """Change order of items in entity list. Args: project_name (str): Project name. - workfile_id (str): Workfile id. - path (Optional[str]): New rootless workfile path.. - task_id (Optional[str]): New parent task id. - attrib (Optional[dict[str, Any]]): New attributes. - data (Optional[dict[str, Any]]): New data. - tags (Optional[Iterable[str]]): New tags. - status (Optional[str]): New status. - active (Optional[bool]): New active state. - thumbnail_id (Optional[str]): New thumbnail id. - created_by (Optional[str]): New created by username. - updated_by (Optional[str]): New updated by username. + list_id (str): Entity list id. + order (list[str]): Item ids in desired order. Must contain + each item of the entity list exactly once. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.9. """ con = get_server_api_connection() - return con.update_workfile_info( + return con.set_entity_list_items_order( project_name=project_name, - workfile_id=workfile_id, - path=path, - task_id=task_id, - attrib=attrib, - data=data, - tags=tags, - status=status, - active=active, - thumbnail_id=thumbnail_id, - created_by=created_by, - updated_by=updated_by, + list_id=list_id, + order=order, ) -def delete_workfile_info( +def get_thumbnail_by_id( project_name: str, - workfile_id: str, -) -> None: - """DEPRECATED Delete workfile entity on server. + thumbnail_id: str, +) -> ThumbnailContent: + """Get thumbnail from server by id. + + Warnings: + Please keep in mind that used endpoint is allowed only for admins + and managers. Use 'get_thumbnail' with entity type and id + to allow access for artists. + + Notes: + It is recommended to use one of prepared entity type specific + methods 'get_folder_thumbnail', 'get_version_thumbnail' or + 'get_workfile_thumbnail'. + We do recommend pass thumbnail id if you have access to it. Each + entity that allows thumbnails has 'thumbnailId' field, so it + can be queried. Args: - project_name (str): Project name. - workfile_id (str): Workfile id to delete. + project_name (str): Project under which the entity is located. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. + + Returns: + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ con = get_server_api_connection() - return con.delete_workfile_info( + return con.get_thumbnail_by_id( project_name=project_name, - workfile_id=workfile_id, + thumbnail_id=thumbnail_id, ) -def get_full_link_type_name( - link_type_name: str, - input_type: str, - output_type: str, -) -> str: - """Calculate full link type name used for query from server. +def get_thumbnail( + project_name: str, + entity_type: str, + entity_id: str, + thumbnail_id: Optional[str] = None, +) -> ThumbnailContent: + """Get thumbnail from server. + + Permissions of thumbnails are related to entities so thumbnails must + be queried per entity. So an entity type and entity id is required + to be passed. + + Notes: + It is recommended to use one of prepared entity type specific + methods 'get_folder_thumbnail', 'get_version_thumbnail' or + 'get_workfile_thumbnail'. + We do recommend pass thumbnail id if you have access to it. Each + entity that allows thumbnails has 'thumbnailId' field, so it + can be queried. Args: - link_type_name (str): Type of link. - input_type (str): Input entity type of link. - output_type (str): Output entity type of link. + project_name (str): Project under which the entity is located. + entity_type (str): Entity type which passed entity id represents. + entity_id (str): Entity id for which thumbnail should be returned. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. Returns: - str: Full name of link type used for query from server. + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ con = get_server_api_connection() - return con.get_full_link_type_name( - link_type_name=link_type_name, - input_type=input_type, - output_type=output_type, + return con.get_thumbnail( + project_name=project_name, + entity_type=entity_type, + entity_id=entity_id, + thumbnail_id=thumbnail_id, ) -def get_link_types( +def get_folder_thumbnail( project_name: str, -) -> list[dict[str, Any]]: - """All link types available on a project. - - Example output: - [ - { - "name": "reference|folder|folder", - "link_type": "reference", - "input_type": "folder", - "output_type": "folder", - "data": {} - } - ] + folder_id: str, + thumbnail_id: Optional[str] = None, +) -> ThumbnailContent: + """Prepared method to receive thumbnail for folder entity. Args: - project_name (str): Name of project where to look for link types. + project_name (str): Project under which the entity is located. + folder_id (str): Folder id for which thumbnail should be returned. + thumbnail_id (Optional[str]): Prepared thumbnail id from entity. + Used only to check if thumbnail was already cached. Returns: - list[dict[str, Any]]: Link types available on project. + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ con = get_server_api_connection() - return con.get_link_types( + return con.get_folder_thumbnail( project_name=project_name, + folder_id=folder_id, + thumbnail_id=thumbnail_id, ) -def get_link_type( +def get_task_thumbnail( project_name: str, - link_type_name: str, - input_type: str, - output_type: str, -) -> Optional[dict[str, Any]]: - """Get link type data. - - There is not dedicated REST endpoint to get single link type, - so method 'get_link_types' is used. - - Example output: - { - "name": "reference|folder|folder", - "link_type": "reference", - "input_type": "folder", - "output_type": "folder", - "data": {} - } + task_id: str, +) -> ThumbnailContent: + """Prepared method to receive thumbnail for task entity. Args: - project_name (str): Project where link type is available. - link_type_name (str): Name of link type. - input_type (str): Input entity type of link. - output_type (str): Output entity type of link. + project_name (str): Project under which the entity is located. + task_id (str): Folder id for which thumbnail should be returned. Returns: - Optional[dict[str, Any]]: Link type information. + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ con = get_server_api_connection() - return con.get_link_type( + return con.get_task_thumbnail( project_name=project_name, - link_type_name=link_type_name, - input_type=input_type, - output_type=output_type, + task_id=task_id, ) -def create_link_type( +def get_version_thumbnail( project_name: str, - link_type_name: str, - input_type: str, - output_type: str, - data: Optional[dict[str, Any]] = None, -) -> None: - """Create or update link type on server. - - Warning: - Because PUT is used for creation it is also used for update. + version_id: str, + thumbnail_id: Optional[str] = None, +) -> ThumbnailContent: + """Prepared method to receive thumbnail for version entity. Args: - project_name (str): Project where link type is created. - link_type_name (str): Name of link type. - input_type (str): Input entity type of link. - output_type (str): Output entity type of link. - data (Optional[dict[str, Any]]): Additional data related to link. + project_name (str): Project under which the entity is located. + version_id (str): Version id for which thumbnail should be + returned. + thumbnail_id (Optional[str]): Prepared thumbnail id from entity. + Used only to check if thumbnail was already cached. - Raises: - HTTPRequestError: Server error happened. + Returns: + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ con = get_server_api_connection() - return con.create_link_type( + return con.get_version_thumbnail( project_name=project_name, - link_type_name=link_type_name, - input_type=input_type, - output_type=output_type, - data=data, + version_id=version_id, + thumbnail_id=thumbnail_id, ) -def delete_link_type( +def get_workfile_thumbnail( project_name: str, - link_type_name: str, - input_type: str, - output_type: str, -) -> None: - """Remove link type from project. + workfile_id: str, + thumbnail_id: Optional[str] = None, +) -> ThumbnailContent: + """Prepared method to receive thumbnail for workfile entity. Args: - project_name (str): Project where link type is created. - link_type_name (str): Name of link type. - input_type (str): Input entity type of link. - output_type (str): Output entity type of link. + project_name (str): Project under which the entity is located. + workfile_id (str): Worfile id for which thumbnail should be + returned. + thumbnail_id (Optional[str]): Prepared thumbnail id from entity. + Used only to check if thumbnail was already cached. - Raises: - HTTPRequestError: Server error happened. + Returns: + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ con = get_server_api_connection() - return con.delete_link_type( + return con.get_workfile_thumbnail( project_name=project_name, - link_type_name=link_type_name, - input_type=input_type, - output_type=output_type, + workfile_id=workfile_id, + thumbnail_id=thumbnail_id, ) -def make_sure_link_type_exists( +def create_thumbnail( project_name: str, - link_type_name: str, - input_type: str, - output_type: str, - data: Optional[dict[str, Any]] = None, -) -> None: - """Make sure link type exists on a project. + src_filepath: str, + thumbnail_id: Optional[str] = None, +) -> str: + """Create new thumbnail on server from passed path. Args: - project_name (str): Name of project. - link_type_name (str): Name of link type. - input_type (str): Input entity type of link. - output_type (str): Output entity type of link. - data (Optional[dict[str, Any]]): Link type related data. + project_name (str): Project where the thumbnail will be created + and can be used. + src_filepath (str): Filepath to thumbnail which should be uploaded. + thumbnail_id (Optional[str]): Prepared if of thumbnail. + + Returns: + str: Created thumbnail id. + + Raises: + ValueError: When thumbnail source cannot be processed. """ con = get_server_api_connection() - return con.make_sure_link_type_exists( + return con.create_thumbnail( project_name=project_name, - link_type_name=link_type_name, - input_type=input_type, - output_type=output_type, - data=data, + src_filepath=src_filepath, + thumbnail_id=thumbnail_id, ) -def create_link( +def create_thumbnail_with_stream( project_name: str, - link_type_name: str, - input_id: str, - input_type: str, - output_id: str, - output_type: str, - link_name: Optional[str] = None, - data: Optional[dict[str, Any]] = None, -) -> CreateLinkResponseData: - """Create link between 2 entities. - - Link has a type which must already exists on a project. - - Example output:: - - { - "id": "59a212c0d2e211eda0e20242ac120002" - } + stream: StreamType, + thumbnail_id: Optional[str] = None, +) -> str: + """Create new thumbnail on server from byte stream. Args: - project_name (str): Project where the link is created. - link_type_name (str): Type of link. - input_id (str): Input entity id. - input_type (str): Entity type of input entity. - output_id (str): Output entity id. - output_type (str): Entity type of output entity. - link_name (Optional[str]): Name of link. - data (Optional[dict[str, Any]]): Additional data to be stored - with the link. + project_name (str): Project where the thumbnail will be created + and can be used. + stream (StreamType): Thumbnail content stream. + thumbnail_id (Optional[str]): Prepared if of thumbnail. Returns: - CreateLinkResponseData: Information about link. + str: Created thumbnail id. Raises: - HTTPRequestError: Server error happened. + ValueError: When a thumbnail source cannot be processed. """ con = get_server_api_connection() - return con.create_link( + return con.create_thumbnail_with_stream( project_name=project_name, - link_type_name=link_type_name, - input_id=input_id, - input_type=input_type, - output_id=output_id, - output_type=output_type, - link_name=link_name, - data=data, + stream=stream, + thumbnail_id=thumbnail_id, ) -def create_links( +def update_thumbnail( project_name: str, - links: list[CreateLinkData], -) -> list[CreateLinksResponseItem]: - """Create multiple links in a single request. + thumbnail_id: str, + src_filepath: str, +) -> None: + """Change thumbnail content by id. - Example of link data:: - [ - { - "input": "59a212c0d2e211eda0e20242ac120001", - "output": "59a212c0d2e211eda0e20242ac120002", - "linkType": "reference|folder|folder", - "name": "my_link", - "data": {"key": "value"} - } - ] + Update can be also used to create new thumbnail. Args: - project_name (str): Project where links are created. - links (list[CreateLinkData]): List of link data. - - Returns: - list[CreateLinksResponseItem]: Information about created links. + project_name (str): Project where the thumbnail will be created + and can be used. + thumbnail_id (str): Thumbnail id to update. + src_filepath (str): Filepath to thumbnail which should be uploaded. Raises: - ValueError: Link data is invalid. + ValueError: When thumbnail source cannot be processed. """ con = get_server_api_connection() - return con.create_links( + return con.update_thumbnail( project_name=project_name, - links=links, + thumbnail_id=thumbnail_id, + src_filepath=src_filepath, ) -def delete_link( +def update_thumbnail_from_stream( project_name: str, - link_id: str, + thumbnail_id: str, + stream: StreamType, ) -> None: - """Remove link by id. + """Change thumbnail content by id. + + Update can be also used to create new thumbnail. Args: - project_name (str): Project where link exists. - link_id (str): Id of link. + project_name (str): Project where the thumbnail will be created + and can be used. + thumbnail_id (str): Thumbnail id to update. + stream (StreamType): Thumbnail content stream. + + """ + con = get_server_api_connection() + return con.update_thumbnail_from_stream( + project_name=project_name, + thumbnail_id=thumbnail_id, + stream=stream, + ) + + +def set_entity_thumbnail( + project_name: str, + entity_type: str, + entity_id: str, + src_filepath: str, +) -> str: + """Upload new thumbnail for an entity. + + Created thumbnail is automatically set on the entity. + + Args: + project_name (str): Project under which the entity is located. + entity_type (str): Entity type. Supported types are 'folder', + 'task', 'version' and 'workfile'. + entity_id (str): Entity id. + src_filepath (str): Filepath to thumbnail which should be uploaded. + + Returns: + str: Created thumbnail id. Raises: - HTTPRequestError: Server error happened. + ValueError: When thumbnail source cannot be processed. """ con = get_server_api_connection() - return con.delete_link( + return con.set_entity_thumbnail( project_name=project_name, - link_id=link_id, + entity_type=entity_type, + entity_id=entity_id, + src_filepath=src_filepath, ) -def get_entities_links( +def set_entity_thumbnail_from_stream( project_name: str, entity_type: str, - entity_ids: Optional[Iterable[str]] = None, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, - link_names: Optional[Iterable[str]] = None, - link_name_regex: Optional[str] = None, -) -> dict[str, list[dict[str, Any]]]: - """Helper method to get links from server for entity types. - - .. highlight:: text - .. code-block:: text + entity_id: str, + stream: StreamType, +) -> str: + """Upload new thumbnail for an entity from byte stream. - Example output: - { - "59a212c0d2e211eda0e20242ac120001": [ - { - "id": "59a212c0d2e211eda0e20242ac120002", - "linkType": "reference", - "description": "reference link between folders", - "projectName": "my_project", - "author": "frantadmin", - "entityId": "b1df109676db11ed8e8c6c9466b19aa8", - "entityType": "folder", - "direction": "out" - }, - ... - ], - ... - } + Created thumbnail is automatically set on the entity. Args: - project_name (str): Project where links are. - entity_type (Literal["folder", "task", "product", - "version", "representations"]): Entity type. - entity_ids (Optional[Iterable[str]]): Ids of entities for which - links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. - link_names (Optional[Iterable[str]]): Link name filters. - link_name_regex (Optional[str]): Regex filter for link name. + project_name (str): Project under which the entity is located. + entity_type (str): Entity type. Supported types are 'folder', + 'task', 'version' and 'workfile'. + entity_id (str): Entity id. + stream (StreamType): Thumbnail content stream. Returns: - dict[str, list[dict[str, Any]]]: Link info by entity ids. + str: Created thumbnail id. """ con = get_server_api_connection() - return con.get_entities_links( + return con.set_entity_thumbnail_from_stream( project_name=project_name, entity_type=entity_type, - entity_ids=entity_ids, - link_types=link_types, - link_direction=link_direction, - link_names=link_names, - link_name_regex=link_name_regex, + entity_id=entity_id, + stream=stream, ) -def get_folders_links( +def get_project_thumbnail( project_name: str, - folder_ids: Optional[Iterable[str]] = None, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> dict[str, list[dict[str, Any]]]: - """Query folders links from server. +) -> ThumbnailContent: + """Get project thumbnail. Args: - project_name (str): Project where links are. - folder_ids (Optional[Iterable[str]]): Ids of folders for which - links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + project_name (str): Project name. Returns: - dict[str, list[dict[str, Any]]]: Link info by folder ids. + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.0. """ con = get_server_api_connection() - return con.get_folders_links( + return con.get_project_thumbnail( project_name=project_name, - folder_ids=folder_ids, - link_types=link_types, - link_direction=link_direction, ) -def get_folder_links( +def set_project_thumbnail( project_name: str, - folder_id: str, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> list[dict[str, Any]]: - """Query folder links from server. + src_filepath: str, +) -> str: + """Upload project thumbnail. Args: - project_name (str): Project where links are. - folder_id (str): Folder id for which links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + project_name (str): Project name. + src_filepath (str): Filepath to thumbnail which should be uploaded. Returns: - list[dict[str, Any]]: Link info of folder. + str: Thumbnail id of project thumbnail. + + Raises: + ValueError: When thumbnail source cannot be processed. + UnsupportedServerVersion: Server version is lower than 1.15.0. """ con = get_server_api_connection() - return con.get_folder_links( + return con.set_project_thumbnail( project_name=project_name, - folder_id=folder_id, - link_types=link_types, - link_direction=link_direction, + src_filepath=src_filepath, ) -def get_tasks_links( +def set_project_thumbnail_from_stream( project_name: str, - task_ids: Optional[Iterable[str]] = None, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> dict[str, list[dict[str, Any]]]: - """Query tasks links from server. + stream: StreamType, +) -> str: + """Upload project thumbnail from byte stream. Args: - project_name (str): Project where links are. - task_ids (Optional[Iterable[str]]): Ids of tasks for which - links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + project_name (str): Project name. + stream (StreamType): Thumbnail content stream. Returns: - dict[str, list[dict[str, Any]]]: Link info by task ids. + str: Thumbnail id of project thumbnail. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.0. """ con = get_server_api_connection() - return con.get_tasks_links( + return con.set_project_thumbnail_from_stream( project_name=project_name, - task_ids=task_ids, - link_types=link_types, - link_direction=link_direction, + stream=stream, ) -def get_task_links( - project_name: str, - task_id: str, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> list[dict[str, Any]]: - """Query task links from server. +def create_user( + user_name: str, + *, + password: Optional[str] = None, + api_key: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + active: Optional[bool] = None, +) -> None: + """Create new user. + + User level and access is defined in 'data', e.g. 'isAdmin', + 'isManager', 'isService', 'isGuest', 'accessGroups' + ({project_name: [access_group_name, ...]}) or + 'defaultAccessGroups' ([access_group_name, ...]). Args: - project_name (str): Project where links are. - task_id (str): Task id for which links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + user_name (str): Name of the user. + password (Optional[str]): Password of the user. Service users + cannot have password. + api_key (Optional[str]): Api key for a service user. + attrib (Optional[dict[str, Any]]): User attributes. + data (Optional[dict[str, Any]]): User data. + active (Optional[bool]): User active state. - Returns: - list[dict[str, Any]]: Link info of task. + """ + con = get_server_api_connection() + return con.create_user( + user_name=user_name, + password=password, + api_key=api_key, + attrib=attrib, + data=data, + active=active, + ) + + +def update_user( + user_name: str, + *, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + active: Optional[bool] = None, +) -> None: + """Update user. + + Non-manager users can change only their own attributes. + + Args: + user_name (str): Name of the user. + attrib (Optional[dict[str, Any]]): User attributes to change. + data (Optional[dict[str, Any]]): User data to change. + active (Optional[bool]): User active state. """ con = get_server_api_connection() - return con.get_task_links( - project_name=project_name, - task_id=task_id, - link_types=link_types, - link_direction=link_direction, + return con.update_user( + user_name=user_name, + attrib=attrib, + data=data, + active=active, ) -def get_products_links( - project_name: str, - product_ids: Optional[Iterable[str]] = None, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> dict[str, list[dict[str, Any]]]: - """Query products links from server. +def delete_user( + user_name: str, +) -> None: + """Delete user. Args: - project_name (str): Project where links are. - product_ids (Optional[Iterable[str]]): Ids of products for which - links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + user_name (str): Name of the user. - Returns: - dict[str, list[dict[str, Any]]]: Link info by product ids. + """ + con = get_server_api_connection() + return con.delete_user( + user_name=user_name, + ) + + +def rename_user( + user_name: str, + new_user_name: str, +) -> None: + """Rename user. + + This is a manager-only operation. Target user name must not exist. + + Args: + user_name (str): Current name of the user. + new_user_name (str): New name of the user. """ con = get_server_api_connection() - return con.get_products_links( - project_name=project_name, - product_ids=product_ids, - link_types=link_types, - link_direction=link_direction, + return con.rename_user( + user_name=user_name, + new_user_name=new_user_name, ) -def get_product_links( - project_name: str, - product_id: str, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> list[dict[str, Any]]: - """Query product links from server. +def change_password( + user_name: str, + *, + password: Optional[str] = None, + api_key: Optional[str] = None, +) -> None: + """Change user password or service user api key. + + Only one of 'password' or 'api_key' should be passed. If both are + passed the password is changed. Args: - project_name (str): Project where links are. - product_id (str): Product id for which links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + user_name (str): Name of the user. + password (Optional[str]): New password. + api_key (Optional[str]): New api key of a service user. Must be + 32 characters long hex string. - Returns: - list[dict[str, Any]]: Link info of product. + """ + con = get_server_api_connection() + return con.change_password( + user_name=user_name, + password=password, + api_key=api_key, + ) + + +def set_user_access_groups( + user_name: str, + access_groups: dict[str, list[str]], +) -> None: + """Set user access groups on projects. + + Projects that are not in 'access_groups' are not affected. Pass + an empty list to remove access to a project. + + Args: + user_name (str): Name of the user. + access_groups (dict[str, list[str]]): Access groups by + project name. """ con = get_server_api_connection() - return con.get_product_links( - project_name=project_name, - product_id=product_id, - link_types=link_types, - link_direction=link_direction, + return con.set_user_access_groups( + user_name=user_name, + access_groups=access_groups, ) -def get_versions_links( - project_name: str, - version_ids: Optional[Iterable[str]] = None, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> dict[str, list[dict[str, Any]]]: - """Query versions links from server. +def get_user_sessions( + user_name: str, +) -> list[dict[str, Any]]: + """Get active sessions of user. Args: - project_name (str): Project where links are. - version_ids (Optional[Iterable[str]]): Ids of versions for which - links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + user_name (str): Name of the user. Returns: - dict[str, list[dict[str, Any]]]: Link info by version ids. + list[dict[str, Any]]: Sessions with 'token', 'isService', + 'lastUsed' and 'clientInfo' keys. """ con = get_server_api_connection() - return con.get_versions_links( - project_name=project_name, - version_ids=version_ids, - link_types=link_types, - link_direction=link_direction, + return con.get_user_sessions( + user_name=user_name, ) -def get_version_links( - project_name: str, - version_id: str, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, +def delete_user_session( + user_name: str, + session_id: str, +) -> None: + """Delete user session (logout). + + Args: + user_name (str): Name of the user. + session_id (str): Session id (token). + + """ + con = get_server_api_connection() + return con.delete_user_session( + user_name=user_name, + session_id=session_id, + ) + + +def get_user_api_keys( + user_name: str, ) -> list[dict[str, Any]]: - """Query version links from server. + """Get api keys of user. Args: - project_name (str): Project where links are. - version_id (str): Version id for which links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + user_name (str): Name of the user. Returns: - list[dict[str, Any]]: Link info of version. + list[dict[str, Any]]: Api keys with 'id', 'label', 'preview', + 'created' and 'expires' keys. """ con = get_server_api_connection() - return con.get_version_links( - project_name=project_name, - version_id=version_id, - link_types=link_types, - link_direction=link_direction, + return con.get_user_api_keys( + user_name=user_name, + ) + + +def create_user_api_key( + user_name: str, + label: str, + key: str, + *, + expires: Optional[float] = None, +) -> None: + """Create api key for user. + + Args: + user_name (str): Name of the user. + label (str): Label of the api key. Must be unique for the user. + key (str): The api key value. + expires (Optional[float]): Expiration timestamp. + + """ + con = get_server_api_connection() + return con.create_user_api_key( + user_name=user_name, + label=label, + key=key, + expires=expires, + ) + + +def update_user_api_key( + user_name: str, + api_key_id: str, + *, + label: Optional[str] = None, + expires: Optional[float] = None, +) -> None: + """Update api key of user. + + Args: + user_name (str): Name of the user. + api_key_id (str): Api key id. + label (Optional[str]): New label of the api key. + expires (Optional[float]): New expiration timestamp. + + """ + con = get_server_api_connection() + return con.update_user_api_key( + user_name=user_name, + api_key_id=api_key_id, + label=label, + expires=expires, ) -def get_representations_links( - project_name: str, - representation_ids: Optional[Iterable[str]] = None, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> dict[str, list[dict[str, Any]]]: - """Query representations links from server. +def delete_user_api_key( + user_name: str, + api_key_id: str, +) -> None: + """Delete api key of user. Args: - project_name (str): Project where links are. - representation_ids (Optional[Iterable[str]]): Ids of - representations for which links should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. - - Returns: - dict[str, list[dict[str, Any]]]: Link info by representation ids. + user_name (str): Name of the user. + api_key_id (str): Api key id. """ con = get_server_api_connection() - return con.get_representations_links( - project_name=project_name, - representation_ids=representation_ids, - link_types=link_types, - link_direction=link_direction, + return con.delete_user_api_key( + user_name=user_name, + api_key_id=api_key_id, ) -def get_representation_links( - project_name: str, - representation_id: str, - link_types: Optional[Iterable[str]] = None, - link_direction: Optional[LinkDirection] = None, -) -> list[dict[str, Any]]: - """Query representation links from server. +def get_user_permissions( + user_name: Optional[str] = None, + project_name: Optional[str] = None, +) -> dict[str, Any]: + """Get user permissions. + + Without 'user_name' are returned permissions of current user. For + current user without 'project_name' is returned user level with + studio and per-project permissions. Otherwise, are returned + studio permissions, or project permissions if 'project_name' + is passed. Args: - project_name (str): Project where links are. - representation_id (str): Representation id for which links - should be received. - link_types (Optional[Iterable[str]]): Link type filters. - link_direction (Optional[Literal["in", "out"]]): Link direction - filter. + user_name (Optional[str]): Name of the user. Current user is used + if not passed. + project_name (Optional[str]): Project name. Returns: - list[dict[str, Any]]: Link info of representation. + dict[str, Any]: User permissions. """ con = get_server_api_connection() - return con.get_representation_links( + return con.get_user_permissions( + user_name=user_name, project_name=project_name, - representation_id=representation_id, - link_types=link_types, - link_direction=link_direction, ) -def get_entity_lists( - project_name: str, - *, - list_ids: Optional[Iterable[str]] = None, - active: Optional[bool] = None, - fields: Optional[Iterable[str]] = None, -) -> Generator[dict[str, Any], None, None]: - """Fetch entity lists from AYON server. - - Warnings: - You can't get list items for lists with different 'entityType' in - one call. - - Notes: - To get list items, you have to pass 'items' field or - 'items.{sub-fields you want}' to 'fields' argument. +def get_access_groups( + project_name: Optional[str] = None, +) -> list[dict[str, Any]]: + """Get access groups. Args: - project_name (str): Project name where entity lists are. - list_ids (Optional[Iterable[str]]): List of entity list ids to - fetch. - active (Optional[bool]): Filter by active state of entity lists. - fields (Optional[Iterable[str]]): Fields to fetch from server. + project_name (Optional[str]): Project name. Studio level access + groups are returned if not passed. Returns: - Generator[dict[str, Any], None, None]: Entity list entities - matching defined filters. + list[dict[str, Any]]: Access groups with 'name' and + 'isProjectLevel' keys. """ con = get_server_api_connection() - return con.get_entity_lists( + return con.get_access_groups( project_name=project_name, - list_ids=list_ids, - active=active, - fields=fields, ) -def get_entity_list_rest( - project_name: str, - list_id: str, -) -> Optional[dict[str, Any]]: - """Get entity list by id using REST API. +def get_access_group_schema( + project_name: Optional[str] = None, +) -> dict[str, Any]: + """Get access group (permissions) settings schema. Args: - project_name (str): Project name. - list_id (str): Entity list id. + project_name (Optional[str]): Project name used as context. Returns: - Optional[dict[str, Any]]: Entity list data or None if not found. + dict[str, Any]: Access group schema. """ con = get_server_api_connection() - return con.get_entity_list_rest( + return con.get_access_group_schema( project_name=project_name, - list_id=list_id, ) -def get_entity_list_by_id( - project_name: str, - list_id: str, - fields: Optional[Iterable[str]] = None, -) -> Optional[dict[str, Any]]: - """Get entity list by id using GraphQl. +def get_access_group( + access_group_name: str, + project_name: Optional[str] = None, +) -> dict[str, Any]: + """Get access group definition (permissions). Args: - project_name (str): Project name. - list_id (str): Entity list id. - fields (Optional[Iterable[str]]): Fields to fetch from server. + access_group_name (str): Access group name. + project_name (Optional[str]): Project name. Studio level access + group is returned if not passed. Returns: - Optional[dict[str, Any]]: Entity list data or None if not found. + dict[str, Any]: Access group permissions. """ con = get_server_api_connection() - return con.get_entity_list_by_id( + return con.get_access_group( + access_group_name=access_group_name, project_name=project_name, - list_id=list_id, - fields=fields, ) -def create_entity_list( - project_name: str, - entity_type: EntityListEntityType, - label: str, - *, - list_type: Optional[str] = None, - access: Optional[dict[str, Any]] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[list[str]] = None, - template: Optional[dict[str, Any]] = None, - entity_list_folder_id: Optional[str] = None, - owner: Optional[str] = None, - active: Optional[bool] = None, - items: Optional[list[dict[str, Any]]] = None, - list_id: Optional[str] = None, -) -> str: - """Create entity list. +def save_access_group( + access_group_name: str, + permissions: dict[str, Any], + project_name: Optional[str] = None, +) -> None: + """Create or update access group. Args: - project_name (str): Project name where entity list lives. - entity_type (EntityListEntityType): Which entity types can be - used in list. - label (str): Entity list label. - list_type (Optional[str]): Entity list type. - access (Optional[dict[str, Any]]): Access control for entity list. - attrib (Optional[dict[str, Any]]): Attribute values of - entity list. - data (Optional[dict[str, Any]]): Custom data of entity list. - tags (Optional[list[str]]): Entity list tags. - template (Optional[dict[str, Any]]): Dynamic list template. - entity_list_folder_id (Optional[str]): Entity list folder id. - owner (Optional[str]): New owner of the list. - active (Optional[bool]): Change active state of entity list. - items (Optional[list[dict[str, Any]]]): Initial items in - entity list. - list_id (Optional[str]): Entity list id. + access_group_name (str): Access group name. + permissions (dict[str, Any]): Access group permissions. + project_name (Optional[str]): Project name. Studio level access + group is saved if not passed. """ con = get_server_api_connection() - return con.create_entity_list( + return con.save_access_group( + access_group_name=access_group_name, + permissions=permissions, project_name=project_name, - entity_type=entity_type, - label=label, - list_type=list_type, - access=access, - attrib=attrib, - data=data, - tags=tags, - template=template, - entity_list_folder_id=entity_list_folder_id, - owner=owner, - active=active, - items=items, - list_id=list_id, ) -def update_entity_list( - project_name: str, - list_id: str, - *, - label: Optional[str] = None, - access: Optional[dict[str, Any]] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[list[str]] = None, - entity_list_folder_id: str | None | type[NOT_SET] = NOT_SET, - owner: Optional[str] = None, - active: Optional[bool] = None, +def delete_access_group( + access_group_name: str, + project_name: Optional[str] = None, ) -> None: - """Update entity list. + """Delete access group. Args: - project_name (str): Project name where entity list lives. - list_id (str): Entity list id that will be updated. - label (Optional[str]): New label of entity list. - access (Optional[dict[str, Any]]): Access control for entity list. - attrib (Optional[dict[str, Any]]): Attribute values of - entity list. - data (Optional[dict[str, Any]]): Custom data of entity list. - tags (Optional[list[str]]): Entity list tags. - entity_list_folder_id (str | None | type[NOT_SET]): New entity - list folder id. Use ``None`` to move entity list to root. - Use 'NOT_SET' to keep current folder. - owner (Optional[str]): New owner of the list. - active (Optional[bool]): Change active state of entity list. + access_group_name (str): Access group name. + project_name (Optional[str]): Project name. Studio level access + group is deleted if not passed. """ con = get_server_api_connection() - return con.update_entity_list( + return con.delete_access_group( + access_group_name=access_group_name, project_name=project_name, - list_id=list_id, - label=label, - access=access, - attrib=attrib, - data=data, - tags=tags, - entity_list_folder_id=entity_list_folder_id, - owner=owner, - active=active, ) -def delete_entity_list( - project_name: str, - list_id: str, +def set_projects_access( + access: dict[str, dict[str, list[str]]], ) -> None: - """Delete entity list from project. + """Set access of multiple users to multiple projects. + + Projects not present in the payload are not affected. Pass an empty + list to revoke access to a project. + + Example:: + + { + "alice": { + "MyProject01": ["editor"], + "MyProject02": ["artist"], + }, + "bob": { + "MyProject01": ["artist", "reviewer"], + "MyProject02": [], + }, + } Args: - project_name (str): Project name. - list_id (str): Entity list id that will be removed. + access (dict[str, dict[str, list[str]]]): Access groups by + project name by user name. """ con = get_server_api_connection() - return con.delete_entity_list( - project_name=project_name, - list_id=list_id, + return con.set_projects_access( + access=access, ) -def get_entity_list_attribute_definitions( +def get_teams( project_name: str, - list_id: str, -) -> list[EntityListAttributeDefinitionDict]: - """Get attribute definitioins on entity list. + *, + show_members: bool = False, +) -> list[dict[str, Any]]: + """Get teams in a project. Args: project_name (str): Project name. - list_id (str): Entity list id. + show_members (bool): Include team members in the response. Returns: - list[EntityListAttributeDefinitionDict]: List of attribute - definitions. + list[dict[str, Any]]: Teams with 'name', 'memberCount', + 'leaders' and optionally 'members' keys. """ con = get_server_api_connection() - return con.get_entity_list_attribute_definitions( + return con.get_teams( project_name=project_name, - list_id=list_id, + show_members=show_members, ) -def set_entity_list_attribute_definitions( +def update_teams( project_name: str, - list_id: str, - attribute_definitions: list[EntityListAttributeDefinitionDict], + teams: list[dict[str, Any]], ) -> None: - """Set attribute definitioins on entity list. + """Create or replace multiple teams in a project. + + Teams that are not in 'teams' are not affected. Args: project_name (str): Project name. - list_id (str): Entity list id. - attribute_definitions (list[EntityListAttributeDefinitionDict]): - List of attribute definitions. + teams (list[dict[str, Any]]): Teams with 'name' and 'members' + keys. Member is a dictionary with 'name', 'leader' + and 'roles' keys. """ con = get_server_api_connection() - return con.set_entity_list_attribute_definitions( + return con.update_teams( project_name=project_name, - list_id=list_id, - attribute_definitions=attribute_definitions, + teams=teams, ) -def create_entity_list_item( +def save_team( project_name: str, - list_id: str, - entity_id: str, - *, - position: Optional[int] = None, - label: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[list[str]] = None, - item_id: Optional[str] = None, -) -> str: - """Create entity list item. - - Args: - project_name (str): Project name where entity list lives. - list_id (str): Entity list id where item will be added. - entity_id (str): Id of entity added to the list. - position (Optional[int]): Position of item in entity list. - label (Optional[str]): Label of item in entity list. - attrib (Optional[dict[str, Any]]): Item attribute values. - data (Optional[dict[str, Any]]): Item data. - tags (Optional[list[str]]): Tags of item in entity list. - item_id (Optional[str]): Id of item that will be created. + team_name: str, + members: list[dict[str, Any]], +) -> None: + """Create or replace a team in a project. - Returns: - str: Item id. + Args: + project_name (str): Project name. + team_name (str): Team name. + members (list[dict[str, Any]]): Team members. Member is + a dictionary with 'name', 'leader' and 'roles' keys. """ con = get_server_api_connection() - return con.create_entity_list_item( + return con.save_team( project_name=project_name, - list_id=list_id, - entity_id=entity_id, - position=position, - label=label, - attrib=attrib, - data=data, - tags=tags, - item_id=item_id, + team_name=team_name, + members=members, ) -def update_entity_list_items( +def delete_team( project_name: str, - list_id: str, - items: list[dict[str, Any]], - mode: EntityListItemMode, + team_name: str, ) -> None: - """Update items in entity list. + """Delete a team from a project. Args: - project_name (str): Project name where entity list live. - list_id (str): Entity list id. - items (list[dict[str, Any]]): Entity list items. - mode (EntityListItemMode): Mode of items update. + project_name (str): Project name. + team_name (str): Team name. """ con = get_server_api_connection() - return con.update_entity_list_items( + return con.delete_team( project_name=project_name, - list_id=list_id, - items=items, - mode=mode, + team_name=team_name, ) -def update_entity_list_item( +def save_team_member( project_name: str, - list_id: str, - item_id: str, + team_name: str, + member_name: str, *, - new_list_id: Optional[str] = None, - position: Optional[int] = None, - label: Optional[str] = None, - attrib: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - tags: Optional[list[str]] = None, + leader: bool = False, + roles: Optional[list[str]] = None, ) -> None: - """Update item in entity list. + """Add or replace a team member. Args: - project_name (str): Project name where entity list live. - list_id (str): Entity list id where item lives. - item_id (str): Item id that will be removed from entity list. - new_list_id (Optional[str]): New entity list id where item will be - added. - position (Optional[int]): Position of item in entity list. - label (Optional[str]): Label of item in entity list. - attrib (Optional[dict[str, Any]]): Attributes of item in entity - list. - data (Optional[dict[str, Any]]): Custom data of item in - entity list. - tags (Optional[list[str]]): Tags of item in entity list. + project_name (str): Project name. + team_name (str): Team name. + member_name (str): User name. + leader (bool): Is user a leader of the team. + roles (Optional[list[str]]): User roles in the team. """ con = get_server_api_connection() - return con.update_entity_list_item( + return con.save_team_member( project_name=project_name, - list_id=list_id, - item_id=item_id, - new_list_id=new_list_id, - position=position, - label=label, - attrib=attrib, - data=data, - tags=tags, + team_name=team_name, + member_name=member_name, + leader=leader, + roles=roles, ) -def delete_entity_list_item( +def delete_team_member( project_name: str, - list_id: str, - item_id: str, + team_name: str, + member_name: str, ) -> None: - """Delete item from entity list. + """Remove a member from a team. Args: - project_name (str): Project name where entity list live. - list_id (str): Entity list id from which item will be removed. - item_id (str): Item id that will be removed from entity list. + project_name (str): Project name. + team_name (str): Team name. + member_name (str): User name. """ con = get_server_api_connection() - return con.delete_entity_list_item( + return con.delete_team_member( project_name=project_name, - list_id=list_id, - item_id=item_id, + team_name=team_name, + member_name=member_name, ) -def get_entity_list_entities( +def get_project_users( project_name: str, - entity_list_id: str, -) -> dict[str, Any]: - """Get entity list items using REST API. +) -> dict[str, list[str]]: + """Get users with explicitly set access groups on a project. + + Managers and admins are not included as they have access to all + projects. Args: project_name (str): Project name. - entity_list_id (str): Entity list id. Returns: - dict[str, Any]: Information about entities on the list. + dict[str, list[str]]: Access groups by user name. """ con = get_server_api_connection() - return con.get_entity_list_entities( + return con.get_project_users( project_name=project_name, - entity_list_id=entity_list_id, ) -def get_entity_list_folders_raw( +def set_project_user_access_groups( project_name: str, -) -> dict[str, Any]: - """Get entity list folders. + user_name: str, + access_groups: list[str], +) -> dict[str, list[str]]: + """Set access groups of a user on a project. + + Pass an empty list to remove user access to the project. Args: project_name (str): Project name. + user_name (str): User name. + access_groups (list[str]): Access group names. Returns: - dict[str, Any]: Raw output of entity list folders output. At this - moment contains only "folders" key with list of folders, - but it can be extended in the future. + dict[str, list[str]]: Access groups by user name of all users + on the project. """ con = get_server_api_connection() - return con.get_entity_list_folders_raw( + return con.set_project_user_access_groups( project_name=project_name, + user_name=user_name, + access_groups=access_groups, ) -def get_entity_list_folders( - project_name: str, -) -> list[dict[str, Any]]: - """Get entity list folders. +def get_services() -> list[dict[str, Any]]: + """Get addon services registered on server. + + Only administrators receive the services, other users get an + empty list. Returns: - list[dict[str, Any]]: List of entity list folders. + list[dict[str, Any]]: List of services. """ con = get_server_api_connection() - return con.get_entity_list_folders( - project_name=project_name, + return con.get_services() + + +def spawn_service( + service_name: str, + addon_name: str, + addon_version: str, + service: str, + hostname: str, + config: Optional[dict[str, Any]] = None, +) -> None: + """Spawn (create) a new addon service. + + Requires administrator privileges. + + Args: + service_name (str): Unique name of the service. + addon_name (str): Name of addon that provides the service. + addon_version (str): Version of addon. + service (str): Name of service defined by the addon. + hostname (str): Name of host (ASH) where service should run. + config (Optional[dict[str, Any]]): Service configuration + e.g. 'volumes', 'ports', 'memLimit', 'user', 'env', + 'storagePath' or 'registryAuth'. + 'registryAuth' is ignored by AYON server older than 1.16.7. + + """ + con = get_server_api_connection() + return con.spawn_service( + service_name=service_name, + addon_name=addon_name, + addon_version=addon_version, + service=service, + hostname=hostname, + config=config, ) -def create_entity_list_folder( - project_name: str, - label: str, - *, - parent_id: str | None = None, - color: str | None = None, - icon: str | None = None, - scope: list[EntityListScope] | None = None, - data: dict[str, Any] | None = None, - access: dict[str, Any] | None = None, - entity_list_folder_id: str | None = None, -) -> str: - """Create entity list folder. +def update_service( + service_name: str, + should_run: Optional[bool] = None, + hostname: Optional[str] = None, + error: Optional[str] = None, + config: Optional[dict[str, Any]] = None, +) -> None: + """Update addon service. + + Requires administrator privileges. Args: - project_name (str): Project name. - label (str): Folder label. - parent_id (str | None): Parent folder id. If None, the folder will - be created in root. - color (str | None): Folder color. - icon (str | None): Folder icon. - scope (list[EntityListScope] | None): Folder scope. Empty list can - be used to scope folder for all views. - data (dict[str, Any] | None): Custom data of entity list folder. - access (dict[str, Any] | None): Access control for - entity list folder. - entity_list_folder_id (str | None): Id of folder that will be - created. If None, a new id will be generated. + service_name (str): Name of the service. + should_run (Optional[bool]): Service should be running. + hostname (Optional[str]): Name of host where service should run. + error (Optional[str]): Error message. Ignored when service + should be running. + config (Optional[dict[str, Any]]): Service configuration + values to change e.g. 'volumes', 'ports', 'memLimit', + 'user', 'env', 'storagePath' or 'registryAuth'. + 'registryAuth' is ignored by AYON server older than 1.16.7. + + Raises: + UnsupportedServerVersion: 'error' is used with server version + lower than 1.16.7. + + """ + con = get_server_api_connection() + return con.update_service( + service_name=service_name, + should_run=should_run, + hostname=hostname, + error=error, + config=config, + ) + + +def delete_service( + service_name: str, +) -> None: + """Delete addon service. + + Requires administrator privileges. + + Args: + service_name (str): Name of the service. + + """ + con = get_server_api_connection() + return con.delete_service( + service_name=service_name, + ) + + +def get_hosts() -> list[dict[str, Any]]: + """Get AYON service hosts (ASH) registered on server. + + Host is registered automatically when it sends heartbeat to server. + + Example output:: + + [ + { + "name": "my-host", + "lastSeen": "2025-01-01T00:00:00", + "health": {"cpu": 0.5, "mem": 42.0}, + } + ] Returns: - str: Created entity list folder id. + list[dict[str, Any]]: List of hosts. """ con = get_server_api_connection() - return con.create_entity_list_folder( - project_name=project_name, - label=label, - parent_id=parent_id, - color=color, - icon=icon, - scope=scope, - data=data, - access=access, - entity_list_folder_id=entity_list_folder_id, + return con.get_hosts() + + +def get_sites( + platform_name: Optional[str] = None, + hostname: Optional[str] = None, +) -> list[dict[str, Any]]: + """Get sites registered on server. + + Non-manager users receive only sites they have used. + + Example output:: + + [ + { + "id": "site-id", + "platform": "windows", + "hostname": "my-machine", + "version": "1.0.0", + "users": ["admin"], + } + ] + + Args: + platform_name (Optional[str]): Filter sites by platform name. + Possible values are 'windows', 'linux' and 'darwin'. + hostname (Optional[str]): Filter sites by machine hostname. + + Returns: + list[dict[str, Any]]: List of sites. + + """ + con = get_server_api_connection() + return con.get_sites( + platform_name=platform_name, + hostname=hostname, ) -def update_entity_list_folder( - project_name: str, - entity_list_folder_id: str, - *, - label: str | None = None, - parent_id: str | None | type[NOT_SET] = NOT_SET, - color: str | None = None, - icon: str | None = None, - scope: list[EntityListScope] | None = None, - data: dict[str, Any] | None = None, - access: dict[str, Any] | None = None, +def delete_site( + site_id: str, ) -> None: - """Update entity list folder. + """Unregister site from server. + + Requires administrator privileges. Args: - project_name (str): Project name. - entity_list_folder_id (str): Folder id that will be updated. - label (str | None): New label of entity list folder. - parent_id (str | None | type[NOT_SET]): New parent id of entity - list folder. If None, the folder will be moved to root. - color (str | None): New color of entity list folder. - icon (str | None): New icon of entity list folder. - scope (list[EntityListScope] | None): New scope of entity list - folder. Empty list can be used to scope folder for all views. - data (dict[str, Any] | None): Custom data of entity list folder. - access (dict[str, Any] | None): Access control for - entity list folder. + site_id (str): Site id. """ con = get_server_api_connection() - return con.update_entity_list_folder( - project_name=project_name, - entity_list_folder_id=entity_list_folder_id, - label=label, - parent_id=parent_id, - color=color, - icon=icon, - scope=scope, - data=data, - access=access, + return con.delete_site( + site_id=site_id, ) -def delete_entity_list_folder( - project_name: str, - entity_list_folder_id: str, +def get_restart_required() -> dict[str, Any]: + """Get information whether server requires restart. + + Example output:: + + { + "required": True, + "reason": "Server restart is required", + } + + Returns: + dict[str, Any]: Restart required information with keys + 'required' and 'reason'. + + """ + con = get_server_api_connection() + return con.get_restart_required() + + +def set_restart_required( + required: bool, + reason: Optional[str] = None, ) -> None: - """Delete entity list folder.""" + """Set or clear the server restart required flag. + + This notifies administrators that the server needs to be restarted. + Use 'trigger_server_restart' to actually restart the server. + + Args: + required (bool): Server requires restart. + reason (Optional[str]): Human-readable reason for the restart. + + """ con = get_server_api_connection() - return con.delete_entity_list_folder( - project_name=project_name, - entity_list_folder_id=entity_list_folder_id, + return con.set_restart_required( + required=required, + reason=reason, ) -def set_entity_list_folders_order( - project_name: str, - order: list[str], -) -> None: - """Change order of entity list folders. +def get_server_config_schema() -> dict[str, Any]: + """Get JSON schema of server configuration. - Args: - project_name (str): Project name. - order (list[str]): List of folder ids in desired order. + Returns: + dict[str, Any]: Server configuration schema. """ con = get_server_api_connection() - return con.set_entity_list_folders_order( - project_name=project_name, - order=order, - ) + return con.get_server_config_schema() -def get_thumbnail_by_id( - project_name: str, - thumbnail_id: str, -) -> ThumbnailContent: - """Get thumbnail from server by id. +def update_server_config( + config: dict[str, Any], +) -> None: + """Partially update server configuration. - Warnings: - Please keep in mind that used endpoint is allowed only for admins - and managers. Use 'get_thumbnail' with entity type and id - to allow access for artists. + Values are recursively merged into current server configuration. + Keys that are not passed remain unchanged. Use the same + structure as 'get_server_config' returns, e.g. + '{"studio_name": "My Studio"}'. - Notes: - It is recommended to use one of prepared entity type specific - methods 'get_folder_thumbnail', 'get_version_thumbnail' or - 'get_workfile_thumbnail'. - We do recommend pass thumbnail id if you have access to it. Each - entity that allows thumbnails has 'thumbnailId' field, so it - can be queried. + Requires administrator privileges. Args: - project_name (str): Project under which the entity is located. - thumbnail_id (Optional[str]): DEPRECATED Use - 'get_thumbnail_by_id'. + config (dict[str, Any]): Server configuration values to change. - Returns: - ThumbnailContent: Thumbnail content wrapper. Does not have to be - valid. + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.9. """ con = get_server_api_connection() - return con.get_thumbnail_by_id( - project_name=project_name, - thumbnail_id=thumbnail_id, + return con.update_server_config( + config=config, ) -def get_thumbnail( - project_name: str, - entity_type: str, - entity_id: str, - thumbnail_id: Optional[str] = None, -) -> ThumbnailContent: - """Get thumbnail from server. - - Permissions of thumbnails are related to entities so thumbnails must - be queried per entity. So an entity type and entity id is required - to be passed. +def get_enums() -> list[dict[str, Any]]: + """Get available enum resolvers. - Notes: - It is recommended to use one of prepared entity type specific - methods 'get_folder_thumbnail', 'get_version_thumbnail' or - 'get_workfile_thumbnail'. - We do recommend pass thumbnail id if you have access to it. Each - entity that allows thumbnails has 'thumbnailId' field, so it - can be queried. + Example output:: - Args: - project_name (str): Project under which the entity is located. - entity_type (str): Entity type which passed entity id represents. - entity_id (str): Entity id for which thumbnail should be returned. - thumbnail_id (Optional[str]): DEPRECATED Use - 'get_thumbnail_by_id'. + [ + { + "name": "statuses", + "label": "Statuses", + ... + } + ] Returns: - ThumbnailContent: Thumbnail content wrapper. Does not have to be - valid. + list[dict[str, Any]]: List of enum resolvers information. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.16.4. """ con = get_server_api_connection() - return con.get_thumbnail( - project_name=project_name, - entity_type=entity_type, - entity_id=entity_id, - thumbnail_id=thumbnail_id, - ) + return con.get_enums() -def get_folder_thumbnail( - project_name: str, - folder_id: str, - thumbnail_id: Optional[str] = None, -) -> ThumbnailContent: - """Prepared method to receive thumbnail for folder entity. +def get_enum( + enum_name: str, + project_name: Optional[str] = None, + user_name: Optional[str] = None, + context: Optional[dict[str, Any]] = None, +) -> list[dict[str, Any]]: + """Get enum items resolved by server. + + Accepted context values depend on the enum resolver, use 'get_enums' + to find out which parameters a resolver accepts. + + Example output:: + + [ + { + "value": "in_progress", + "label": "In progress", + ... + } + ] Args: - project_name (str): Project under which the entity is located. - folder_id (str): Folder id for which thumbnail should be returned. - thumbnail_id (Optional[str]): Prepared thumbnail id from entity. - Used only to check if thumbnail was already cached. + enum_name (str): Name of the enum e.g. 'statuses'. + project_name (Optional[str]): Project name for project context. + user_name (Optional[str]): Resolve enum for different user. + Only administrators can use this. + context (Optional[dict[str, Any]]): Additional context + parameters for the resolver. Lists are joined with comma. Returns: - ThumbnailContent: Thumbnail content wrapper. Does not have to be - valid. + list[dict[str, Any]]: List of enum items. """ con = get_server_api_connection() - return con.get_folder_thumbnail( + return con.get_enum( + enum_name=enum_name, project_name=project_name, - folder_id=folder_id, - thumbnail_id=thumbnail_id, + user_name=user_name, + context=context, ) -def get_task_thumbnail( +def get_reviewables( project_name: str, - task_id: str, -) -> ThumbnailContent: - """Prepared method to receive thumbnail for task entity. + entity_type: ReviewablesEntityType, + entity_ids: Iterable[str], + *, + latest: bool = False, + latest_done: bool = False, +) -> list[dict[str, Any]]: + """Get reviewables for multiple entities of one entity type. + + Each item in output represents a version with its reviewables. Args: - project_name (str): Project under which the entity is located. - task_id (str): Folder id for which thumbnail should be returned. + project_name (str): Project name. + entity_type (ReviewablesEntityType): Entity type of passed + entity ids. Supported types are 'folder', 'product', + 'version' and 'task'. + entity_ids (Iterable[str]): Entity ids for which reviewables + should be returned. + latest (bool): Return only reviewables of the latest version. + latest_done (bool): Return only reviewables of the latest + approved versions. Returns: - ThumbnailContent: Thumbnail content wrapper. Does not have to be - valid. + list[dict[str, Any]]: Versions with reviewables. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.16.4. """ con = get_server_api_connection() - return con.get_task_thumbnail( + return con.get_reviewables( project_name=project_name, - task_id=task_id, + entity_type=entity_type, + entity_ids=entity_ids, + latest=latest, + latest_done=latest_done, ) -def get_version_thumbnail( +def get_version_reviewables( project_name: str, version_id: str, - thumbnail_id: Optional[str] = None, -) -> ThumbnailContent: - """Prepared method to receive thumbnail for version entity. + *, + latest: bool = False, + latest_done: bool = False, +) -> dict[str, Any]: + """Get reviewables of a version. + + Notes: + Server responds with an error if version does not have + any reviewables and 'latest' or 'latest_done' is used. Args: - project_name (str): Project under which the entity is located. - version_id (str): Version id for which thumbnail should be - returned. - thumbnail_id (Optional[str]): Prepared thumbnail id from entity. - Used only to check if thumbnail was already cached. + project_name (str): Project name. + version_id (str): Version id. + latest (bool): Return only reviewables of the latest version. + latest_done (bool): Return only reviewables of the latest + approved versions. Returns: - ThumbnailContent: Thumbnail content wrapper. Does not have to be - valid. + dict[str, Any]: Version information with reviewables. + + Raises: + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. """ con = get_server_api_connection() - return con.get_version_thumbnail( + return con.get_version_reviewables( project_name=project_name, version_id=version_id, - thumbnail_id=thumbnail_id, + latest=latest, + latest_done=latest_done, ) -def get_workfile_thumbnail( +def get_product_reviewables( project_name: str, - workfile_id: str, - thumbnail_id: Optional[str] = None, -) -> ThumbnailContent: - """Prepared method to receive thumbnail for workfile entity. + product_id: str, + *, + latest: bool = False, + latest_done: bool = False, +) -> list[dict[str, Any]]: + """Get reviewables of all versions of a product. Args: - project_name (str): Project under which the entity is located. - workfile_id (str): Worfile id for which thumbnail should be - returned. - thumbnail_id (Optional[str]): Prepared thumbnail id from entity. - Used only to check if thumbnail was already cached. + project_name (str): Project name. + product_id (str): Product id. + latest (bool): Return only reviewables of the latest version. + latest_done (bool): Return only reviewables of the latest + approved versions. Returns: - ThumbnailContent: Thumbnail content wrapper. Does not have to be - valid. + list[dict[str, Any]]: Versions with reviewables. + + Raises: + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. """ con = get_server_api_connection() - return con.get_workfile_thumbnail( + return con.get_product_reviewables( project_name=project_name, - workfile_id=workfile_id, - thumbnail_id=thumbnail_id, + product_id=product_id, + latest=latest, + latest_done=latest_done, ) -def create_thumbnail( +def get_folder_reviewables( project_name: str, - src_filepath: str, - thumbnail_id: Optional[str] = None, -) -> str: - """Create new thumbnail on server from passed path. + folder_id: str, + *, + latest: bool = False, + latest_done: bool = False, +) -> list[dict[str, Any]]: + """Get reviewables of all versions under a folder. Args: - project_name (str): Project where the thumbnail will be created - and can be used. - src_filepath (str): Filepath to thumbnail which should be uploaded. - thumbnail_id (Optional[str]): Prepared if of thumbnail. + project_name (str): Project name. + folder_id (str): Folder id. + latest (bool): Return only reviewables of the latest versions. + latest_done (bool): Return only reviewables of the latest + approved versions. Returns: - str: Created thumbnail id. + list[dict[str, Any]]: Versions with reviewables. Raises: - ValueError: When thumbnail source cannot be processed. + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. """ con = get_server_api_connection() - return con.create_thumbnail( + return con.get_folder_reviewables( project_name=project_name, - src_filepath=src_filepath, - thumbnail_id=thumbnail_id, + folder_id=folder_id, + latest=latest, + latest_done=latest_done, ) -def create_thumbnail_with_stream( +def get_task_reviewables( project_name: str, - stream: StreamType, - thumbnail_id: Optional[str] = None, -) -> str: - """Create new thumbnail on server from byte stream. + task_id: str, + *, + latest: bool = False, + latest_done: bool = False, +) -> list[dict[str, Any]]: + """Get reviewables of all versions linked to a task. Args: - project_name (str): Project where the thumbnail will be created - and can be used. - stream (StreamType): Thumbnail content stream. - thumbnail_id (Optional[str]): Prepared if of thumbnail. + project_name (str): Project name. + task_id (str): Task id. + latest (bool): Return only reviewables of the latest versions. + latest_done (bool): Return only reviewables of the latest + approved versions. Returns: - str: Created thumbnail id. + list[dict[str, Any]]: Versions with reviewables. Raises: - ValueError: When a thumbnail source cannot be processed. + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. """ con = get_server_api_connection() - return con.create_thumbnail_with_stream( + return con.get_task_reviewables( project_name=project_name, - stream=stream, - thumbnail_id=thumbnail_id, + task_id=task_id, + latest=latest, + latest_done=latest_done, ) -def update_thumbnail( +def sort_version_reviewables( project_name: str, - thumbnail_id: str, - src_filepath: str, + version_id: str, + reviewable_ids: list[str], ) -> None: - """Change thumbnail content by id. - - Update can be also used to create new thumbnail. + """Change order of reviewables of a version. Args: - project_name (str): Project where the thumbnail will be created - and can be used. - thumbnail_id (str): Thumbnail id to update. - src_filepath (str): Filepath to thumbnail which should be uploaded. - - Raises: - ValueError: When thumbnail source cannot be processed. + project_name (str): Project name. + version_id (str): Version id. + reviewable_ids (list[str]): Reviewable (activity) ids in + the desired order. Must contain all reviewables of + the version. """ con = get_server_api_connection() - return con.update_thumbnail( + return con.sort_version_reviewables( project_name=project_name, - thumbnail_id=thumbnail_id, - src_filepath=src_filepath, + version_id=version_id, + reviewable_ids=reviewable_ids, ) -def update_thumbnail_from_stream( +def update_reviewable( project_name: str, - thumbnail_id: str, - stream: StreamType, + version_id: str, + reviewable_id: str, + *, + label: Optional[str] = None, ) -> None: - """Change thumbnail content by id. + """Update reviewable of a version. - Update can be also used to create new thumbnail. + At this moment only label of reviewable can be changed. Args: - project_name (str): Project where the thumbnail will be created - and can be used. - thumbnail_id (str): Thumbnail id to update. - stream (StreamType): Thumbnail content stream. + project_name (str): Project name. + version_id (str): Version id. + reviewable_id (str): Reviewable (activity) id. + label (Optional[str]): New label of reviewable. """ con = get_server_api_connection() - return con.update_thumbnail_from_stream( + return con.update_reviewable( project_name=project_name, - thumbnail_id=thumbnail_id, - stream=stream, + version_id=version_id, + reviewable_id=reviewable_id, + label=label, ) diff --git a/ayon_api/_api_helpers/__init__.py b/ayon_api/_api_helpers/__init__.py index 6e81904a8..014e705e4 100644 --- a/ayon_api/_api_helpers/__init__.py +++ b/ayon_api/_api_helpers/__init__.py @@ -17,6 +17,10 @@ from .actions import ActionsAPI from .links import LinksAPI from .lists import ListsAPI +from .users import UsersAPI +from .services import ServicesAPI +from .system import SystemAPI +from .reviewables import ReviewablesAPI __all__ = ( @@ -39,4 +43,8 @@ "ActionsAPI", "LinksAPI", "ListsAPI", + "UsersAPI", + "ServicesAPI", + "SystemAPI", + "ReviewablesAPI", ) diff --git a/ayon_api/_api_helpers/bundles_addons.py b/ayon_api/_api_helpers/bundles_addons.py index 9bb7bc214..09804daf9 100644 --- a/ayon_api/_api_helpers/bundles_addons.py +++ b/ayon_api/_api_helpers/bundles_addons.py @@ -2,8 +2,9 @@ import os import typing -from typing import Optional, Any +from typing import Optional, Any, Literal +from ayon_api.exceptions import UnsupportedServerVersion from ayon_api.utils import ( RequestTypes, prepare_query_string, @@ -461,7 +462,9 @@ def get_addon_settings_schema( self, addon_name: str, addon_version: str, - project_name: Optional[str] = None + project_name: Optional[str] = None, + variant: Optional[str] = None, + site_id: Optional[str] = None, ) -> dict[str, Any]: """Sudio/Project settings schema of an addon. @@ -473,19 +476,26 @@ def get_addon_settings_schema( addon_version (str): Version of addon. project_name (Optional[str]): Schema for specific project or default studio schemas. + variant (Optional[str]): Settings variant used as context for + the schema. Server uses 'production' if not passed. + site_id (Optional[str]): Site id used as context for project + schema. Ignored if 'project_name' is not passed. Returns: dict[str, Any]: Schema of studio/project settings. """ args = tuple() + query_data = {"variant": variant or None} if project_name: args = (project_name, ) + query_data["site_id"] = site_id or None + query = prepare_query_string(query_data) endpoint = self.get_addon_endpoint( addon_name, addon_version, "schema", *args ) - result = self.get(endpoint) + result = self.get(f"{endpoint}{query}") result.raise_for_status() return result.data @@ -900,3 +910,540 @@ def get_addons_settings( use_site=use_site, only_values=only_values ) + + def set_addon_studio_settings( + self, + addon_name: str, + addon_version: str, + settings: dict[str, Any], + variant: Optional[str] = None, + ) -> None: + """Set studio settings of an addon. + + Server calculates studio overrides from passed settings values. + Explicit (un)pinning of fields can be done with + '__pinned_fields__' and '__unpinned_fields__' keys in settings. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + settings (dict[str, Any]): Full settings values. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({"variant": variant or None}) + response = self.raw_post( + f"addons/{addon_name}/{addon_version}/settings{query}", + json=settings, + ) + response.raise_for_status() + + def set_addon_project_settings( + self, + addon_name: str, + addon_version: str, + project_name: str, + settings: dict[str, Any], + variant: Optional[str] = None, + site_id: Optional[str] = None, + ) -> None: + """Set project settings of an addon. + + Server calculates project overrides from passed settings values. + If 'site_id' is passed then project site overrides of current user + are set instead. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + settings (dict[str, Any]): Full settings values. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id for which are site overrides + set. Project overrides are set if not passed. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({ + "site_id": site_id or None, + "variant": variant or None, + }) + response = self.raw_post( + f"addons/{addon_name}/{addon_version}" + f"/settings/{project_name}{query}", + json=settings, + ) + response.raise_for_status() + + def get_addon_studio_overrides( + self, + addon_name: str, + addon_version: str, + variant: Optional[str] = None, + as_version: Optional[str] = None, + ) -> dict[str, Any]: + """Get studio overrides of an addon. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + as_version (Optional[str]): Use overrides of different addon + version. + + Returns: + dict[str, Any]: Overrides information by settings path. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({ + "variant": variant or None, + "as": as_version or None, + }) + response = self.get( + f"addons/{addon_name}/{addon_version}/overrides{query}" + ) + response.raise_for_status() + return response.data + + def modify_addon_studio_overrides( + self, + addon_name: str, + addon_version: str, + action: Literal["delete", "pin"], + path: list[str], + variant: Optional[str] = None, + ) -> None: + """Modify studio override of an addon on a settings path. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + action (Literal["delete", "pin"]): Remove override or pin + current value as override. + path (list[str]): Path to settings key. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({"variant": variant or None}) + response = self.post( + f"addons/{addon_name}/{addon_version}/overrides{query}", + action=action, + path=path, + ) + response.raise_for_status() + + def delete_addon_studio_overrides( + self, + addon_name: str, + addon_version: str, + variant: Optional[str] = None, + ) -> None: + """Delete all studio overrides of an addon. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({"variant": variant or None}) + response = self.delete( + f"addons/{addon_name}/{addon_version}/overrides{query}" + ) + response.raise_for_status() + + def get_addon_project_overrides( + self, + addon_name: str, + addon_version: str, + project_name: str, + variant: Optional[str] = None, + site_id: Optional[str] = None, + as_version: Optional[str] = None, + ) -> dict[str, Any]: + """Get project overrides of an addon. + + Output contains studio and project overrides, and site overrides + if 'site_id' is passed. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to include site overrides. + as_version (Optional[str]): Use overrides of different addon + version. + + Returns: + dict[str, Any]: Overrides information by settings path. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({ + "site_id": site_id or None, + "variant": variant or None, + "as": as_version or None, + }) + response = self.get( + f"addons/{addon_name}/{addon_version}" + f"/overrides/{project_name}{query}" + ) + response.raise_for_status() + return response.data + + def modify_addon_project_overrides( + self, + addon_name: str, + addon_version: str, + project_name: str, + action: Literal["delete", "pin"], + path: list[str], + variant: Optional[str] = None, + site_id: Optional[str] = None, + ) -> None: + """Modify project override of an addon on a settings path. + + Project site override of current user is modified if 'site_id' + is passed. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + action (Literal["delete", "pin"]): Remove override or pin + current value as override. + path (list[str]): Path to settings key. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to modify site overrides. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({ + "site_id": site_id or None, + "variant": variant or None, + }) + response = self.post( + f"addons/{addon_name}/{addon_version}" + f"/overrides/{project_name}{query}", + action=action, + path=path, + ) + response.raise_for_status() + + def delete_addon_project_overrides( + self, + addon_name: str, + addon_version: str, + project_name: str, + variant: Optional[str] = None, + site_id: Optional[str] = None, + ) -> None: + """Delete all project overrides of an addon. + + Project site overrides of current user are deleted if 'site_id' + is passed. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to delete site overrides. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({ + "site_id": site_id or None, + "variant": variant or None, + }) + response = self.delete( + f"addons/{addon_name}/{addon_version}" + f"/overrides/{project_name}{query}" + ) + response.raise_for_status() + + def get_addon_studio_raw_overrides( + self, + addon_name: str, + addon_version: str, + variant: Optional[str] = None, + ) -> dict[str, Any]: + """Get raw studio overrides of an addon. + + Raw overrides are stored data without any processing. Only admins + can access them. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + + Returns: + dict[str, Any]: Raw overrides data. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({"variant": variant or None}) + response = self.get( + f"addons/{addon_name}/{addon_version}/rawOverrides{query}" + ) + response.raise_for_status() + return response.data + + def set_addon_studio_raw_overrides( + self, + addon_name: str, + addon_version: str, + overrides: dict[str, Any], + variant: Optional[str] = None, + ) -> None: + """Set raw studio overrides of an addon. + + Warnings: + Data are stored without any validation. Only admins can use + this. Use with caution. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + overrides (dict[str, Any]): Raw overrides data. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({"variant": variant or None}) + response = self.raw_put( + f"addons/{addon_name}/{addon_version}/rawOverrides{query}", + json=overrides, + ) + response.raise_for_status() + + def get_addon_project_raw_overrides( + self, + addon_name: str, + addon_version: str, + project_name: str, + variant: Optional[str] = None, + site_id: Optional[str] = None, + ) -> dict[str, Any]: + """Get raw project overrides of an addon. + + Raw overrides are stored data without any processing. Project + overrides are accessible only by admins, project site overrides + of current user are returned if 'site_id' is passed. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to get site overrides. + + Returns: + dict[str, Any]: Raw overrides data. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({ + "site_id": site_id or None, + "variant": variant or None, + }) + response = self.get( + f"addons/{addon_name}/{addon_version}" + f"/rawOverrides/{project_name}{query}" + ) + response.raise_for_status() + return response.data + + def set_addon_project_raw_overrides( + self, + addon_name: str, + addon_version: str, + project_name: str, + overrides: dict[str, Any], + variant: Optional[str] = None, + site_id: Optional[str] = None, + ) -> None: + """Set raw project overrides of an addon. + + Project site overrides of current user are set if 'site_id' + is passed. + + Warnings: + Data are stored without any validation. Use with caution. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + project_name (str): Project name. + overrides (dict[str, Any]): Raw overrides data. + variant (Optional[str]): Name of settings variant. Used + 'default_settings_variant' by default. + site_id (Optional[str]): Site id to set site overrides. + + """ + if variant is None: + variant = self.get_default_settings_variant() + + query = prepare_query_string({ + "site_id": site_id or None, + "variant": variant or None, + }) + response = self.raw_put( + f"addons/{addon_name}/{addon_version}" + f"/rawOverrides/{project_name}{query}", + json=overrides, + ) + response.raise_for_status() + + def set_addon_site_settings( + self, + addon_name: str, + addon_version: str, + settings: dict[str, Any], + site_id: Optional[str] = None, + ) -> None: + """Set site settings of an addon for current user. + + Args: + addon_name (str): Name of addon. + addon_version (str): Version of addon. + settings (dict[str, Any]): Site settings values. + site_id (Optional[str]): Site id for which are settings set. + Using 'site_id' attribute if not passed. + + Raises: + ValueError: If site id is not available. + + """ + if site_id is None: + site_id = self.get_site_id() + + if not site_id: + raise ValueError("Site id is not available.") + + query = prepare_query_string({"site_id": site_id}) + response = self.raw_put( + f"addons/{addon_name}/{addon_version}/siteSettings{query}", + json=settings, + ) + response.raise_for_status() + + def migrate_addon_settings( + self, + addon_name: str, + source_version: str, + target_version: str, + source_variant: Optional[str] = None, + target_variant: Optional[str] = None, + with_projects: Optional[bool] = None, + ) -> dict[str, Any]: + """Migrate settings of an addon between addon versions. + + Args: + addon_name (str): Name of addon. + source_version (str): Source addon version. + target_version (str): Target addon version. + source_variant (Optional[str]): Source settings variant. Server + uses 'production' if not passed. + target_variant (Optional[str]): Target settings variant. Server + uses 'production' if not passed. + with_projects (Optional[bool]): Migrate also project settings. + Server uses 'True' if not passed. + + Returns: + dict[str, Any]: Server response with 'events' created during + the migration. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.16.8. + + """ + if self.get_server_version_tuple() < (1, 16, 8): + raise UnsupportedServerVersion( + "Function 'migrate_addon_settings' is supported" + " for AYON server 1.16.8 and above." + ) + body = { + "sourceVersion": source_version, + "targetVersion": target_version, + } + for key, value in ( + ("sourceVariant", source_variant), + ("targetVariant", target_variant), + ("withProjects", with_projects), + ): + if value is not None: + body[key] = value + + response = self.post(f"addons/{addon_name}/migrate", **body) + response.raise_for_status() + return response.data + + def migrate_settings_by_bundle( + self, + source_bundle: str, + target_bundle: str, + source_variant: str, + target_variant: str, + with_projects: bool = True, + ) -> None: + """Migrate settings of addons from source to target bundle. + + Settings of addons present in both bundles are migrated. + + Args: + source_bundle (str): Source bundle name. + target_bundle (str): Target bundle name. + source_variant (str): Source settings variant. + target_variant (str): Target settings variant. + with_projects (bool): Migrate also project settings. + + """ + response = self.post( + "migrateSettingsByBundle", + sourceBundle=source_bundle, + targetBundle=target_bundle, + sourceVariant=source_variant, + targetVariant=target_variant, + withProjects=with_projects, + ) + response.raise_for_status() diff --git a/ayon_api/_api_helpers/events.py b/ayon_api/_api_helpers/events.py index 03aaf77d2..f1535afd4 100644 --- a/ayon_api/_api_helpers/events.py +++ b/ayon_api/_api_helpers/events.py @@ -342,6 +342,45 @@ def delete_event(self, event_id: str) -> None: response = self.delete(f"events/{event_id}") response.raise_for_status() + def delete_events(self, events_filter: EventFilter) -> None: + """Delete events matching filter. + + Requires administrator privileges. + + Example filter:: + + { + "conditions": [ + { + "key": "topic", + "value": "my.topic", + "operator": "eq", + } + ], + "operator": "and", + } + + Args: + events_filter (EventFilter): Filtering conditions to find events + to delete. For more technical specifications look to server + backed 'ayon_server.sqlfilter.QueryFilter'. + + Raises: + ValueError: Filter does not contain any conditions. Server would + delete all events. + + """ + if not events_filter or not events_filter.get("conditions"): + raise ValueError( + "Filter without conditions would delete all events." + ) + response = self.post( + "eventops", + type="delete", + filter=events_filter, + ) + response.raise_for_status() + def enroll_event_job( self, source_topic: Union[str, list[str]], diff --git a/ayon_api/_api_helpers/folders.py b/ayon_api/_api_helpers/folders.py index d10cfeb7a..47920ed68 100644 --- a/ayon_api/_api_helpers/folders.py +++ b/ayon_api/_api_helpers/folders.py @@ -493,6 +493,103 @@ def get_folder_ids_with_products( for folder in folders } + def search_folders( + self, + project_name: str, + search: Optional[str] = None, + folder_search: Optional[str] = None, + folder_filter: Optional[AdvancedFilterDict] = None, + task_search: Optional[str] = None, + task_filter: Optional[AdvancedFilterDict] = None, + ) -> list[str]: + """Search folders using text search and filters. + + Returns ids of folders matching the folder conditions, or containing + tasks matching the task conditions. + + Args: + project_name (str): Project name. + search (Optional[str]): Fulltext search applied to both + folders and tasks. + folder_search (Optional[str]): Fulltext search applied + to folders. + folder_filter (Optional[AdvancedFilterDict]): Filter applied + to folders. + task_search (Optional[str]): Fulltext search applied to tasks. + task_filter (Optional[AdvancedFilterDict]): Filter applied + to tasks. + + Returns: + list[str]: Matching folder ids. + + Raises: + UnsupportedServerVersion: 'search' is used with server version + lower than 1.15.4. + + """ + if search and self.get_server_version_tuple() < (1, 15, 4): + raise UnsupportedServerVersion( + "Argument 'search' of 'search_folders' is supported" + " for AYON server 1.15.4 and above." + ) + body = { + key: value + for key, value in ( + ("search", search), + ("folderSearch", folder_search), + ("folderFilter", folder_filter), + ("taskSearch", task_search), + ("taskFilter", task_filter), + ) + if value is not None + } + response = self.post( + f"projects/{project_name}/folders/search", + **body + ) + response.raise_for_status() + return response.data["folderIds"] + + def get_entity_groups( + self, + project_name: str, + entity_type: str, + grouping_key: str, + empty: bool = False, + ) -> dict[str, Any]: + """Get groups of entities based on grouping key. + + Example output:: + + { + "groups": [ + {"value": "john.doe", "count": 42, ...}, + ... + ], + "key": "assignees", + "entityType": "task" + } + + Args: + project_name (str): Project name. + entity_type (str): Entity type, e.g. 'folder' or 'task'. + grouping_key (str): Grouping key, e.g. 'status', 'tags', + 'taskType', 'folderType', 'productType', 'productBaseType', + 'assignees' (tasks only) or 'attrib.{attribute name}'. + empty (bool): Include empty groups. + + Returns: + dict[str, Any]: Entity groups data. + + """ + query = prepare_query_string({"empty": "true" if empty else None}) + response = self.get( + f"projects/{project_name}/grouping/{entity_type}/{grouping_key}" + f"{query}" + ) + response.raise_for_status() + return response.data + def create_folder( self, project_name: str, diff --git a/ayon_api/_api_helpers/lists.py b/ayon_api/_api_helpers/lists.py index 585504fb4..99dfec895 100644 --- a/ayon_api/_api_helpers/lists.py +++ b/ayon_api/_api_helpers/lists.py @@ -4,6 +4,7 @@ import typing from typing import Optional, Iterable, Any, Generator +from ayon_api.exceptions import UnsupportedServerVersion from ayon_api.utils import NOT_SET, create_entity_id from ayon_api.graphql_queries import entity_lists_graphql_query @@ -675,3 +676,48 @@ def set_entity_list_folders_order( order=order, ) response.raise_for_status() + + def materialize_entity_list( + self, project_name: str, list_id: str + ) -> dict[str, Any]: + """Materialize entity list. + + Args: + project_name (str): Project name. + list_id (str): Entity list id. + + Returns: + dict[str, Any]: Summary of materialized entity list. + + """ + response = self.post( + f"projects/{project_name}/lists/{list_id}/materialize" + ) + response.raise_for_status() + return response.data + + def set_entity_list_items_order( + self, project_name: str, list_id: str, order: list[str] + ) -> None: + """Change order of items in entity list. + + Args: + project_name (str): Project name. + list_id (str): Entity list id. + order (list[str]): Item ids in desired order. Must contain + each item of the entity list exactly once. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.9. + + """ + if self.get_server_version_tuple() < (1, 15, 9): + raise UnsupportedServerVersion( + "Function 'set_entity_list_items_order' is supported" + " for AYON server 1.15.9 and above." + ) + response = self.post( + f"projects/{project_name}/lists/{list_id}/order", + order=order, + ) + response.raise_for_status() diff --git a/ayon_api/_api_helpers/projects.py b/ayon_api/_api_helpers/projects.py index df602d808..faf364e0f 100644 --- a/ayon_api/_api_helpers/projects.py +++ b/ayon_api/_api_helpers/projects.py @@ -11,6 +11,7 @@ PROJECT_NAME_REGEX, DEFAULT_PRODUCT_TYPE_FIELDS, ) +from ayon_api.exceptions import UnsupportedServerVersion from ayon_api.utils import prepare_query_string, fill_own_attribs from ayon_api.graphql_queries import projects_graphql_query @@ -140,6 +141,85 @@ def get_build_in_anatomy_preset(self) -> AnatomyPresetDict: ) return self.get_built_in_anatomy_preset() + def get_anatomy_schema(self) -> dict[str, Any]: + """Get anatomy JSON schema. + + The schema is used to display the anatomy preset editor form. + + Returns: + dict[str, Any]: Anatomy JSON schema. + + """ + response = self.get("anatomy/schema") + response.raise_for_status() + return response.data + + def save_project_anatomy_preset( + self, preset_name: str, preset: AnatomyPresetDict + ) -> None: + """Create or update anatomy preset. + + Built-in preset '__builtin__' and primary preset reference + '__primary__' cannot be updated. + + Args: + preset_name (str): Anatomy preset name. + preset (AnatomyPresetDict): Anatomy preset values. + + """ + response = self.raw_put(f"anatomy/presets/{preset_name}", json=preset) + response.raise_for_status() + + def rename_project_anatomy_preset( + self, preset_name: str, new_preset_name: str + ) -> None: + """Rename anatomy preset. + + Args: + preset_name (str): Current anatomy preset name. + new_preset_name (str): New anatomy preset name. + + """ + response = self.post( + f"anatomy/presets/{preset_name}/rename", + name=new_preset_name, + ) + response.raise_for_status() + + def delete_project_anatomy_preset(self, preset_name: str) -> None: + """Delete anatomy preset. + + Args: + preset_name (str): Anatomy preset name. + + """ + response = self.delete(f"anatomy/presets/{preset_name}") + response.raise_for_status() + + def set_primary_anatomy_preset(self, preset_name: str) -> None: + """Set anatomy preset as primary. + + Primary preset is used as default preset for new projects. Use + preset name '_' to unset any primary preset (built-in preset + is used as default). + + Args: + preset_name (str): Anatomy preset name. + + """ + response = self.post(f"anatomy/presets/{preset_name}/primary") + response.raise_for_status() + + def unset_primary_anatomy_preset(self, preset_name: str) -> None: + """Unset primary flag of anatomy preset. + + Args: + preset_name (str): Anatomy preset name. + + """ + response = self.delete(f"anatomy/presets/{preset_name}/primary") + response.raise_for_status() + def get_rest_project( self, project_name: str ) -> Optional[ProjectDict]: @@ -540,6 +620,300 @@ def delete_project(self, project_name: str): f"Failed to delete project \"{project_name}\". {detail}" ) + def rename_project( + self, + project_name: str, + new_project_name: str, + new_project_code: Optional[str] = None, + ) -> None: + """Rename project. + + Args: + project_name (str): Current project name. + new_project_name (str): New project name. + new_project_code (Optional[str]): New project code. Project code + is unchanged if not passed. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.0. + + """ + if self.get_server_version_tuple() < (1, 15, 0): + raise UnsupportedServerVersion( + "Function 'rename_project' is supported" + " for AYON server 1.15.0 and above." + ) + body = {"name": new_project_name} + if new_project_code is not None: + body["code"] = new_project_code + response = self.post(f"projects/{project_name}/rename", **body) + response.raise_for_status() + + def get_project_anatomy(self, project_name: str) -> AnatomyPresetDict: + """Get project anatomy. + + Args: + project_name (str): Project name. + + Returns: + AnatomyPresetDict: Project anatomy in anatomy preset format. + + """ + response = self.get(f"projects/{project_name}/anatomy") + response.raise_for_status() + return response.data + + def set_project_anatomy( + self, project_name: str, anatomy: AnatomyPresetDict + ) -> None: + """Set project anatomy. + + Args: + project_name (str): Project name. + anatomy (AnatomyPresetDict): Anatomy data in anatomy + preset format. + + """ + response = self.raw_post( + f"projects/{project_name}/anatomy", + json=anatomy, + ) + response.raise_for_status() + + def get_project_stats(self, project_name: str) -> dict[str, Any]: + """Get project statistics. + + Example output:: + + { + "counts": { + "folders": 10, + "products": 20, + "versions": 30, + "representations": 40, + "tasks": 50 + } + } + + Args: + project_name (str): Project name. + + Returns: + dict[str, Any]: Project statistics. + + """ + response = self.get(f"projects/{project_name}/stats") + response.raise_for_status() + return response.data + + def get_project_tags(self, project_name: str) -> dict[str, list[str]]: + """Get tags used on entities in project. + + Example output:: + + { + "folders": ["tag1"], + "tasks": [], + "products": [], + "versions": ["tag2"], + "representations": [], + "workfiles": [] + } + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Used tags by entity type (plural). + + """ + response = self.get(f"projects/{project_name}/tags") + response.raise_for_status() + return response.data + + def get_project_bundle( + self, project_name: str, variant: str = "production" + ) -> dict[str, Any]: + """Get project bundle information. + + Information contains addon versions used by project, installer + version, dependency packages and available options for each. + + Args: + project_name (str): Project name. + variant (str): Bundle variant 'production' or 'staging'. + + Returns: + dict[str, Any]: Project bundle information. + + """ + query = prepare_query_string({"variant": variant}) + response = self.get(f"projects/{project_name}/bundle{query}") + response.raise_for_status() + return response.data + + def set_project_bundle( + self, + project_name: str, + addons: dict[str, Optional[str]], + installer_version: Optional[str] = None, + dependency_packages: Optional[dict[str, Optional[str]]] = None, + variant: str = "production", + ) -> None: + """Set (freeze) project bundle. + + Args: + project_name (str): Project name. + addons (dict[str, Optional[str]]): Addon versions by addon name. + Use 'None' to disable an addon. + installer_version (Optional[str]): Installer version. + dependency_packages (Optional[dict[str, Optional[str]]]): + Dependency package names by platform name. + variant (str): Bundle variant 'production' or 'staging'. + + """ + body = {"addons": addons} + if installer_version is not None: + body["installerVersion"] = installer_version + if dependency_packages is not None: + body["dependencyPackages"] = dependency_packages + query = prepare_query_string({"variant": variant}) + response = self.post( + f"projects/{project_name}/bundle{query}", + **body + ) + response.raise_for_status() + + def unset_project_bundle( + self, project_name: str, variant: str = "production" + ) -> None: + """Unset (unfreeze) project bundle. + + Args: + project_name (str): Project name. + variant (str): Bundle variant 'production' or 'staging'. + + """ + query = prepare_query_string({"variant": variant}) + response = self.delete(f"projects/{project_name}/bundle{query}") + response.raise_for_status() + + def get_project_entity_uris( + self, + project_name: str, + entity_type: str, + entity_ids: Iterable[str], + ) -> list[dict[str, str]]: + """Get AYON URIs for project entities. + + Example output:: + + [ + { + "id": "0254c370005811ee9a740242ac130004", + "uri": "ayon+entity://demo/assets/hero" + }, + ... + ] + + Args: + project_name (str): Project name. + entity_type (str): Entity type, e.g. 'folder', 'task', + 'product', 'version', 'representation' or 'workfile'. + entity_ids (Iterable[str]): Entity ids. + + Returns: + list[dict[str, str]]: Entity URIs with entity ids. + + """ + response = self.post( + f"projects/{project_name}/uris", + entityType=entity_type, + ids=list(entity_ids), + ) + response.raise_for_status() + return response.data["uris"] + + def resolve_uris( + self, + uris: Iterable[str], + resolve_roots: bool = False, + path_only: bool = False, + site_id: Optional[str] = None, + ) -> list[dict[str, Any]]: + """Resolve AYON URIs to entities. + + Each URI starts with 'ayon://{project_name}/{path}' with optional + query arguments 'product', 'version', 'representation' or + 'task', 'workfile'. + + Example output:: + + [ + { + "uri": "ayon://demo/assets/hero?product=modelMain", + "entities": [ + { + "projectName": "demo", + "folderId": "0254c370005811ee9a740242ac130004", + "productId": "0255ce50005811ee9a740242ac130004", + "target": "product" + } + ] + }, + ... + ] + + Args: + uris (Iterable[str]): AYON URIs to resolve. + resolve_roots (bool): Resolve roots in representation paths. + Requires site id. + path_only (bool): Return only file paths of entities. + site_id (Optional[str]): Site id used to resolve roots. Site id + set on connection is used if not passed. + + Returns: + list[dict[str, Any]]: Resolved URIs. + + """ + query = prepare_query_string({ + "pathOnly": "true" if path_only else None, + "site_id": site_id, + }) + response = self.post( + f"resolve{query}", + uris=list(uris), + resolveRoots=resolve_roots, + ) + response.raise_for_status() + return response.data + + def get_project_file_info( + self, project_name: str, file_id: str + ) -> dict[str, Any]: + """Get information about project file. + + Example output:: + + { + "size": 1024, + "filename": "image.png", + "contentType": "image/png", + "mediaInfo": None + } + + Args: + project_name (str): Project name. + file_id (str): File id. + + Returns: + dict[str, Any]: File information. + + """ + response = self.get(f"projects/{project_name}/files/{file_id}/info") + response.raise_for_status() + return response.data + def get_raw_project_folders(self) -> dict[str, Any]: """Get project folders (raw data).""" response = self.get("projectFolders") @@ -684,6 +1058,30 @@ def get_project_root_overrides_by_site_id( roots = self.get_project_root_overrides(project_name) return roots.get(site_id, {}) + def set_project_root_overrides( + self, + project_name: str, + site_id: str, + roots: dict[str, Optional[str]], + ) -> None: + """Set root overrides of logged user for a site. + + Roots defined in project anatomy with empty value are removed, + and all overrides for the site are removed if no root value + is left. + + Args: + project_name (str): Project name. + site_id (str): Site id for which overrides are set. + roots (dict[str, Optional[str]]): Root values by root name. + + """ + response = self.raw_put( + f"projects/{project_name}/roots/{site_id}", + json=roots, + ) + response.raise_for_status() + def get_project_roots_for_site( self, project_name: str, site_id: Optional[str] = None ) -> dict[str, str]: diff --git a/ayon_api/_api_helpers/reviewables.py b/ayon_api/_api_helpers/reviewables.py new file mode 100644 index 000000000..d36b6b552 --- /dev/null +++ b/ayon_api/_api_helpers/reviewables.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import typing +from typing import Any, Iterable, Optional + +from ayon_api.exceptions import UnsupportedServerVersion +from ayon_api.utils import prepare_query_string + +from .base import BaseServerAPI + +if typing.TYPE_CHECKING: + from ayon_api.typing import ReviewablesEntityType + + +class ReviewablesAPI(BaseServerAPI): + def get_reviewables( + self, + project_name: str, + entity_type: ReviewablesEntityType, + entity_ids: Iterable[str], + *, + latest: bool = False, + latest_done: bool = False, + ) -> list[dict[str, Any]]: + """Get reviewables for multiple entities of one entity type. + + Each item in output represents a version with its reviewables. + + Args: + project_name (str): Project name. + entity_type (ReviewablesEntityType): Entity type of passed + entity ids. Supported types are 'folder', 'product', + 'version' and 'task'. + entity_ids (Iterable[str]): Entity ids for which reviewables + should be returned. + latest (bool): Return only reviewables of the latest version. + latest_done (bool): Return only reviewables of the latest + approved versions. + + Returns: + list[dict[str, Any]]: Versions with reviewables. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.16.4. + + """ + if self.get_server_version_tuple() < (1, 16, 4): + raise UnsupportedServerVersion( + "Function 'get_reviewables' is supported" + " for AYON server 1.16.4 and above." + ) + query = self._prepare_reviewables_query(latest, latest_done) + response = self.post( + f"projects/{project_name}/{entity_type}s/reviewables/list{query}", + entityIds=list(entity_ids), + ) + response.raise_for_status() + return response.data + + def get_version_reviewables( + self, + project_name: str, + version_id: str, + *, + latest: bool = False, + latest_done: bool = False, + ) -> dict[str, Any]: + """Get reviewables of a version. + + Notes: + Server responds with an error if version does not have + any reviewables and 'latest' or 'latest_done' is used. + + Args: + project_name (str): Project name. + version_id (str): Version id. + latest (bool): Return only reviewables of the latest version. + latest_done (bool): Return only reviewables of the latest + approved versions. + + Returns: + dict[str, Any]: Version information with reviewables. + + Raises: + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. + + """ + return self._get_entity_reviewables( + project_name, "versions", version_id, latest, latest_done + ) + + def get_product_reviewables( + self, + project_name: str, + product_id: str, + *, + latest: bool = False, + latest_done: bool = False, + ) -> list[dict[str, Any]]: + """Get reviewables of all versions of a product. + + Args: + project_name (str): Project name. + product_id (str): Product id. + latest (bool): Return only reviewables of the latest version. + latest_done (bool): Return only reviewables of the latest + approved versions. + + Returns: + list[dict[str, Any]]: Versions with reviewables. + + Raises: + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. + + """ + return self._get_entity_reviewables( + project_name, "products", product_id, latest, latest_done + ) + + def get_folder_reviewables( + self, + project_name: str, + folder_id: str, + *, + latest: bool = False, + latest_done: bool = False, + ) -> list[dict[str, Any]]: + """Get reviewables of all versions under a folder. + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + latest (bool): Return only reviewables of the latest versions. + latest_done (bool): Return only reviewables of the latest + approved versions. + + Returns: + list[dict[str, Any]]: Versions with reviewables. + + Raises: + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. + + """ + return self._get_entity_reviewables( + project_name, "folders", folder_id, latest, latest_done + ) + + def get_task_reviewables( + self, + project_name: str, + task_id: str, + *, + latest: bool = False, + latest_done: bool = False, + ) -> list[dict[str, Any]]: + """Get reviewables of all versions linked to a task. + + Args: + project_name (str): Project name. + task_id (str): Task id. + latest (bool): Return only reviewables of the latest versions. + latest_done (bool): Return only reviewables of the latest + approved versions. + + Returns: + list[dict[str, Any]]: Versions with reviewables. + + Raises: + UnsupportedServerVersion: 'latest' or 'latest_done' is used + with server version lower than 1.16.4. + + """ + return self._get_entity_reviewables( + project_name, "tasks", task_id, latest, latest_done + ) + + def sort_version_reviewables( + self, + project_name: str, + version_id: str, + reviewable_ids: list[str], + ) -> None: + """Change order of reviewables of a version. + + Args: + project_name (str): Project name. + version_id (str): Version id. + reviewable_ids (list[str]): Reviewable (activity) ids in + the desired order. Must contain all reviewables of + the version. + + """ + response = self.patch( + f"projects/{project_name}/versions/{version_id}/reviewables", + sort=reviewable_ids, + ) + response.raise_for_status() + + def update_reviewable( + self, + project_name: str, + version_id: str, + reviewable_id: str, + *, + label: Optional[str] = None, + ) -> None: + """Update reviewable of a version. + + At this moment only label of reviewable can be changed. + + Args: + project_name (str): Project name. + version_id (str): Version id. + reviewable_id (str): Reviewable (activity) id. + label (Optional[str]): New label of reviewable. + + """ + kwargs = {} + if label is not None: + kwargs["label"] = label + + response = self.patch( + f"projects/{project_name}/versions/{version_id}" + f"/reviewables/{reviewable_id}", + **kwargs + ) + response.raise_for_status() + + def _prepare_reviewables_query( + self, latest: bool, latest_done: bool + ) -> str: + if ( + (latest or latest_done) + and self.get_server_version_tuple() < (1, 16, 4) + ): + raise UnsupportedServerVersion( + "Arguments 'latest' and 'latest_done' are supported" + " for AYON server 1.16.4 and above." + ) + return prepare_query_string({ + "latest": "true" if latest else None, + "latest_done": "true" if latest_done else None, + }) + + def _get_entity_reviewables( + self, + project_name: str, + entity_type: str, + entity_id: str, + latest: bool, + latest_done: bool, + ) -> Any: + query = self._prepare_reviewables_query(latest, latest_done) + response = self.get( + f"projects/{project_name}/{entity_type}/{entity_id}" + f"/reviewables{query}" + ) + response.raise_for_status() + return response.data diff --git a/ayon_api/_api_helpers/services.py b/ayon_api/_api_helpers/services.py new file mode 100644 index 000000000..82a74b39d --- /dev/null +++ b/ayon_api/_api_helpers/services.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from typing import Optional, Any + +from ayon_api.exceptions import UnsupportedServerVersion + +from .base import BaseServerAPI + + +class ServicesAPI(BaseServerAPI): + def get_services(self) -> list[dict[str, Any]]: + """Get addon services registered on server. + + Only administrators receive the services, other users get an + empty list. + + Returns: + list[dict[str, Any]]: List of services. + + """ + response = self.get("services") + response.raise_for_status() + return response.data["services"] + + def spawn_service( + self, + service_name: str, + addon_name: str, + addon_version: str, + service: str, + hostname: str, + config: Optional[dict[str, Any]] = None, + ) -> None: + """Spawn (create) a new addon service. + + Requires administrator privileges. + + Args: + service_name (str): Unique name of the service. + addon_name (str): Name of addon that provides the service. + addon_version (str): Version of addon. + service (str): Name of service defined by the addon. + hostname (str): Name of host (ASH) where service should run. + config (Optional[dict[str, Any]]): Service configuration + e.g. 'volumes', 'ports', 'memLimit', 'user', 'env', + 'storagePath' or 'registryAuth'. + 'registryAuth' is ignored by AYON server older than 1.16.7. + + """ + body: dict[str, Any] = { + "addonName": addon_name, + "addonVersion": addon_version, + "service": service, + "hostname": hostname, + } + if config is not None: + body["config"] = config + response = self.put(f"services/{service_name}", **body) + response.raise_for_status() + + def update_service( + self, + service_name: str, + should_run: Optional[bool] = None, + hostname: Optional[str] = None, + error: Optional[str] = None, + config: Optional[dict[str, Any]] = None, + ) -> None: + """Update addon service. + + Requires administrator privileges. + + Args: + service_name (str): Name of the service. + should_run (Optional[bool]): Service should be running. + hostname (Optional[str]): Name of host where service should run. + error (Optional[str]): Error message. Ignored when service + should be running. + config (Optional[dict[str, Any]]): Service configuration + values to change e.g. 'volumes', 'ports', 'memLimit', + 'user', 'env', 'storagePath' or 'registryAuth'. + 'registryAuth' is ignored by AYON server older than 1.16.7. + + Raises: + UnsupportedServerVersion: 'error' is used with server version + lower than 1.16.7. + + """ + if ( + error is not None + and self.get_server_version_tuple() < (1, 16, 7) + ): + raise UnsupportedServerVersion( + "Argument 'error' of 'update_service' is supported" + " for AYON server 1.16.7 and above." + ) + body: dict[str, Any] = {} + if config: + body.update(config) + for key, value in ( + ("shouldRun", should_run), + ("hostname", hostname), + ("error", error), + ): + if value is not None: + body[key] = value + + response = self.patch(f"services/{service_name}", **body) + response.raise_for_status() + + def delete_service(self, service_name: str) -> None: + """Delete addon service. + + Requires administrator privileges. + + Args: + service_name (str): Name of the service. + + """ + response = self.delete(f"services/{service_name}") + response.raise_for_status() + + def get_hosts(self) -> list[dict[str, Any]]: + """Get AYON service hosts (ASH) registered on server. + + Host is registered automatically when it sends heartbeat to server. + + Example output:: + + [ + { + "name": "my-host", + "lastSeen": "2025-01-01T00:00:00", + "health": {"cpu": 0.5, "mem": 42.0}, + } + ] + + Returns: + list[dict[str, Any]]: List of hosts. + + """ + response = self.get("hosts") + response.raise_for_status() + return response.data["hosts"] diff --git a/ayon_api/_api_helpers/system.py b/ayon_api/_api_helpers/system.py new file mode 100644 index 000000000..8ff21b0f2 --- /dev/null +++ b/ayon_api/_api_helpers/system.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from typing import Optional, Any + +from ayon_api.exceptions import UnsupportedServerVersion +from ayon_api.utils import prepare_query_string + +from .base import BaseServerAPI + + +class SystemAPI(BaseServerAPI): + def get_sites( + self, + platform_name: Optional[str] = None, + hostname: Optional[str] = None, + ) -> list[dict[str, Any]]: + """Get sites registered on server. + + Non-manager users receive only sites they have used. + + Example output:: + + [ + { + "id": "site-id", + "platform": "windows", + "hostname": "my-machine", + "version": "1.0.0", + "users": ["admin"], + } + ] + + Args: + platform_name (Optional[str]): Filter sites by platform name. + Possible values are 'windows', 'linux' and 'darwin'. + hostname (Optional[str]): Filter sites by machine hostname. + + Returns: + list[dict[str, Any]]: List of sites. + + """ + query = prepare_query_string({ + "platform": platform_name or None, + "hostname": hostname or None, + }) + response = self.get(f"system/sites{query}") + response.raise_for_status() + return response.data + + def delete_site(self, site_id: str) -> None: + """Unregister site from server. + + Requires administrator privileges. + + Args: + site_id (str): Site id. + + """ + response = self.delete(f"system/sites/{site_id}") + response.raise_for_status() + + def get_restart_required(self) -> dict[str, Any]: + """Get information whether server requires restart. + + Example output:: + + { + "required": True, + "reason": "Server restart is required", + } + + Returns: + dict[str, Any]: Restart required information with keys + 'required' and 'reason'. + + """ + response = self.get("system/restartRequired") + response.raise_for_status() + return response.data + + def set_restart_required( + self, + required: bool, + reason: Optional[str] = None, + ) -> None: + """Set or clear the server restart required flag. + + This notifies administrators that the server needs to be restarted. + Use 'trigger_server_restart' to actually restart the server. + + Args: + required (bool): Server requires restart. + reason (Optional[str]): Human-readable reason for the restart. + + """ + body: dict[str, Any] = {"required": required} + if reason is not None: + body["reason"] = reason + response = self.post("system/restartRequired", **body) + response.raise_for_status() + + def get_server_config_schema(self) -> dict[str, Any]: + """Get JSON schema of server configuration. + + Returns: + dict[str, Any]: Server configuration schema. + + """ + response = self.get("config/schema") + response.raise_for_status() + return response.data + + def update_server_config(self, config: dict[str, Any]) -> None: + """Partially update server configuration. + + Values are recursively merged into current server configuration. + Keys that are not passed remain unchanged. Use the same + structure as 'get_server_config' returns, e.g. + '{"studio_name": "My Studio"}'. + + Requires administrator privileges. + + Args: + config (dict[str, Any]): Server configuration values to change. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.9. + + """ + if self.get_server_version_tuple() < (1, 15, 9): + raise UnsupportedServerVersion( + "Function 'update_server_config' is supported" + " for AYON server 1.15.9 and above." + ) + response = self.raw_patch("config", json=config) + response.raise_for_status() + + def get_enums(self) -> list[dict[str, Any]]: + """Get available enum resolvers. + + Example output:: + + [ + { + "name": "statuses", + "label": "Statuses", + ... + } + ] + + Returns: + list[dict[str, Any]]: List of enum resolvers information. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.16.4. + + """ + if self.get_server_version_tuple() < (1, 16, 4): + raise UnsupportedServerVersion( + "Function 'get_enums' is supported" + " for AYON server 1.16.4 and above." + ) + response = self.get("enum") + response.raise_for_status() + return response.data + + def get_enum( + self, + enum_name: str, + project_name: Optional[str] = None, + user_name: Optional[str] = None, + context: Optional[dict[str, Any]] = None, + ) -> list[dict[str, Any]]: + """Get enum items resolved by server. + + Accepted context values depend on the enum resolver, use 'get_enums' + to find out which parameters a resolver accepts. + + Example output:: + + [ + { + "value": "in_progress", + "label": "In progress", + ... + } + ] + + Args: + enum_name (str): Name of the enum e.g. 'statuses'. + project_name (Optional[str]): Project name for project context. + user_name (Optional[str]): Resolve enum for different user. + Only administrators can use this. + context (Optional[dict[str, Any]]): Additional context + parameters for the resolver. Lists are joined with comma. + + Returns: + list[dict[str, Any]]: List of enum items. + + """ + query_data: dict[str, Any] = {} + if context: + for key, value in context.items(): + if isinstance(value, bool): + value = "true" if value else "false" + elif isinstance(value, (list, tuple, set)): + value = ",".join(str(item) for item in value) + query_data[key] = value + + if project_name is not None: + query_data["project_name"] = project_name + if user_name is not None: + query_data["user"] = user_name + + query = prepare_query_string(query_data) + response = self.get(f"enum/{enum_name}{query}") + response.raise_for_status() + return response.data diff --git a/ayon_api/_api_helpers/tasks.py b/ayon_api/_api_helpers/tasks.py index 1cae5caa5..3b4b55470 100644 --- a/ayon_api/_api_helpers/tasks.py +++ b/ayon_api/_api_helpers/tasks.py @@ -1,7 +1,7 @@ from __future__ import annotations import typing -from typing import Optional, Iterable, Generator, Any +from typing import Optional, Iterable, Generator, Any, Literal from ayon_api.utils import ( prepare_list_filters, @@ -513,3 +513,32 @@ def delete_task(self, project_name: str, task_id: str) -> None: f"projects/{project_name}/tasks/{task_id}" ) response.raise_for_status() + + def assign_users_to_task( + self, + project_name: str, + task_id: str, + users: Iterable[str], + mode: Literal["add", "remove", "set"] = "add", + ) -> None: + """Change users assigned to a task. + + Notes: + Users without manager role can only assign or unassign + themselves. + + Args: + project_name (str): Project name. + task_id (str): Task id. + users (Iterable[str]): User names. + mode (Literal["add", "remove", "set"]): How passed users are + applied. 'add' adds users to current assignees, 'remove' + removes them and 'set' replaces assignees with passed users. + + """ + response = self.post( + f"projects/{project_name}/tasks/{task_id}/assign", + mode=mode, + users=list(users), + ) + response.raise_for_status() diff --git a/ayon_api/_api_helpers/thumbnails.py b/ayon_api/_api_helpers/thumbnails.py index 9d534fd5e..13b2f1e8f 100644 --- a/ayon_api/_api_helpers/thumbnails.py +++ b/ayon_api/_api_helpers/thumbnails.py @@ -5,6 +5,7 @@ import typing from typing import Optional +from ayon_api.exceptions import UnsupportedServerVersion from ayon_api.utils import ( get_media_mime_type_for_stream, get_media_mime_type, @@ -358,6 +359,189 @@ def update_thumbnail_from_stream( ) response.raise_for_status() + def set_entity_thumbnail( + self, + project_name: str, + entity_type: str, + entity_id: str, + src_filepath: str, + ) -> str: + """Upload new thumbnail for an entity. + + Created thumbnail is automatically set on the entity. + + Args: + project_name (str): Project under which the entity is located. + entity_type (str): Entity type. Supported types are 'folder', + 'task', 'version' and 'workfile'. + entity_id (str): Entity id. + src_filepath (str): Filepath to thumbnail which should be uploaded. + + Returns: + str: Created thumbnail id. + + Raises: + ValueError: When thumbnail source cannot be processed. + + """ + if not os.path.exists(src_filepath): + raise ValueError("Entered filepath does not exist.") + + endpoint = self._get_entity_thumbnail_endpoint( + project_name, entity_type, entity_id + ) + mime_type = get_media_mime_type(src_filepath) + response = self.upload_file( + endpoint, + src_filepath, + request_type=RequestTypes.post, + headers={"Content-Type": mime_type}, + ) + response.raise_for_status() + return response.json()["id"] + + def set_entity_thumbnail_from_stream( + self, + project_name: str, + entity_type: str, + entity_id: str, + stream: StreamType, + ) -> str: + """Upload new thumbnail for an entity from byte stream. + + Created thumbnail is automatically set on the entity. + + Args: + project_name (str): Project under which the entity is located. + entity_type (str): Entity type. Supported types are 'folder', + 'task', 'version' and 'workfile'. + entity_id (str): Entity id. + stream (StreamType): Thumbnail content stream. + + Returns: + str: Created thumbnail id. + + """ + endpoint = self._get_entity_thumbnail_endpoint( + project_name, entity_type, entity_id + ) + mime_type = get_media_mime_type_for_stream(stream) + response = self.upload_file_from_stream( + endpoint, + stream, + request_type=RequestTypes.post, + headers={"Content-Type": mime_type}, + ) + response.raise_for_status() + return response.json()["id"] + + def get_project_thumbnail(self, project_name: str) -> ThumbnailContent: + """Get project thumbnail. + + Args: + project_name (str): Project name. + + Returns: + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.0. + + """ + if self.get_server_version_tuple() < (1, 15, 0): + raise UnsupportedServerVersion( + "Function 'get_project_thumbnail' is supported" + " for AYON server 1.15.0 and above." + ) + response = self.raw_get(f"projects/{project_name}/thumbnail") + if ( + response.status_code == 200 + and "X-Thumbnail-Id" not in response.headers + ): + # Project skeleton thumbnail does not return thumbnail id + # header, use reserved id of project thumbnail + return ThumbnailContent( + project_name, + "0" * 32, + response.content, + response.content_type, + ) + return self._prepare_thumbnail_content(project_name, response) + + def set_project_thumbnail( + self, project_name: str, src_filepath: str + ) -> str: + """Upload project thumbnail. + + Args: + project_name (str): Project name. + src_filepath (str): Filepath to thumbnail which should be uploaded. + + Returns: + str: Thumbnail id of project thumbnail. + + Raises: + ValueError: When thumbnail source cannot be processed. + UnsupportedServerVersion: Server version is lower than 1.15.0. + + """ + if self.get_server_version_tuple() < (1, 15, 0): + raise UnsupportedServerVersion( + "Function 'set_project_thumbnail' is supported" + " for AYON server 1.15.0 and above." + ) + if not os.path.exists(src_filepath): + raise ValueError("Entered filepath does not exist.") + + mime_type = get_media_mime_type(src_filepath) + response = self.upload_file( + f"api/projects/{project_name}/thumbnail", + src_filepath, + request_type=RequestTypes.post, + headers={"Content-Type": mime_type}, + ) + response.raise_for_status() + return response.json()["id"] + + def set_project_thumbnail_from_stream( + self, project_name: str, stream: StreamType + ) -> str: + """Upload project thumbnail from byte stream. + + Args: + project_name (str): Project name. + stream (StreamType): Thumbnail content stream. + + Returns: + str: Thumbnail id of project thumbnail. + + Raises: + UnsupportedServerVersion: Server version is lower than 1.15.0. + + """ + if self.get_server_version_tuple() < (1, 15, 0): + raise UnsupportedServerVersion( + "Function 'set_project_thumbnail_from_stream' is supported" + " for AYON server 1.15.0 and above." + ) + mime_type = get_media_mime_type_for_stream(stream) + response = self.upload_file_from_stream( + f"api/projects/{project_name}/thumbnail", + stream, + request_type=RequestTypes.post, + headers={"Content-Type": mime_type}, + ) + response.raise_for_status() + return response.json()["id"] + + def _get_entity_thumbnail_endpoint( + self, project_name: str, entity_type: str, entity_id: str + ) -> str: + return ( + f"api/projects/{project_name}/{entity_type}s/{entity_id}/thumbnail" + ) + def _prepare_thumbnail_content( self, project_name: str, diff --git a/ayon_api/_api_helpers/users.py b/ayon_api/_api_helpers/users.py new file mode 100644 index 000000000..411c6f2a5 --- /dev/null +++ b/ayon_api/_api_helpers/users.py @@ -0,0 +1,598 @@ +from __future__ import annotations + +from typing import Optional, Any + +from ayon_api.utils import prepare_query_string + +from .base import BaseServerAPI + + +class UsersAPI(BaseServerAPI): + """Users, access groups, teams and project users REST endpoints.""" + + # --- Users --- + def create_user( + self, + user_name: str, + *, + password: Optional[str] = None, + api_key: Optional[str] = None, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + active: Optional[bool] = None, + ) -> None: + """Create new user. + + User level and access is defined in 'data', e.g. 'isAdmin', + 'isManager', 'isService', 'isGuest', 'accessGroups' + ({project_name: [access_group_name, ...]}) or + 'defaultAccessGroups' ([access_group_name, ...]). + + Args: + user_name (str): Name of the user. + password (Optional[str]): Password of the user. Service users + cannot have password. + api_key (Optional[str]): Api key for a service user. + attrib (Optional[dict[str, Any]]): User attributes. + data (Optional[dict[str, Any]]): User data. + active (Optional[bool]): User active state. + + """ + body = { + key: value + for key, value in ( + ("password", password), + ("apiKey", api_key), + ("attrib", attrib), + ("data", data), + ("active", active), + ) + if value is not None + } + response = self.put(f"users/{user_name}", **body) + response.raise_for_status() + + def update_user( + self, + user_name: str, + *, + attrib: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + active: Optional[bool] = None, + ) -> None: + """Update user. + + Non-manager users can change only their own attributes. + + Args: + user_name (str): Name of the user. + attrib (Optional[dict[str, Any]]): User attributes to change. + data (Optional[dict[str, Any]]): User data to change. + active (Optional[bool]): User active state. + + """ + body = { + key: value + for key, value in ( + ("attrib", attrib), + ("data", data), + ("active", active), + ) + if value is not None + } + response = self.patch(f"users/{user_name}", **body) + response.raise_for_status() + + def delete_user(self, user_name: str) -> None: + """Delete user. + + Args: + user_name (str): Name of the user. + + """ + response = self.delete(f"users/{user_name}") + response.raise_for_status() + + def rename_user(self, user_name: str, new_user_name: str) -> None: + """Rename user. + + This is a manager-only operation. Target user name must not exist. + + Args: + user_name (str): Current name of the user. + new_user_name (str): New name of the user. + + """ + if self.get_server_version_tuple() < (1, 15, 0): + # Older servers have only the deprecated PATCH endpoint + response = self.patch( + f"users/{user_name}/rename", newName=new_user_name + ) + else: + response = self.post( + f"users/{user_name}/rename", name=new_user_name + ) + response.raise_for_status() + + def change_password( + self, + user_name: str, + *, + password: Optional[str] = None, + api_key: Optional[str] = None, + ) -> None: + """Change user password or service user api key. + + Only one of 'password' or 'api_key' should be passed. If both are + passed the password is changed. + + Args: + user_name (str): Name of the user. + password (Optional[str]): New password. + api_key (Optional[str]): New api key of a service user. Must be + 32 characters long hex string. + + """ + body = {} + if password is not None: + body["password"] = password + if api_key is not None: + body["apiKey"] = api_key + if not body: + raise ValueError("Password or api key must be passed.") + response = self.patch(f"users/{user_name}/password", **body) + response.raise_for_status() + + def set_user_access_groups( + self, + user_name: str, + access_groups: dict[str, list[str]], + ) -> None: + """Set user access groups on projects. + + Projects that are not in 'access_groups' are not affected. Pass + an empty list to remove access to a project. + + Args: + user_name (str): Name of the user. + access_groups (dict[str, list[str]]): Access groups by + project name. + + """ + response = self.patch( + f"users/{user_name}/accessGroups", + accessGroups=[ + {"project": project_name, "accessGroups": list(groups)} + for project_name, groups in access_groups.items() + ], + ) + response.raise_for_status() + + def get_user_sessions(self, user_name: str) -> list[dict[str, Any]]: + """Get active sessions of user. + + Args: + user_name (str): Name of the user. + + Returns: + list[dict[str, Any]]: Sessions with 'token', 'isService', + 'lastUsed' and 'clientInfo' keys. + + """ + response = self.get(f"users/{user_name}/sessions") + response.raise_for_status() + return response.data["sessions"] + + def delete_user_session(self, user_name: str, session_id: str) -> None: + """Delete user session (logout). + + Args: + user_name (str): Name of the user. + session_id (str): Session id (token). + + """ + response = self.delete(f"users/{user_name}/sessions/{session_id}") + response.raise_for_status() + + def get_user_api_keys(self, user_name: str) -> list[dict[str, Any]]: + """Get api keys of user. + + Args: + user_name (str): Name of the user. + + Returns: + list[dict[str, Any]]: Api keys with 'id', 'label', 'preview', + 'created' and 'expires' keys. + + """ + response = self.get(f"users/{user_name}/apikeys") + response.raise_for_status() + return response.data + + def create_user_api_key( + self, + user_name: str, + label: str, + key: str, + *, + expires: Optional[float] = None, + ) -> None: + """Create api key for user. + + Args: + user_name (str): Name of the user. + label (str): Label of the api key. Must be unique for the user. + key (str): The api key value. + expires (Optional[float]): Expiration timestamp. + + """ + response = self.post( + f"users/{user_name}/apikeys", + label=label, + key=key, + expires=expires, + ) + response.raise_for_status() + + def update_user_api_key( + self, + user_name: str, + api_key_id: str, + *, + label: Optional[str] = None, + expires: Optional[float] = None, + ) -> None: + """Update api key of user. + + Args: + user_name (str): Name of the user. + api_key_id (str): Api key id. + label (Optional[str]): New label of the api key. + expires (Optional[float]): New expiration timestamp. + + """ + response = self.patch( + f"users/{user_name}/apikeys/{api_key_id}", + label=label, + expires=expires, + ) + response.raise_for_status() + + def delete_user_api_key(self, user_name: str, api_key_id: str) -> None: + """Delete api key of user. + + Args: + user_name (str): Name of the user. + api_key_id (str): Api key id. + + """ + response = self.delete(f"users/{user_name}/apikeys/{api_key_id}") + response.raise_for_status() + + def get_user_permissions( + self, + user_name: Optional[str] = None, + project_name: Optional[str] = None, + ) -> dict[str, Any]: + """Get user permissions. + + Without 'user_name' are returned permissions of current user. For + current user without 'project_name' is returned user level with + studio and per-project permissions. Otherwise, are returned + studio permissions, or project permissions if 'project_name' + is passed. + + Args: + user_name (Optional[str]): Name of the user. Current user is used + if not passed. + project_name (Optional[str]): Project name. + + Returns: + dict[str, Any]: User permissions. + + """ + if user_name is None: + user_name = "me" + endpoint = f"users/{user_name}/permissions" + if project_name: + endpoint += f"/{project_name}" + response = self.get(endpoint) + response.raise_for_status() + return response.data + + # --- Access groups --- + def get_access_groups( + self, project_name: Optional[str] = None + ) -> list[dict[str, Any]]: + """Get access groups. + + Args: + project_name (Optional[str]): Project name. Studio level access + groups are returned if not passed. + + Returns: + list[dict[str, Any]]: Access groups with 'name' and + 'isProjectLevel' keys. + + """ + response = self.get(f"accessGroups/{project_name or '_'}") + response.raise_for_status() + return response.data + + def get_access_group_schema( + self, project_name: Optional[str] = None + ) -> dict[str, Any]: + """Get access group (permissions) settings schema. + + Args: + project_name (Optional[str]): Project name used as context. + + Returns: + dict[str, Any]: Access group schema. + + """ + query = prepare_query_string({"project_name": project_name or None}) + response = self.get(f"accessGroups/_schema{query}") + response.raise_for_status() + return response.data + + def get_access_group( + self, + access_group_name: str, + project_name: Optional[str] = None, + ) -> dict[str, Any]: + """Get access group definition (permissions). + + Args: + access_group_name (str): Access group name. + project_name (Optional[str]): Project name. Studio level access + group is returned if not passed. + + Returns: + dict[str, Any]: Access group permissions. + + """ + response = self.get( + f"accessGroups/{access_group_name}/{project_name or '_'}" + ) + response.raise_for_status() + return response.data + + def save_access_group( + self, + access_group_name: str, + permissions: dict[str, Any], + project_name: Optional[str] = None, + ) -> None: + """Create or update access group. + + Args: + access_group_name (str): Access group name. + permissions (dict[str, Any]): Access group permissions. + project_name (Optional[str]): Project name. Studio level access + group is saved if not passed. + + """ + response = self.raw_put( + f"accessGroups/{access_group_name}/{project_name or '_'}", + json=permissions, + ) + response.raise_for_status() + + def delete_access_group( + self, + access_group_name: str, + project_name: Optional[str] = None, + ) -> None: + """Delete access group. + + Args: + access_group_name (str): Access group name. + project_name (Optional[str]): Project name. Studio level access + group is deleted if not passed. + + """ + response = self.delete( + f"accessGroups/{access_group_name}/{project_name or '_'}" + ) + response.raise_for_status() + + def set_projects_access( + self, access: dict[str, dict[str, list[str]]] + ) -> None: + """Set access of multiple users to multiple projects. + + Projects not present in the payload are not affected. Pass an empty + list to revoke access to a project. + + Example:: + + { + "alice": { + "MyProject01": ["editor"], + "MyProject02": ["artist"], + }, + "bob": { + "MyProject01": ["artist", "reviewer"], + "MyProject02": [], + }, + } + + Args: + access (dict[str, dict[str, list[str]]]): Access groups by + project name by user name. + + """ + response = self.raw_post("access", json=access) + response.raise_for_status() + + # --- Teams --- + def get_teams( + self, + project_name: str, + *, + show_members: bool = False, + ) -> list[dict[str, Any]]: + """Get teams in a project. + + Args: + project_name (str): Project name. + show_members (bool): Include team members in the response. + + Returns: + list[dict[str, Any]]: Teams with 'name', 'memberCount', + 'leaders' and optionally 'members' keys. + + """ + query = prepare_query_string( + {"show_members": "true" if show_members else None} + ) + response = self.get(f"projects/{project_name}/teams{query}") + response.raise_for_status() + return response.data + + def update_teams( + self, + project_name: str, + teams: list[dict[str, Any]], + ) -> None: + """Create or replace multiple teams in a project. + + Teams that are not in 'teams' are not affected. + + Args: + project_name (str): Project name. + teams (list[dict[str, Any]]): Teams with 'name' and 'members' + keys. Member is a dictionary with 'name', 'leader' + and 'roles' keys. + + """ + response = self.raw_patch( + f"projects/{project_name}/teams", json=teams + ) + response.raise_for_status() + + def save_team( + self, + project_name: str, + team_name: str, + members: list[dict[str, Any]], + ) -> None: + """Create or replace a team in a project. + + Args: + project_name (str): Project name. + team_name (str): Team name. + members (list[dict[str, Any]]): Team members. Member is + a dictionary with 'name', 'leader' and 'roles' keys. + + """ + response = self.put( + f"projects/{project_name}/teams/{team_name}", + members=members, + ) + response.raise_for_status() + + def delete_team(self, project_name: str, team_name: str) -> None: + """Delete a team from a project. + + Args: + project_name (str): Project name. + team_name (str): Team name. + + """ + response = self.delete(f"projects/{project_name}/teams/{team_name}") + response.raise_for_status() + + def save_team_member( + self, + project_name: str, + team_name: str, + member_name: str, + *, + leader: bool = False, + roles: Optional[list[str]] = None, + ) -> None: + """Add or replace a team member. + + Args: + project_name (str): Project name. + team_name (str): Team name. + member_name (str): User name. + leader (bool): Is user a leader of the team. + roles (Optional[list[str]]): User roles in the team. + + """ + response = self.put( + f"projects/{project_name}/teams/{team_name}" + f"/members/{member_name}", + name=member_name, + leader=leader, + roles=list(roles or []), + ) + response.raise_for_status() + + def delete_team_member( + self, + project_name: str, + team_name: str, + member_name: str, + ) -> None: + """Remove a member from a team. + + Args: + project_name (str): Project name. + team_name (str): Team name. + member_name (str): User name. + + """ + response = self.delete( + f"projects/{project_name}/teams/{team_name}" + f"/members/{member_name}" + ) + response.raise_for_status() + + # --- Project users --- + def get_project_users(self, project_name: str) -> dict[str, list[str]]: + """Get users with explicitly set access groups on a project. + + Managers and admins are not included as they have access to all + projects. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Access groups by user name. + + """ + response = self.get(f"projects/{project_name}/users") + response.raise_for_status() + return response.data + + def set_project_user_access_groups( + self, + project_name: str, + user_name: str, + access_groups: list[str], + ) -> dict[str, list[str]]: + """Set access groups of a user on a project. + + Pass an empty list to remove user access to the project. + + Args: + project_name (str): Project name. + user_name (str): User name. + access_groups (list[str]): Access group names. + + Returns: + dict[str, list[str]]: Access groups by user name of all users + on the project. + + """ + response = self.raw_patch( + f"projects/{project_name}/users/{user_name}", + json=list(access_groups), + ) + response.raise_for_status() + return response.data diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index 7cb03c4d4..c380369ab 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -94,6 +94,10 @@ ActionsAPI, LinksAPI, ListsAPI, + UsersAPI, + ServicesAPI, + SystemAPI, + ReviewablesAPI, ) if typing.TYPE_CHECKING: @@ -249,6 +253,10 @@ class ServerAPI( ActionsAPI, LinksAPI, ListsAPI, + UsersAPI, + ServicesAPI, + SystemAPI, + ReviewablesAPI, ): """Base handler of connection to server. diff --git a/ayon_api/typing.py b/ayon_api/typing.py index 5869d1bd4..68a272676 100644 --- a/ayon_api/typing.py +++ b/ayon_api/typing.py @@ -51,6 +51,13 @@ "review-session", ] +ReviewablesEntityType = Literal[ + "folder", + "product", + "version", + "task", +] + EventFilterValueType = Union[ None, str, int, float, diff --git a/tests/test_addon_settings_api.py b/tests/test_addon_settings_api.py new file mode 100644 index 000000000..ad40d84c0 --- /dev/null +++ b/tests/test_addon_settings_api.py @@ -0,0 +1,1062 @@ +"""Tests of addon settings API (overrides, raw overrides, migration). + +To run use: pytest --envfile {environment path}. +Make sure you have set AYON_SERVER_URL and AYON_API_KEY in your environment. +The tests expect an admin user. + +Tests use an addon from the production bundle that has a settings model. +Studio level writes are done only on a unique, throwaway settings variant +(not 'production' or 'staging') and the raw studio overrides of that +variant are restored in fixture teardown. Project level writes are done +on the test project created by 'project_entity_fixture'. + +""" + +import copy +import uuid + +import pytest + +from ayon_api import ( + delete_addon_project_overrides, + delete_addon_studio_overrides, + delete_site, + get_addon_project_overrides, + get_addon_project_raw_overrides, + get_addon_project_settings, + get_addon_settings_schema, + get_addon_site_settings, + get_addon_site_settings_schema, + get_addon_studio_overrides, + get_addon_studio_raw_overrides, + get_addon_studio_settings, + get_addons_info, + get_bundles, + get_client_version, + get_info, + get_server_version_tuple, + get_site_id, + get_sites, + migrate_addon_settings, + migrate_settings_by_bundle, + modify_addon_project_overrides, + modify_addon_studio_overrides, + set_addon_project_raw_overrides, + set_addon_project_settings, + set_addon_site_settings, + set_addon_studio_raw_overrides, + set_addon_studio_settings, + set_client_version, + set_site_id, + exceptions, +) + + +PREFERRED_ADDONS = ("core",) + + +# --- Helpers --- +def _unique_name(prefix="ayon-api-test"): + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + +def _get_value(data, path): + for key in path: + if not isinstance(data, dict) or key not in data: + raise KeyError(key) + data = data[key] + return data + + +def _has_path(data, path): + try: + _get_value(data, path) + except KeyError: + return False + return True + + +def _set_value(data, path, value): + for key in path[:-1]: + data = data.setdefault(key, {}) + data[path[-1]] = value + + +def _build_overrides(path, value): + overrides = {} + _set_value(overrides, path, value) + return overrides + + +def _override_key(path): + return "root_" + "_".join(path) + + +def _resolve_ref(schema, prop): + """Resolve '$ref' (directly or via 'allOf') of a schema property.""" + ref = prop.get("$ref") + if ref is None: + all_of = prop.get("allOf") + if all_of and len(all_of) == 1: + ref = all_of[0].get("$ref") + if not ref or not ref.startswith("#/definitions/"): + return None + return schema.get("definitions", {}).get(ref.split("/")[-1]) + + +def _find_bool_path(schema, required_scopes=("studio", "project")): + """Find path to a boolean settings field that is not inside a group. + + Group fields are stored as a whole in overrides, which would make + assertions on override paths ambiguous. + + """ + def _crawl(model_schema, path, depth): + if depth > 5 or model_schema.get("isgroup"): + return None + properties = model_schema.get("properties") or {} + sub_models = [] + for name, prop in properties.items(): + scope = prop.get("scope") or ["studio", "project"] + if any(s not in scope for s in required_scopes): + continue + if prop.get("disabled") or prop.get("conditionalEnum"): + continue + if prop.get("type") == "boolean": + return [*path, name] + sub_schema = _resolve_ref(schema, prop) + if sub_schema is not None: + sub_models.append((name, sub_schema)) + + for name, sub_schema in sub_models: + result = _crawl(sub_schema, [*path, name], depth + 1) + if result: + return result + return None + + return _crawl(schema, [], 0) + + +def _get_production_bundle(): + bundles = get_bundles() + production_name = bundles.get("productionBundle") + if not production_name: + return None + for bundle in bundles["bundles"]: + if bundle["name"] == production_name: + return bundle + return None + + +def _iter_bundle_addons(bundle): + addons = { + name: version + for name, version in bundle.get("addons", {}).items() + if version + } + ordered = [name for name in PREFERRED_ADDONS if name in addons] + ordered.extend(sorted(name for name in addons if name not in ordered)) + for name in ordered: + yield name, addons[name] + + +# --- Fixtures --- +@pytest.fixture(scope="module") +def production_bundle(): + bundle = _get_production_bundle() + if bundle is None: + pytest.skip("Server does not have a production bundle.") + return bundle + + +@pytest.fixture(scope="module") +def settings_addon(production_bundle): + """Addon from production bundle with a usable settings model. + + Returns: + dict[str, Any]: 'name', 'version' of the addon and 'path' to + a boolean settings field usable on studio and project level. + + """ + for addon_name, addon_version in _iter_bundle_addons(production_bundle): + try: + schema = get_addon_settings_schema(addon_name, addon_version) + except exceptions.HTTPRequestError: + continue + if not schema or not schema.get("properties"): + continue + path = _find_bool_path(schema) + if not path: + continue + settings = get_addon_studio_settings( + addon_name, addon_version, variant="production" + ) + if not _has_path(settings, path): + continue + if not isinstance(_get_value(settings, path), bool): + continue + return { + "name": addon_name, + "version": addon_version, + "path": path, + } + pytest.skip( + "No addon in production bundle has settings with a boolean field." + ) + + +@pytest.fixture +def studio_variant(settings_addon): + """Unique settings variant used for studio level writes. + + Raw studio overrides of the variant are stored before the test and + restored after it (there should not be any). + + """ + variant = _unique_name() + name = settings_addon["name"] + version = settings_addon["version"] + original = get_addon_studio_raw_overrides( + name, version, variant=variant + ) + try: + yield variant + finally: + current = get_addon_studio_raw_overrides( + name, version, variant=variant + ) + if current != original: + if original: + set_addon_studio_raw_overrides( + name, version, original, variant=variant + ) + else: + delete_addon_studio_overrides(name, version, variant=variant) + + +@pytest.fixture +def project_addon(settings_addon, project_entity_fixture): + """Settings addon with the test project name. + + Project overrides of the addon on the test project are restored + after the test. + + """ + project_name = project_entity_fixture["name"] + name = settings_addon["name"] + version = settings_addon["version"] + original = get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) + try: + yield {**settings_addon, "project_name": project_name} + finally: + current = get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) + if current != original: + if original: + set_addon_project_raw_overrides( + name, + version, + project_name, + original, + variant="production", + ) + else: + delete_addon_project_overrides( + name, version, project_name, variant="production" + ) + + +@pytest.fixture +def project_site_id(project_addon): + """Unique site id used for project site overrides. + + Project site overrides are stored only in the test project (which is + deleted at the end of the session) but are removed after the test too. + + """ + site_id = _unique_name() + try: + yield site_id + finally: + delete_addon_project_overrides( + project_addon["name"], + project_addon["version"], + project_addon["project_name"], + site_id=site_id, + ) + + +@pytest.fixture +def site_settings_addon(production_bundle): + """Addon from production bundle that has site settings.""" + for addon_name, addon_version in _iter_bundle_addons(production_bundle): + try: + schema = get_addon_site_settings_schema(addon_name, addon_version) + except exceptions.HTTPRequestError: + continue + if schema and schema.get("properties"): + return { + "name": addon_name, + "version": addon_version, + "schema": schema, + } + pytest.skip("No addon in production bundle has site settings.") + + +@pytest.fixture +def registered_site(): + """Register a unique site id on server using the connection. + + Site id (and client version, which is required for site registration) + is set on the connection and 'get_info' registers the site. Site is + unregistered and connection values are restored after the test. + Unregistering the site removes its site settings on server. + + """ + site_id = _unique_name() + orig_site_id = get_site_id() + orig_client_version = get_client_version() + registered = False + try: + set_site_id(site_id) + if not orig_client_version: + set_client_version("0.0.0") + get_info() + registered = any(site["id"] == site_id for site in get_sites()) + if not registered: + pytest.skip("Unable to register a test site on server.") + yield site_id + finally: + set_site_id(orig_site_id) + set_client_version(orig_client_version) + if registered: + delete_site(site_id) + + +@pytest.fixture +def min_server_1_16_8(): + if get_server_version_tuple() < (1, 16, 8): + pytest.skip("Server version is lower than 1.16.8.") + + +# --- Schema --- +def test_get_addon_settings_schema_variant(settings_addon, studio_variant): + """Studio schema accepts 'variant' argument. + + Verifies: + - Schema for production and custom variant is returned. + - Both schemas have the same top level properties. + + """ + name = settings_addon["name"] + version = settings_addon["version"] + default_schema = get_addon_settings_schema(name, version) + production_schema = get_addon_settings_schema( + name, version, variant="production" + ) + variant_schema = get_addon_settings_schema( + name, version, variant=studio_variant + ) + for schema in (default_schema, production_schema, variant_schema): + assert isinstance(schema, dict) + assert "properties" in schema + assert "title" in schema + + assert ( + set(production_schema["properties"]) + == set(variant_schema["properties"]) + == set(default_schema["properties"]) + ) + + +def test_get_addon_settings_schema_project_variant_site( + project_addon, project_site_id +): + """Project schema accepts 'variant' and 'site_id' arguments.""" + name = project_addon["name"] + version = project_addon["version"] + project_name = project_addon["project_name"] + + studio_schema = get_addon_settings_schema(name, version) + project_schema = get_addon_settings_schema( + name, version, project_name, variant="production" + ) + site_schema = get_addon_settings_schema( + name, + version, + project_name, + variant="production", + site_id=project_site_id, + ) + for schema in (project_schema, site_schema): + assert isinstance(schema, dict) + assert set(schema["properties"]) == set(studio_schema["properties"]) + + # Site id is ignored without project name + studio_site_schema = get_addon_settings_schema( + name, version, site_id=project_site_id + ) + assert set(studio_site_schema["properties"]) == set( + studio_schema["properties"] + ) + + +# --- Studio settings --- +def test_set_addon_studio_settings(settings_addon, studio_variant): + """Set full studio settings and check calculated overrides. + + Verifies: + - Changed value is returned by studio settings getter. + - Only changed value is stored in raw overrides. + - Overrides listing contains the changed field on studio level. + + """ + name = settings_addon["name"] + version = settings_addon["version"] + path = settings_addon["path"] + + settings = get_addon_studio_settings(name, version, variant=studio_variant) + new_value = not _get_value(settings, path) + new_settings = copy.deepcopy(settings) + _set_value(new_settings, path, new_value) + + set_addon_studio_settings( + name, version, new_settings, variant=studio_variant + ) + + updated = get_addon_studio_settings(name, version, variant=studio_variant) + assert _get_value(updated, path) is new_value + + raw = get_addon_studio_raw_overrides(name, version, variant=studio_variant) + assert _get_value(raw, path) is new_value + + overrides = get_addon_studio_overrides( + name, version, variant=studio_variant + ) + item = overrides[_override_key(path)] + assert item["path"] == path + assert item["value"] is new_value + assert item["level"] == "studio" + + # Production settings were not affected + production = get_addon_studio_settings( + name, version, variant="production" + ) + assert _get_value(production, path) is _get_value(settings, path) + + +def test_set_addon_studio_settings_explicit_pin( + settings_addon, studio_variant +): + """Unchanged value is stored when pinned with '__pinned_fields__'.""" + name = settings_addon["name"] + version = settings_addon["version"] + path = settings_addon["path"] + + settings = get_addon_studio_settings(name, version, variant=studio_variant) + value = _get_value(settings, path) + payload = copy.deepcopy(settings) + payload["__pinned_fields__"] = [path] + set_addon_studio_settings(name, version, payload, variant=studio_variant) + + raw = get_addon_studio_raw_overrides(name, version, variant=studio_variant) + assert _get_value(raw, path) is value + + +def test_get_addon_studio_overrides_as_version( + settings_addon, studio_variant +): + """Overrides with 'as_version' of the same version are equal.""" + name = settings_addon["name"] + version = settings_addon["version"] + path = settings_addon["path"] + + settings = get_addon_studio_settings(name, version, variant=studio_variant) + new_value = not _get_value(settings, path) + set_addon_studio_raw_overrides( + name, + version, + _build_overrides(path, new_value), + variant=studio_variant, + ) + + overrides = get_addon_studio_overrides( + name, version, variant=studio_variant + ) + as_version_overrides = get_addon_studio_overrides( + name, version, variant=studio_variant, as_version=version + ) + assert overrides == as_version_overrides + assert overrides[_override_key(path)]["value"] is new_value + + +def test_modify_addon_studio_overrides(settings_addon, studio_variant): + """Remove and pin studio override on a settings path. + + Verifies: + - 'delete' action removes the override and default value is used. + - 'pin' action stores current value as override. + + """ + name = settings_addon["name"] + version = settings_addon["version"] + path = settings_addon["path"] + + default_value = _get_value( + get_addon_studio_settings(name, version, variant=studio_variant), + path, + ) + set_addon_studio_raw_overrides( + name, + version, + _build_overrides(path, not default_value), + variant=studio_variant, + ) + + modify_addon_studio_overrides( + name, version, "delete", path, variant=studio_variant + ) + raw = get_addon_studio_raw_overrides(name, version, variant=studio_variant) + assert not _has_path(raw, path) + settings = get_addon_studio_settings(name, version, variant=studio_variant) + assert _get_value(settings, path) is default_value + overrides = get_addon_studio_overrides( + name, version, variant=studio_variant + ) + assert _override_key(path) not in overrides + + modify_addon_studio_overrides( + name, version, "pin", path, variant=studio_variant + ) + raw = get_addon_studio_raw_overrides(name, version, variant=studio_variant) + assert _get_value(raw, path) is default_value + overrides = get_addon_studio_overrides( + name, version, variant=studio_variant + ) + assert overrides[_override_key(path)]["value"] is default_value + + +def test_delete_addon_studio_overrides(settings_addon, studio_variant): + """Delete all studio overrides of the variant.""" + name = settings_addon["name"] + version = settings_addon["version"] + path = settings_addon["path"] + + default_value = _get_value( + get_addon_studio_settings(name, version, variant=studio_variant), + path, + ) + set_addon_studio_raw_overrides( + name, + version, + _build_overrides(path, not default_value), + variant=studio_variant, + ) + assert get_addon_studio_raw_overrides( + name, version, variant=studio_variant + ) + + delete_addon_studio_overrides(name, version, variant=studio_variant) + + assert get_addon_studio_raw_overrides( + name, version, variant=studio_variant + ) == {} + assert get_addon_studio_overrides( + name, version, variant=studio_variant + ) == {} + settings = get_addon_studio_settings(name, version, variant=studio_variant) + assert _get_value(settings, path) is default_value + + +def test_studio_raw_overrides_roundtrip(settings_addon, studio_variant): + """Raw studio overrides are stored and returned as they are.""" + name = settings_addon["name"] + version = settings_addon["version"] + path = settings_addon["path"] + + assert get_addon_studio_raw_overrides( + name, version, variant=studio_variant + ) == {} + + default_value = _get_value( + get_addon_studio_settings(name, version, variant=studio_variant), + path, + ) + overrides = _build_overrides(path, not default_value) + set_addon_studio_raw_overrides( + name, version, overrides, variant=studio_variant + ) + + assert get_addon_studio_raw_overrides( + name, version, variant=studio_variant + ) == overrides + settings = get_addon_studio_settings(name, version, variant=studio_variant) + assert _get_value(settings, path) is (not default_value) + + +# --- Project settings --- +def test_set_addon_project_settings(project_addon): + """Set full project settings and check calculated overrides. + + Verifies: + - Changed value is returned by project settings getter. + - Changed value is stored in raw project overrides. + - Overrides listing contains the field on project level. + + """ + name = project_addon["name"] + version = project_addon["version"] + path = project_addon["path"] + project_name = project_addon["project_name"] + + settings = get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ) + new_value = not _get_value(settings, path) + new_settings = copy.deepcopy(settings) + _set_value(new_settings, path, new_value) + + set_addon_project_settings( + name, version, project_name, new_settings, variant="production" + ) + + updated = get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ) + assert _get_value(updated, path) is new_value + + raw = get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) + assert _get_value(raw, path) is new_value + + overrides = get_addon_project_overrides( + name, version, project_name, variant="production" + ) + item = overrides[_override_key(path)] + assert item["path"] == path + assert item["value"] is new_value + assert item["level"] == "project" + + +def test_modify_addon_project_overrides(project_addon): + """Remove and pin project override on a settings path.""" + name = project_addon["name"] + version = project_addon["version"] + path = project_addon["path"] + project_name = project_addon["project_name"] + + delete_addon_project_overrides( + name, version, project_name, variant="production" + ) + studio_value = _get_value( + get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ), + path, + ) + set_addon_project_raw_overrides( + name, + version, + project_name, + _build_overrides(path, not studio_value), + variant="production", + ) + + modify_addon_project_overrides( + name, version, project_name, "delete", path, variant="production" + ) + raw = get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) + assert not _has_path(raw, path) + settings = get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ) + assert _get_value(settings, path) is studio_value + + modify_addon_project_overrides( + name, version, project_name, "pin", path, variant="production" + ) + raw = get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) + assert _get_value(raw, path) is studio_value + overrides = get_addon_project_overrides( + name, version, project_name, variant="production" + ) + item = overrides[_override_key(path)] + assert item["level"] == "project" + assert item["value"] is studio_value + + +def test_project_raw_overrides_and_delete(project_addon): + """Raw project overrides roundtrip and deletion of all overrides.""" + name = project_addon["name"] + version = project_addon["version"] + path = project_addon["path"] + project_name = project_addon["project_name"] + + delete_addon_project_overrides( + name, version, project_name, variant="production" + ) + assert get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) == {} + + studio_value = _get_value( + get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ), + path, + ) + overrides = _build_overrides(path, not studio_value) + set_addon_project_raw_overrides( + name, version, project_name, overrides, variant="production" + ) + assert get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) == overrides + settings = get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ) + assert _get_value(settings, path) is (not studio_value) + + delete_addon_project_overrides( + name, version, project_name, variant="production" + ) + assert get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) == {} + settings = get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ) + assert _get_value(settings, path) is studio_value + + +def test_project_site_overrides(project_addon, project_site_id): + """Project site overrides of current user with 'site_id' argument. + + Verifies: + - Raw site overrides are separated from project overrides. + - Overrides listing with site id contains the site level override. + - Pin/delete actions and deletion of all site overrides. + + """ + name = project_addon["name"] + version = project_addon["version"] + path = project_addon["path"] + project_name = project_addon["project_name"] + site_id = project_site_id + + assert get_addon_project_raw_overrides( + name, version, project_name, site_id=site_id + ) == {} + project_raw = get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) + + project_value = _get_value( + get_addon_project_settings( + name, version, project_name, variant="production", use_site=False + ), + path, + ) + overrides = _build_overrides(path, not project_value) + set_addon_project_raw_overrides( + name, version, project_name, overrides, site_id=site_id + ) + assert get_addon_project_raw_overrides( + name, version, project_name, site_id=site_id + ) == overrides + # Project overrides are not affected + assert get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) == project_raw + + site_settings = get_addon_project_settings( + name, version, project_name, variant="production", site_id=site_id + ) + assert _get_value(site_settings, path) is (not project_value) + + listing = get_addon_project_overrides( + name, version, project_name, variant="production", site_id=site_id + ) + item = listing[_override_key(path)] + assert item["level"] == "site" + assert item["value"] is (not project_value) + + # Site overrides are not listed without site id + listing = get_addon_project_overrides( + name, version, project_name, variant="production" + ) + item = listing.get(_override_key(path)) + assert item is None or item["level"] != "site" + + modify_addon_project_overrides( + name, version, project_name, "delete", path, site_id=site_id + ) + raw = get_addon_project_raw_overrides( + name, version, project_name, site_id=site_id + ) + assert not _has_path(raw, path) + + modify_addon_project_overrides( + name, version, project_name, "pin", path, site_id=site_id + ) + raw = get_addon_project_raw_overrides( + name, version, project_name, site_id=site_id + ) + assert _get_value(raw, path) is project_value + + delete_addon_project_overrides( + name, version, project_name, site_id=site_id + ) + assert get_addon_project_raw_overrides( + name, version, project_name, site_id=site_id + ) == {} + + +def test_set_addon_project_settings_site(project_addon, project_site_id): + """Set full project settings as site overrides with 'site_id'.""" + name = project_addon["name"] + version = project_addon["version"] + path = project_addon["path"] + project_name = project_addon["project_name"] + site_id = project_site_id + + project_raw = get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) + settings = get_addon_project_settings( + name, version, project_name, variant="production", site_id=site_id + ) + new_value = not _get_value(settings, path) + new_settings = copy.deepcopy(settings) + _set_value(new_settings, path, new_value) + + set_addon_project_settings( + name, + version, + project_name, + new_settings, + variant="production", + site_id=site_id, + ) + + raw = get_addon_project_raw_overrides( + name, version, project_name, site_id=site_id + ) + assert _get_value(raw, path) is new_value + assert get_addon_project_raw_overrides( + name, version, project_name, variant="production" + ) == project_raw + + updated = get_addon_project_settings( + name, version, project_name, variant="production", site_id=site_id + ) + assert _get_value(updated, path) is new_value + + +# --- Site settings --- +def test_set_addon_site_settings(site_settings_addon, registered_site): + """Set site settings of current user for a registered test site. + + Verifies: + - Site settings of unused site are defaults. + - Changed value is returned after set. + - Site settings of different site are not affected. + + """ + name = site_settings_addon["name"] + version = site_settings_addon["version"] + schema = site_settings_addon["schema"] + site_id = registered_site + + defaults = get_addon_site_settings(name, version, site_id=site_id) + assert isinstance(defaults, dict) + # Site id from connection is used by default + assert get_addon_site_settings(name, version) == defaults + + new_settings = copy.deepcopy(defaults) + path = _find_bool_path(schema, required_scopes=()) + if path and isinstance(_get_value(defaults, path), bool): + _set_value(new_settings, path, not _get_value(defaults, path)) + else: + path = None + + set_addon_site_settings(name, version, new_settings, site_id=site_id) + assert get_addon_site_settings( + name, version, site_id=site_id + ) == new_settings + if path is not None: + assert new_settings != defaults + other_site = _unique_name() + assert get_addon_site_settings( + name, version, site_id=other_site + ) != new_settings + + # Site id from connection is used if not passed + set_addon_site_settings(name, version, defaults) + assert get_addon_site_settings( + name, version, site_id=site_id + ) == defaults + + +def test_set_addon_site_settings_without_site_id(): + """ValueError is raised if site id is not available.""" + orig_site_id = get_site_id() + try: + set_site_id(None) + with pytest.raises(ValueError): + set_addon_site_settings("addon", "1.0.0", {}) + finally: + set_site_id(orig_site_id) + + +# --- Migration --- +def test_migrate_addon_settings_same_version( + settings_addon, min_server_1_16_8 +): + """Server refuses migration to the same addon version.""" + with pytest.raises(exceptions.HTTPRequestError): + migrate_addon_settings( + settings_addon["name"], + settings_addon["version"], + settings_addon["version"], + with_projects=False, + ) + + +def test_migrate_addon_settings( + settings_addon, studio_variant, min_server_1_16_8 +): + """Migrate studio settings from other installed addon version. + + Both source and target variant is the unique test variant and project + settings are not migrated, so production settings are not affected. + Source version does not have overrides for the variant, so target + overrides are replaced (removed) by the migration. + + """ + name = settings_addon["name"] + version = settings_addon["version"] + path = settings_addon["path"] + + other_version = None + for addon in get_addons_info(details=False)["addons"]: + if addon["name"] != name: + continue + for addon_version in addon["versions"]: + if addon_version != version: + other_version = addon_version + break + if other_version is None: + pytest.skip(f"Addon '{name}' does not have other version installed.") + + assert get_addon_studio_raw_overrides( + name, other_version, variant=studio_variant + ) == {} + + default_value = _get_value( + get_addon_studio_settings(name, version, variant=studio_variant), + path, + ) + set_addon_studio_raw_overrides( + name, + version, + _build_overrides(path, not default_value), + variant=studio_variant, + ) + + result = migrate_addon_settings( + name, + other_version, + version, + source_variant=studio_variant, + target_variant=studio_variant, + with_projects=False, + ) + assert isinstance(result, dict) + events = result["events"] + assert isinstance(events, list) + assert len(events) == 1 + summary = events[0]["summary"] + assert summary["variant"] == studio_variant + assert summary["source_version"] == other_version + assert summary["addon_version"] == version + + # Converted (empty) source overrides replaced the target overrides + assert get_addon_studio_raw_overrides( + name, version, variant=studio_variant + ) != _build_overrides(path, not default_value) + # Source overrides stay untouched + assert get_addon_studio_raw_overrides( + name, other_version, variant=studio_variant + ) == {} + + +def test_migrate_settings_by_bundle_same_bundle( + settings_addon, production_bundle +): + """Migrate production bundle settings to itself without projects. + + Source and target are the same bundle and variant, so the migration + does not change any data (overrides are compared with themselves). + + """ + name = settings_addon["name"] + version = settings_addon["version"] + original = get_addon_studio_raw_overrides( + name, version, variant="production" + ) + try: + migrate_settings_by_bundle( + production_bundle["name"], + production_bundle["name"], + "production", + "production", + with_projects=False, + ) + assert get_addon_studio_raw_overrides( + name, version, variant="production" + ) == original + finally: + current = get_addon_studio_raw_overrides( + name, version, variant="production" + ) + if current != original and original: + set_addon_studio_raw_overrides( + name, version, original, variant="production" + ) + + +def test_migrate_settings_by_bundle_invalid(production_bundle): + """Invalid migration requests are refused before any change. + + Verifies: + - Custom variant must match the bundle name. + - Unknown bundle is refused. + + """ + bundle_name = production_bundle["name"] + with pytest.raises(exceptions.HTTPRequestError): + migrate_settings_by_bundle( + bundle_name, + bundle_name, + "production", + _unique_name(), + with_projects=False, + ) + + with pytest.raises(exceptions.HTTPRequestError): + migrate_settings_by_bundle( + _unique_name(), + bundle_name, + "production", + "production", + with_projects=False, + ) diff --git a/tests/test_projects_api.py b/tests/test_projects_api.py new file mode 100644 index 000000000..0d621a85b --- /dev/null +++ b/tests/test_projects_api.py @@ -0,0 +1,726 @@ +"""Tests of project related server API methods. + +Covers anatomy presets, project anatomy, project bundle, root overrides, +entity URIs, project files, folder search, entity grouping and entity +and project thumbnails. + +To run use: pytest --envfile {environment path}. +Make sure you have set AYON_SERVER_URL and AYON_API_KEY in your +environment. Tests expect to run as an admin user. + +""" +import os +import uuid + +import pytest + +from ayon_api import ( + create_folder, + create_project, + create_task, + delete_folder, + delete_project, + delete_project_anatomy_preset, + delete_project_file, + delete_task, + get_anatomy_schema, + get_built_in_anatomy_preset, + get_bundles, + get_entity_groups, + get_folder_by_id, + get_folder_thumbnail, + get_project, + get_project_anatomy, + get_project_anatomy_preset, + get_project_anatomy_presets, + get_project_bundle, + get_project_entity_uris, + get_project_file_info, + get_project_root_overrides, + get_project_stats, + get_project_tags, + get_project_thumbnail, + get_server_version_tuple, + get_task_by_id, + get_task_thumbnail, + rename_project, + rename_project_anatomy_preset, + resolve_uris, + save_project_anatomy_preset, + search_folders, + set_entity_thumbnail, + set_entity_thumbnail_from_stream, + set_primary_anatomy_preset, + set_project_anatomy, + set_project_bundle, + set_project_root_overrides, + set_project_thumbnail, + set_project_thumbnail_from_stream, + unset_primary_anatomy_preset, + unset_project_bundle, + upload_project_file, +) +from ayon_api.exceptions import HTTPRequestError + +from .conftest import project_entity_fixture, project_name_fixture + + +RESOURCES_DIR = os.path.join(os.path.dirname(__file__), "resources") +IMAGE_PATH = os.path.join(RESOURCES_DIR, "ayon-symbol.png") +PROJECT_THUMBNAIL_ID = "0" * 32 + + +def _unique_suffix() -> str: + return uuid.uuid4().hex[:12] + + +def _normalize_id(entity_id): + if entity_id is None: + return None + return entity_id.replace("-", "") + + +def _require_server_version(version): + if get_server_version_tuple() < version: + version_str = ".".join(str(part) for part in version) + pytest.skip(f"Requires AYON server {version_str} or newer.") + + +def _get_primary_preset_name(): + for preset in get_project_anatomy_presets(): + if preset.get("primary"): + return preset["name"] + return None + + +def _get_preset_names(): + return {preset["name"] for preset in get_project_anatomy_presets()} + + +# --- Fixtures --- +@pytest.fixture +def temp_project(): + """Temporary project created and deleted by the test. + + Yields a dictionary with 'name' and 'code' keys. A test that renames + the project must update the 'name' key so the teardown removes + the correct project. + + """ + suffix = _unique_suffix() + holder = { + "name": f"AYONApiTmp_{suffix}", + "code": f"tmp{suffix[:8]}", + } + create_project(holder["name"], holder["code"]) + try: + yield holder + finally: + if get_project(holder["name"]): + delete_project(holder["name"]) + + +@pytest.fixture +def anatomy_preset_names(): + """Unique anatomy preset names, the presets are removed on teardown. + + Original primary preset is restored on teardown. + + """ + suffix = _unique_suffix() + names = [f"test_preset_{suffix}", f"test_preset_renamed_{suffix}"] + orig_primary = _get_primary_preset_name() + try: + yield names + finally: + for name in names: + delete_project_anatomy_preset(name) + + current_primary = _get_primary_preset_name() + if current_primary != orig_primary: + # Preset name '_' unsets any primary preset + set_primary_anatomy_preset(orig_primary or "_") + + +@pytest.fixture +def test_hierarchy(project_entity_fixture): + """Folder with a task in the shared test project. + + Folder has a unique tag. Both entities are removed on teardown. + + """ + project_name = project_entity_fixture["name"] + suffix = _unique_suffix() + folder_name = f"projects_api_folder_{suffix}" + task_name = f"projects_api_task_{suffix}" + tag = f"projects_api_tag_{suffix}" + task_type = project_entity_fixture["taskTypes"][0]["name"] + + folder_id = create_folder( + project_name, + folder_name, + folder_type="Folder", + tags=[tag], + ) + task_id = None + try: + task_id = create_task( + project_name, + task_name, + task_type, + folder_id, + ) + yield { + "project_name": project_name, + "folder_id": folder_id, + "folder_name": folder_name, + "task_id": task_id, + "task_name": task_name, + "task_type": task_type, + "tag": tag, + } + finally: + if task_id is not None: + delete_task(project_name, task_id) + delete_folder(project_name, folder_id, force=True) + + +# --- Anatomy --- +def test_get_anatomy_schema(): + """Anatomy schema is a JSON schema describing the anatomy model.""" + schema = get_anatomy_schema() + assert isinstance(schema, dict) + properties = schema.get("properties") + assert isinstance(properties, dict) + for key in ("roots", "templates", "attributes", "statuses"): + assert key in properties + + +def test_anatomy_preset_lifecycle(anatomy_preset_names): + """Save, rename, set/unset primary and delete an anatomy preset. + + Verifies: + - Saved preset is listed and its values are stored. + - Renamed preset is listed only under the new name. + - Preset can be marked as primary and the flag can be removed. + - Deleted preset is no longer listed. + + """ + preset_name, new_preset_name = anatomy_preset_names + + preset = get_built_in_anatomy_preset() + root_path = f"C:/ayon_api_test/{preset_name}" + preset["roots"][0]["windows"] = root_path + + save_project_anatomy_preset(preset_name, preset) + assert preset_name in _get_preset_names() + stored = get_project_anatomy_preset(preset_name) + assert stored["roots"][0]["windows"] == root_path + + # Update existing preset + updated_root_path = f"{root_path}_updated" + preset["roots"][0]["windows"] = updated_root_path + save_project_anatomy_preset(preset_name, preset) + stored = get_project_anatomy_preset(preset_name) + assert stored["roots"][0]["windows"] == updated_root_path + + rename_project_anatomy_preset(preset_name, new_preset_name) + preset_names = _get_preset_names() + assert preset_name not in preset_names + assert new_preset_name in preset_names + stored = get_project_anatomy_preset(new_preset_name) + assert stored["roots"][0]["windows"] == updated_root_path + + set_primary_anatomy_preset(new_preset_name) + assert _get_primary_preset_name() == new_preset_name + primary = get_project_anatomy_preset() + assert primary["roots"][0]["windows"] == updated_root_path + + unset_primary_anatomy_preset(new_preset_name) + assert _get_primary_preset_name() is None + + delete_project_anatomy_preset(new_preset_name) + assert new_preset_name not in _get_preset_names() + + +def test_rename_missing_anatomy_preset_fails(anatomy_preset_names): + """Renaming a preset that does not exist raises an error.""" + preset_name, new_preset_name = anatomy_preset_names + with pytest.raises(HTTPRequestError): + rename_project_anatomy_preset(preset_name, new_preset_name) + + +def test_save_builtin_anatomy_preset_fails(): + """Built-in preset cannot be overridden.""" + preset = get_built_in_anatomy_preset() + with pytest.raises(HTTPRequestError): + save_project_anatomy_preset("__builtin__", preset) + + +# --- Project --- +def test_rename_project(temp_project): + """Rename a temporary project including its code.""" + _require_server_version((1, 15, 0)) + + old_name = temp_project["name"] + suffix = _unique_suffix() + new_name = f"AYONApiTmpRenamed_{suffix}" + new_code = f"ren{suffix[:8]}" + + rename_project(old_name, new_name, new_code) + temp_project["name"] = new_name + + assert get_project(old_name) is None + project = get_project(new_name) + assert project is not None + assert project["name"] == new_name + assert project["code"] == new_code + + # Rename without code change keeps the code + newer_name = f"AYONApiTmpRenamed2_{suffix}" + rename_project(new_name, newer_name) + temp_project["name"] = newer_name + + project = get_project(newer_name) + assert project is not None + assert project["code"] == new_code + + +def test_get_project_anatomy(project_entity_fixture): + """Project anatomy is returned in anatomy preset format.""" + project_name = project_entity_fixture["name"] + anatomy = get_project_anatomy(project_name) + + for key in ("roots", "templates", "attributes", "statuses"): + assert key in anatomy + + root_names = {root["name"] for root in anatomy["roots"]} + assert root_names == set(project_entity_fixture["config"]["roots"]) + + +def test_set_project_anatomy(temp_project): + """Changed project anatomy is stored on the project.""" + project_name = temp_project["name"] + anatomy = get_project_anatomy(project_name) + + new_fps = 37.0 + if anatomy["attributes"].get("fps") == new_fps: + new_fps = 41.0 + anatomy["attributes"]["fps"] = new_fps + + root_path = f"C:/ayon_api_test/{project_name}" + anatomy["roots"][0]["windows"] = root_path + root_name = anatomy["roots"][0]["name"] + + set_project_anatomy(project_name, anatomy) + + stored = get_project_anatomy(project_name) + assert stored["attributes"]["fps"] == new_fps + assert stored["roots"][0]["windows"] == root_path + + project = get_project(project_name) + assert project["attrib"]["fps"] == new_fps + assert project["config"]["roots"][root_name]["windows"] == root_path + + +def test_get_project_stats(test_hierarchy): + """Project statistics contain counts of entities.""" + project_name = test_hierarchy["project_name"] + stats = get_project_stats(project_name) + + counts = stats["counts"] + assert set(counts) == { + "folders", + "products", + "versions", + "representations", + "tasks", + } + for value in counts.values(): + assert isinstance(value, int) + assert counts["folders"] >= 1 + assert counts["tasks"] >= 1 + + +def test_get_project_tags(test_hierarchy): + """Tags used on project entities are returned by entity type.""" + project_name = test_hierarchy["project_name"] + tags = get_project_tags(project_name) + + assert set(tags) == { + "folders", + "tasks", + "products", + "versions", + "representations", + "workfiles", + } + assert test_hierarchy["tag"] in tags["folders"] + assert test_hierarchy["tag"] not in tags["tasks"] + + +def test_project_bundle(temp_project): + """Freeze and unfreeze project bundle of a temporary project. + + An empty addons mapping is used so no addon settings are changed. + + """ + project_name = temp_project["name"] + variant = "production" + bundle_name = f"__project__{project_name}__{variant}" + has_production_bundle = bool(get_bundles().get("productionBundle")) + + if has_production_bundle: + bundle_info = get_project_bundle(project_name, variant=variant) + for key in ( + "addons", + "addonMetadata", + "installerOptions", + "dependencyPackageOptions", + ): + assert key in bundle_info + else: + # Information can't be received without production bundle + with pytest.raises(HTTPRequestError): + get_project_bundle(project_name, variant=variant) + + try: + set_project_bundle(project_name, {}, variant=variant) + + project = get_project(project_name) + assert project["data"]["bundle"][variant] == bundle_name + + if has_production_bundle: + bundle_info = get_project_bundle(project_name, variant=variant) + # All addons are disabled by the frozen project bundle + for addon_version in bundle_info["addons"].values(): + assert addon_version == "__disable__" + + finally: + unset_project_bundle(project_name, variant=variant) + + project = get_project(project_name) + assert variant not in project["data"].get("bundle", {}) + + +def test_set_project_root_overrides(project_entity_fixture): + """Set and remove root overrides of a site for the logged user. + + Uses a unique site id that is not used by any real site. + + """ + project_name = project_entity_fixture["name"] + root_name = next(iter(project_entity_fixture["config"]["roots"])) + site_id = f"ayon-api-test-site-{_unique_suffix()}" + root_path = f"C:/ayon_api_test/{site_id}" + + try: + set_project_root_overrides( + project_name, site_id, {root_name: root_path} + ) + overrides = get_project_root_overrides(project_name) + assert overrides[site_id] == {root_name: root_path} + + # Empty value removes the root override, which removes all + # overrides of the site + set_project_root_overrides(project_name, site_id, {root_name: None}) + overrides = get_project_root_overrides(project_name) + assert site_id not in overrides + + finally: + set_project_root_overrides(project_name, site_id, {}) + + assert site_id not in get_project_root_overrides(project_name) + + +def test_get_project_entity_uris(test_hierarchy): + """URIs of folder and task are built from their path.""" + project_name = test_hierarchy["project_name"] + folder_id = test_hierarchy["folder_id"] + task_id = test_hierarchy["task_id"] + folder_uri = ( + f"ayon+entity://{project_name}/{test_hierarchy['folder_name']}" + ) + task_uri = f"{folder_uri}?task={test_hierarchy['task_name']}" + + folder_uris = get_project_entity_uris( + project_name, "folder", [folder_id] + ) + assert len(folder_uris) == 1 + assert _normalize_id(folder_uris[0]["id"]) == folder_id + assert folder_uris[0]["uri"] == folder_uri + + task_uris = get_project_entity_uris(project_name, "task", {task_id}) + assert len(task_uris) == 1 + assert _normalize_id(task_uris[0]["id"]) == task_id + assert task_uris[0]["uri"] == task_uri + + assert get_project_entity_uris(project_name, "folder", []) == [] + + +def test_resolve_uris(test_hierarchy): + """Resolve folder and task URIs back to entity ids.""" + project_name = test_hierarchy["project_name"] + folder_id = test_hierarchy["folder_id"] + task_id = test_hierarchy["task_id"] + folder_uri = f"ayon://{project_name}/{test_hierarchy['folder_name']}" + task_uri = f"{folder_uri}?task={test_hierarchy['task_name']}" + missing_uri = f"{folder_uri}_missing" + + result = resolve_uris([folder_uri, task_uri, missing_uri]) + assert [item["uri"] for item in result] == [ + folder_uri, task_uri, missing_uri + ] + folder_result, task_result, missing_result = result + + assert not folder_result.get("error") + assert len(folder_result["entities"]) == 1 + folder_entity = folder_result["entities"][0] + assert folder_entity["projectName"] == project_name + assert folder_entity["target"] == "folder" + assert _normalize_id(folder_entity["folderId"]) == folder_id + + assert not task_result.get("error") + assert len(task_result["entities"]) == 1 + task_entity = task_result["entities"][0] + assert task_entity["target"] == "task" + assert _normalize_id(task_entity["folderId"]) == folder_id + assert _normalize_id(task_entity["taskId"]) == task_id + + assert missing_result["entities"] == [] + + path_only_result = resolve_uris([folder_uri], path_only=True) + assert len(path_only_result) == 1 + assert len(path_only_result[0]["entities"]) == 1 + # Only file path is returned which is not available for folders + assert "folderId" not in path_only_result[0]["entities"][0] + + +def test_resolve_uris_invalid(): + """Invalid URI and unknown project are reported as errors.""" + missing_project = f"AYONApiMissing_{_unique_suffix()}" + invalid_uri = "invalid://project/path" + missing_uri = f"ayon://{missing_project}/folder" + + result = resolve_uris([invalid_uri, missing_uri]) + assert len(result) == 2 + invalid_result, missing_result = result + assert invalid_result["uri"] == invalid_uri + assert invalid_result["error"] + assert missing_result["uri"] == missing_uri + assert missing_project in missing_result["error"] + + +def test_get_project_file_info(project_entity_fixture): + """Uploaded project file information is returned.""" + project_name = project_entity_fixture["name"] + filename = f"projects_api_{_unique_suffix()}.png" + + response = upload_project_file( + project_name, + IMAGE_PATH, + filename=filename, + content_type="image/png", + ) + file_id = response.json()["id"] + try: + file_info = get_project_file_info(project_name, file_id) + assert file_info["size"] == os.path.getsize(IMAGE_PATH) + assert file_info["filename"] == filename + assert file_info["contentType"] == "image/png" + + finally: + delete_project_file(project_name, file_id) + + with pytest.raises(HTTPRequestError): + get_project_file_info(project_name, file_id) + + +# --- Folders --- +def test_search_folders(test_hierarchy): + """Search folders by text and by filters on folders and tasks.""" + project_name = test_hierarchy["project_name"] + folder_id = test_hierarchy["folder_id"] + folder_name = test_hierarchy["folder_name"] + task_name = test_hierarchy["task_name"] + + assert search_folders( + project_name, folder_search=folder_name + ) == [folder_id] + + assert search_folders(project_name, task_search=task_name) == [folder_id] + + folder_filter = { + "conditions": [ + {"key": "name", "value": folder_name, "operator": "eq"}, + ], + "operator": "and", + } + assert search_folders( + project_name, folder_filter=folder_filter + ) == [folder_id] + + task_filter = { + "conditions": [ + {"key": "name", "value": task_name, "operator": "eq"}, + ], + "operator": "and", + } + assert search_folders(project_name, task_filter=task_filter) == [ + folder_id + ] + + # Folder name does not match task conditions + assert search_folders(project_name, task_search=folder_name) == [] + assert search_folders( + project_name, folder_search=f"{folder_name}_missing" + ) == [] + + +def test_search_folders_global_search(test_hierarchy): + """Global search matches folders and folders of matching tasks.""" + _require_server_version((1, 15, 4)) + + project_name = test_hierarchy["project_name"] + folder_id = test_hierarchy["folder_id"] + + assert search_folders( + project_name, search=test_hierarchy["folder_name"] + ) == [folder_id] + assert search_folders( + project_name, search=test_hierarchy["task_name"] + ) == [folder_id] + + +def test_get_entity_groups(test_hierarchy, project_entity_fixture): + """Group folders by folder type and tasks by task type.""" + project_name = test_hierarchy["project_name"] + + result = get_entity_groups(project_name, "folder", "folderType") + assert result["key"] == "folderType" + assert result["entityType"] == "folder" + groups_by_value = {group["value"]: group for group in result["groups"]} + assert groups_by_value["Folder"]["count"] >= 1 + for group in result["groups"]: + assert group["count"] > 0 + + result = get_entity_groups( + project_name, "folder", "folderType", empty=True + ) + group_values = {group["value"] for group in result["groups"]} + folder_type_names = { + folder_type["name"] + for folder_type in project_entity_fixture["folderTypes"] + } + assert folder_type_names <= group_values + + result = get_entity_groups(project_name, "task", "taskType") + assert result["entityType"] == "task" + groups_by_value = {group["value"]: group for group in result["groups"]} + assert groups_by_value[test_hierarchy["task_type"]]["count"] >= 1 + + +def test_get_entity_groups_invalid_key(project_entity_fixture): + """Invalid grouping key raises an error.""" + with pytest.raises(HTTPRequestError): + get_entity_groups( + project_entity_fixture["name"], "folder", "invalidKey" + ) + + +# --- Thumbnails --- +def test_set_entity_thumbnail(test_hierarchy): + """Upload thumbnail of a folder from a file path.""" + project_name = test_hierarchy["project_name"] + folder_id = test_hierarchy["folder_id"] + + thumbnail_id = set_entity_thumbnail( + project_name, "folder", folder_id, IMAGE_PATH + ) + assert thumbnail_id + + folder = get_folder_by_id( + project_name, folder_id, fields={"id", "thumbnailId"} + ) + assert _normalize_id(folder["thumbnailId"]) == thumbnail_id + + thumbnail = get_folder_thumbnail(project_name, folder_id) + assert thumbnail.is_valid + assert _normalize_id(thumbnail.id) == thumbnail_id + assert thumbnail.content + + +def test_set_entity_thumbnail_from_stream(test_hierarchy): + """Upload thumbnail of a task from a byte stream.""" + project_name = test_hierarchy["project_name"] + task_id = test_hierarchy["task_id"] + + with open(IMAGE_PATH, "rb") as stream: + thumbnail_id = set_entity_thumbnail_from_stream( + project_name, "task", task_id, stream + ) + assert thumbnail_id + + task = get_task_by_id( + project_name, task_id, fields={"id", "thumbnailId"} + ) + assert _normalize_id(task["thumbnailId"]) == thumbnail_id + + thumbnail = get_task_thumbnail(project_name, task_id) + assert thumbnail.is_valid + assert _normalize_id(thumbnail.id) == thumbnail_id + assert thumbnail.content + + +def test_set_entity_thumbnail_invalid(test_hierarchy): + """Invalid inputs are refused before any request is sent.""" + project_name = test_hierarchy["project_name"] + folder_id = test_hierarchy["folder_id"] + + with pytest.raises(ValueError): + set_entity_thumbnail( + project_name, + "folder", + folder_id, + os.path.join(RESOURCES_DIR, "missing-image.png"), + ) + + with pytest.raises(ValueError): + set_entity_thumbnail( + project_name, "product", folder_id, IMAGE_PATH + ) + + +def test_project_thumbnail(temp_project): + """Set project thumbnail from file path and from stream.""" + _require_server_version((1, 15, 0)) + + project_name = temp_project["name"] + + # Project without thumbnail returns placeholder which is not valid + thumbnail = get_project_thumbnail(project_name) + assert not thumbnail.is_valid + + thumbnail_id = set_project_thumbnail(project_name, IMAGE_PATH) + assert thumbnail_id == PROJECT_THUMBNAIL_ID + thumbnail = get_project_thumbnail(project_name) + assert thumbnail.is_valid + assert thumbnail.id == PROJECT_THUMBNAIL_ID + assert thumbnail.content + assert thumbnail.content_type == "image/png" + + with open(IMAGE_PATH, "rb") as stream: + thumbnail_id = set_project_thumbnail_from_stream( + project_name, stream + ) + assert thumbnail_id == PROJECT_THUMBNAIL_ID + thumbnail = get_project_thumbnail(project_name) + assert thumbnail.is_valid + assert thumbnail.id == PROJECT_THUMBNAIL_ID + assert thumbnail.content + + with pytest.raises(ValueError): + set_project_thumbnail( + project_name, + os.path.join(RESOURCES_DIR, "missing-image.png"), + ) diff --git a/tests/test_review_tasks_lists_api.py b/tests/test_review_tasks_lists_api.py new file mode 100644 index 000000000..4b28efed9 --- /dev/null +++ b/tests/test_review_tasks_lists_api.py @@ -0,0 +1,575 @@ +"""Tests of reviewables, task assignees and entity list order endpoints. + +Tests run against a real AYON server and use the test project created +by 'project_entity_fixture'. Every entity created by the tests is +removed at the end. + +To run use: pytest --envfile {environment path}. +Make sure you have set AYON_SERVER_URL and AYON_API_KEY in +your environment. + +""" +import os +import uuid + +import pytest + +from ayon_api import ( + assign_users_to_task, + create_entity_list, + create_entity_list_item, + create_folder, + create_product, + create_task, + create_version, + delete_entity_list, + delete_folder, + get_entity_list_rest, + get_folder_reviewables, + get_product_reviewables, + get_reviewables, + get_server_version_tuple, + get_task_by_id, + get_task_reviewables, + get_user, + get_version_reviewables, + materialize_entity_list, + set_entity_list_items_order, + sort_version_reviewables, + update_reviewable, + upload_reviewable, +) +from ayon_api.exceptions import HTTPRequestError + +from .conftest import project_entity_fixture # noqa: F401 + +REVIEWABLE_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "resources", + "ayon-symbol.png", +) + + +def _unique_name(prefix): + return f"{prefix}_{uuid.uuid4().hex[:8]}" + + +def _find_status(project_entity, done): + """Find project status usable on versions. + + Args: + project_entity (dict[str, Any]): Project entity. + done (bool): Look for status with 'done' state. + + Returns: + Optional[str]: Status name or None if not found. + + """ + for status in project_entity["statuses"]: + scope = status.get("scope") + if scope and "version" not in scope: + continue + if (status.get("state") == "done") is done: + return status["name"] + return None + + +def _upload(project_name, version_id, label): + response = upload_reviewable( + project_name, version_id, REVIEWABLE_PATH, label=label + ) + response.raise_for_status() + return response.json() + + +def _get_filtered(func, *args, **kwargs): + """Call single entity reviewables getter with 'latest' filter. + + AYON server 1.16.x builds SQL condition 'versions.id = $1AND ...' + (missing space) for single entity endpoints with 'latest' or + 'latest_done', which PostgreSQL 15+ refuses. Mark the test as + expected failure in that case instead of failing on server bug. + + """ + try: + return func(*args, **kwargs) + except HTTPRequestError as exc: + response = exc.response + if response is not None and response.status_code >= 500: + pytest.xfail( + "Server fails on 'latest' filter of single entity" + f" reviewables endpoint: {exc}" + ) + raise + + +def _reviewables_by_version_id(versions): + return { + version["id"]: version["reviewables"] + for version in versions + } + + +@pytest.fixture +def folder_fixture(project_entity_fixture): # noqa: F811 + """Create a folder in test project and delete it after test.""" + project_name = project_entity_fixture["name"] + folder_id = create_folder( + project_name, + _unique_name("review_test"), + folder_type=project_entity_fixture["folderTypes"][0]["name"], + ) + try: + yield folder_id + finally: + delete_folder(project_name, folder_id, force=True) + + +@pytest.fixture +def task_fixture(project_entity_fixture, folder_fixture): # noqa: F811 + """Create a task without assignees under the test folder.""" + return create_task( + project_entity_fixture["name"], + _unique_name("task"), + project_entity_fixture["taskTypes"][0]["name"], + folder_fixture, + ) + + +@pytest.fixture +def reviewables_fixture( + project_entity_fixture, # noqa: F811 + folder_fixture, + task_fixture, +): + """Create product with 2 versions and upload reviewables to them. + + Version 1 has 'done' status and two reviewables, version 2 has + a not 'done' status and one reviewable. Version 3 lives in other + product and has no reviewables. + + """ + if get_server_version_tuple() < (1, 16, 4): + pytest.skip("Reviewables endpoints require AYON server 1.16.4+") + + project_name = project_entity_fixture["name"] + done_status = _find_status(project_entity_fixture, True) + not_done_status = _find_status(project_entity_fixture, False) + + product_id = create_product( + project_name, _unique_name("review"), "review", folder_fixture + ) + version_1_id = create_version( + project_name, + 1, + product_id, + task_id=task_fixture, + status=done_status, + ) + version_2_id = create_version( + project_name, + 2, + product_id, + task_id=task_fixture, + status=not_done_status, + ) + empty_product_id = create_product( + project_name, _unique_name("empty"), "review", folder_fixture + ) + empty_version_id = create_version(project_name, 1, empty_product_id) + + v1_reviewables = [ + _upload(project_name, version_1_id, "first"), + _upload(project_name, version_1_id, "second"), + ] + v2_reviewables = [ + _upload(project_name, version_2_id, "third"), + ] + return { + "project_name": project_name, + "folder_id": folder_fixture, + "task_id": task_fixture, + "product_id": product_id, + "version_1_id": version_1_id, + "version_2_id": version_2_id, + "empty_product_id": empty_product_id, + "empty_version_id": empty_version_id, + "done_status": done_status, + "v1_activity_ids": [r["activityId"] for r in v1_reviewables], + "v2_activity_ids": [r["activityId"] for r in v2_reviewables], + } + + +def test_upload_reviewable_response(reviewables_fixture): + """Upload of reviewable returns activity ids used by other tests.""" + activity_ids = ( + reviewables_fixture["v1_activity_ids"] + + reviewables_fixture["v2_activity_ids"] + ) + assert len(set(activity_ids)) == 3 + assert all(activity_ids) + + +def test_get_version_reviewables(reviewables_fixture): + """Version reviewables contain version info and its reviewables.""" + project_name = reviewables_fixture["project_name"] + version_id = reviewables_fixture["version_1_id"] + + version = get_version_reviewables(project_name, version_id) + assert version["id"] == version_id + assert version["name"] == "v001" + assert version["productId"] == reviewables_fixture["product_id"] + assert version["productType"] == "review" + + reviewables = version["reviewables"] + assert {r["activityId"] for r in reviewables} == set( + reviewables_fixture["v1_activity_ids"] + ) + for reviewable in reviewables: + assert reviewable["fileId"] + assert reviewable["filename"] == "ayon-symbol.png" + assert reviewable["mimetype"] == "image/png" + assert reviewable["author"]["name"] == get_user()["name"] + assert {r["label"] for r in reviewables} == {"first", "second"} + + +def test_get_version_reviewables_without_reviewables(reviewables_fixture): + """Version without reviewables returns empty 'reviewables'.""" + project_name = reviewables_fixture["project_name"] + version_id = reviewables_fixture["empty_version_id"] + + version = get_version_reviewables(project_name, version_id) + assert version["id"] == version_id + assert version["reviewables"] == [] + + # With 'latest' the server uses inner join and the version is not + # found -> server responds with error + with pytest.raises(HTTPRequestError): + get_version_reviewables(project_name, version_id, latest=True) + + +def test_get_product_reviewables(reviewables_fixture): + """Product reviewables contain all versions and filter by latest.""" + project_name = reviewables_fixture["project_name"] + product_id = reviewables_fixture["product_id"] + version_1_id = reviewables_fixture["version_1_id"] + version_2_id = reviewables_fixture["version_2_id"] + + by_id = _reviewables_by_version_id( + get_product_reviewables(project_name, product_id) + ) + assert set(by_id) == {version_1_id, version_2_id} + assert len(by_id[version_1_id]) == 2 + assert len(by_id[version_2_id]) == 1 + + latest = _get_filtered( + get_product_reviewables, project_name, product_id, latest=True + ) + assert [v["id"] for v in latest] == [version_2_id] + + if reviewables_fixture["done_status"] is None: + pytest.skip("Project does not have status with 'done' state") + + latest_done = _get_filtered( + get_product_reviewables, project_name, product_id, latest_done=True + ) + assert [v["id"] for v in latest_done] == [version_1_id] + + +def test_get_folder_reviewables(reviewables_fixture): + """Folder reviewables contain versions of all products in folder.""" + project_name = reviewables_fixture["project_name"] + folder_id = reviewables_fixture["folder_id"] + version_1_id = reviewables_fixture["version_1_id"] + version_2_id = reviewables_fixture["version_2_id"] + + by_id = _reviewables_by_version_id( + get_folder_reviewables(project_name, folder_id) + ) + assert set(by_id) == { + version_1_id, + version_2_id, + reviewables_fixture["empty_version_id"], + } + assert by_id[reviewables_fixture["empty_version_id"]] == [] + + # Latest version of the empty product has no reviewables, so it is + # not in the output + latest = _get_filtered( + get_folder_reviewables, project_name, folder_id, latest=True + ) + assert [v["id"] for v in latest] == [version_2_id] + + if reviewables_fixture["done_status"] is None: + pytest.skip("Project does not have status with 'done' state") + + latest_done = _get_filtered( + get_folder_reviewables, project_name, folder_id, latest_done=True + ) + assert [v["id"] for v in latest_done] == [version_1_id] + + +def test_get_task_reviewables(reviewables_fixture): + """Task reviewables contain only versions linked to the task.""" + project_name = reviewables_fixture["project_name"] + task_id = reviewables_fixture["task_id"] + version_1_id = reviewables_fixture["version_1_id"] + version_2_id = reviewables_fixture["version_2_id"] + + by_id = _reviewables_by_version_id( + get_task_reviewables(project_name, task_id) + ) + assert set(by_id) == {version_1_id, version_2_id} + + latest = _get_filtered( + get_task_reviewables, project_name, task_id, latest=True + ) + assert [v["id"] for v in latest] == [version_2_id] + + +@pytest.mark.parametrize( + "entity_type", ["version", "product", "folder", "task"] +) +def test_get_reviewables(reviewables_fixture, entity_type): + """Batch reviewables endpoint works for all supported entity types.""" + project_name = reviewables_fixture["project_name"] + version_1_id = reviewables_fixture["version_1_id"] + version_2_id = reviewables_fixture["version_2_id"] + entity_ids = { + "version": [version_1_id, version_2_id], + "product": [reviewables_fixture["product_id"]], + "folder": [reviewables_fixture["folder_id"]], + "task": [reviewables_fixture["task_id"]], + }[entity_type] + + by_id = _reviewables_by_version_id( + get_reviewables(project_name, entity_type, entity_ids) + ) + assert {version_1_id, version_2_id} <= set(by_id) + assert {r["activityId"] for r in by_id[version_1_id]} == set( + reviewables_fixture["v1_activity_ids"] + ) + assert {r["activityId"] for r in by_id[version_2_id]} == set( + reviewables_fixture["v2_activity_ids"] + ) + + # Iterable (not only list) of entity ids is accepted + latest = get_reviewables( + project_name, + entity_type, + iter(entity_ids), + latest=entity_type != "version", + ) + latest_ids = {v["id"] for v in latest} + if entity_type == "version": + assert latest_ids == {version_1_id, version_2_id} + else: + assert latest_ids == {version_2_id} + + +def test_get_reviewables_empty_ids(reviewables_fixture): + """Batch reviewables endpoint with unknown ids returns empty list.""" + result = get_reviewables( + reviewables_fixture["project_name"], + "version", + [uuid.uuid4().hex], + ) + assert result == [] + + +def test_sort_version_reviewables(reviewables_fixture): + """Order of reviewables is changed and invalid order is refused.""" + project_name = reviewables_fixture["project_name"] + version_id = reviewables_fixture["version_1_id"] + + current_order = [ + r["activityId"] + for r in get_version_reviewables( + project_name, version_id + )["reviewables"] + ] + assert len(current_order) == 2 + + for new_order in ( + list(reversed(current_order)), + list(current_order), + ): + sort_version_reviewables(project_name, version_id, new_order) + version = get_version_reviewables(project_name, version_id) + assert [ + r["activityId"] for r in version["reviewables"] + ] == new_order + + # All reviewables of the version must be passed + with pytest.raises(HTTPRequestError): + sort_version_reviewables( + project_name, version_id, current_order[:1] + ) + + # Reviewables of other versions are not allowed + with pytest.raises(HTTPRequestError): + sort_version_reviewables( + project_name, + version_id, + current_order + reviewables_fixture["v2_activity_ids"], + ) + + +def test_update_reviewable(reviewables_fixture): + """Label of reviewable can be changed.""" + project_name = reviewables_fixture["project_name"] + version_id = reviewables_fixture["version_2_id"] + activity_id = reviewables_fixture["v2_activity_ids"][0] + new_label = _unique_name("label") + + update_reviewable( + project_name, version_id, activity_id, label=new_label + ) + version = get_version_reviewables(project_name, version_id) + labels = { + r["activityId"]: r["label"] + for r in version["reviewables"] + } + assert labels == {activity_id: new_label} + + # Nothing to change -> no error and label stays + update_reviewable(project_name, version_id, activity_id) + version = get_version_reviewables(project_name, version_id) + assert version["reviewables"][0]["label"] == new_label + + # Empty label is refused by server + with pytest.raises(HTTPRequestError): + update_reviewable(project_name, version_id, activity_id, label="") + + +def _get_assignees(project_name, task_id): + task = get_task_by_id( + project_name, task_id, fields={"id", "assignees"} + ) + return set(task["assignees"]) + + +def test_assign_users_to_task( + project_entity_fixture, # noqa: F811 + task_fixture, +): + """Current user is added, removed and set as task assignee.""" + project_name = project_entity_fixture["name"] + task_id = task_fixture + username = get_user()["name"] + + assert _get_assignees(project_name, task_id) == set() + + # Default mode is 'add' + assign_users_to_task(project_name, task_id, [username]) + assert _get_assignees(project_name, task_id) == {username} + + # Adding already assigned user does not change anything + assign_users_to_task(project_name, task_id, [username], mode="add") + assert _get_assignees(project_name, task_id) == {username} + + assign_users_to_task(project_name, task_id, [username], mode="remove") + assert _get_assignees(project_name, task_id) == set() + + # Removing not assigned user does not change anything + assign_users_to_task(project_name, task_id, [username], mode="remove") + assert _get_assignees(project_name, task_id) == set() + + # Iterable (not only list) of users is accepted + assign_users_to_task( + project_name, task_id, iter([username]), mode="set" + ) + assert _get_assignees(project_name, task_id) == {username} + + assign_users_to_task(project_name, task_id, [], mode="set") + assert _get_assignees(project_name, task_id) == set() + + +@pytest.fixture +def entity_list_fixture( + project_entity_fixture, # noqa: F811 + folder_fixture, +): + """Create entity list of folders with 3 items. + + Yields: + tuple[str, str, list[str]]: Project name, list id and item ids + in their initial order. + + """ + project_name = project_entity_fixture["name"] + folder_type = project_entity_fixture["folderTypes"][0]["name"] + folder_ids = [ + create_folder( + project_name, + _unique_name("list_item"), + folder_type=folder_type, + parent_id=folder_fixture, + ) + for _ in range(3) + ] + list_id = create_entity_list( + project_name, "folder", _unique_name("test_list") + ) + try: + item_ids = [ + create_entity_list_item( + project_name, list_id, folder_id, position=idx + ) + for idx, folder_id in enumerate(folder_ids) + ] + yield project_name, list_id, item_ids + finally: + delete_entity_list(project_name, list_id) + + +def _get_list_item_ids_in_order(project_name, list_id): + entity_list = get_entity_list_rest(project_name, list_id) + items = sorted(entity_list["items"], key=lambda i: i["position"]) + return [item["id"] for item in items] + + +def test_set_entity_list_items_order(entity_list_fixture): + """Order of entity list items is changed.""" + if get_server_version_tuple() < (1, 15, 9): + pytest.skip("Items order endpoint requires AYON server 1.15.9+") + + project_name, list_id, item_ids = entity_list_fixture + assert _get_list_item_ids_in_order(project_name, list_id) == item_ids + + new_order = [item_ids[2], item_ids[0], item_ids[1]] + set_entity_list_items_order(project_name, list_id, new_order) + assert _get_list_item_ids_in_order(project_name, list_id) == new_order + + # Order must contain each item exactly once + for invalid_order in ( + new_order[:2], + new_order + [new_order[0]], + new_order[:2] + [uuid.uuid4().hex], + ): + with pytest.raises(HTTPRequestError): + set_entity_list_items_order( + project_name, list_id, invalid_order + ) + assert _get_list_item_ids_in_order(project_name, list_id) == new_order + + +def test_materialize_entity_list(entity_list_fixture): + """Materialize entity list returns list summary. + + Materialization is not implemented on some server versions + (e.g. 1.16.8 responds with 501), in that case the test is skipped. + + """ + project_name, list_id, item_ids = entity_list_fixture + try: + summary = materialize_entity_list(project_name, list_id) + except HTTPRequestError as exc: + if exc.response is not None and exc.response.status_code == 501: + pytest.skip("Materialize is not implemented on server") + raise + + assert summary["id"] == list_id + assert summary["entityType"] == "folder" + assert "label" in summary + assert summary["count"] >= len(item_ids) diff --git a/tests/test_server_version_validation.py b/tests/test_server_version_validation.py new file mode 100644 index 000000000..5a09f7152 --- /dev/null +++ b/tests/test_server_version_validation.py @@ -0,0 +1,348 @@ +"""Tests of server version validation of version dependent methods. + +Methods using endpoints or arguments that were added in newer AYON server +versions raise 'UnsupportedServerVersion' when the connected server is older. +These tests patch the server version of a 'ServerAPI' instance and replace +all request methods so no request can reach any server. + +These tests do NOT need a running AYON server. + +""" +import io + +import pytest + +from ayon_api import ServerAPI +from ayon_api.exceptions import UnsupportedServerVersion + +# Minimal PNG signature so mime type detection recognizes the content +PNG_CONTENT = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + +# Methods that send a request to server (or would fetch server info) +REQUEST_METHODS = ( + "get_info", + "get", + "post", + "put", + "patch", + "delete", + "raw_get", + "raw_post", + "raw_put", + "raw_patch", + "raw_delete", + "query_graphql", + "upload_file", + "upload_file_from_stream", + "_upload_file", + "_do_rest_request", +) + + +class RequestSent(Exception): + """Raised by patched request methods instead of sending a request.""" + def __init__(self, method_name, args, kwargs): + super().__init__(method_name) + self.method_name = method_name + self.call_args = args + self.call_kwargs = kwargs + + +def _version_below(threshold): + """Version tuple just below the threshold version.""" + major, minor, patch = threshold + if patch > 0: + return major, minor, patch - 1, "", "" + return major, minor - 1, 99, "", "" + + +def _version_at(threshold): + major, minor, patch = threshold + return major, minor, patch, "", "" + + +def _create_blocker(method_name): + def _blocker(*args, **kwargs): + raise RequestSent(method_name, args, kwargs) + return _blocker + + +def _create_api(server_version): + """Create 'ServerAPI' that cannot send any request. + + Args: + server_version (ServerVersion): Version returned by + 'get_server_version_tuple'. + + Returns: + ServerAPI: Server api object. + + """ + api = ServerAPI( + "http://127.0.0.1:1", + site_id=None, + create_session=False, + ) + api.get_server_version_tuple = lambda: server_version + for method_name in REQUEST_METHODS: + setattr(api, method_name, _create_blocker(method_name)) + return api + + +@pytest.fixture +def thumbnail_path(tmp_path): + path = tmp_path / "thumbnail.png" + path.write_bytes(PNG_CONTENT) + return str(path) + + +# Each item contains test id, minimum server version and callback that +# calls the gated method with gated argument. +GATED_CALLS = [ + ( + "rename_project", + (1, 15, 0), + lambda api, path: api.rename_project("old_name", "new_name"), + ), + ( + "get_project_thumbnail", + (1, 15, 0), + lambda api, path: api.get_project_thumbnail("project"), + ), + ( + "set_project_thumbnail", + (1, 15, 0), + lambda api, path: api.set_project_thumbnail("project", path), + ), + ( + "set_project_thumbnail_from_stream", + (1, 15, 0), + lambda api, path: api.set_project_thumbnail_from_stream( + "project", io.BytesIO(PNG_CONTENT) + ), + ), + ( + "search_folders-search", + (1, 15, 4), + lambda api, path: api.search_folders("project", search="text"), + ), + ( + "update_server_config", + (1, 15, 9), + lambda api, path: api.update_server_config({"studio_name": "x"}), + ), + ( + "set_entity_list_items_order", + (1, 15, 9), + lambda api, path: api.set_entity_list_items_order( + "project", "list_id", ["item_id"] + ), + ), + ( + "get_enums", + (1, 16, 4), + lambda api, path: api.get_enums(), + ), + ( + "get_reviewables", + (1, 16, 4), + lambda api, path: api.get_reviewables( + "project", "version", ["version_id"] + ), + ), + ( + "get_version_reviewables-latest", + (1, 16, 4), + lambda api, path: api.get_version_reviewables( + "project", "version_id", latest=True + ), + ), + ( + "get_version_reviewables-latest_done", + (1, 16, 4), + lambda api, path: api.get_version_reviewables( + "project", "version_id", latest_done=True + ), + ), + ( + "get_product_reviewables-latest", + (1, 16, 4), + lambda api, path: api.get_product_reviewables( + "project", "product_id", latest=True + ), + ), + ( + "get_folder_reviewables-latest_done", + (1, 16, 4), + lambda api, path: api.get_folder_reviewables( + "project", "folder_id", latest_done=True + ), + ), + ( + "get_task_reviewables-latest", + (1, 16, 4), + lambda api, path: api.get_task_reviewables( + "project", "task_id", latest=True + ), + ), + ( + "update_service-error", + (1, 16, 7), + lambda api, path: api.update_service( + "service", should_run=False, error="Failed" + ), + ), + ( + "migrate_addon_settings", + (1, 16, 8), + lambda api, path: api.migrate_addon_settings( + "addon", "1.0.0", "1.0.1" + ), + ), +] + +# Calls of gated methods without the gated argument, they must work +# with server older than the version required by the argument. +UNGATED_ARG_CALLS = [ + ( + "search_folders-no-search", + (1, 15, 4), + lambda api: api.search_folders("project", folder_search="text"), + ), + ( + "get_version_reviewables", + (1, 16, 4), + lambda api: api.get_version_reviewables("project", "version_id"), + ), + ( + "get_product_reviewables", + (1, 16, 4), + lambda api: api.get_product_reviewables("project", "product_id"), + ), + ( + "get_folder_reviewables", + (1, 16, 4), + lambda api: api.get_folder_reviewables("project", "folder_id"), + ), + ( + "get_task_reviewables", + (1, 16, 4), + lambda api: api.get_task_reviewables("project", "task_id"), + ), + ( + "update_service-no-error", + (1, 16, 7), + lambda api: api.update_service("service", should_run=False), + ), +] + + +def _gated_params(calls): + return [ + pytest.param(threshold, func, id=test_id) + for test_id, threshold, func in calls + ] + + +@pytest.mark.parametrize("threshold, func", _gated_params(GATED_CALLS)) +def test_unsupported_version_raises(threshold, func, thumbnail_path): + """Gated method raises before any request on older server. + + Verifies: + - 'UnsupportedServerVersion' is raised when server version is + just below the required version. + - No request method is called (blocked request methods would + raise 'RequestSent' instead). + + """ + api = _create_api(_version_below(threshold)) + with pytest.raises(UnsupportedServerVersion): + func(api, thumbnail_path) + + +@pytest.mark.parametrize("threshold, func", _gated_params(GATED_CALLS)) +def test_supported_version_sends_request(threshold, func, thumbnail_path): + """Gated method sends request on server with the required version. + + Verifies: + - Version check does not raise when server version equals + the required version and request is sent. + + """ + api = _create_api(_version_at(threshold)) + with pytest.raises(RequestSent): + func(api, thumbnail_path) + + +@pytest.mark.parametrize( + "threshold, func", _gated_params(UNGATED_ARG_CALLS) +) +def test_ungated_arguments_on_older_server(threshold, func): + """Method without gated argument works on older server. + + Verifies: + - Request is sent when the gated argument is not used even if + server version is below the version required by the argument. + + """ + api = _create_api(_version_below(threshold)) + with pytest.raises(RequestSent): + func(api) + + +class _FakeResponse: + def raise_for_status(self): + pass + + +def _create_rename_user_api(server_version): + api = _create_api(server_version) + calls = [] + + def _create_recorder(method_name): + def _recorder(entrypoint, **kwargs): + calls.append((method_name, entrypoint, kwargs)) + return _FakeResponse() + return _recorder + + api.post = _create_recorder("post") + api.patch = _create_recorder("patch") + return api, calls + + +@pytest.mark.parametrize( + "server_version", + [(1, 14, 99, "", ""), (1, 14, 0, "", ""), (1, 13, 5, "", "")] +) +def test_rename_user_old_server_uses_patch(server_version): + """'rename_user' uses deprecated PATCH endpoint before 1.15.0. + + Verifies: + - Single PATCH request is sent to 'users/{name}/rename'. + - New name is passed in 'newName' key. + + """ + api, calls = _create_rename_user_api(server_version) + api.rename_user("old_user", "new_user") + assert calls == [ + ("patch", "users/old_user/rename", {"newName": "new_user"}) + ] + + +@pytest.mark.parametrize( + "server_version", + [(1, 15, 0, "", ""), (1, 15, 1, "", ""), (1, 16, 8, "", "")] +) +def test_rename_user_new_server_uses_post(server_version): + """'rename_user' uses POST endpoint from 1.15.0. + + Verifies: + - Single POST request is sent to 'users/{name}/rename'. + - New name is passed in 'name' key. + + """ + api, calls = _create_rename_user_api(server_version) + api.rename_user("old_user", "new_user") + assert calls == [ + ("post", "users/old_user/rename", {"name": "new_user"}) + ] diff --git a/tests/test_system_api.py b/tests/test_system_api.py new file mode 100644 index 000000000..18434861d --- /dev/null +++ b/tests/test_system_api.py @@ -0,0 +1,531 @@ +"""Tests of system, services and events related server API. + +Tests run against a real AYON server and require an admin user. + +To run use: pytest --envfile {environment path}. +Make sure you have set AYON_SERVER_URL and AYON_API_KEY in your environment. + +All entities created by the tests use unique names and are removed after +the test. Server-wide state that is changed by a test is restored. + +""" +import time +import uuid + +import pytest + +from ayon_api import ( + delete_event, + delete_events, + delete_service, + delete_site, + dispatch_event, + get_addons_info, + get_enum, + get_enums, + get_events, + get_hosts, + get_project, + get_restart_required, + get_server_api_connection, + get_server_config, + get_server_config_schema, + get_server_version_tuple, + get_services, + get_sites, + get_user, + set_restart_required, + spawn_service, + update_server_config, + update_service, + exceptions, +) + +TEST_PREFIX = "ayon-api-test" + + +def _unique_name(prefix=TEST_PREFIX): + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + +def _topic_filter(topic): + """Events filter matching single topic. + + Never returns empty filter, empty filter would delete all events. + + """ + assert topic + return { + "conditions": [ + { + "key": "topic", + "value": topic, + "operator": "eq", + } + ], + "operator": "and", + } + + +def _require_server_version(version, func_name): + if get_server_version_tuple() < version: + version_str = ".".join(str(part) for part in version) + pytest.skip( + f"'{func_name}' requires AYON server {version_str} or newer" + ) + + +def _get_service(service_name): + for service in get_services(): + if service["name"] == service_name: + return service + return None + + +def _find_addon_service(): + """Find addon version that defines a service with an image. + + Returns: + Optional[tuple[str, str, str]]: Addon name, addon version and + service name. + + """ + for addon in get_addons_info(details=True)["addons"]: + for version, version_info in addon["versions"].items(): + services = version_info.get("services") or {} + for service_name, service_def in services.items(): + if isinstance(service_def, dict) and service_def.get("image"): + return addon["name"], version, service_name + return None + + +# --- Services --- +def test_get_services(): + """Tests 'get_services' returns services in expected shape. + + Verifies: + - Result is a list. + - Each service contains name, host, addon and run state keys. + + """ + services = get_services() + assert isinstance(services, list) + for service in services: + for key in ( + "name", + "hostname", + "addonName", + "addonVersion", + "service", + "shouldRun", + "isRunning", + "data", + ): + assert key in service + + +def test_get_hosts(): + """Tests 'get_hosts' returns hosts in expected shape. + + Verifies: + - Result is a list. + - Each host contains name, last seen time and health information. + + """ + hosts = get_hosts() + assert isinstance(hosts, list) + for host in hosts: + assert isinstance(host["name"], str) + assert "lastSeen" in host + assert set(host["health"]) >= {"cpu", "mem"} + + +@pytest.fixture +def addon_service(): + """Addon service definition usable for spawning a service. + + Server allows spawning only services defined by installed addon, + the test is skipped if there is no such addon on server. + + """ + addon_service = _find_addon_service() + if addon_service is None: + pytest.skip("Server does not have any addon defining a service") + return addon_service + + +@pytest.fixture +def spawned_service_name(): + """Unique service name, the service is deleted on teardown.""" + service_name = _unique_name() + yield service_name + if _get_service(service_name) is not None: + delete_service(service_name) + + +def test_spawn_update_delete_service(addon_service, spawned_service_name): + """Tests spawn, update and delete of addon service. + + Service is spawned on a non-existent hostname so no service host + will ever run it, and it is stopped right after spawn. + + Verifies: + - Spawned service is listed with passed hostname and addon info. + - 'update_service' changes 'shouldRun', 'hostname' and config. + - 'update_service' sets 'error' (server 1.16.7+). + - Deleted service is not listed anymore. + + """ + addon_name, addon_version, service = addon_service + service_name = spawned_service_name + hostname = _unique_name(f"{TEST_PREFIX}-host") + spawn_service( + service_name, + addon_name, + addon_version, + service, + hostname, + ) + # Stop the service immediately + update_service(service_name, should_run=False) + + service_data = _get_service(service_name) + assert service_data is not None + assert service_data["hostname"] == hostname + assert service_data["addonName"] == addon_name + assert service_data["addonVersion"] == addon_version + assert service_data["service"] == service + assert service_data["shouldRun"] is False + + new_hostname = _unique_name(f"{TEST_PREFIX}-host") + env = {"AYON_API_TEST": service_name} + update_service( + service_name, + hostname=new_hostname, + config={"env": env}, + ) + service_data = _get_service(service_name) + assert service_data["hostname"] == new_hostname + assert service_data["shouldRun"] is False + assert service_data["data"]["env"] == env + + if get_server_version_tuple() >= (1, 16, 7): + error_msg = "AYON api test error" + update_service(service_name, should_run=False, error=error_msg) + service_data = _get_service(service_name) + assert service_data["data"]["error"] == error_msg + + delete_service(service_name) + assert _get_service(service_name) is None + + +# --- Sites --- +@pytest.fixture +def temp_site(): + """Register temporary site on server. + + Server registers a site when '/api/info' is requested with site + headers 'x-ayon-site-id', 'x-ayon-platform', 'x-ayon-hostname' + and 'x-ayon-version'. Site is deleted on teardown if still exists. + + """ + site_id = _unique_name() + site_info = { + "id": site_id, + "platform": "linux", + "hostname": _unique_name(f"{TEST_PREFIX}-host"), + "version": "0.0.0", + } + con = get_server_api_connection() + headers = con.get_headers() + headers.update({ + "x-ayon-site-id": site_info["id"], + "x-ayon-platform": site_info["platform"], + "x-ayon-hostname": site_info["hostname"], + "x-ayon-version": site_info["version"], + }) + response = con.raw_get("info", headers=headers) + response.raise_for_status() + + yield site_info + + if any(site["id"] == site_id for site in get_sites()): + delete_site(site_id) + + +def test_get_sites(temp_site): + """Tests 'get_sites' with and without filters. + + Verifies: + - Registered site is listed with platform, hostname, version + and the current user. + - Filter by hostname returns only the registered site. + - Filter by different platform does not return the site. + + """ + sites = get_sites() + assert isinstance(sites, list) + site_by_id = {site["id"]: site for site in sites} + assert temp_site["id"] in site_by_id + site = site_by_id[temp_site["id"]] + assert site["platform"] == temp_site["platform"] + assert site["hostname"] == temp_site["hostname"] + assert site["version"] == temp_site["version"] + assert get_user()["name"] in site["users"] + + sites = get_sites(hostname=temp_site["hostname"]) + assert [site["id"] for site in sites] == [temp_site["id"]] + + sites = get_sites( + platform_name=temp_site["platform"], + hostname=temp_site["hostname"], + ) + assert [site["id"] for site in sites] == [temp_site["id"]] + + sites = get_sites( + platform_name="windows", + hostname=temp_site["hostname"], + ) + assert sites == [] + + +def test_delete_site(temp_site): + """Tests 'delete_site' unregisters site. + + Verifies: + - Deleted site is not listed anymore. + - Deleting a non-existent site raises an error. + + """ + site_id = temp_site["id"] + delete_site(site_id) + assert all(site["id"] != site_id for site in get_sites()) + + with pytest.raises(exceptions.HTTPRequestError): + delete_site(site_id) + + +# --- Restart required --- +def test_get_restart_required(): + """Tests 'get_restart_required' response shape.""" + result = get_restart_required() + assert isinstance(result["required"], bool) + if result["required"]: + assert isinstance(result["reason"], str) + + +def test_set_restart_required(): + """Tests set and clear of server restart required flag. + + Test is skipped if server already requires restart, so the original + flag is not changed. + + Verifies: + - Flag is set after 'set_restart_required(True)'. + - Flag is cleared after 'set_restart_required(False)'. + + """ + original = get_restart_required() + if original["required"]: + pytest.skip("Server already requires restart, not changing the flag") + + try: + set_restart_required(True, reason="AYON api test") + # Server ignores restart required events younger than 2 seconds + time.sleep(2.5) + result = get_restart_required() + assert result["required"] is True + assert isinstance(result["reason"], str) + + finally: + set_restart_required(False) + + assert get_restart_required()["required"] is False + + +# --- Server config --- +def test_get_server_config_schema(): + """Tests 'get_server_config_schema' returns JSON schema. + + Verifies: + - Schema has object properties containing 'studio_name' which + is also available in server config. + + """ + schema = get_server_config_schema() + assert isinstance(schema, dict) + assert schema["title"] == "Server Configuration" + assert "studio_name" in schema["properties"] + assert "studio_name" in get_server_config() + + +def test_update_server_config(): + """Tests partial update of server config. + + Studio name is changed and restored afterwards. + + Verifies: + - Studio name is changed. + - Other config values are unchanged. + - Original studio name is restored. + + """ + _require_server_version((1, 15, 9), "update_server_config") + + original_config = get_server_config() + original_name = original_config["studio_name"] + new_name = f"AYON api test {uuid.uuid4().hex[:8]}" + try: + update_server_config({"studio_name": new_name}) + config = get_server_config() + assert config["studio_name"] == new_name + + config.pop("studio_name") + expected = dict(original_config) + expected.pop("studio_name") + assert config == expected + + finally: + update_server_config({"studio_name": original_name}) + + assert get_server_config()["studio_name"] == original_name + + +# --- Enums --- +def test_get_enums(): + """Tests 'get_enums' lists enum resolvers. + + Verifies: + - Each resolver has name, label and accepted params. + - Built-in resolvers are available. + + """ + _require_server_version((1, 16, 4), "get_enums") + + enums = get_enums() + assert isinstance(enums, list) + for enum_info in enums: + assert isinstance(enum_info["name"], str) + assert isinstance(enum_info["label"], str) + assert isinstance(enum_info["acceptedParams"], dict) + + enums_by_name = {enum_info["name"]: enum_info for enum_info in enums} + assert {"folderTypes", "taskTypes", "statuses"} <= set(enums_by_name) + assert "project_name" in enums_by_name["folderTypes"]["acceptedParams"] + + +def test_get_enum(project_entity_fixture): + """Tests 'get_enum' with project, user and context parameters. + + Verifies: + - Items without project have value and label. + - Project folder types match folder types of the project. + - Resolving for user (admin only) works. + - Context parameters are passed to the resolver. + - Invalid context value raises an error. + + """ + project_name = project_entity_fixture["name"] + project_entity = get_project(project_name) + + items = get_enum("folderTypes") + assert isinstance(items, list) + for item in items: + assert "value" in item + assert isinstance(item["label"], str) + + items = get_enum("folderTypes", project_name=project_name) + expected_folder_types = { + folder_type["name"] + for folder_type in project_entity["folderTypes"] + } + assert {item["value"] for item in items} == expected_folder_types + + items = get_enum( + "folderTypes", + project_name=project_name, + user_name=get_user()["name"], + ) + assert {item["value"] for item in items} == expected_folder_types + + expected_statuses = { + status["name"] + for status in project_entity["statuses"] + if "task" in (status.get("scope") or []) + } + items = get_enum( + "statuses", + project_name=project_name, + context={"entity_type": "task"}, + ) + assert {item["value"] for item in items} == expected_statuses + + with pytest.raises(exceptions.HTTPRequestError): + get_enum( + "statuses", + project_name=project_name, + context={"entity_type": "invalid"}, + ) + + +# --- Events --- +@pytest.fixture +def event_topics(): + """Two unique event topics, events are deleted on teardown.""" + topics = [ + f"ayon_api.test.delete_events.{uuid.uuid4().hex}" + for _ in range(2) + ] + yield topics + for event in list(get_events(topics=topics, fields={"id"})): + delete_event(event["id"]) + + +def _get_event_ids(topic): + return { + event["id"] + for event in get_events(topics=[topic], fields={"id"}) + } + + +def test_delete_events(event_topics): + """Tests 'delete_events' removes only events matching filter. + + Verifies: + - Events with filtered topic are deleted. + - Events with other topic are kept. + + """ + topic_to_delete, topic_to_keep = event_topics + created_ids = {} + for topic in event_topics: + created_ids[topic] = { + dispatch_event( + topic, + sender="ayon_api_tests", + description=f"AYON api test event {idx}", + )["id"] + for idx in range(3) + } + + for topic in event_topics: + assert _get_event_ids(topic) == created_ids[topic] + + delete_events(_topic_filter(topic_to_delete)) + + assert _get_event_ids(topic_to_delete) == set() + assert _get_event_ids(topic_to_keep) == created_ids[topic_to_keep] + + +@pytest.mark.parametrize( + "events_filter", + [ + None, + {}, + {"conditions": [], "operator": "and"}, + ], +) +def test_delete_events_without_conditions_raises(events_filter): + """Tests 'delete_events' refuses filter that would delete all events.""" + with pytest.raises(ValueError): + delete_events(events_filter) diff --git a/tests/test_users_api.py b/tests/test_users_api.py new file mode 100644 index 000000000..94a1ffdb8 --- /dev/null +++ b/tests/test_users_api.py @@ -0,0 +1,670 @@ +"""Tests of users, access groups, teams and project users endpoints. + +Tests are running against a real AYON server and expect that the +connection is authorized as an admin user. + +To run use: pytest --envfile {environment path}. +Make sure you have set AYON_SERVER_URL and AYON_API_KEY in your +environment. + +All users, access groups and teams created by the tests have unique names +and are removed at the end of each test. + +""" + +import time +import uuid + +import pytest + +from ayon_api import ( + ServerAPI, + get_base_url, + get_user, + create_user, + update_user, + delete_user, + rename_user, + change_password, + set_user_access_groups, + get_user_sessions, + delete_user_session, + get_user_api_keys, + create_user_api_key, + update_user_api_key, + delete_user_api_key, + get_user_permissions, + get_access_groups, + get_access_group_schema, + get_access_group, + save_access_group, + delete_access_group, + set_projects_access, + get_teams, + update_teams, + save_team, + delete_team, + save_team_member, + delete_team_member, + get_project_users, + set_project_user_access_groups, +) +from ayon_api.exceptions import AuthenticationError, HTTPRequestError + +from .conftest import project_entity_fixture + + +def _unique_name(prefix): + return f"{prefix}{uuid.uuid4().hex[:12]}" + + +def _new_password(): + # Admin is not affected by password complexity check, but use + # a strong password anyway + return f"Ayon_{uuid.uuid4().hex}.9" + + +def _user_exists(user_name): + try: + return get_user(user_name) is not None + except HTTPRequestError: + return False + + +def _safe_delete_user(user_name): + if _user_exists(user_name): + delete_user(user_name) + + +@pytest.fixture +def temp_user(): + """Create a temporary non-manager user with a password. + + Yields: + dict[str, str]: Mutable dictionary with 'name' and 'password'. + Tests that rename the user must update the 'name' so + the teardown removes the right user. + + """ + user_info = { + "name": _unique_name("ayonapitest"), + "password": _new_password(), + } + create_user( + user_info["name"], + password=user_info["password"], + attrib={"fullName": "AYON API Test User"}, + data={"isAdmin": False, "isManager": False}, + active=True, + ) + try: + yield user_info + finally: + _safe_delete_user(user_info["name"]) + + +@pytest.fixture +def temp_access_group(): + """Create a temporary studio level access group. + + Yields: + str: Name of the access group. + + """ + access_group_name = _unique_name("ayonapitest_ag_") + save_access_group(access_group_name, {"read": {"enabled": False}}) + try: + yield access_group_name + finally: + studio_names = { + access_group["name"] + for access_group in get_access_groups() + } + if access_group_name in studio_names: + delete_access_group(access_group_name) + + +def _login(user_name, password): + """Login to server as a different user. + + Returns: + ServerAPI: Connection authorized as the user. + + """ + con = ServerAPI(get_base_url()) + con.login(user_name, password) + return con + + +def _get_team(project_name, team_name, show_members=True): + for team in get_teams(project_name, show_members=show_members): + if team["name"] == team_name: + return team + return None + + +def _safe_delete_team(project_name, team_name): + if _get_team(project_name, team_name, show_members=False) is not None: + delete_team(project_name, team_name) + + +# --- Users --- +def test_create_update_delete_user(): + """Create a user, update attributes and active state, then delete.""" + user_name = _unique_name("ayonapitest") + create_user( + user_name, + password=_new_password(), + attrib={"fullName": "Before Update"}, + active=True, + ) + try: + user = get_user(user_name) + assert user["name"] == user_name + assert user["attrib"]["fullName"] == "Before Update" + assert user["active"] is True + + # Creating already existing user fails + with pytest.raises(HTTPRequestError): + create_user(user_name, password=_new_password()) + + update_user( + user_name, + attrib={"fullName": "After Update"}, + active=False, + ) + user = get_user(user_name) + assert user["attrib"]["fullName"] == "After Update" + assert user["active"] is False + + delete_user(user_name) + assert not _user_exists(user_name) + finally: + _safe_delete_user(user_name) + + +def test_create_service_user_with_api_key(): + """Service users can be created with api key, and key can change. + + Service users must not have password. + """ + user_name = _unique_name("ayonapitestsvc") + create_user( + user_name, + api_key=uuid.uuid4().hex, + data={"isService": True}, + ) + try: + user = get_user(user_name) + assert user["name"] == user_name + assert user["data"].get("isService") is True + + change_password(user_name, api_key=uuid.uuid4().hex) + + # Password can't be set on service user on creation + invalid_name = _unique_name("ayonapitestsvc") + try: + with pytest.raises(HTTPRequestError): + create_user( + invalid_name, + password=_new_password(), + data={"isService": True}, + ) + finally: + _safe_delete_user(invalid_name) + finally: + _safe_delete_user(user_name) + + +def test_rename_user(temp_user): + """Rename user and validate the old name does not exist anymore.""" + old_name = temp_user["name"] + new_name = _unique_name("ayonapitestrenamed") + rename_user(old_name, new_name) + # Make sure the fixture deletes the renamed user + temp_user["name"] = new_name + + assert not _user_exists(old_name) + user = get_user(new_name) + assert user["name"] == new_name + assert user["attrib"]["fullName"] == "AYON API Test User" + + +def test_change_password(temp_user): + """Change password and validate login works only with the new one.""" + user_name = temp_user["name"] + new_password = _new_password() + + with pytest.raises(ValueError): + change_password(user_name) + + change_password(user_name, password=new_password) + temp_user["password"] = new_password + + con = _login(user_name, new_password) + try: + assert con.get_user()["name"] == user_name + finally: + con.logout() + + with pytest.raises(AuthenticationError): + _login(user_name, "invalid_" + new_password) + + +def test_set_user_access_groups( + project_entity_fixture, temp_user, temp_access_group +): + """Set and remove access groups of a user on a project.""" + project_name = project_entity_fixture["name"] + user_name = temp_user["name"] + + set_user_access_groups( + user_name, {project_name: [temp_access_group]} + ) + user = get_user(user_name) + assert user["data"]["accessGroups"][project_name] == [ + temp_access_group + ] + assert get_project_users(project_name)[user_name] == [ + temp_access_group + ] + + # Empty list removes access to the project + set_user_access_groups(user_name, {project_name: []}) + user = get_user(user_name) + assert project_name not in user["data"].get("accessGroups", {}) + assert user_name not in get_project_users(project_name) + + +def test_user_sessions(temp_user): + """Login as temp user, list its sessions and delete the session.""" + user_name = temp_user["name"] + con = _login(user_name, temp_user["password"]) + try: + token = con.access_token + sessions = get_user_sessions(user_name) + assert isinstance(sessions, list) + sessions_by_token = { + session["token"]: session + for session in sessions + } + assert token in sessions_by_token + session = sessions_by_token[token] + assert session["isService"] is False + assert isinstance(session["lastUsed"], (int, float)) + + delete_user_session(user_name, token) + tokens = { + session["token"] + for session in get_user_sessions(user_name) + } + assert token not in tokens + + # Deleting not existing session fails + with pytest.raises(HTTPRequestError): + delete_user_session(user_name, token) + finally: + # Session was already removed on server, avoid logout request + con.logout(soft=True) + + +@pytest.fixture +def temp_user_with_api_keys(): + """Create a temporary user with initialized 'apiKeys' in data. + + Server (1.16.8) api key endpoints fail with 500 if user does not have + 'apiKeys' set in data, because 'data->'apiKeys'' is NULL and is + iterated. The key is pre-filled on creation to work around it. + + Yields: + str: Name of the user. + + """ + user_name = _unique_name("ayonapitest") + create_user( + user_name, + password=_new_password(), + data={"apiKeys": []}, + ) + try: + yield user_name + finally: + _safe_delete_user(user_name) + + +def test_user_api_keys(temp_user_with_api_keys): + """Create, list, update and delete api key of a user.""" + user_name = temp_user_with_api_keys + assert get_user_api_keys(user_name) == [] + + key = uuid.uuid4().hex + label = _unique_name("label_") + expires = time.time() + 3600 + create_user_api_key(user_name, label, key, expires=expires) + + api_keys = get_user_api_keys(user_name) + assert len(api_keys) == 1 + api_key = api_keys[0] + assert api_key["label"] == label + assert api_key["expires"] == pytest.approx(expires) + assert api_key["preview"] == f"{key[:4]}*****{key[-4:]}" + assert isinstance(api_key["created"], (int, float)) + api_key_id = api_key["id"] + + # Label must be unique + with pytest.raises(HTTPRequestError): + create_user_api_key(user_name, label, uuid.uuid4().hex) + + new_label = _unique_name("label_") + new_expires = expires + 3600 + update_user_api_key( + user_name, api_key_id, label=new_label, expires=new_expires + ) + api_key = get_user_api_keys(user_name)[0] + assert api_key["id"] == api_key_id + assert api_key["label"] == new_label + assert api_key["expires"] == pytest.approx(new_expires) + + # Update only label, expiration stays unchanged + update_user_api_key(user_name, api_key_id, label=label) + api_key = get_user_api_keys(user_name)[0] + assert api_key["label"] == label + assert api_key["expires"] == pytest.approx(new_expires) + + delete_user_api_key(user_name, api_key_id) + assert get_user_api_keys(user_name) == [] + + +def test_get_user_permissions( + project_entity_fixture, temp_user, temp_access_group +): + """Get permissions of current user and of other user.""" + project_name = project_entity_fixture["name"] + user_name = temp_user["name"] + + my_permissions = get_user_permissions() + assert my_permissions["user_level"] in ("admin", "manager") + + my_project_permissions = get_user_permissions( + project_name=project_name + ) + assert "read" in my_project_permissions + assert "project" in my_project_permissions + + studio_permissions = get_user_permissions(user_name) + assert "studio" in studio_permissions + + # User without access to the project can't have project permissions + with pytest.raises(HTTPRequestError): + get_user_permissions(user_name, project_name) + + set_user_access_groups( + user_name, {project_name: [temp_access_group]} + ) + try: + project_permissions = get_user_permissions(user_name, project_name) + assert "read" in project_permissions + finally: + set_user_access_groups(user_name, {project_name: []}) + + +# --- Access groups --- +def test_access_group_schema(project_entity_fixture): + """Access group schema is a settings schema with properties.""" + schema = get_access_group_schema() + assert isinstance(schema, dict) + assert "read" in schema["properties"] + + project_schema = get_access_group_schema( + project_entity_fixture["name"] + ) + assert "read" in project_schema["properties"] + + +def test_studio_access_group(): + """Create, update, read and delete studio level access group.""" + access_group_name = _unique_name("ayonapitest_ag_") + save_access_group(access_group_name, {"read": {"enabled": True}}) + try: + access_groups = { + access_group["name"]: access_group + for access_group in get_access_groups() + } + assert access_group_name in access_groups + assert access_groups[access_group_name]["isProjectLevel"] is False + + access_group = get_access_group(access_group_name) + assert access_group["read"]["enabled"] is True + + # Save existing access group updates it + save_access_group(access_group_name, {"read": {"enabled": False}}) + access_group = get_access_group(access_group_name) + assert access_group["read"]["enabled"] is False + + delete_access_group(access_group_name) + names = { + access_group["name"] + for access_group in get_access_groups() + } + assert access_group_name not in names + with pytest.raises(HTTPRequestError): + get_access_group(access_group_name) + finally: + names = { + access_group["name"] + for access_group in get_access_groups() + } + if access_group_name in names: + delete_access_group(access_group_name) + + +def test_project_access_group(project_entity_fixture, temp_access_group): + """Project level override of a studio access group.""" + project_name = project_entity_fixture["name"] + + access_groups = { + access_group["name"]: access_group + for access_group in get_access_groups(project_name) + } + assert access_groups[temp_access_group]["isProjectLevel"] is False + + save_access_group( + temp_access_group, + {"read": {"enabled": True}}, + project_name=project_name, + ) + try: + access_groups = { + access_group["name"]: access_group + for access_group in get_access_groups(project_name) + } + assert access_groups[temp_access_group]["isProjectLevel"] is True + + project_group = get_access_group(temp_access_group, project_name) + assert project_group["read"]["enabled"] is True + # Studio access group is not affected + studio_group = get_access_group(temp_access_group) + assert studio_group["read"]["enabled"] is False + finally: + delete_access_group(temp_access_group, project_name) + + access_groups = { + access_group["name"]: access_group + for access_group in get_access_groups(project_name) + } + assert access_groups[temp_access_group]["isProjectLevel"] is False + # Project access group falls back to studio data + project_group = get_access_group(temp_access_group, project_name) + assert project_group["read"]["enabled"] is False + + # Studio access group still exists + assert temp_access_group in { + access_group["name"] + for access_group in get_access_groups() + } + + +def test_set_projects_access( + project_entity_fixture, temp_user, temp_access_group +): + """Set and revoke project access of a user in bulk.""" + project_name = project_entity_fixture["name"] + user_name = temp_user["name"] + + set_projects_access({user_name: {project_name: [temp_access_group]}}) + try: + assert get_project_users(project_name)[user_name] == [ + temp_access_group + ] + user = get_user(user_name) + assert user["data"]["accessGroups"][project_name] == [ + temp_access_group + ] + finally: + set_projects_access({user_name: {project_name: []}}) + + assert user_name not in get_project_users(project_name) + + +# --- Project users --- +def test_project_users( + project_entity_fixture, temp_user, temp_access_group +): + """Set access groups of a user on a project via project endpoint.""" + project_name = project_entity_fixture["name"] + user_name = temp_user["name"] + + project_users = get_project_users(project_name) + assert isinstance(project_users, dict) + assert user_name not in project_users + + result = set_project_user_access_groups( + project_name, user_name, [temp_access_group] + ) + try: + assert result[user_name] == [temp_access_group] + assert get_project_users(project_name) == result + finally: + result = set_project_user_access_groups( + project_name, user_name, [] + ) + assert user_name not in result + assert user_name not in get_project_users(project_name) + + +# --- Teams --- +def test_teams(project_entity_fixture, temp_user): + """Create team, modify its members and delete it.""" + project_name = project_entity_fixture["name"] + user_name = temp_user["name"] + team_name = _unique_name("team_") + + try: + save_team( + project_name, + team_name, + [{"name": user_name, "leader": True, "roles": ["lead"]}], + ) + team = _get_team(project_name, team_name) + assert team is not None + assert team["memberCount"] == 1 + assert team["members"] == [ + {"name": user_name, "leader": True, "roles": ["lead"]} + ] + assert [leader["name"] for leader in team["leaders"]] == [ + user_name + ] + + # Members are not returned without 'show_members' + team = _get_team(project_name, team_name, show_members=False) + assert team.get("members") is None + assert team["memberCount"] == 1 + + # Replace member + save_team_member( + project_name, + team_name, + user_name, + leader=False, + roles=["artist", "compositor"], + ) + team = _get_team(project_name, team_name) + assert team["memberCount"] == 1 + assert team["members"] == [ + { + "name": user_name, + "leader": False, + "roles": ["artist", "compositor"], + } + ] + assert team["leaders"] == [] + + delete_team_member(project_name, team_name, user_name) + team = _get_team(project_name, team_name) + assert team["memberCount"] == 0 + assert team["members"] == [] + + # Saving member to not existing team fails + with pytest.raises(HTTPRequestError): + save_team_member( + project_name, _unique_name("team_"), user_name + ) + + delete_team(project_name, team_name) + assert _get_team(project_name, team_name) is None + finally: + _safe_delete_team(project_name, team_name) + + +def test_update_teams(project_entity_fixture, temp_user): + """Create and replace multiple teams at once.""" + project_name = project_entity_fixture["name"] + user_name = temp_user["name"] + team_names = [_unique_name("team_"), _unique_name("team_")] + + try: + update_teams( + project_name, + [ + {"name": team_names[0], "members": []}, + { + "name": team_names[1], + "members": [ + {"name": user_name, "leader": False, "roles": []} + ], + }, + ], + ) + teams_by_name = { + team["name"]: team + for team in get_teams(project_name, show_members=True) + } + assert teams_by_name[team_names[0]]["memberCount"] == 0 + assert teams_by_name[team_names[1]]["memberCount"] == 1 + + # Replace only first team, second must stay unchanged + update_teams( + project_name, + [ + { + "name": team_names[0], + "members": [ + {"name": user_name, "leader": True, "roles": []} + ], + }, + ], + ) + teams_by_name = { + team["name"]: team + for team in get_teams(project_name, show_members=True) + } + first_team = teams_by_name[team_names[0]] + assert first_team["memberCount"] == 1 + assert first_team["members"][0]["leader"] is True + assert teams_by_name[team_names[1]]["memberCount"] == 1 + finally: + for team_name in team_names: + _safe_delete_team(project_name, team_name) + + names = {team["name"] for team in get_teams(project_name)} + assert not names & set(team_names)