diff --git a/docs/reference.rst b/docs/reference.rst index 0c7f248f..db304d82 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -105,6 +105,16 @@ The documentation for all of the methods you'll need in your scripts lives in he Shotgun.schema Shotgun.entity_types +.. rubric:: Custom Entity Configuration + +.. autosummary:: + :nosignatures: + + Shotgun.custom_entity_read + Shotgun.custom_entity_enable + Shotgun.custom_entity_configure + Shotgun.custom_entity_disable + Connection & Authentication =========================== @@ -192,6 +202,17 @@ Methods allow you to introspect and modify the Shotgun schema. .. automethod:: Shotgun.schema .. automethod:: Shotgun.entity_types +Custom Entity Configuration +=========================== + +Methods to read and configure Custom Entities at the site level. They require administrator +privileges and a server running v8.88.0 or higher. + +.. automethod:: Shotgun.custom_entity_read +.. automethod:: Shotgun.custom_entity_enable +.. automethod:: Shotgun.custom_entity_configure +.. automethod:: Shotgun.custom_entity_disable + ********** Exceptions ********** diff --git a/shotgun_api3/shotgun.py b/shotgun_api3/shotgun.py index a51d4a46..e74b3cf0 100644 --- a/shotgun_api3/shotgun.py +++ b/shotgun_api3/shotgun.py @@ -329,6 +329,14 @@ def ensure_return_image_urls_support(self) -> bool: {"version": (3, 3, 0), "label": "return thumbnail URLs"}, False ) + def ensure_custom_entity_config_support(self) -> None: + """ + Ensures server has support for the custom entity config API (read, enable, configure, disable), added in v8.88.0. + """ + self._ensure_support( + {"version": (8, 88, 0), "label": "custom entity config API"} + ) + def __str__(self) -> str: return "ServerCapabilities: host %s, version %s, is_dev %s" % ( self.host, @@ -3671,6 +3679,152 @@ def user_subscriptions_create( return response.get("status") == "success" + def custom_entity_read(self, entity_type: str) -> Dict[str, Any]: + """ + Read the current configuration of a Custom Entity. + + >>> sg.custom_entity_read("CustomEntity08") + { + "entity_type": "CustomEntity08", + "enabled": True, + "display_name": "My Shots", + "entity_config": {"enable_tasks": True, ...} + } + + :param str entity_type: The Custom Entity type to read, in its singular + CamelCase form (e.g. ``"CustomEntity08"``). Required. + :returns: The entity config snapshot dict with ``entity_type``, ``enabled``, + ``display_name``, and ``entity_config``. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + return self._call_rpc("custom_entity_read", {"entity_type": entity_type}) + + def custom_entity_enable(self, entity_type: str) -> Dict[str, Any]: + """ + Enable a Custom Entity. + + This call is idempotent: if the entity is already enabled the current + snapshot is returned without error. To set the display name or feature + flags use :meth:`custom_entity_configure` after enabling. + + >>> sg.custom_entity_enable("CustomEntity08") + { + "entity_type": "CustomEntity08", + "enabled": True, + "display_name": "Custom Entity08", + "entity_config": { + "enable_tasks": False, + "enable_versions": False, + "enable_publishes": False, + "enable_detail_page": True, + "include_in_search": False, + "include_in_global_menu": True, + } + } + + :param str entity_type: The Custom Entity type to enable, in its singular + CamelCase form (e.g. ``"CustomEntity08"``). Required. + :returns: The entity config snapshot dict with ``entity_type``, ``enabled``, + ``display_name``, and ``entity_config``. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid + (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + return self._call_rpc("custom_entity_enable", {"entity_type": entity_type}) + + def custom_entity_configure( + self, + entity_type: str, + display_name: Optional[str] = None, + entity_config: Optional[Dict[str, bool]] = None, + ) -> Dict[str, Any]: + """ + Update an already-enabled Custom Entity's display name and/or feature flags. + + Only the keys present in ``entity_config`` are mutated; omitted flags + keep their current values. + + >>> sg.custom_entity_configure("CustomEntity08", display_name="Episode") + { + "entity_type": "CustomEntity08", + "enabled": True, + "display_name": "Episode", + "entity_config": {...} + } + + :param str entity_type: The Custom Entity type to configure, in its singular + CamelCase form (e.g. ``"CustomEntity08"``). The entity must already be + enabled. Required. + :param str display_name: Optional new display name for the entity. + :param dict entity_config: Optional dict of feature flag booleans. Only the + flags present are mutated; omitted flags are left unchanged. Keys and + boolean values are passed through as-is. Recognized flags: + ``enable_tasks``, ``enable_versions``, ``enable_publishes``, + ``enable_detail_page``, ``include_in_search``, ``include_in_global_menu``. + :returns: The updated entity config snapshot dict with ``entity_type``, + ``enabled``, ``display_name``, and ``entity_config``. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid or not + yet enabled (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + params = {"entity_type": entity_type} + if display_name is not None: + params["display_name"] = display_name + if entity_config is not None: + params["entity_config"] = entity_config + + return self._call_rpc("custom_entity_configure", params) + + def custom_entity_disable( + self, entity_type: str, force: bool = False + ) -> Dict[str, Any]: + """ + Disable a Custom Entity, clearing its feature flags. + + This call is idempotent: if the entity is already disabled the current + snapshot is returned without error and the record guard is not checked. + + Disabling a Custom Entity that has existing records does **not** delete the + data, but it does make the data inaccessible: the records will not appear in + the UI, will not be returned via the API, and any fields on other entities + that link to it become broken references. Because this is destructive in + effect, the server refuses to disable an entity that still has records unless + ``force`` is ``True``, and the error reports how many records were found. + + >>> sg.custom_entity_disable("CustomEntity08") + { + "entity_type": "CustomEntity08", + "enabled": False, + "display_name": "My Shots" + } + + :param str entity_type: The Custom Entity type to disable, in its singular + CamelCase form (e.g. ``"CustomEntity08"``). Required. + :param bool force: Disable the entity even if it still has active records. + Defaults to ``False``, which makes the call fail rather than render + existing data unreachable. Only a literal ``True`` is accepted; any + other truthy value is treated as ``False``. + :returns: The entity config snapshot dict with ``enabled`` set to ``False``. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid + (fault code 104), or if the entity has active records and ``force`` + was not set (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + params = {"entity_type": entity_type} + if force: + params["force"] = True + + return self._call_rpc("custom_entity_disable", params) + def _build_opener(self, handler) -> urllib.request.OpenerDirector: """ Build urllib2 opener with appropriate proxy handler. diff --git a/tests/test_unit.py b/tests/test_unit.py index 786a83f0..2ab08f62 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -854,5 +854,96 @@ def test_urlib(self): assert response is not None +class CustomEntityConfigTestBase(unittest.TestCase): + """Shared setup for the custom entity config API test cases. + + The custom_entity_* methods are gated on server version 8.88.0""" + + def setUp(self): + self.sg = api.Shotgun( + "http://server_path", "script_name", "api_key", connect=False + ) + self.set_server_version([8, 88, 0]) + + def set_server_version(self, version): + self.sg._server_caps = api.shotgun.ServerCapabilities( + self.sg.config.server, {"version": version} + ) + + +class TestShotgunCustomEntityRead(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_read""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_entity_type_sent(self, call_rpc): + self.sg.custom_entity_read("CustomEntity08") + self.assertEqual("custom_entity_read", call_rpc.call_args[0][0]) + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + +class TestShotgunCustomEntityEnable(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_enable""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_entity_type_sent(self, call_rpc): + self.sg.custom_entity_enable("CustomEntity08") + self.assertEqual("custom_entity_enable", call_rpc.call_args[0][0]) + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + +class TestShotgunCustomEntityConfigure(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_configure""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_optional_params_omitted_by_default(self, call_rpc): + self.sg.custom_entity_configure("CustomEntity08") + self.assertEqual("custom_entity_configure", call_rpc.call_args[0][0]) + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_display_name_sent_without_entity_config(self, call_rpc): + self.sg.custom_entity_configure("CustomEntity08", display_name="Episode") + self.assertEqual( + {"entity_type": "CustomEntity08", "display_name": "Episode"}, + call_rpc.call_args[0][1], + ) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_entity_config_sent_without_display_name(self, call_rpc): + entity_config = {"enable_versions": False} + self.sg.custom_entity_configure("CustomEntity08", entity_config=entity_config) + self.assertEqual( + {"entity_type": "CustomEntity08", "entity_config": entity_config}, + call_rpc.call_args[0][1], + ) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_empty_optional_params_sent(self, call_rpc): + """Empty values are distinct from omitted ones and must reach the server.""" + self.sg.custom_entity_configure( + "CustomEntity08", display_name="", entity_config={} + ) + self.assertEqual( + {"entity_type": "CustomEntity08", "display_name": "", "entity_config": {}}, + call_rpc.call_args[0][1], + ) + + +class TestShotgunCustomEntityDisable(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_disable""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_force_omitted_by_default(self, call_rpc): + self.sg.custom_entity_disable("CustomEntity08") + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_force_sent_when_set(self, call_rpc): + self.sg.custom_entity_disable("CustomEntity08", force=True) + self.assertEqual( + {"entity_type": "CustomEntity08", "force": True}, call_rpc.call_args[0][1] + ) + + if __name__ == "__main__": unittest.main()