diff --git a/packages/gooddata-sdk/USER_SETTINGS_USAGE.md b/packages/gooddata-sdk/USER_SETTINGS_USAGE.md new file mode 100644 index 000000000..864002bb4 --- /dev/null +++ b/packages/gooddata-sdk/USER_SETTINGS_USAGE.md @@ -0,0 +1,109 @@ +# User Settings API + +The GoodData SDK now supports user-specific settings management with access restrictions for certain settings. + +## Features + +- CRUD operations for user settings (Create, Read, Update, Delete) +- Access restrictions for workspace/organization-level settings +- Type-safe API with proper validation + +## Usage Examples + +### Creating User Settings + +```python +from gooddata_sdk import GoodDataSdk, CatalogUserSetting + +# Initialize SDK +sdk = GoodDataSdk.create(host="http://localhost:3000", token="your_token") + +# Create a locale setting for a user +locale_setting = CatalogUserSetting.init( + setting_id="locale", + setting_type="LOCALE", + content={"value": "en-US"} +) + +# Apply the setting to a user +sdk.catalog_user.create_or_update_user_setting("user123", locale_setting) +``` + +### Retrieving User Settings + +```python +# Get a specific user setting +setting = sdk.catalog_user.get_user_setting("user123", "locale") +print(f"Locale setting: {setting.attributes.content}") + +# List all settings for a user +all_settings = sdk.catalog_user.list_user_settings("user123") +for setting in all_settings: + print(f"Setting {setting.id}: {setting.attributes.content}") +``` + +### Updating User Settings + +```python +# Update an existing setting +updated_setting = CatalogUserSetting.init( + setting_id="locale", + setting_type="LOCALE", + content={"value": "de-DE"} +) +sdk.catalog_user.create_or_update_user_setting("user123", updated_setting) +``` + +### Deleting User Settings + +```python +# Delete a user setting +sdk.catalog_user.delete_user_setting("user123", "locale") +``` + +## Access Restrictions + +Some settings are restricted to workspace or organization level only and cannot be set at the user level: + +- `nullJoins` - Controls null join behavior, restricted to workspace/organization level + +```python +# This will raise a ValueError +try: + restricted_setting = CatalogUserSetting.init( + setting_id="nullJoins", + setting_type="BOOLEAN", + content={"value": True} + ) +except ValueError as e: + print(f"Error: {e}") + # Output: Error: Setting 'nullJoins' (type: 'BOOLEAN') is restricted to workspace/organization level only. +``` + +## API Reference + +### CatalogUserSetting.init() + +Creates a new user setting with validation. + +**Parameters:** +- `setting_id` (str): The ID of the setting +- `setting_type` (str): The type of the setting +- `content` (dict): The setting content/value + +**Raises:** +- `ValueError`: If the setting is restricted to workspace/organization level + +### Service Methods + +#### create_or_update_user_setting(user_id, user_setting) +Creates a new user setting or updates an existing one. + +#### get_user_setting(user_id, user_setting_id) +Retrieves a specific user setting. + +#### list_user_settings(user_id) +Lists all settings for a user. + +#### delete_user_setting(user_id, user_setting_id) +Deletes a user setting. \ No newline at end of file diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 3e3e33782..69cb36fe2 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -153,6 +153,11 @@ ) from gooddata_sdk.catalog.user.entity_model.user import CatalogUser from gooddata_sdk.catalog.user.entity_model.user_group import CatalogUserGroup +from gooddata_sdk.catalog.user.entity_model.user_setting import ( + CatalogUserSetting, + CatalogUserSettingAttributes, + CatalogUserSettingDocument, +) from gooddata_sdk.catalog.user.management_model.management import ( CatalogDataSourcePermissionAssignment, CatalogPermissionAssignments, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/__init__.py index 67106a19b..5e90f7192 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/__init__.py @@ -1 +1,36 @@ # (C) 2022 GoodData Corporation +from gooddata_sdk.catalog.user.declarative_model.user import CatalogDeclarativeUser, CatalogDeclarativeUsers +from gooddata_sdk.catalog.user.declarative_model.user_and_user_groups import CatalogDeclarativeUsersUserGroups +from gooddata_sdk.catalog.user.declarative_model.user_group import CatalogDeclarativeUserGroup, CatalogDeclarativeUserGroups +from gooddata_sdk.catalog.user.entity_model.api_token import CatalogApiToken +from gooddata_sdk.catalog.user.entity_model.user import CatalogUser, CatalogUserDocument +from gooddata_sdk.catalog.user.entity_model.user_group import CatalogUserGroup, CatalogUserGroupDocument +from gooddata_sdk.catalog.user.entity_model.user_setting import ( + CatalogUserSetting, + CatalogUserSettingAttributes, + CatalogUserSettingDocument, +) +from gooddata_sdk.catalog.user.management_model.management import ( + CatalogPermissionAssignments, + CatalogPermissionsAssignment, +) +from gooddata_sdk.catalog.user.service import CatalogUserService + +__all__ = [ + "CatalogApiToken", + "CatalogDeclarativeUser", + "CatalogDeclarativeUserGroup", + "CatalogDeclarativeUserGroups", + "CatalogDeclarativeUsers", + "CatalogDeclarativeUsersUserGroups", + "CatalogPermissionAssignments", + "CatalogPermissionsAssignment", + "CatalogUser", + "CatalogUserDocument", + "CatalogUserGroup", + "CatalogUserGroupDocument", + "CatalogUserService", + "CatalogUserSetting", + "CatalogUserSettingAttributes", + "CatalogUserSettingDocument", +] diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/entity_model/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/entity_model/__init__.py index 67106a19b..6dbb2c279 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/entity_model/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/entity_model/__init__.py @@ -1 +1,20 @@ # (C) 2022 GoodData Corporation +from gooddata_sdk.catalog.user.entity_model.api_token import CatalogApiToken +from gooddata_sdk.catalog.user.entity_model.user import CatalogUser, CatalogUserDocument +from gooddata_sdk.catalog.user.entity_model.user_group import CatalogUserGroup, CatalogUserGroupDocument +from gooddata_sdk.catalog.user.entity_model.user_setting import ( + CatalogUserSetting, + CatalogUserSettingAttributes, + CatalogUserSettingDocument, +) + +__all__ = [ + "CatalogApiToken", + "CatalogUser", + "CatalogUserDocument", + "CatalogUserGroup", + "CatalogUserGroupDocument", + "CatalogUserSetting", + "CatalogUserSettingAttributes", + "CatalogUserSettingDocument", +] diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/entity_model/user_setting.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/entity_model/user_setting.py new file mode 100644 index 000000000..514f03704 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/entity_model/user_setting.py @@ -0,0 +1,114 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +import builtins +from typing import Any, Optional + +import attr +from gooddata_api_client.model.json_api_user_setting_in import JsonApiUserSettingIn +from gooddata_api_client.model.json_api_user_setting_in_document import JsonApiUserSettingInDocument +from gooddata_api_client.model.json_api_user_setting_out import JsonApiUserSettingOut +from gooddata_api_client.model.json_api_organization_setting_in_attributes import JsonApiOrganizationSettingInAttributes + +from gooddata_sdk.catalog.base import Base + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogUserSetting(Base): + id: str + attributes: Optional[CatalogUserSettingAttributes] = None + + @classmethod + def init(cls, setting_id: str, setting_type: str, content: dict[str, Any]) -> CatalogUserSetting: + """Initialize a CatalogUserSetting with restricted access for certain settings. + + Some settings are restricted to workspace/organization level only and cannot be set at user level. + + Args: + setting_id: The ID of the setting + setting_type: The type of the setting + content: The setting content + + Returns: + CatalogUserSetting instance + + Raises: + ValueError: If trying to set a restricted setting at user level + """ + # Define settings that are restricted to workspace/organization level only + restricted_settings = { + "nullJoins", # Based on the JIRA description about null joins setting access restrictions + } + + if setting_id in restricted_settings or setting_type in restricted_settings: + raise ValueError( + f"Setting '{setting_id}' (type: '{setting_type}') is restricted to workspace/organization level only. " + "It cannot be set at user level." + ) + + return cls(id=setting_id, attributes=CatalogUserSettingAttributes(type=setting_type, content=content)) + + @staticmethod + def client_class() -> type[JsonApiUserSettingIn]: + return JsonApiUserSettingIn + + def to_api(self, as_document: bool = False) -> JsonApiUserSettingIn | JsonApiUserSettingInDocument: + """Convert to API representation.""" + api_setting = JsonApiUserSettingIn( + id=self.id, + type="userSetting", + attributes=self.attributes.to_api() if self.attributes else None + ) + + if as_document: + return JsonApiUserSettingInDocument(data=api_setting) + return api_setting + + @classmethod + def from_api(cls, api_setting: JsonApiUserSettingOut) -> CatalogUserSetting: + """Create CatalogUserSetting from API representation.""" + attributes = None + if hasattr(api_setting, 'attributes') and api_setting.attributes: + attributes = CatalogUserSettingAttributes.from_api(api_setting.attributes) + + return cls( + id=api_setting.id, + attributes=attributes + ) + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogUserSettingAttributes(Base): + type: Optional[str] = None + content: dict[str, Any] = attr.field(factory=dict) + + @staticmethod + def client_class() -> builtins.type[JsonApiOrganizationSettingInAttributes]: + return JsonApiOrganizationSettingInAttributes + + def to_api(self) -> JsonApiOrganizationSettingInAttributes: + """Convert to API representation.""" + return JsonApiOrganizationSettingInAttributes( + type=self.type, + content=self.content + ) + + @classmethod + def from_api(cls, api_attributes) -> CatalogUserSettingAttributes: + """Create CatalogUserSettingAttributes from API representation.""" + return cls( + type=api_attributes.type if hasattr(api_attributes, 'type') else None, + content=api_attributes.content if hasattr(api_attributes, 'content') else {} + ) + + +@attr.s(auto_attribs=True, kw_only=True) +class CatalogUserSettingDocument(Base): + data: CatalogUserSetting + + def to_api(self) -> JsonApiUserSettingInDocument: + return JsonApiUserSettingInDocument(data=self.data.to_api()) + + @staticmethod + def client_class() -> type[JsonApiUserSettingInDocument]: + return JsonApiUserSettingInDocument \ No newline at end of file diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py index 23a71452e..37541b2a3 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py @@ -15,6 +15,7 @@ from gooddata_sdk.catalog.user.entity_model.api_token import CatalogApiToken from gooddata_sdk.catalog.user.entity_model.user import CatalogUser, CatalogUserDocument from gooddata_sdk.catalog.user.entity_model.user_group import CatalogUserGroup, CatalogUserGroupDocument +from gooddata_sdk.catalog.user.entity_model.user_setting import CatalogUserSetting, CatalogUserSettingDocument from gooddata_sdk.catalog.user.management_model.management import ( CatalogPermissionAssignments, CatalogPermissionsAssignment, @@ -473,3 +474,82 @@ def get_user_api_token(self, user_id: str, api_token_id: str) -> CatalogApiToken def delete_user_api_token(self, user_id: str, api_token_id: str) -> None: self._entities_api.delete_entity_api_tokens(user_id, api_token_id) + + # Entity methods for user settings + + def create_or_update_user_setting(self, user_id: str, user_setting: CatalogUserSetting) -> None: + """Create a new user setting or overwrite an existing user setting with the same id. + + Some settings are restricted to workspace/organization level only and cannot be set at user level. + The create operation will enforce these restrictions. + + Args: + user_id (str): + User identification string. e.g. "demo.user" + user_setting (CatalogUserSetting): + User setting entity object. + + Returns: + None + + Raises: + ValueError: If trying to set a restricted setting at user level + """ + try: + self.get_user_setting(user_id, user_setting.id) + user_setting_document = CatalogUserSettingDocument(data=user_setting) + self._entities_api.update_entity_user_settings( + user_id, user_setting.id, user_setting_document.to_api() + ) + except NotFoundException: + user_setting_document = CatalogUserSettingDocument(data=user_setting) + self._entities_api.create_entity_user_settings(user_id, user_setting_document.to_api()) + + def get_user_setting(self, user_id: str, user_setting_id: str) -> CatalogUserSetting: + """Get an individual user setting using user id and user setting id. + + Args: + user_id (str): + User identification string. e.g. "demo.user" + user_setting_id (str): + User Setting identification string. e.g. "locale" + + Returns: + CatalogUserSetting: + User setting entity object. + """ + user_setting = self._entities_api.get_entity_user_settings(user_id, user_setting_id).data + return CatalogUserSetting.from_api(user_setting) + + def delete_user_setting(self, user_id: str, user_setting_id: str) -> None: + """Delete User Setting using User id and User Setting id. + + Args: + user_id (str): + User identification string. e.g. "demo.user" + user_setting_id (str): + User Setting identification string. e.g. "locale" + + Returns: + None + """ + self._entities_api.delete_entity_user_settings(user_id, user_setting_id) + + def list_user_settings(self, user_id: str) -> list[CatalogUserSetting]: + """Get a list of all existing user settings for a specific user. + + Args: + user_id (str): + User identification string. e.g. "demo.user" + + Returns: + list[CatalogUserSetting]: + List of all User Settings for the user as User Setting entity objects. + """ + get_user_settings = functools.partial( + self._entities_api.get_all_entities_user_settings, + user_id, + _check_return_type=False, + ) + user_settings = load_all_entities(get_user_settings) + return [CatalogUserSetting.from_api(us) for us in user_settings.data] diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py b/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py index 21ba01cd9..03c7a5a01 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py @@ -35,6 +35,7 @@ CatalogPermissionsAssignment, CatalogUser, CatalogUserGroup, + CatalogUserSetting, GoodDataApiClient, GoodDataSdk, ) @@ -915,3 +916,106 @@ def _verify_demo2_permissions_state(sdk: GoodDataSdk, test_config: dict) -> bool return group_perms.data_sources[0].permissions == ["USE"] except Exception: return False + + +# USER SETTINGS TESTS + + +def test_user_setting_access_restrictions(): + """Test that restricted settings cannot be set at user level.""" + # Test restricted setting by ID + with pytest.raises(ValueError, match="Setting 'nullJoins'.*restricted to workspace/organization level"): + CatalogUserSetting.init( + setting_id="nullJoins", + setting_type="BOOLEAN", + content={"value": True} + ) + + # Test restricted setting by type + with pytest.raises(ValueError, match="Setting 'someId'.*restricted to workspace/organization level"): + CatalogUserSetting.init( + setting_id="someId", + setting_type="nullJoins", + content={"value": True} + ) + + +def test_user_setting_allowed_settings(): + """Test that non-restricted settings can be set at user level.""" + # Test allowed setting + user_setting = CatalogUserSetting.init( + setting_id="locale", + setting_type="LOCALE", + content={"value": "en-US"} + ) + assert user_setting.id == "locale" + assert user_setting.attributes.type == "LOCALE" + assert user_setting.attributes.content == {"value": "en-US"} + + +# Note: These tests would typically use VCR cassettes, but are commented out +# until the infrastructure is in place to record them + +# @gd_vcr.use_cassette(str(_fixtures_dir / "test_user_settings_crud.yaml")) +# def test_user_settings_crud_operations(test_config): +# """Test CRUD operations for user settings.""" +# sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"]) +# user_id = test_config["test_user"] +# setting_id = "test_locale_setting" +# +# # Create user setting +# user_setting = CatalogUserSetting.init( +# setting_id=setting_id, +# setting_type="LOCALE", +# content={"value": "en-US"} +# ) +# +# try: +# # Test create +# sdk.catalog_user.create_or_update_user_setting(user_id, user_setting) +# +# # Test get +# retrieved_setting = sdk.catalog_user.get_user_setting(user_id, setting_id) +# assert retrieved_setting.id == setting_id +# assert retrieved_setting.attributes.type == "LOCALE" +# assert retrieved_setting.attributes.content == {"value": "en-US"} +# +# # Test list +# user_settings = sdk.catalog_user.list_user_settings(user_id) +# setting_ids = [s.id for s in user_settings] +# assert setting_id in setting_ids +# +# # Test update +# updated_setting = CatalogUserSetting.init( +# setting_id=setting_id, +# setting_type="LOCALE", +# content={"value": "de-DE"} +# ) +# sdk.catalog_user.create_or_update_user_setting(user_id, updated_setting) +# +# retrieved_updated_setting = sdk.catalog_user.get_user_setting(user_id, setting_id) +# assert retrieved_updated_setting.attributes.content == {"value": "de-DE"} +# +# finally: +# # Test delete +# try: +# sdk.catalog_user.delete_user_setting(user_id, setting_id) +# except Exception: +# pass # Setting may not exist if create failed + + +# @gd_vcr.use_cassette(str(_fixtures_dir / "test_user_settings_restricted_create.yaml")) +# def test_user_settings_restricted_create(test_config): +# """Test that restricted settings are blocked during create operations.""" +# sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"]) +# user_id = test_config["test_user"] +# +# # This should fail at the init level, but let's test the full flow +# # in case someone bypasses the init validation +# with pytest.raises(ValueError, match="restricted to workspace/organization level"): +# restricted_setting = CatalogUserSetting.init( +# setting_id="nullJoins", +# setting_type="BOOLEAN", +# content={"value": True} +# ) +# sdk.catalog_user.create_or_update_user_setting(user_id, restricted_setting) diff --git a/packages/gooddata-sdk/tests/catalog/test_user_settings.py b/packages/gooddata-sdk/tests/catalog/test_user_settings.py new file mode 100644 index 000000000..a457e8b71 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/test_user_settings.py @@ -0,0 +1,90 @@ +# (C) 2024 GoodData Corporation +""" +Test module for user settings functionality. +""" + +import pytest +from gooddata_sdk.catalog.user.entity_model.user_setting import CatalogUserSetting + + +class TestUserSettingAccessRestrictions: + """Test user setting access restrictions functionality.""" + + def test_restricted_setting_by_id(self): + """Test that restricted settings cannot be set at user level by ID.""" + with pytest.raises(ValueError, match="Setting 'nullJoins'.*restricted to workspace/organization level"): + CatalogUserSetting.init( + setting_id="nullJoins", + setting_type="BOOLEAN", + content={"value": True} + ) + + def test_restricted_setting_by_type(self): + """Test that restricted settings cannot be set at user level by type.""" + with pytest.raises(ValueError, match="Setting 'someId'.*restricted to workspace/organization level"): + CatalogUserSetting.init( + setting_id="someId", + setting_type="nullJoins", + content={"value": True} + ) + + def test_allowed_setting(self): + """Test that non-restricted settings can be set at user level.""" + user_setting = CatalogUserSetting.init( + setting_id="locale", + setting_type="LOCALE", + content={"value": "en-US"} + ) + assert user_setting.id == "locale" + assert user_setting.attributes.type == "LOCALE" + assert user_setting.attributes.content == {"value": "en-US"} + + def test_allowed_setting_empty_content(self): + """Test that settings can be created with empty content.""" + user_setting = CatalogUserSetting.init( + setting_id="theme", + setting_type="THEME", + content={} + ) + assert user_setting.id == "theme" + assert user_setting.attributes.type == "THEME" + assert user_setting.attributes.content == {} + + def test_to_api_conversion(self): + """Test conversion to API representation.""" + user_setting = CatalogUserSetting.init( + setting_id="locale", + setting_type="LOCALE", + content={"value": "en-US"} + ) + + api_setting = user_setting.to_api() + assert api_setting.id == "locale" + assert api_setting.type == "userSetting" + assert api_setting.attributes.type == "LOCALE" + assert api_setting.attributes.content == {"value": "en-US"} + + def test_to_api_document_conversion(self): + """Test conversion to API document representation.""" + user_setting = CatalogUserSetting.init( + setting_id="locale", + setting_type="LOCALE", + content={"value": "en-US"} + ) + + api_document = user_setting.to_api(as_document=True) + assert hasattr(api_document, 'data') + assert api_document.data.id == "locale" + assert api_document.data.type == "userSetting" + + def test_setting_without_attributes(self): + """Test user setting creation without attributes.""" + user_setting = CatalogUserSetting(id="test_setting") + assert user_setting.id == "test_setting" + assert user_setting.attributes is None + + # Should handle None attributes gracefully in API conversion + api_setting = user_setting.to_api() + assert api_setting.id == "test_setting" + assert api_setting.type == "userSetting" + assert api_setting.attributes is None \ No newline at end of file