From 755e3c98236571a5dcebe1ee4cc045805e837667 Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 11:56:22 +0530 Subject: [PATCH 01/27] feat(dpi_ng): add SAP DPI NG consent service module to sdk --- src/sap_cloud_sdk/core/dpi_ng/__init__.py | 1 + .../core/dpi_ng/consent/__init__.py | 147 +++++ src/sap_cloud_sdk/core/dpi_ng/consent/auth.py | 171 ++++++ .../core/dpi_ng/consent/client.py | 216 ++++++++ .../core/dpi_ng/consent/config.py | 54 ++ .../core/dpi_ng/consent/dtos/__init__.py | 13 + .../core/dpi_ng/consent/dtos/consent.py | 52 ++ .../core/dpi_ng/consent/dtos/utils.py | 19 + .../core/dpi_ng/consent/entities/__init__.py | 1 + .../core/dpi_ng/consent/entities/consent.py | 76 +++ .../consent/entities/consent_configuration.py | 161 ++++++ .../consent/entities/consent_purpose.py | 49 ++ .../consent/entities/consent_retention.py | 40 ++ .../consent/entities/consent_template.py | 69 +++ .../core/dpi_ng/consent/exceptions.py | 45 ++ .../core/dpi_ng/consent/py.typed | 0 .../core/dpi_ng/consent/services/__init__.py | 15 + .../core/dpi_ng/consent/services/_query.py | 18 + .../services/consent_configuration_service.py | 512 ++++++++++++++++++ .../services/consent_purpose_service.py | 151 ++++++ .../services/consent_retention_service.py | 93 ++++ .../consent/services/consent_service.py | 112 ++++ .../services/consent_template_service.py | 211 ++++++++ .../core/dpi_ng/consent/user-guide.md | 216 ++++++++ tests/core/integration/dpi_ng/__init__.py | 0 .../integration/dpi_ng/consent/__init__.py | 0 .../integration/dpi_ng/consent/conftest.py | 81 +++ .../dpi_ng/consent/consent_creation.feature | 41 ++ .../integration/dpi_ng/consent/data_bdd.py | 58 ++ .../consent/test_consent_creation_bdd.py | 330 +++++++++++ tests/core/unit/dpi_ng/__init__.py | 0 tests/core/unit/dpi_ng/consent/__init__.py | 0 .../core/unit/dpi_ng/consent/unit/__init__.py | 0 .../core/unit/dpi_ng/consent/unit/conftest.py | 10 + .../dpi_ng/consent/unit/entities/__init__.py | 0 .../dpi_ng/consent/unit/entities/conftest.py | 171 ++++++ .../entities/test_configuration_entities.py | 36 ++ .../unit/entities/test_consent_entities.py | 64 +++ .../unit/entities/test_purpose_entities.py | 36 ++ .../unit/entities/test_retention_entities.py | 36 ++ .../unit/entities/test_template_entities.py | 46 ++ .../dpi_ng/consent/unit/services/__init__.py | 0 .../dpi_ng/consent/unit/services/conftest.py | 53 ++ .../services/test_configuration_service.py | 239 ++++++++ .../unit/services/test_consent_service.py | 156 ++++++ .../unit/services/test_purpose_service.py | 138 +++++ .../unit/services/test_retention_service.py | 97 ++++ .../unit/services/test_template_service.py | 212 ++++++++ .../unit/dpi_ng/consent/unit/test_auth.py | 154 ++++++ .../unit/dpi_ng/consent/unit/test_config.py | 117 ++++ .../consent/unit/test_consent_client.py | 155 ++++++ .../unit/dpi_ng/consent/unit/test_dtos.py | 118 ++++ .../dpi_ng/consent/unit/test_exceptions.py | 108 ++++ .../dpi_ng/consent/unit/test_odata_client.py | 310 +++++++++++ 54 files changed, 5208 insertions(+) create mode 100644 src/sap_cloud_sdk/core/dpi_ng/__init__.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/auth.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/client.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/config.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/dtos/__init__.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/dtos/utils.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/entities/__init__.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/py.typed create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/services/__init__.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py create mode 100644 src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md create mode 100644 tests/core/integration/dpi_ng/__init__.py create mode 100644 tests/core/integration/dpi_ng/consent/__init__.py create mode 100644 tests/core/integration/dpi_ng/consent/conftest.py create mode 100644 tests/core/integration/dpi_ng/consent/consent_creation.feature create mode 100644 tests/core/integration/dpi_ng/consent/data_bdd.py create mode 100644 tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py create mode 100644 tests/core/unit/dpi_ng/__init__.py create mode 100644 tests/core/unit/dpi_ng/consent/__init__.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/__init__.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/conftest.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/entities/__init__.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/entities/conftest.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/services/__init__.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/services/conftest.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/test_auth.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/test_config.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/test_consent_client.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/test_dtos.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/test_exceptions.py create mode 100644 tests/core/unit/dpi_ng/consent/unit/test_odata_client.py diff --git a/src/sap_cloud_sdk/core/dpi_ng/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/__init__.py new file mode 100644 index 00000000..0a90f24a --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/__init__.py @@ -0,0 +1 @@ +"""DPI Next Gen SDK modules.""" diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py new file mode 100644 index 00000000..ea006e18 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py @@ -0,0 +1,147 @@ +"""Consent SDK - Python client for the DPI V2 Consent Repository. + +Quickstart:: + + from sap_cloud_sdk.core.dpi_ng.consent import create_client, BearerTokenAuth + + with create_client( + base_url="https://consent.cfapps.eu10.hana.ondemand.com", + auth=BearerTokenAuth(""), + ) as client: + consents = client.consents.list_consents(filter="lifecycleStatusCode eq '1'") + client.consents.withdraw_consent(WithdrawConsentRequest(...)) + +Entity objects returned by service methods are python-odata entity instances. +Access fields as attributes: ``entity.purpose_name``, ``entity.consent_id``, etc. +""" + +from .auth import ( + AuthProvider, + BearerTokenAuth, + ClientCertificateAuth, + ClientCredentialsAuth, +) +from .services import ( + ConsentConfigurationService, + ConsentPurposeService, + ConsentRetentionService, + ConsentService, + ConsentTemplateService, +) +from .config import ConsentSDKConfig +from .exceptions import ( + AuthenticationError, + AuthorizationError, + ClientCreationError, + ConflictError, + ConsentSDKError, + NotFoundError, + ODataError, + ValidationError, +) +from .dtos import ( + CheckConsentExistsResult, + CreateConsentRequest, + WithdrawConsentRequest, +) + + +class ConsentClient: + """Top-level SDK client providing access to all Consent Service endpoints. + + Access each OData service through its typed attribute: + + - ``client.consents`` - consent creation, withdrawal, and reads (consentServices) + - ``client.purposes`` - purpose CRUD and lifecycle (consentPurposeExternalServices) + - ``client.templates`` - template CRUD and lifecycle (consentTemplateExternalServices) + - ``client.retention`` - retention rule CRUD and lifecycle (consentRetentionExternalServices) + - ``client.configuration`` - reference data CRUD (consentConfigurationExternalServices) + """ + + def __init__(self, config: ConsentSDKConfig) -> None: + """Initialise all service clients from the given config.""" + from .client import _ODataClient + + self._odata = _ODataClient(config) + self.consents: ConsentService = ConsentService(self._odata) + self.purposes: ConsentPurposeService = ConsentPurposeService(self._odata) + self.templates: ConsentTemplateService = ConsentTemplateService(self._odata) + self.retention: ConsentRetentionService = ConsentRetentionService(self._odata) + self.configuration: ConsentConfigurationService = ConsentConfigurationService( + self._odata + ) + + def close(self) -> None: + """Close the underlying OData HTTP session.""" + self._odata.close() + + def __enter__(self) -> "ConsentClient": + """Support use as a context manager.""" + return self + + def __exit__(self, *_: object) -> None: + """Close the session on context manager exit.""" + self.close() + + +def create_client( + config: ConsentSDKConfig | None = None, + *, + base_url: str | None = None, + auth: AuthProvider | None = None, + timeout: float = 30.0, + verify_ssl: bool = True, +) -> ConsentClient: + """Instantiate a ConsentClient from a config object or individual keyword arguments. + + Args: + config: Pre-built ConsentSDKConfig. When provided, all other kwargs are ignored. + base_url: Host-only root URL of the consent service (no path). + auth: Authentication strategy (BearerTokenAuth, ClientCredentialsAuth, etc.). + timeout: HTTP request timeout in seconds. + verify_ssl: Verify TLS certificates. + + Raises: + ClientCreationError: If required fields are missing or invalid. + """ + try: + if config is None: + if not base_url or not auth: + raise ValueError( + "base_url and auth are required when config is not provided" + ) + config = ConsentSDKConfig( + base_url=base_url, + auth=auth, + timeout=timeout, + verify_ssl=verify_ssl, + ) + return ConsentClient(config) + except (ValueError, TypeError) as exc: + raise ClientCreationError(str(exc)) from exc + + +__all__ = [ + # factory + top-level client + "create_client", + "ConsentClient", + "ConsentSDKConfig", + # auth strategies + "AuthProvider", + "BearerTokenAuth", + "ClientCredentialsAuth", + "ClientCertificateAuth", + # exceptions + "ConsentSDKError", + "ClientCreationError", + "AuthenticationError", + "AuthorizationError", + "ValidationError", + "NotFoundError", + "ConflictError", + "ODataError", + # request / response DTOs + "CreateConsentRequest", + "WithdrawConsentRequest", + "CheckConsentExistsResult", +] diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py b/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py new file mode 100644 index 00000000..2a4a7dd4 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py @@ -0,0 +1,171 @@ +"""Authentication strategy implementations for the Consent SDK. + +Each provider implements AuthProvider.apply(), which configures the +requests.Session passed to it to inject the chosen auth mechanism. + +Supported strategies: + - BearerTokenAuth - static bearer token (e.g. already-fetched XSUAA token) + - ClientCredentialsAuth - OAuth2 client_credentials flow with automatic token refresh + - ClientCertificateAuth - mTLS with client certificate + private key +""" + +from __future__ import annotations + +import logging +import time +from abc import ABC, abstractmethod + +import requests +import requests.auth + +logger = logging.getLogger(__name__) + + +class AuthProvider(ABC): + """Abstract base for all authentication strategies. + + Subclasses configure a ``requests.Session`` to inject their chosen + mechanism (headers, cert, custom auth flow, etc.). + Adding a new auth type means subclassing this; nothing else changes. + """ + + @abstractmethod + def apply(self, session: requests.Session) -> None: + """Configure the requests.Session to inject authentication.""" + + +class BearerTokenAuth(AuthProvider): + """Static bearer token - use when the caller manages token lifecycle externally.""" + + def __init__(self, token: str) -> None: + """Store the bearer token, raising ValueError if empty.""" + logger.info("Invoked BearerTokenAuth.__init__") + if not token: + logger.error("token is empty") + raise ValueError("token must not be empty") + self._token = token + logger.info("Exiting BearerTokenAuth.__init__") + + def apply(self, session: requests.Session) -> None: + """Inject the static bearer token into the session Authorization header.""" + logger.info("Invoked BearerTokenAuth.apply") + session.headers["Authorization"] = f"Bearer {self._token}" + logger.info("Exiting BearerTokenAuth.apply") + + +class ClientCredentialsAuth(AuthProvider): + """OAuth2 client_credentials flow with automatic token refresh. + + Fetches a bearer token from ``token_url`` using ``client_id`` / ``client_secret`` + and refreshes it transparently 60 seconds before it expires. + """ + + def __init__(self, token_url: str, client_id: str, client_secret: str) -> None: + """Store OAuth2 credentials, raising ValueError if any field is empty.""" + logger.info("Invoked ClientCredentialsAuth.__init__") + if not token_url or not client_id or not client_secret: + logger.error("token_url, client_id, or client_secret is empty") + raise ValueError("token_url, client_id, and client_secret are all required") + self._token_url = token_url + self._client_id = client_id + self._client_secret = client_secret + logger.info("Exiting ClientCredentialsAuth.__init__") + + def apply(self, session: requests.Session) -> None: + """Attach the OAuth2 flow handler to the session so tokens are fetched on demand.""" + logger.info("Invoked ClientCredentialsAuth.apply") + session.auth = _OAuth2Flow( + self._token_url, self._client_id, self._client_secret + ) + logger.info("Exiting ClientCredentialsAuth.apply") + + +class ClientCertificateAuth(AuthProvider): + """Mutual TLS (mTLS) authentication using a client certificate and private key. + + Args: + cert_file: Path to the client certificate PEM file. + key_file: Path to the client private key PEM file. + ca_file: Path to the CA certificate PEM file for server verification. + When omitted, the system CA bundle is used. + """ + + def __init__( + self, + cert_file: str, + key_file: str, + ca_file: str | None = None, + ) -> None: + """Store mTLS paths, raising ValueError if cert_file or key_file is empty.""" + logger.info("Invoked ClientCertificateAuth.__init__") + if not cert_file or not key_file: + logger.error("cert_file or key_file is empty") + raise ValueError("cert_file and key_file are required") + self._cert_file = cert_file + self._key_file = key_file + self._ca_file = ca_file + logger.info("Exiting ClientCertificateAuth.__init__") + + def apply(self, session: requests.Session) -> None: + """Configure the session with the client cert/key pair and optional CA bundle.""" + logger.info("Invoked ClientCertificateAuth.apply") + session.cert = (self._cert_file, self._key_file) + if self._ca_file: + session.verify = self._ca_file + logger.debug("Custom CA bundle applied — ca_file=%s", self._ca_file) + logger.info("Exiting ClientCertificateAuth.apply") + + +# ------------------------------------------------------------------ +# Internal OAuth2 flow - not part of the public API +# ------------------------------------------------------------------ + + +class _OAuth2Flow(requests.auth.AuthBase): + """requests.auth.AuthBase implementation that handles token fetch and refresh.""" + + # 60-second buffer before actual expiry to avoid clock-skew races + _EXPIRY_BUFFER = 60.0 + + def __init__(self, token_url: str, client_id: str, client_secret: str) -> None: + """Initialise with OAuth2 endpoint and credentials; token is fetched lazily.""" + self._token_url = token_url + self._client_id = client_id + self._client_secret = client_secret + self._access_token: str | None = None + self._expires_at: float = 0.0 + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + """Inject a valid bearer token, refreshing it if expired or not yet fetched.""" + if self._is_expired(): + logger.debug("Token expired or absent — fetching new token") + self._fetch_token() + r.headers["Authorization"] = f"Bearer {self._access_token}" + return r + + def _is_expired(self) -> bool: + """Return True if no token has been fetched or the expiry buffer has been reached.""" + return self._access_token is None or time.monotonic() >= self._expires_at + + def _fetch_token(self) -> None: + """POST to the token URL and store the new access token and its expiry time.""" + logger.info("Invoked _OAuth2Flow._fetch_token") + try: + resp = requests.post( + self._token_url, + data={ + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + ) + resp.raise_for_status() + payload = resp.json() + self._access_token = payload["access_token"] + expires_in: float = float(payload.get("expires_in", 3600)) + self._expires_at = time.monotonic() + expires_in - self._EXPIRY_BUFFER + logger.debug("Token acquired — expires_in=%.0fs", expires_in) + except Exception: + logger.exception("Failed to fetch access token from %s", self._token_url) + raise + logger.info("Exiting _OAuth2Flow._fetch_token") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py new file mode 100644 index 00000000..387ecb63 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -0,0 +1,216 @@ +"""OData v4 client backed by python-odata (https://pypi.org/project/python-odata/).""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import requests +from odata import ODataService +from odata.flags import ODataServerFlags + +from .config import ConsentSDKConfig +from .exceptions import ( + AuthenticationError, + AuthorizationError, + ConflictError, + NotFoundError, + ODataError, + ValidationError, +) + +if TYPE_CHECKING: + from odata.query import Query + +logger = logging.getLogger(__name__) + +# Maps service name -> make_entities factory function +_ENTITY_FACTORIES: dict[str, Any] = {} + + +def _register_factories() -> None: + """Populate _ENTITY_FACTORIES with one make_entities callable per service endpoint.""" + from .entities.consent import _make_entities as consent_entities + from .entities.consent_configuration import _make_entities as config_entities + from .entities.consent_purpose import _make_entities as purpose_entities + from .entities.consent_retention import _make_entities as retention_entities + from .entities.consent_template import _make_entities as template_entities + + _ENTITY_FACTORIES["consentServices"] = consent_entities + _ENTITY_FACTORIES["consentPurposeExternalServices"] = purpose_entities + _ENTITY_FACTORIES["consentTemplateExternalServices"] = template_entities + _ENTITY_FACTORIES["consentRetentionExternalServices"] = retention_entities + _ENTITY_FACTORIES["consentConfigurationExternalServices"] = config_entities + + +_register_factories() + + +class _ODataClient: + """OData v4 client - one ODataService per SAP service endpoint, entity classes bound per service.""" + + def __init__(self, config: ConsentSDKConfig) -> None: + """Initialise the HTTP session and apply the configured auth strategy.""" + logger.info("Invoked ODataClient.__init__") + self._config = config + self._session = requests.Session() + self._session.headers.update( + { + "Accept": "application/json;odata.metadata=minimal", + "Content-Type": "application/json", + } + ) + self._session.verify = config.verify_ssl + config.auth.apply(self._session) + self._services: dict[str, ODataService] = {} + self._server_flags = ODataServerFlags( + provide_odata_type_annotation=False, + skip_null_properties=True, + ) + # Maps service_name -> dict of entity class name -> entity class + self._entity_classes: dict[str, tuple] = {} + logger.info("Exiting ODataClient.__init__") + + # ------------------------------------------------------------------ + # ODataService / entity class registry + # ------------------------------------------------------------------ + + def _get_service(self, service_name: str) -> ODataService: + """Return (and lazily create) the ODataService for the given service endpoint.""" + logger.info("Invoked ODataClient._get_service") + if service_name not in self._services: + url = f"{self._config.base_url}{self._config.service_path}/{service_name}/" + logger.debug("Creating new ODataService — url=%s", url) + self._services[service_name] = ODataService( + url, + session=self._session, + reflect_entities=False, + server_flags=self._server_flags, + ) + logger.info("Exiting ODataClient._get_service") + return self._services[service_name] + + def get_entity_classes(self, service_name: str) -> tuple: + """Return the tuple of entity classes bound to the given service endpoint.""" + logger.info("Invoked ODataClient.get_entity_classes") + if service_name not in self._entity_classes: + svc = self._get_service(service_name) + factory = _ENTITY_FACTORIES[service_name] + self._entity_classes[service_name] = factory(svc) + logger.debug("Entity classes created for service_name=%s", service_name) + logger.info("Exiting ODataClient.get_entity_classes") + return self._entity_classes[service_name] + + # ------------------------------------------------------------------ + # Query / save / delete - python-odata ORM operations + # ------------------------------------------------------------------ + + def query(self, service_name: str, entity_cls: type) -> Query: + """Return a Query builder for the given entity class.""" + logger.info("Invoked ODataClient.query") + svc = self._get_service(service_name) + result = svc.query(entity_cls) + logger.info("Exiting ODataClient.query") + return result + + def save(self, entity: Any) -> None: + """POST (new entity) or PATCH (existing entity, dirty fields only).""" + logger.info("Invoked ODataClient.save") + entity.__odata_service__.save(entity) + logger.info("Exiting ODataClient.save") + + def delete_entity(self, entity: Any) -> None: + """DELETE the entity from the service.""" + logger.info("Invoked ODataClient.delete_entity") + entity.__odata_service__.delete(entity) + logger.info("Exiting ODataClient.delete_entity") + + # ------------------------------------------------------------------ + # Actions - raw POST (not modelled as python-odata Action descriptors) + # ------------------------------------------------------------------ + + def call_action( + self, + service: str, + path: str, + body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + """POST an OData action and return the parsed response body, or None for 204.""" + logger.info("Invoked ODataClient.call_action") + svc = self._get_service(service) + url = f"{svc.url}{path}" + logger.debug("Posting action — url=%s", url) + resp = self._session.post( + url, json=body or {}, params=params, timeout=self._config.timeout + ) + self._raise_for_status(resp) + if resp.status_code == 204 or not resp.content: + logger.info("Exiting ODataClient.call_action — 204 No Content") + return None + result = resp.json() + logger.info("Exiting ODataClient.call_action") + return result + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close the underlying requests.Session and release connection pool resources.""" + logger.info("Invoked ODataClient.close") + self._session.close() + logger.info("Exiting ODataClient.close") + + def __enter__(self) -> _ODataClient: + """Support use as a context manager.""" + return self + + def __exit__(self, *_: Any) -> None: + """Close the session on context manager exit.""" + self.close() + + # ------------------------------------------------------------------ + # Error handling + # ------------------------------------------------------------------ + + @staticmethod + def _raise_for_status(resp: requests.Response) -> None: + """Translate 4xx/5xx HTTP responses into typed ConsentSDK exceptions.""" + if resp.status_code < 400: + return + try: + body = resp.json() + odata_error: dict[str, Any] = body.get("error", {}) + message: str = odata_error.get("message") or resp.text + details: list[dict[str, Any]] = odata_error.get("details", []) + if details: + detail_messages = "; ".join( + f"{d['target']}: {d['message']}" + if d.get("target") + else d["message"] + for d in details + if d.get("message") + ) + if detail_messages: + message = f"{message} - {detail_messages}" + except Exception: + odata_error = {} + message = resp.text + + logger.error( + "HTTP error response — status=%d message=%s", resp.status_code, message + ) + match resp.status_code: + case 401: + raise AuthenticationError(message, odata_error) + case 403: + raise AuthorizationError(message, odata_error) + case 404: + raise NotFoundError(message, odata_error) + case 409: + raise ConflictError(message, odata_error) + case 400 | 422: + raise ValidationError(message, odata_error) + case _: + raise ODataError(message, resp.status_code, odata_error) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py new file mode 100644 index 00000000..888a4cfc --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py @@ -0,0 +1,54 @@ +"""Configuration for the Consent SDK client.""" + +import logging +import re +from dataclasses import dataclass + +from .auth import AuthProvider + +logger = logging.getLogger(__name__) + +_URL_PATTERN = re.compile(r"^https?://[^\s/$.?#].[^\s]*$") + + +@dataclass +class ConsentSDKConfig: + """Configuration for the Consent SDK client. + + Args: + base_url: Root URL of the consent service (e.g. https://consent.cfapps.eu10.hana.ondemand.com). + auth: Authentication strategy - one of BearerTokenAuth, ClientCredentialsAuth, + ClientCertificateAuth, or a custom AuthProvider subclass. + timeout: HTTP request timeout in seconds (default 30). + verify_ssl: Verify TLS certificates - set False only in local dev. + Ignored when ClientCertificateAuth provides its own ca_file. + service_path: OData service base path prefix - do not override unless + deploying to a non-standard environment. + """ + + base_url: str + auth: AuthProvider + timeout: float = 30.0 + verify_ssl: bool = True + service_path: str = "/sap/cp/kernel/dpi/consent/odata/v4" + + def __post_init__(self) -> None: + """Validate base_url format and auth type after dataclass construction.""" + logger.info("Invoked ConsentSDKConfig.__post_init__") + if not _URL_PATTERN.match(self.base_url): + logger.error("Invalid base_url — value=%r", self.base_url) + raise ValueError( + f"base_url must be a valid HTTP(S) URL, got: {self.base_url!r}" + ) + if not isinstance(self.auth, AuthProvider): + logger.error( + "auth is not an AuthProvider instance — type=%s", type(self.auth) + ) + raise ValueError("auth must be an AuthProvider instance") + self.base_url = self.base_url.rstrip("/") + logger.debug( + "Config validated — base_url=%s verify_ssl=%s", + self.base_url, + self.verify_ssl, + ) + logger.info("Exiting ConsentSDKConfig.__post_init__") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/__init__.py new file mode 100644 index 00000000..3007d501 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/__init__.py @@ -0,0 +1,13 @@ +"""Consent SDK model exports - action DTOs.""" + +from .consent import ( + CheckConsentExistsResult, + CreateConsentRequest, + WithdrawConsentRequest, +) + +__all__ = [ + "CheckConsentExistsResult", + "CreateConsentRequest", + "WithdrawConsentRequest", +] diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py new file mode 100644 index 00000000..999fd6e5 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py @@ -0,0 +1,52 @@ +"""Action input/output DTOs for consentServices - not OData entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .utils import _CamelSerializable + + +@dataclass +class CheckConsentExistsResult: + """Returned by checkConsentExists action.""" + + consent_id: str | None = None + consent_exists: bool | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CheckConsentExistsResult: + """Construct from a raw OData action response dict.""" + return cls( + consent_id=data.get("consentId"), consent_exists=data.get("consentExists") + ) + + +@dataclass +class CreateConsentRequest(_CamelSerializable): + """Input for createConsentFromTemplate / createConsentFromTemplateAsync.""" + + data_subject_id: str + template_name: str + language_code: str + data_subject_type_name: str + jurisdiction_code: str + data_subject_description: str | None = None + outbound_channel_type_name: str | None = None + outbound_channel: str | None = None + valid_from: str | None = None + application_template_id: str | None = None + controller_name: str | None = None + granted_by: str | None = None + granted_at: str | None = None + submission_site: str | None = None + + +@dataclass +class WithdrawConsentRequest(_CamelSerializable): + """Input for withdrawConsent and terminateConsent.""" + + consent_id: str + withdrawn_by: str + withdrawn_at: str | None = None diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/utils.py b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/utils.py new file mode 100644 index 00000000..f8d0edb7 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/utils.py @@ -0,0 +1,19 @@ +"""Shared serialization utilities for dpi_ng.consent DTOs.""" + +from __future__ import annotations + +import re +from typing import Any + +_RE = re.compile(r"_([a-z])") + + +def _to_camel(s: str) -> str: + return _RE.sub(lambda m: m.group(1).upper(), s) + + +class _CamelSerializable: + """Mixin that serialises snake_case dataclass fields to camelCase for OData action payloads.""" + + def to_dict(self) -> dict[str, Any]: + return {_to_camel(k): v for k, v in self.__dict__.items() if v is not None} diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/__init__.py new file mode 100644 index 00000000..fb15a365 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/__init__.py @@ -0,0 +1 @@ +"""Entity class factories for all Consent OData service endpoints.""" diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py new file mode 100644 index 00000000..47911a89 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py @@ -0,0 +1,76 @@ +"""python-odata entity classes for consentServices.""" + +from __future__ import annotations + +from typing import Any + +from odata.property import ( + BooleanProperty, + DatetimeProperty, + StringProperty, + UUIDProperty, +) + + +def _make_entities(Service: Any) -> tuple: + """Create and return all entity classes bound to the given ODataService instance.""" + + class Consent(Service.Entity): + """OData entity representing a consent record.""" + + __odata_collection__ = "consents" + tenant = StringProperty("tenant") + consent_id = UUIDProperty("consentId", primary_key=True) + template_id = UUIDProperty("templateId") + purpose_id = UUIDProperty("purposeId") + controller_id = UUIDProperty("controllerId") + jurisdiction_code = StringProperty("jurisdictionCode") + consent_model_code = StringProperty("consentModelCode") + application_id = UUIDProperty("applicationId") + application_template_id = StringProperty("applicationTemplateId") + valid_from = DatetimeProperty("validFrom") + start_of_expiration = DatetimeProperty("startOfExpiration") + valid_to = DatetimeProperty("validTo") + data_subject_type_id = UUIDProperty("dataSubjectTypeId") + data_subject_id = StringProperty("dataSubjectId") + data_subject_description = StringProperty("dataSubjectDescription") + granted_at = DatetimeProperty("grantedAt") + granted_by = StringProperty("grantedBy") + withdrawn_at = DatetimeProperty("withdrawnAt") + withdrawn_by = StringProperty("withdrawnBy") + submission_site = StringProperty("submissionSite") + outbound_channel = StringProperty("outboundChannel") + outbound_channel_type_id = UUIDProperty("outboundChannelTypeId") + language_code = StringProperty("languageCode") + third_party_id = UUIDProperty("thirdPartyId") + third_party_function_code = StringProperty("thirdPartyFunctionCode") + consent_status_code = StringProperty("consentStatusCode") + lifecycle_status_code = StringProperty("lifecycleStatusCode") + purpose_description_text_id = StringProperty("purposeDescriptionTextId") + purpose_explanatory_text_id = StringProperty("purposeExplanatoryTextId") + template_description_text_id = StringProperty("templateDescriptionTextId") + template_explanatory_text_id = StringProperty("templateExplanatoryTextId") + template_question_text_id = StringProperty("templateQuestionTextId") + template_consequence_text_id = StringProperty("templateConsequenceTextId") + template_data_privacy_statement_text_id = StringProperty( + "templateDataPrivacyStatementTextId" + ) + purpose_sensitive_data_flag = BooleanProperty("purposeSensitiveDataFlag") + third_party_sensitive_data_flag = BooleanProperty("thirdPartySensitiveDataFlag") + template_name = StringProperty("templateName") + purpose_name = StringProperty("purposeName") + application_name = StringProperty("applicationName") + application_description = StringProperty("applicationDescription") + third_party_name = StringProperty("thirdPartyName") + controller_name = StringProperty("controllerName") + controller_description = StringProperty("controllerDescription") + data_subject_type_name = StringProperty("dataSubjectTypeName") + outbound_channel_type_name = StringProperty("outboundChannelTypeName") + purpose_description = StringProperty("purposeDescription") + template_description = StringProperty("templateDescription") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + return (Consent,) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py new file mode 100644 index 00000000..87de4196 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py @@ -0,0 +1,161 @@ +"""python-odata entity classes for consentConfigurationExternalServices.""" + +from __future__ import annotations + +from typing import Any + +from odata.property import DatetimeProperty, StringProperty, UUIDProperty + + +def _make_entities(Service: Any) -> tuple: + """Create and return all entity classes bound to the given ODataService instance.""" + + class ThirdParty(Service.Entity): + """OData entity representing a third-party reference record.""" + + __odata_collection__ = "thirdParties" + tenant = StringProperty("tenant") + third_party_id = UUIDProperty("thirdPartyId", primary_key=True) + third_party_name = StringProperty("thirdPartyName") + formatted_description = StringProperty("formattedDescription") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class Jurisdiction(Service.Entity): + """OData entity representing a jurisdiction reference record.""" + + __odata_collection__ = "jurisdictions" + tenant = StringProperty("tenant") + jurisdiction_id = UUIDProperty("jurisdictionId", primary_key=True) + jurisdiction_code = StringProperty("jurisdictionCode") + description = StringProperty("description") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class JurisdictionText(Service.Entity): + """OData entity representing a localised description for a jurisdiction.""" + + __odata_collection__ = "jurisdictionTexts" + jurisdiction_id = UUIDProperty("jurisdictionId", primary_key=True) + language_code = StringProperty("languageCode", primary_key=True) + description = StringProperty("description") + + class Language(Service.Entity): + """OData entity representing a language reference record.""" + + __odata_collection__ = "languages" + language_code = StringProperty("languageCode", primary_key=True) + description = StringProperty("description") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class LanguageDescription(Service.Entity): + """OData entity representing an additional description for a language.""" + + __odata_collection__ = "languageDescriptions" + language_code = StringProperty("languageCode", primary_key=True) + description = StringProperty("description") + + class SourceInfo(Service.Entity): + """OData entity representing a data source reference record.""" + + __odata_collection__ = "sourceInfos" + tenant = StringProperty("tenant") + source_id = UUIDProperty("sourceId", primary_key=True) + source_name = StringProperty("sourceName") + description = StringProperty("description") + data_url = StringProperty("dataURL") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class Controller(Service.Entity): + """OData entity representing a data controller reference record.""" + + __odata_collection__ = "controllers" + tenant = StringProperty("tenant") + controller_id = UUIDProperty("controllerId", primary_key=True) + controller_name = StringProperty("controllerName") + source_id = UUIDProperty("sourceId") + source_name = StringProperty("sourceName") + description = StringProperty("description") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class DataSubjectType(Service.Entity): + """OData entity representing a data subject type reference record.""" + + __odata_collection__ = "dataSubjectTypes" + tenant = StringProperty("tenant") + data_subject_type_id = UUIDProperty("dataSubjectTypeId", primary_key=True) + data_subject_type_name = StringProperty("dataSubjectTypeName") + master_data_source_id = UUIDProperty("masterDataSourceId") + master_data_source_name = StringProperty("masterDataSourceName") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class Application(Service.Entity): + """OData entity representing an application reference record.""" + + __odata_collection__ = "applications" + tenant = StringProperty("tenant") + application_id = UUIDProperty("applicationId", primary_key=True) + application_name = StringProperty("applicationName") + source_id = UUIDProperty("sourceId") + source_name = StringProperty("sourceName") + description = StringProperty("description") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class MasterDataSource(Service.Entity): + """OData entity representing a master data source reference record.""" + + __odata_collection__ = "masterDataSources" + tenant = StringProperty("tenant") + master_data_source_id = UUIDProperty("masterDataSourceId", primary_key=True) + master_data_source_name = StringProperty("masterDataSourceName") + description = StringProperty("description") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class OutboundChannelType(Service.Entity): + """OData entity representing an outbound communication channel type.""" + + __odata_collection__ = "outboundChannelTypes" + tenant = StringProperty("tenant") + outbound_channel_type_id = UUIDProperty( + "outboundChannelTypeId", primary_key=True + ) + outbound_channel_type_name = StringProperty("outboundChannelTypeName") + description = StringProperty("description") + created_at = DatetimeProperty("createdAt") + changed_at = DatetimeProperty("changedAt") + + return ( + ThirdParty, + Jurisdiction, + JurisdictionText, + Language, + LanguageDescription, + SourceInfo, + Controller, + DataSubjectType, + Application, + MasterDataSource, + OutboundChannelType, + ) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py new file mode 100644 index 00000000..9576d11f --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py @@ -0,0 +1,49 @@ +"""python-odata entity classes for consentPurposeExternalServices.""" + +from __future__ import annotations + +from typing import Any + +from odata.property import ( + BooleanProperty, + DatetimeProperty, + StringProperty, + UUIDProperty, +) + + +def _make_entities(Service: Any) -> tuple: + """Create and return all entity classes bound to the given ODataService instance.""" + + class ConsentPurpose(Service.Entity): + """OData entity representing a consent purpose record.""" + + __odata_collection__ = "consentPurposes" + tenant = StringProperty("tenant") + purpose_id = UUIDProperty("purposeId", primary_key=True) + purpose_name = StringProperty("purposeName") + lifecycle_status_code = StringProperty("lifecycleStatusCode") + lifecycle_status_domain_description = StringProperty( + "lifecycleStatusDomainDescription" + ) + sensitive_data_flag = BooleanProperty("sensitiveDataFlag") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class ConsentPurposeText(Service.Entity): + """OData entity representing a localised text for a consent purpose.""" + + __odata_collection__ = "consentPurposeTexts" + tenant = StringProperty("tenant") + purpose_id = UUIDProperty("purposeId", primary_key=True) + type_code = StringProperty("typeCode", primary_key=True) + language_code = StringProperty("languageCode", primary_key=True) + text = StringProperty("text") + changed_at = DatetimeProperty("changedAt") + + return ( + ConsentPurpose, + ConsentPurposeText, + ) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py new file mode 100644 index 00000000..67df962d --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py @@ -0,0 +1,40 @@ +"""python-odata entity classes for consentRetentionExternalServices.""" + +from __future__ import annotations + +from typing import Any + +from odata.property import ( + DatetimeProperty, + IntegerProperty, + StringProperty, + UUIDProperty, +) + + +def _make_entities(Service: Any) -> tuple: + """Create and return all entity classes bound to the given ODataService instance.""" + + class ConsentRetentionRule(Service.Entity): + """OData entity representing a data retention rule for consents.""" + + __odata_collection__ = "consentRetentionRules" + tenant = StringProperty("tenant") + rule_id = UUIDProperty("ruleId", primary_key=True) + rule_name = StringProperty("ruleName") + lifecycle_status_code = StringProperty("lifecycleStatusCode") + purpose_id = UUIDProperty("purposeId") + controller_id = UUIDProperty("controllerId") + jurisdiction_code = StringProperty("jurisdictionCode") + consent_model_code = StringProperty("consentModelCode") + retention_years = IntegerProperty("retentionYears") + retention_months = IntegerProperty("retentionMonths") + retention_days = IntegerProperty("retentionDays") + purpose_name = StringProperty("purposeName") + controller_name = StringProperty("controllerName") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + return (ConsentRetentionRule,) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py new file mode 100644 index 00000000..82c7cc6c --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py @@ -0,0 +1,69 @@ +"""python-odata entity classes for consentTemplateExternalServices.""" + +from __future__ import annotations + +from typing import Any + +from odata.property import ( + BooleanProperty, + DatetimeProperty, + StringProperty, + UUIDProperty, +) + + +def _make_entities(Service: Any) -> tuple: + """Create and return all entity classes bound to the given ODataService instance.""" + + class ConsentTemplate(Service.Entity): + """OData entity representing a consent template record.""" + + __odata_collection__ = "consentTemplates" + tenant = StringProperty("tenant") + template_id = UUIDProperty("templateId", primary_key=True) + template_name = StringProperty("templateName") + purpose_id = UUIDProperty("purposeId") + controller_id = UUIDProperty("controllerId") + application_id = UUIDProperty("applicationId") + jurisdiction_code = StringProperty("jurisdictionCode") + consent_model_code = StringProperty("consentModelCode") + application_template_id = StringProperty("applicationTemplateId") + validity_period = StringProperty("validityPeriod") + expiring_period = StringProperty("expiringPeriod") + lifecycle_status_code = StringProperty("lifecycleStatusCode") + purpose_name = StringProperty("purposeName") + controller_name = StringProperty("controllerName") + application_name = StringProperty("applicationName") + created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + class ConsentTemplateText(Service.Entity): + """OData entity representing a localised text for a consent template.""" + + __odata_collection__ = "consentTemplateTexts" + tenant = StringProperty("tenant") + template_id = UUIDProperty("templateId", primary_key=True) + type_code = StringProperty("typeCode", primary_key=True) + language_code = StringProperty("languageCode", primary_key=True) + text = StringProperty("text") + changed_at = DatetimeProperty("changedAt") + + class TemplateThirdPartyPersData(Service.Entity): + """OData entity linking a consent template to a third party's personal data handling.""" + + __odata_collection__ = "templateThirdPartyPersDatas" + tenant = StringProperty("tenant") + template_id = UUIDProperty("templateId", primary_key=True) + third_party_id = UUIDProperty("thirdPartyId", primary_key=True) + third_party_function_code = StringProperty("thirdPartyFunctionCode") + sensitive_data_flag = BooleanProperty("sensitiveDataFlag") + created_at = DatetimeProperty("createdAt") + changed_at = DatetimeProperty("changedAt") + + return ( + ConsentTemplate, + ConsentTemplateText, + TemplateThirdPartyPersData, + ) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py b/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py new file mode 100644 index 00000000..6d9420ac --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py @@ -0,0 +1,45 @@ +"""Custom exception hierarchy for the Consent SDK.""" + + +class ConsentSDKError(Exception): + """Base exception for all Consent SDK errors.""" + + def __init__(self, message: str, odata_error: dict | None = None) -> None: + """Store the OData error payload alongside the human-readable message.""" + super().__init__(message) + self.odata_error = odata_error or {} + + +class ClientCreationError(ConsentSDKError): + """Raised when the SDK client fails to initialize.""" + + +class AuthenticationError(ConsentSDKError): + """Raised when the bearer token is missing or rejected.""" + + +class AuthorizationError(ConsentSDKError): + """Raised when the caller lacks the required OData role.""" + + +class ValidationError(ConsentSDKError): + """Raised when request input fails server-side validation.""" + + +class NotFoundError(ConsentSDKError): + """Raised when the requested resource does not exist.""" + + +class ConflictError(ConsentSDKError): + """Raised when the operation conflicts with existing state (e.g. duplicate name).""" + + +class ODataError(ConsentSDKError): + """Raised for unexpected OData service error responses.""" + + def __init__( + self, message: str, status_code: int, odata_error: dict | None = None + ) -> None: + """Store the HTTP status code alongside the OData error payload.""" + super().__init__(message, odata_error) + self.status_code = status_code diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/py.typed b/src/sap_cloud_sdk/core/dpi_ng/consent/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/__init__.py new file mode 100644 index 00000000..530fdf69 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/__init__.py @@ -0,0 +1,15 @@ +"""Consent SDK service clients.""" + +from .consent_configuration_service import ConsentConfigurationService +from .consent_purpose_service import ConsentPurposeService +from .consent_retention_service import ConsentRetentionService +from .consent_service import ConsentService +from .consent_template_service import ConsentTemplateService + +__all__ = [ + "ConsentService", + "ConsentPurposeService", + "ConsentTemplateService", + "ConsentRetentionService", + "ConsentConfigurationService", +] diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py new file mode 100644 index 00000000..0b837e09 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py @@ -0,0 +1,18 @@ +"""Shared OData query helper used by all consent service clients.""" + +from __future__ import annotations + +from typing import Any + + +def _apply_query(q: Any, params: dict[str, Any]) -> Any: + """Apply OData query options (filter, top, skip, orderby) to a Query builder.""" + if "filter" in params: + q = q.raw({"$filter": params["filter"]}) + if "top" in params: + q = q.limit(int(params["top"])) + if "skip" in params: + q = q.offset(int(params["skip"])) + if "orderby" in params: + q = q.raw({"$orderby": params["orderby"]}) + return q diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py new file mode 100644 index 00000000..a14840c7 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py @@ -0,0 +1,512 @@ +"""Service client for consentConfigurationExternalServices.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..client import _ODataClient +from ._query import _apply_query + +logger = logging.getLogger(__name__) + +_SVC = "consentConfigurationExternalServices" + + +class ConsentConfigurationService: + """Client for consentConfigurationExternalServices - CRUD on reference/configuration data.""" + + def __init__(self, client: _ODataClient) -> None: + """Bind entity classes from the consentConfigurationExternalServices endpoint.""" + logger.info("Invoked ConsentConfigurationService.__init__") + self._client = client + ( + self.ThirdParty, + self.Jurisdiction, + self.JurisdictionText, + self.Language, + self.LanguageDescription, + self.SourceInfo, + self.Controller, + self.DataSubjectType, + self.Application, + self.MasterDataSource, + self.OutboundChannelType, + ) = client.get_entity_classes(_SVC) + logger.info("Exiting ConsentConfigurationService.__init__") + + # ------ thirdParties ------ + + def list_third_parties(self, **query: Any) -> list[Any]: + """Return all third-party records, optionally filtered/paged via OData query kwargs.""" + logger.info("Invoked ConsentConfigurationService.list_third_parties") + result = _apply_query(self._client.query(_SVC, self.ThirdParty), query).all() + logger.info("Exiting ConsentConfigurationService.list_third_parties") + return result + + def get_third_party(self, third_party_id: str) -> Any: + """Return a single ThirdParty entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_third_party") + result = self._client.query(_SVC, self.ThirdParty).get(third_party_id) + logger.info("Exiting ConsentConfigurationService.get_third_party") + return result + + def create_third_party(self, body: dict[str, Any]) -> Any: + """Create a new ThirdParty entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_third_party") + entity = self.ThirdParty() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_third_party") + return entity + + def update_third_party(self, third_party_id: str, body: dict[str, Any]) -> Any: + """Fetch a ThirdParty by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_third_party") + entity = self._client.query(_SVC, self.ThirdParty).get(third_party_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_third_party") + return entity + + def delete_third_party(self, third_party_id: str) -> None: + """Delete a ThirdParty entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_third_party") + entity = self._client.query(_SVC, self.ThirdParty).get(third_party_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_third_party") + + # ------ jurisdictions ------ + + def list_jurisdictions(self, **query: Any) -> list[Any]: + """Return all jurisdiction records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_jurisdictions") + result = _apply_query(self._client.query(_SVC, self.Jurisdiction), query).all() + logger.info("Exiting ConsentConfigurationService.list_jurisdictions") + return result + + def get_jurisdiction(self, jurisdiction_id: str) -> Any: + """Return a single Jurisdiction entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_jurisdiction") + result = self._client.query(_SVC, self.Jurisdiction).get(jurisdiction_id) + logger.info("Exiting ConsentConfigurationService.get_jurisdiction") + return result + + def create_jurisdiction(self, body: dict[str, Any]) -> Any: + """Create a new Jurisdiction entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_jurisdiction") + entity = self.Jurisdiction() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_jurisdiction") + return entity + + def update_jurisdiction(self, jurisdiction_id: str, body: dict[str, Any]) -> Any: + """Fetch a Jurisdiction by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_jurisdiction") + entity = self._client.query(_SVC, self.Jurisdiction).get(jurisdiction_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_jurisdiction") + return entity + + def delete_jurisdiction(self, jurisdiction_id: str) -> None: + """Delete a Jurisdiction entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_jurisdiction") + entity = self._client.query(_SVC, self.Jurisdiction).get(jurisdiction_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_jurisdiction") + + # ------ jurisdictionTexts ------ + + def list_jurisdiction_texts(self, **query: Any) -> list[Any]: + """Return all jurisdiction text records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_jurisdiction_texts") + result = _apply_query( + self._client.query(_SVC, self.JurisdictionText), query + ).all() + logger.info("Exiting ConsentConfigurationService.list_jurisdiction_texts") + return result + + def create_jurisdiction_text(self, body: dict[str, Any]) -> Any: + """Create a new JurisdictionText entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_jurisdiction_text") + entity = self.JurisdictionText() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_jurisdiction_text") + return entity + + def update_jurisdiction_text( + self, jurisdiction_id: str, language_code: str, body: dict[str, Any] + ) -> Any: + """Fetch a JurisdictionText by composite key, apply updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_jurisdiction_text") + entity = self._client.query(_SVC, self.JurisdictionText).get( + jurisdictionId=jurisdiction_id, languageCode=language_code + ) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_jurisdiction_text") + return entity + + def delete_jurisdiction_text( + self, jurisdiction_id: str, language_code: str + ) -> None: + """Delete a JurisdictionText by its composite key.""" + logger.info("Invoked ConsentConfigurationService.delete_jurisdiction_text") + entity = self._client.query(_SVC, self.JurisdictionText).get( + jurisdictionId=jurisdiction_id, languageCode=language_code + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_jurisdiction_text") + + # ------ languages ------ + + def list_languages(self, **query: Any) -> list[Any]: + """Return all language reference records.""" + logger.info("Invoked ConsentConfigurationService.list_languages") + result = _apply_query(self._client.query(_SVC, self.Language), query).all() + logger.info("Exiting ConsentConfigurationService.list_languages") + return result + + def get_language(self, language_code: str) -> Any: + """Return a single Language entity by its code.""" + logger.info("Invoked ConsentConfigurationService.get_language") + result = self._client.query(_SVC, self.Language).get(language_code) + logger.info("Exiting ConsentConfigurationService.get_language") + return result + + # ------ languageDescriptions ------ + + def list_language_descriptions(self, **query: Any) -> list[Any]: + """Return all language description records.""" + logger.info("Invoked ConsentConfigurationService.list_language_descriptions") + result = _apply_query( + self._client.query(_SVC, self.LanguageDescription), query + ).all() + logger.info("Exiting ConsentConfigurationService.list_language_descriptions") + return result + + def create_language_description(self, body: dict[str, Any]) -> Any: + """Create a new LanguageDescription entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_language_description") + entity = self.LanguageDescription() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_language_description") + return entity + + def update_language_description( + self, language_code: str, body: dict[str, Any] + ) -> Any: + """Fetch a LanguageDescription by code, apply updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_language_description") + entity = self._client.query(_SVC, self.LanguageDescription).get(language_code) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_language_description") + return entity + + def delete_language_description(self, language_code: str) -> None: + """Delete a LanguageDescription entity by its language code.""" + logger.info("Invoked ConsentConfigurationService.delete_language_description") + entity = self._client.query(_SVC, self.LanguageDescription).get(language_code) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_language_description") + + # ------ sourceInfos ------ + + def list_source_infos(self, **query: Any) -> list[Any]: + """Return all source info records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_source_infos") + result = _apply_query(self._client.query(_SVC, self.SourceInfo), query).all() + logger.info("Exiting ConsentConfigurationService.list_source_infos") + return result + + def get_source_info(self, source_id: str) -> Any: + """Return a single SourceInfo entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_source_info") + result = self._client.query(_SVC, self.SourceInfo).get(source_id) + logger.info("Exiting ConsentConfigurationService.get_source_info") + return result + + def create_source_info(self, body: dict[str, Any]) -> Any: + """Create a new SourceInfo entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_source_info") + entity = self.SourceInfo() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_source_info") + return entity + + def update_source_info(self, source_id: str, body: dict[str, Any]) -> Any: + """Fetch a SourceInfo by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_source_info") + entity = self._client.query(_SVC, self.SourceInfo).get(source_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_source_info") + return entity + + def delete_source_info(self, source_id: str) -> None: + """Delete a SourceInfo entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_source_info") + entity = self._client.query(_SVC, self.SourceInfo).get(source_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_source_info") + + # ------ controllers ------ + + def list_controllers(self, **query: Any) -> list[Any]: + """Return all controller records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_controllers") + result = _apply_query(self._client.query(_SVC, self.Controller), query).all() + logger.info("Exiting ConsentConfigurationService.list_controllers") + return result + + def get_controller(self, controller_id: str) -> Any: + """Return a single Controller entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_controller") + result = self._client.query(_SVC, self.Controller).get(controller_id) + logger.info("Exiting ConsentConfigurationService.get_controller") + return result + + def create_controller(self, body: dict[str, Any]) -> Any: + """Create a new Controller entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_controller") + entity = self.Controller() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_controller") + return entity + + def update_controller(self, controller_id: str, body: dict[str, Any]) -> Any: + """Fetch a Controller by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_controller") + entity = self._client.query(_SVC, self.Controller).get(controller_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_controller") + return entity + + def delete_controller(self, controller_id: str) -> None: + """Delete a Controller entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_controller") + entity = self._client.query(_SVC, self.Controller).get(controller_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_controller") + + # ------ dataSubjectTypes ------ + + def list_data_subject_types(self, **query: Any) -> list[Any]: + """Return all data subject type records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_data_subject_types") + result = _apply_query( + self._client.query(_SVC, self.DataSubjectType), query + ).all() + logger.info("Exiting ConsentConfigurationService.list_data_subject_types") + return result + + def get_data_subject_type(self, data_subject_type_id: str) -> Any: + """Return a single DataSubjectType entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_data_subject_type") + result = self._client.query(_SVC, self.DataSubjectType).get( + data_subject_type_id + ) + logger.info("Exiting ConsentConfigurationService.get_data_subject_type") + return result + + def create_data_subject_type(self, body: dict[str, Any]) -> Any: + """Create a new DataSubjectType entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_data_subject_type") + entity = self.DataSubjectType() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_data_subject_type") + return entity + + def update_data_subject_type( + self, data_subject_type_id: str, body: dict[str, Any] + ) -> Any: + """Fetch a DataSubjectType by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_data_subject_type") + entity = self._client.query(_SVC, self.DataSubjectType).get( + data_subject_type_id + ) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_data_subject_type") + return entity + + def delete_data_subject_type(self, data_subject_type_id: str) -> None: + """Delete a DataSubjectType entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_data_subject_type") + entity = self._client.query(_SVC, self.DataSubjectType).get( + data_subject_type_id + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_data_subject_type") + + # ------ applications ------ + + def list_applications(self, **query: Any) -> list[Any]: + """Return all application records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_applications") + result = _apply_query(self._client.query(_SVC, self.Application), query).all() + logger.info("Exiting ConsentConfigurationService.list_applications") + return result + + def get_application(self, application_id: str) -> Any: + """Return a single Application entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_application") + result = self._client.query(_SVC, self.Application).get(application_id) + logger.info("Exiting ConsentConfigurationService.get_application") + return result + + def create_application(self, body: dict[str, Any]) -> Any: + """Create a new Application entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_application") + entity = self.Application() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_application") + return entity + + def update_application(self, application_id: str, body: dict[str, Any]) -> Any: + """Fetch an Application by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_application") + entity = self._client.query(_SVC, self.Application).get(application_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_application") + return entity + + def delete_application(self, application_id: str) -> None: + """Delete an Application entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_application") + entity = self._client.query(_SVC, self.Application).get(application_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_application") + + # ------ masterDataSources ------ + + def list_master_data_sources(self, **query: Any) -> list[Any]: + """Return all master data source records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_master_data_sources") + result = _apply_query( + self._client.query(_SVC, self.MasterDataSource), query + ).all() + logger.info("Exiting ConsentConfigurationService.list_master_data_sources") + return result + + def get_master_data_source(self, master_data_source_id: str) -> Any: + """Return a single MasterDataSource entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_master_data_source") + result = self._client.query(_SVC, self.MasterDataSource).get( + master_data_source_id + ) + logger.info("Exiting ConsentConfigurationService.get_master_data_source") + return result + + def create_master_data_source(self, body: dict[str, Any]) -> Any: + """Create a new MasterDataSource entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_master_data_source") + entity = self.MasterDataSource() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_master_data_source") + return entity + + def update_master_data_source( + self, master_data_source_id: str, body: dict[str, Any] + ) -> Any: + """Fetch a MasterDataSource by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_master_data_source") + entity = self._client.query(_SVC, self.MasterDataSource).get( + master_data_source_id + ) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_master_data_source") + return entity + + def delete_master_data_source(self, master_data_source_id: str) -> None: + """Delete a MasterDataSource entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_master_data_source") + entity = self._client.query(_SVC, self.MasterDataSource).get( + master_data_source_id + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_master_data_source") + + # ------ outboundChannelTypes ------ + + def list_outbound_channel_types(self, **query: Any) -> list[Any]: + """Return all outbound channel type records, optionally filtered/paged.""" + logger.info("Invoked ConsentConfigurationService.list_outbound_channel_types") + result = _apply_query( + self._client.query(_SVC, self.OutboundChannelType), query + ).all() + logger.info("Exiting ConsentConfigurationService.list_outbound_channel_types") + return result + + def get_outbound_channel_type(self, outbound_channel_type_id: str) -> Any: + """Return a single OutboundChannelType entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.get_outbound_channel_type") + result = self._client.query(_SVC, self.OutboundChannelType).get( + outbound_channel_type_id + ) + logger.info("Exiting ConsentConfigurationService.get_outbound_channel_type") + return result + + def create_outbound_channel_type(self, body: dict[str, Any]) -> Any: + """Create a new OutboundChannelType entity and return it.""" + logger.info("Invoked ConsentConfigurationService.create_outbound_channel_type") + entity = self.OutboundChannelType() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.create_outbound_channel_type") + return entity + + def update_outbound_channel_type( + self, outbound_channel_type_id: str, body: dict[str, Any] + ) -> Any: + """Fetch an OutboundChannelType by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentConfigurationService.update_outbound_channel_type") + entity = self._client.query(_SVC, self.OutboundChannelType).get( + outbound_channel_type_id + ) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentConfigurationService.update_outbound_channel_type") + return entity + + def delete_outbound_channel_type(self, outbound_channel_type_id: str) -> None: + """Delete an OutboundChannelType entity by its UUID.""" + logger.info("Invoked ConsentConfigurationService.delete_outbound_channel_type") + entity = self._client.query(_SVC, self.OutboundChannelType).get( + outbound_channel_type_id + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_outbound_channel_type") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py new file mode 100644 index 00000000..83a54e25 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py @@ -0,0 +1,151 @@ +"""Service client for consentPurposeExternalServices.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..client import _ODataClient +from ._query import _apply_query + +logger = logging.getLogger(__name__) + +_SVC = "consentPurposeExternalServices" + + +class ConsentPurposeService: + """Client for consentPurposeExternalServices - CRUD on purposes and their texts.""" + + def __init__(self, client: _ODataClient) -> None: + """Bind entity classes from the consentPurposeExternalServices endpoint.""" + logger.info("Invoked ConsentPurposeService.__init__") + self._client = client + ( + self.ConsentPurpose, + self.ConsentPurposeText, + ) = client.get_entity_classes(_SVC) + logger.info("Exiting ConsentPurposeService.__init__") + + # ------ consentPurposes ------ + + def list_purposes(self, **query: Any) -> list[Any]: + """Return all consent purposes, optionally filtered/paged via OData query kwargs.""" + logger.info("Invoked ConsentPurposeService.list_purposes") + result = _apply_query( + self._client.query(_SVC, self.ConsentPurpose), query + ).all() + logger.info("Exiting ConsentPurposeService.list_purposes") + return result + + def get_purpose(self, purpose_id: str) -> Any: + """Return a single ConsentPurpose entity by its UUID.""" + logger.info("Invoked ConsentPurposeService.get_purpose") + result = self._client.query(_SVC, self.ConsentPurpose).get(purpose_id) + logger.info("Exiting ConsentPurposeService.get_purpose") + return result + + def create_purpose(self, body: dict[str, Any]) -> Any: + """Create a new ConsentPurpose entity and return it.""" + logger.info("Invoked ConsentPurposeService.create_purpose") + entity = self.ConsentPurpose() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentPurposeService.create_purpose") + return entity + + def update_purpose(self, purpose_id: str, body: dict[str, Any]) -> Any: + """Fetch a ConsentPurpose by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentPurposeService.update_purpose") + entity = self._client.query(_SVC, self.ConsentPurpose).get(purpose_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentPurposeService.update_purpose") + return entity + + def delete_purpose(self, purpose_id: str) -> None: + """Delete a ConsentPurpose by its UUID.""" + logger.info("Invoked ConsentPurposeService.delete_purpose") + entity = self._client.query(_SVC, self.ConsentPurpose).get(purpose_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentPurposeService.delete_purpose") + + # ------ lifecycle actions ------ + + def set_purpose_active(self, purpose_id: str) -> Any: + """Activate a consent purpose and return the refreshed entity.""" + logger.info("Invoked ConsentPurposeService.set_purpose_active") + self._client.call_action( + _SVC, "consentPurposeSetConsentPurposeToActive", {"purposeId": purpose_id} + ) + result = self.get_purpose(purpose_id) + logger.info("Exiting ConsentPurposeService.set_purpose_active") + return result + + def set_purpose_inactive(self, purpose_id: str) -> Any: + """Deactivate a consent purpose and return the refreshed entity.""" + logger.info("Invoked ConsentPurposeService.set_purpose_inactive") + self._client.call_action( + _SVC, "consentPurposeSetConsentPurposeToInactive", {"purposeId": purpose_id} + ) + result = self.get_purpose(purpose_id) + logger.info("Exiting ConsentPurposeService.set_purpose_inactive") + return result + + # ------ consentPurposeTexts ------ + + def list_purpose_texts(self, **query: Any) -> list[Any]: + """Return all purpose text records, optionally filtered/paged.""" + logger.info("Invoked ConsentPurposeService.list_purpose_texts") + result = _apply_query( + self._client.query(_SVC, self.ConsentPurposeText), query + ).all() + logger.info("Exiting ConsentPurposeService.list_purpose_texts") + return result + + def get_purpose_text( + self, purpose_id: str, type_code: str, language_code: str + ) -> Any: + """Return a single ConsentPurposeText by its composite key.""" + logger.info("Invoked ConsentPurposeService.get_purpose_text") + result = self._client.query(_SVC, self.ConsentPurposeText).get( + purposeId=purpose_id, typeCode=type_code, languageCode=language_code + ) + logger.info("Exiting ConsentPurposeService.get_purpose_text") + return result + + def create_purpose_text(self, body: dict[str, Any]) -> Any: + """Create a new ConsentPurposeText entity and return it.""" + logger.info("Invoked ConsentPurposeService.create_purpose_text") + entity = self.ConsentPurposeText() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentPurposeService.create_purpose_text") + return entity + + def update_purpose_text( + self, purpose_id: str, type_code: str, language_code: str, body: dict[str, Any] + ) -> Any: + """Fetch a ConsentPurposeText by composite key, apply updates, and PATCH it.""" + logger.info("Invoked ConsentPurposeService.update_purpose_text") + entity = self._client.query(_SVC, self.ConsentPurposeText).get( + purposeId=purpose_id, typeCode=type_code, languageCode=language_code + ) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentPurposeService.update_purpose_text") + return entity + + def delete_purpose_text( + self, purpose_id: str, type_code: str, language_code: str + ) -> None: + """Delete a ConsentPurposeText by its composite key.""" + logger.info("Invoked ConsentPurposeService.delete_purpose_text") + entity = self._client.query(_SVC, self.ConsentPurposeText).get( + purposeId=purpose_id, typeCode=type_code, languageCode=language_code + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentPurposeService.delete_purpose_text") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py new file mode 100644 index 00000000..508bd834 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py @@ -0,0 +1,93 @@ +"""Service client for consentRetentionExternalServices.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..client import _ODataClient +from ._query import _apply_query + +logger = logging.getLogger(__name__) + +_SVC = "consentRetentionExternalServices" + + +class ConsentRetentionService: + """Client for consentRetentionExternalServices - CRUD on data retention rules.""" + + def __init__(self, client: _ODataClient) -> None: + """Bind entity classes from the consentRetentionExternalServices endpoint.""" + logger.info("Invoked ConsentRetentionService.__init__") + self._client = client + (self.ConsentRetentionRule,) = client.get_entity_classes(_SVC) + logger.info("Exiting ConsentRetentionService.__init__") + + # ------ consentRetentionRules ------ + + def list_rules(self, **query: Any) -> list[Any]: + """Return all retention rules, optionally filtered/paged via OData query kwargs.""" + logger.info("Invoked ConsentRetentionService.list_rules") + result = _apply_query( + self._client.query(_SVC, self.ConsentRetentionRule), query + ).all() + logger.info("Exiting ConsentRetentionService.list_rules") + return result + + def get_rule(self, rule_id: str) -> Any: + """Return a single ConsentRetentionRule entity by its UUID.""" + logger.info("Invoked ConsentRetentionService.get_rule") + result = self._client.query(_SVC, self.ConsentRetentionRule).get(rule_id) + logger.info("Exiting ConsentRetentionService.get_rule") + return result + + def create_rule(self, body: dict[str, Any]) -> Any: + """Create a new ConsentRetentionRule entity and return it.""" + logger.info("Invoked ConsentRetentionService.create_rule") + entity = self.ConsentRetentionRule() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentRetentionService.create_rule") + return entity + + def update_rule(self, rule_id: str, body: dict[str, Any]) -> Any: + """Fetch a ConsentRetentionRule by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentRetentionService.update_rule") + entity = self._client.query(_SVC, self.ConsentRetentionRule).get(rule_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentRetentionService.update_rule") + return entity + + def delete_rule(self, rule_id: str) -> None: + """Delete a ConsentRetentionRule by its UUID.""" + logger.info("Invoked ConsentRetentionService.delete_rule") + entity = self._client.query(_SVC, self.ConsentRetentionRule).get(rule_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentRetentionService.delete_rule") + + # ------ lifecycle actions ------ + + def set_rule_active(self, rule_id: str) -> Any: + """Activate a retention rule and return the refreshed entity.""" + logger.info("Invoked ConsentRetentionService.set_rule_active") + self._client.call_action( + _SVC, "consentRetentionRuleSetConsentRetentionToActive", {"ruleId": rule_id} + ) + result = self.get_rule(rule_id) + logger.info("Exiting ConsentRetentionService.set_rule_active") + return result + + def set_rule_inactive(self, rule_id: str) -> Any: + """Deactivate a retention rule and return the refreshed entity.""" + logger.info("Invoked ConsentRetentionService.set_rule_inactive") + self._client.call_action( + _SVC, + "consentRetentionRuleSetConsentRetentionToInactive", + {"ruleId": rule_id}, + ) + result = self.get_rule(rule_id) + logger.info("Exiting ConsentRetentionService.set_rule_inactive") + return result diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py new file mode 100644 index 00000000..446bff98 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py @@ -0,0 +1,112 @@ +"""Service client for consentServices.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..client import _ODataClient +from ..dtos.consent import ( + CheckConsentExistsResult, + CreateConsentRequest, + WithdrawConsentRequest, +) +from ._query import _apply_query + +logger = logging.getLogger(__name__) + +_SVC = "consentServices" + + +class ConsentService: + """Client for consentServices - consent creation, withdrawal, and reads.""" + + def __init__(self, client: _ODataClient) -> None: + """Bind entity classes from the consentServices endpoint.""" + logger.info("Invoked ConsentService.__init__") + self._client = client + (self.Consent,) = client.get_entity_classes(_SVC) + logger.info("Exiting ConsentService.__init__") + + # ------ consents ------ + + def list_consents(self, **query: Any) -> list[Any]: + """Return all consents, optionally filtered/paged via OData query kwargs.""" + logger.info("Invoked ConsentService.list_consents") + result = _apply_query(self._client.query(_SVC, self.Consent), query).all() + logger.info("Exiting ConsentService.list_consents") + return result + + def get_consent(self, consent_id: str) -> Any: + """Return a single Consent entity by its UUID.""" + logger.info("Invoked ConsentService.get_consent") + result = self._client.query(_SVC, self.Consent).get(consent_id) + logger.info("Exiting ConsentService.get_consent") + return result + + def delete_consent(self, consent_id: str) -> None: + """Delete a Consent entity by its UUID.""" + logger.info("Invoked ConsentService.delete_consent") + entity = self._client.query(_SVC, self.Consent).get(consent_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentService.delete_consent") + + # ------ actions ------ + + def create_consent_from_template(self, request: CreateConsentRequest) -> list[Any]: + """Invoke createConsentFromTemplate and return the resulting Consent entities.""" + logger.info("Invoked ConsentService.create_consent_from_template") + result = self._client.call_action( + _SVC, "createConsentFromTemplate", request.to_dict() + ) + if result is None: + logger.info( + "Exiting ConsentService.create_consent_from_template — empty result" + ) + return [] + values = result.get("value", result) if isinstance(result, dict) else result + if not isinstance(values, list): + values = [values] + entities = [_dict_to_entity(self.Consent, v) for v in values] + logger.info("Exiting ConsentService.create_consent_from_template") + return entities + + def withdraw_consent( + self, request: WithdrawConsentRequest + ) -> dict[str, Any] | None: + """Invoke withdrawConsent and return the raw action response.""" + logger.info("Invoked ConsentService.withdraw_consent") + result = self._client.call_action(_SVC, "withdrawConsent", request.to_dict()) + logger.info("Exiting ConsentService.withdraw_consent") + return result + + def terminate_consent( + self, request: WithdrawConsentRequest + ) -> dict[str, Any] | None: + """Invoke terminateConsent and return the raw action response.""" + logger.info("Invoked ConsentService.terminate_consent") + result = self._client.call_action(_SVC, "terminateConsent", request.to_dict()) + logger.info("Exiting ConsentService.terminate_consent") + return result + + def check_consent_exists( + self, data_subject_id: str, template_id: str + ) -> CheckConsentExistsResult: + """Check whether a consent record exists for the given data subject and template.""" + logger.info("Invoked ConsentService.check_consent_exists") + result = self._client.call_action( + _SVC, + "checkConsentExists", + {"dataSubjectId": data_subject_id, "templateId": template_id}, + ) + check_result = CheckConsentExistsResult.from_dict(result or {}) + logger.info("Exiting ConsentService.check_consent_exists") + return check_result + + +def _dict_to_entity(entity_cls: type, data: dict[str, Any]) -> Any: + """Wrap a raw dict as a persisted OData entity instance.""" + entity = entity_cls() + entity.__odata__.update(data) + entity.__odata__.persisted = True + return entity diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py new file mode 100644 index 00000000..6eee1871 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py @@ -0,0 +1,211 @@ +"""Service client for consentTemplateExternalServices.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..client import _ODataClient +from ._query import _apply_query + +logger = logging.getLogger(__name__) + +_SVC = "consentTemplateExternalServices" + + +class ConsentTemplateService: + """Client for consentTemplateExternalServices - CRUD on templates and related entities.""" + + def __init__(self, client: _ODataClient) -> None: + """Bind entity classes from the consentTemplateExternalServices endpoint.""" + logger.info("Invoked ConsentTemplateService.__init__") + self._client = client + ( + self.ConsentTemplate, + self.ConsentTemplateText, + self.TemplateThirdPartyPersData, + ) = client.get_entity_classes(_SVC) + logger.info("Exiting ConsentTemplateService.__init__") + + # ------ consentTemplates ------ + + def list_templates(self, **query: Any) -> list[Any]: + """Return all consent templates, optionally filtered/paged via OData query kwargs.""" + logger.info("Invoked ConsentTemplateService.list_templates") + result = _apply_query( + self._client.query(_SVC, self.ConsentTemplate), query + ).all() + logger.info("Exiting ConsentTemplateService.list_templates") + return result + + def get_template(self, template_id: str) -> Any: + """Return a single ConsentTemplate entity by its UUID.""" + logger.info("Invoked ConsentTemplateService.get_template") + result = self._client.query(_SVC, self.ConsentTemplate).get(template_id) + logger.info("Exiting ConsentTemplateService.get_template") + return result + + def create_template(self, body: dict[str, Any]) -> Any: + """Create a new ConsentTemplate entity and return it.""" + logger.info("Invoked ConsentTemplateService.create_template") + entity = self.ConsentTemplate() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentTemplateService.create_template") + return entity + + def update_template(self, template_id: str, body: dict[str, Any]) -> Any: + """Fetch a ConsentTemplate by ID, apply field updates, and PATCH it.""" + logger.info("Invoked ConsentTemplateService.update_template") + entity = self._client.query(_SVC, self.ConsentTemplate).get(template_id) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentTemplateService.update_template") + return entity + + def delete_template(self, template_id: str) -> None: + """Delete a ConsentTemplate by its UUID.""" + logger.info("Invoked ConsentTemplateService.delete_template") + entity = self._client.query(_SVC, self.ConsentTemplate).get(template_id) + self._client.delete_entity(entity) + logger.info("Exiting ConsentTemplateService.delete_template") + + # ------ lifecycle actions ------ + + def set_template_active(self, template_id: str) -> Any: + """Activate a consent template and return the refreshed entity.""" + logger.info("Invoked ConsentTemplateService.set_template_active") + self._client.call_action( + _SVC, + "consentTemplateSetConsentTemplateToActive", + {"templateId": template_id}, + ) + result = self.get_template(template_id) + logger.info("Exiting ConsentTemplateService.set_template_active") + return result + + def set_template_inactive(self, template_id: str) -> Any: + """Deactivate a consent template and return the refreshed entity.""" + logger.info("Invoked ConsentTemplateService.set_template_inactive") + self._client.call_action( + _SVC, + "consentTemplateSetConsentTemplateToInactive", + {"templateId": template_id}, + ) + result = self.get_template(template_id) + logger.info("Exiting ConsentTemplateService.set_template_inactive") + return result + + # ------ consentTemplateTexts ------ + + def list_template_texts(self, **query: Any) -> list[Any]: + """Return all template text records, optionally filtered/paged.""" + logger.info("Invoked ConsentTemplateService.list_template_texts") + result = _apply_query( + self._client.query(_SVC, self.ConsentTemplateText), query + ).all() + logger.info("Exiting ConsentTemplateService.list_template_texts") + return result + + def get_template_text( + self, template_id: str, type_code: str, language_code: str + ) -> Any: + """Return a single ConsentTemplateText by its composite key.""" + logger.info("Invoked ConsentTemplateService.get_template_text") + result = self._client.query(_SVC, self.ConsentTemplateText).get( + templateId=template_id, typeCode=type_code, languageCode=language_code + ) + logger.info("Exiting ConsentTemplateService.get_template_text") + return result + + def create_template_text(self, body: dict[str, Any]) -> Any: + """Create a new ConsentTemplateText entity and return it.""" + logger.info("Invoked ConsentTemplateService.create_template_text") + entity = self.ConsentTemplateText() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentTemplateService.create_template_text") + return entity + + def update_template_text( + self, template_id: str, type_code: str, language_code: str, body: dict[str, Any] + ) -> Any: + """Fetch a ConsentTemplateText by composite key, apply updates, and PATCH it.""" + logger.info("Invoked ConsentTemplateService.update_template_text") + entity = self._client.query(_SVC, self.ConsentTemplateText).get( + templateId=template_id, typeCode=type_code, languageCode=language_code + ) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentTemplateService.update_template_text") + return entity + + def delete_template_text( + self, template_id: str, type_code: str, language_code: str + ) -> None: + """Delete a ConsentTemplateText by its composite key.""" + logger.info("Invoked ConsentTemplateService.delete_template_text") + entity = self._client.query(_SVC, self.ConsentTemplateText).get( + templateId=template_id, typeCode=type_code, languageCode=language_code + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentTemplateService.delete_template_text") + + # ------ templateThirdPartyPersDatas ------ + + def list_third_party_pers_data(self, **query: Any) -> list[Any]: + """Return all template third-party personal data records.""" + logger.info("Invoked ConsentTemplateService.list_third_party_pers_data") + result = _apply_query( + self._client.query(_SVC, self.TemplateThirdPartyPersData), query + ).all() + logger.info("Exiting ConsentTemplateService.list_third_party_pers_data") + return result + + def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> Any: + """Return a single TemplateThirdPartyPersData by its composite key.""" + logger.info("Invoked ConsentTemplateService.get_third_party_pers_data") + result = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( + templateId=template_id, thirdPartyId=third_party_id + ) + logger.info("Exiting ConsentTemplateService.get_third_party_pers_data") + return result + + def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: + """Create a new TemplateThirdPartyPersData entity and return it.""" + logger.info("Invoked ConsentTemplateService.create_third_party_pers_data") + entity = self.TemplateThirdPartyPersData() + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentTemplateService.create_third_party_pers_data") + return entity + + def update_third_party_pers_data( + self, template_id: str, third_party_id: str, body: dict[str, Any] + ) -> Any: + """Fetch a TemplateThirdPartyPersData by composite key, apply updates, and PATCH it.""" + logger.info("Invoked ConsentTemplateService.update_third_party_pers_data") + entity = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( + templateId=template_id, thirdPartyId=third_party_id + ) + for k, v in body.items(): + setattr(entity, k, v) + self._client.save(entity) + logger.info("Exiting ConsentTemplateService.update_third_party_pers_data") + return entity + + def delete_third_party_pers_data( + self, template_id: str, third_party_id: str + ) -> None: + """Delete a TemplateThirdPartyPersData by its composite key.""" + logger.info("Invoked ConsentTemplateService.delete_third_party_pers_data") + entity = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( + templateId=template_id, thirdPartyId=third_party_id + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentTemplateService.delete_third_party_pers_data") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md new file mode 100644 index 00000000..9b133c59 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -0,0 +1,216 @@ +# Consent SDK - User Guide + +## Installation + +```bash +pip install sap-cloud-sdk +``` + +Requires Python 3.11+. The SDK depends on [python-odata](https://pypi.org/project/python-odata/) for entity modelling and OData query building. + +--- + +## Quick start + +```python +from sap_cloud_sdk.core.dpi_ng.consent import create_client, BearerTokenAuth + +with create_client( + base_url="https://", + auth=BearerTokenAuth(""), +) as client: + # List consents with an OData filter + consents = client.consents.list_consents(filter="lifecycle_status_code eq '1'") + for c in consents: + print(c.consent_id, c.lifecycle_status_code) +``` + +--- + +## Authentication + +Pass one of the built-in `AuthProvider` implementations to `create_client`: + +| Class | When to use | +|---|---| +| `BearerTokenAuth(token)` | You hold a pre-fetched token and manage refresh yourself | +| `ClientCredentialsAuth(token_url, client_id, client_secret)` | OAuth2 client credentials; token is fetched and refreshed automatically | +| `ClientCertificateAuth(cert_file, key_file, ca_file=None)` | Mutual TLS with a client certificate and key | + +Custom auth providers implement `AuthProvider.apply(session: requests.Session)`. + +--- + +## Client structure + +`ConsentClient` exposes five service attributes: + +| Attribute | OData service | Purpose | +|---|---|---| +| `client.consents` | `consentServices` | Consent creation, withdrawal, termination, reads | +| `client.purposes` | `consentPurposeExternalServices` | Purpose CRUD and lifecycle | +| `client.templates` | `consentTemplateExternalServices` | Template CRUD, lifecycle, third-party assignment | +| `client.retention` | `consentRetentionExternalServices` | Retention rule CRUD and lifecycle | +| `client.configuration` | `consentConfigurationExternalServices` | Reference data (controllers, applications, jurisdictions, ...) | + +--- + +## Entity model (python-odata) + +Entity classes are defined using [python-odata](https://pypi.org/project/python-odata/) descriptors and live under `entities/`. Each `make_entities(Service)` factory binds the classes to a specific `ODataService` instance so that query and save operations go to the right endpoint. + +### Property types + +| Descriptor | OData type | Python type | +|---|---|---| +| `StringProperty` | `Edm.String` | `str` | +| `UUIDProperty` | `Edm.Guid` | `uuid.UUID` / `str` | +| `BooleanProperty` | `Edm.Boolean` | `bool` | +| `DatetimeProperty` | `Edm.DateTimeOffset` | `datetime` | +| `IntegerProperty` | `Edm.Int32` | `int` | + +Properties declared with `primary_key=True` form the entity key used by `GET` and `DELETE` requests. + +### Reading a field + +```python +purpose = client.purposes.list_purposes()[0] +print(purpose.purpose_id) # uuid.UUID +print(purpose.purpose_name) # str +print(purpose.sensitive_data_flag) # bool +``` + +--- + +## Querying + +All `list_*` methods accept OData query kwargs: + +| Kwarg | OData option | Example | +|---|---|---| +| `filter` | `$filter` | `filter="lifecycle_status_code eq '2'"` | +| `top` | `$top` | `top=10` | +| `skip` | `$skip` | `skip=20` | +| `orderby` | `$orderby` | `orderby="changed_at desc"` | + +```python +# First page of active purposes +active = client.purposes.list_purposes(filter="lifecycle_status_code eq '2'", top=50) + +# A single consent by UUID +consent = client.consents.get_consent("3fa85f64-5717-4562-b3fc-2c963f66afa6") +``` + +--- + +## Creating and saving entities + +Entities are created by instantiating the class, setting attributes, and calling `save` (which issues a `POST` for new entities and a `PATCH` for dirty existing entities). + +```python +# Create a new controller via configuration service +ctrl = client.configuration.create_controller({ + "controller_name": "AB Corp", + "description": "Main data controller", +}) +print(ctrl.controller_id) # UUID assigned by the service +``` + +Internally the service calls `ODataClient.save(entity)`, which delegates to `entity.__odata_service__.save(entity)`. + +--- + +## OData actions + +Actions that are not standard CRUD are called via `ODataClient.call_action` and are exposed as named methods. + +### Consent creation from a template + +```python +from sap_cloud_sdk.core.dpi_ng.consent import CreateConsentRequest + +request = CreateConsentRequest( + data_subject_id="user@example.com", + template_name="", + language_code="EN", + data_subject_type_name="", + jurisdiction_code="", +) +consents = client.consents.create_consent_from_template(request) +``` + +### Async consent creation + +```python +result = client.consents.create_consent_from_template_async(request) +# result.request_id, result.status + +# Poll until done +status = client.consents.get_async_consent_status(result.request_id) +``` + +### Withdraw / terminate + +```python +from sap_cloud_sdk.core.dpi_ng.consent import WithdrawConsentRequest + +client.consents.withdraw_consent( + WithdrawConsentRequest(consent_id="", withdrawn_by="User request") +) +client.consents.terminate_consent( + WithdrawConsentRequest(consent_id="", withdrawn_by="Contract end") +) +``` + +### Lifecycle transitions (purposes, templates, retention) + +```python +client.purposes.set_purpose_active(purpose_id) +client.purposes.set_purpose_inactive(purpose_id) + +client.templates.set_template_active(template_id) +client.templates.set_template_inactive(template_id) + +client.retention.set_rule_active(rule_id) +client.retention.set_rule_inactive(rule_id) +``` + +--- + +## Error handling + +All SDK errors inherit from `ConsentSDKError`. + +| Exception | HTTP status | +|---|---| +| `AuthenticationError` | 401 | +| `AuthorizationError` | 403 | +| `ValidationError` | 400 / 422 | +| `NotFoundError` | 404 | +| `ConflictError` | 409 | +| `ODataError` | other 4xx / 5xx | + +```python +from sap_cloud_sdk.core.dpi_ng.consent import NotFoundError, ValidationError + +try: + consent = client.consents.get_consent(some_id) +except NotFoundError: + print("Consent not found") +except ValidationError as exc: + print("Bad request:", exc) +``` + +--- + +## Context manager + +Always use the client as a context manager so the underlying `requests.Session` is closed properly: + +```python +with create_client(base_url=..., auth=...) as client: + ... +# session is closed here +``` + +Or call `client.close()` explicitly when not using `with`. diff --git a/tests/core/integration/dpi_ng/__init__.py b/tests/core/integration/dpi_ng/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/integration/dpi_ng/consent/__init__.py b/tests/core/integration/dpi_ng/consent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/integration/dpi_ng/consent/conftest.py b/tests/core/integration/dpi_ng/consent/conftest.py new file mode 100644 index 00000000..3e54fb8b --- /dev/null +++ b/tests/core/integration/dpi_ng/consent/conftest.py @@ -0,0 +1,81 @@ +"""Pytest configuration and fixtures for consent integration tests.""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +from dotenv import load_dotenv + +from sap_cloud_sdk.core.dpi_ng.consent import ( + AuthProvider, + BearerTokenAuth, + ClientCertificateAuth, + ClientCredentialsAuth, + ConsentClient, + create_client, +) + +# __file__ is at tests/core/integration/dpi_ng/consent/conftest.py — 6 levels up to project root +_ENV_FILE = Path(__file__).parent.parent.parent.parent.parent.parent / ".env_integration_tests" + + +def _resolve_auth() -> AuthProvider | None: + if token := os.getenv("CONSENT_BEARER_TOKEN"): + return BearerTokenAuth(token) + token_url = os.getenv("CONSENT_TOKEN_URL") + client_id = os.getenv("CONSENT_CLIENT_ID") + client_secret = os.getenv("CONSENT_CLIENT_SECRET") + if token_url and client_id and client_secret: + return ClientCredentialsAuth(token_url=token_url, client_id=client_id, client_secret=client_secret) + cert_file = os.getenv("CONSENT_CERT_FILE") + key_file = os.getenv("CONSENT_KEY_FILE") + if cert_file and key_file: + return ClientCertificateAuth(cert_file=cert_file, key_file=key_file, ca_file=os.getenv("CONSENT_CA_FILE")) + return None + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "integration: mark test as integration test") + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + for item in items: + if "integration" in str(item.fspath): + item.add_marker(pytest.mark.integration) + + +@pytest.fixture(scope="module") +def live_client() -> Iterator[ConsentClient]: + if _ENV_FILE.exists(): + load_dotenv(_ENV_FILE, override=True) + base_url = os.getenv("CONSENT_BASE_URL", "") + auth = _resolve_auth() + if not base_url or auth is None: + pytest.skip("No integration credentials in .env — set CONSENT_BASE_URL plus one auth flow") + with create_client(base_url=base_url, auth=auth) as client: + yield client + + +@dataclass +class ConsentScenarioContext: + client: Any = None + result: Any = None + last_error: Exception | None = None + controller_id: str | None = None + application_id: str | None = None + jurisdiction_id: str | None = None + data_subject_type_id: str | None = None + third_party_id: str | None = None + purpose_id: str | None = None + template_id: str | None = None + consent_ids: list = field(default_factory=list) + + +@pytest.fixture(scope="module") +def context() -> ConsentScenarioContext: + return ConsentScenarioContext() diff --git a/tests/core/integration/dpi_ng/consent/consent_creation.feature b/tests/core/integration/dpi_ng/consent/consent_creation.feature new file mode 100644 index 00000000..fde361ce --- /dev/null +++ b/tests/core/integration/dpi_ng/consent/consent_creation.feature @@ -0,0 +1,41 @@ +Feature: Consent SDK Integration + As a developer using the SAP Consent SDK + I want to create and activate consent configuration entities, purposes, templates, and records + So that I can manage end-to-end consent lifecycle for data subjects + + Background: + Given a configured consent client + + Scenario: Set up configuration entities + When I create a controller + Then the controller should have a valid id + When I create an application + Then the application should have a valid id + When I reuse or create a jurisdiction named "India" + Then the jurisdiction should have a valid id + When I create a data subject type + Then the data subject type should have a valid id + When I create a third party + Then the third party should have a valid id + + Scenario: Set up consent purpose + When I create a purpose + Then the purpose should have a valid id + When I add an English explanatory text to the purpose + Then the purpose text should be saved successfully + When I activate the purpose + Then the purpose lifecycle status should be "active" + + Scenario: Set up consent template + When I create a consent template using the purpose, controller, and application + Then the template should have a valid id + When I assign the third party as a "RECIPIENT" to the template + Then the third party recipient assignment should succeed + When I assign the third party as a "SOURCE" to the template + Then the third party source assignment should succeed + When I activate the template + Then the template lifecycle status should be "active" + + Scenario: Create consent record from template + When I create a consent from the template for data subject "DS-IT-CREATION-001" + Then at least one consent record should be returned diff --git a/tests/core/integration/dpi_ng/consent/data_bdd.py b/tests/core/integration/dpi_ng/consent/data_bdd.py new file mode 100644 index 00000000..8a86ac48 --- /dev/null +++ b/tests/core/integration/dpi_ng/consent/data_bdd.py @@ -0,0 +1,58 @@ +"""Canonical test data for consent integration tests.""" + +from __future__ import annotations + +import secrets + +# 6-char hex, unique per pytest session +_RUN_SUFFIX = secrets.token_hex(3) + +# Domain / lifecycle codes + +LIFECYCLE_INITIAL = "1" +LIFECYCLE_ACTIVE = "2" +LIFECYCLE_INACTIVE = "3" + +CONSENT_MODEL_OPT_IN = "1" +CONSENT_MODEL_OPT_OUT = "2" + +THIRD_PARTY_FUNC_RECIPIENT = "1" +THIRD_PARTY_FUNC_SOURCE = "2" + +PURPOSE_TEXT_TYPE_EXPLANATORY = "01" +PURPOSE_TEXT_TYPE_DESCRIPTION = "02" + +# Integration test scenario — AB Corp / marketing-lead-india + +CONTROLLER_NAME = f"abcorp-india_{_RUN_SUFFIX}" +CONTROLLER_DESC = "AB Corp India Pvt Ltd" + +APP_NAME = f"order-mgmt-{_RUN_SUFFIX}" +APP_DESC = "Order Management" + +JURISDICTION_CODE = "IND" +JURISDICTION_TEXT = "India" +JURISDICTION_LANG = "en" + +DATA_SUBJECT_TYPE = f"order-partner_{_RUN_SUFFIX}" + +THIRD_PARTY_NAME = f"absuppliers-india_{_RUN_SUFFIX}" +THIRD_PARTY_DESC = "AB Suppliers India Pvt Ltd" + +PURPOSE_NAME = f"marketing-lead-purpose_{_RUN_SUFFIX}" +PURPOSE_TEXT_LANG = "en" +PURPOSE_EXPLANATORY_TEXT = ( + "AB Corp processes your personal data for marketing purposes. " + "You can withdraw your consent at any time." +) + +TEMPLATE_NAME = f"marketing-lead-india_{_RUN_SUFFIX}" +TEMPLATE_VALIDITY_PERIOD = 100 + +TEMPLATE_THIRD_PARTY_FUNCS = ( + THIRD_PARTY_FUNC_RECIPIENT, + THIRD_PARTY_FUNC_SOURCE, +) + +DATA_SUBJECT_ID = "I12345" +CONSENT_LANG = "en" diff --git a/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py b/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py new file mode 100644 index 00000000..f2471f65 --- /dev/null +++ b/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py @@ -0,0 +1,330 @@ +"""BDD step definitions for consent_creation.feature. + +Covers the 12-step consent creation flow across 4 scenarios: + Scenario 1 — Set up configuration entities (controller, app, jurisdiction, DST, third party) + Scenario 2 — Set up consent purpose (create, add text, activate) + Scenario 3 — Set up consent template (create, assign third party x2, activate) + Scenario 4 — Create consent record from template + +State flows through the module-scoped ConsentScenarioContext fixture defined in conftest.py. +""" + +from __future__ import annotations + +import pytest +from pytest_bdd import scenarios, given, when, then + +from sap_cloud_sdk.core.dpi_ng.consent import ConsentClient, CreateConsentRequest + +from tests.core.integration.dpi_ng.consent import data_bdd as data +from tests.core.integration.dpi_ng.consent.conftest import ConsentScenarioContext + +pytestmark = pytest.mark.integration + +scenarios("consent_creation.feature") + + +# --------------------------------------------------------------------------- +# Background / shared +# --------------------------------------------------------------------------- + +@given("a configured consent client") +def configured_consent_client(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + context.client = live_client + print("[step 0] consent client configured") + + +# --------------------------------------------------------------------------- +# Scenario 1 — Configuration entities +# --------------------------------------------------------------------------- + +@when("I create a controller") +def create_controller(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + controller = live_client.configuration.create_controller({ + "controller_name": data.CONTROLLER_NAME, + "description": data.CONTROLLER_DESC, + }) + context.controller_id = controller.controller_id + print(f"[step 1] controller created: id={controller.controller_id} name={controller.controller_name}") + + +@then("the controller should have a valid id") +def controller_has_valid_id(context: ConsentScenarioContext) -> None: + assert context.controller_id, "controller_id must be set after creation" + + +@when("I create an application") +def create_application(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + app = live_client.configuration.create_application({ + "application_name": data.APP_NAME, + "description": data.APP_DESC, + }) + context.application_id = app.application_id + print(f"[step 2] application created: id={app.application_id} name={app.application_name}") + + +@then("the application should have a valid id") +def application_has_valid_id(context: ConsentScenarioContext) -> None: + assert context.application_id, "application_id must be set after creation" + + +@when('I reuse or create a jurisdiction named "India"') +def reuse_or_create_jurisdiction(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + existing = [ + j for j in live_client.configuration.list_jurisdictions() + if j.jurisdiction_code == data.JURISDICTION_CODE + ] + if existing: + jurisdiction = existing[0] + print( + f"[step 3a] jurisdiction reused: id={jurisdiction.jurisdiction_id} " + f"code={jurisdiction.jurisdiction_code}" + ) + else: + jurisdiction = live_client.configuration.create_jurisdiction({ + "jurisdiction_code": data.JURISDICTION_CODE, + }) + print( + f"[step 3a] jurisdiction created: id={jurisdiction.jurisdiction_id} " + f"code={jurisdiction.jurisdiction_code}" + ) + + context.jurisdiction_id = jurisdiction.jurisdiction_id + + try: + j_text = live_client.configuration.create_jurisdiction_text({ + "jurisdiction_id": jurisdiction.jurisdiction_id, + "language_code": data.JURISDICTION_LANG, + "description": data.JURISDICTION_TEXT, + }) + print( + f"[step 3b] jurisdiction text added: lang={j_text.language_code} " + f"text='{j_text.description}'" + ) + except Exception as exc: + print( + f"[step 3b] jurisdiction text already present " + f"(lang={data.JURISDICTION_LANG}) — {exc}" + ) + + +@then("the jurisdiction should have a valid id") +def jurisdiction_has_valid_id(context: ConsentScenarioContext) -> None: + assert context.jurisdiction_id, "jurisdiction_id must be set" + + +@when("I create a data subject type") +def create_data_subject_type(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + dst = live_client.configuration.create_data_subject_type({ + "data_subject_type_name": data.DATA_SUBJECT_TYPE, + }) + context.data_subject_type_id = dst.data_subject_type_id + print(f"[step 4] data subject type created: id={dst.data_subject_type_id} name={dst.data_subject_type_name}") + + +@then("the data subject type should have a valid id") +def data_subject_type_has_valid_id(context: ConsentScenarioContext) -> None: + assert context.data_subject_type_id, "data_subject_type_id must be set after creation" + + +@when("I create a third party") +def create_third_party(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + tp = live_client.configuration.create_third_party({ + "third_party_name": data.THIRD_PARTY_NAME, + "formatted_description": data.THIRD_PARTY_DESC, + }) + context.third_party_id = tp.third_party_id + print(f"[step 5] third party created: id={tp.third_party_id} name={tp.third_party_name}") + + +@then("the third party should have a valid id") +def third_party_has_valid_id(context: ConsentScenarioContext) -> None: + assert context.third_party_id, "third_party_id must be set after creation" + + +# --------------------------------------------------------------------------- +# Scenario 2 — Consent purpose +# --------------------------------------------------------------------------- + +@when("I create a purpose") +def create_purpose(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + purpose = live_client.purposes.create_purpose({ + "purpose_name": data.PURPOSE_NAME, + }) + context.purpose_id = purpose.purpose_id + print(f"[step 6] purpose created: id={purpose.purpose_id} name={purpose.purpose_name}") + + +@then("the purpose should have a valid id") +def purpose_has_valid_id(context: ConsentScenarioContext) -> None: + assert context.purpose_id, "purpose_id must be set after creation" + + +@when("I add an English explanatory text to the purpose") +def add_purpose_text(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + assert context.purpose_id, "purpose_id must be set (scenario 2 depends on scenario 1 passing)" + p_text = live_client.purposes.create_purpose_text({ + "purpose_id": context.purpose_id, + "language_code": data.PURPOSE_TEXT_LANG, + "type_code": data.PURPOSE_TEXT_TYPE_EXPLANATORY, + "text": data.PURPOSE_EXPLANATORY_TEXT, + }) + context.result = p_text + print( + f"[step 7] purpose text added: purpose_id={p_text.purpose_id} " + f"lang={p_text.language_code} type_code={p_text.type_code}" + ) + + +@then("the purpose text should be saved successfully") +def purpose_text_saved(context: ConsentScenarioContext) -> None: + assert context.result is not None, "purpose text result must be set" + assert context.result.purpose_id == context.purpose_id, ( + f"Expected purpose_id={context.purpose_id} but got {context.result.purpose_id}" + ) + + +@when("I activate the purpose") +def activate_purpose(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + assert context.purpose_id, "purpose_id must be set before activation" + activated = live_client.purposes.set_purpose_active(context.purpose_id) + context.result = activated + print( + f"[step 8] purpose activated: id={activated.purpose_id} " + f"status={activated.lifecycle_status_code}" + ) + + +@then('the purpose lifecycle status should be "active"') +def purpose_lifecycle_active(context: ConsentScenarioContext) -> None: + assert context.result.lifecycle_status_code == data.LIFECYCLE_ACTIVE, ( + f"Expected lifecycle_status_code='{data.LIFECYCLE_ACTIVE}' (active) " + f"but got '{context.result.lifecycle_status_code}'" + ) + + +# --------------------------------------------------------------------------- +# Scenario 3 — Consent template +# --------------------------------------------------------------------------- + +@when("I create a consent template using the purpose, controller, and application") +def create_template(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + assert context.purpose_id, "purpose_id required" + assert context.controller_id, "controller_id required" + assert context.application_id, "application_id required" + template = live_client.templates.create_template({ + "template_name": data.TEMPLATE_NAME, + "jurisdiction_code": data.JURISDICTION_CODE, + "consent_model_code": data.CONSENT_MODEL_OPT_IN, + "purpose_id": context.purpose_id, + "controller_id": context.controller_id, + "application_id": context.application_id, + "validity_period": data.TEMPLATE_VALIDITY_PERIOD, + }) + context.template_id = template.template_id + print( + f"[step 9] template created: id={template.template_id} " + f"name={template.template_name} jurisdiction={template.jurisdiction_code}" + ) + + +@then("the template should have a valid id") +def template_has_valid_id(context: ConsentScenarioContext) -> None: + assert context.template_id, "template_id must be set after creation" + + +@when('I assign the third party as a "RECIPIENT" to the template') +def assign_third_party_recipient(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + assert context.template_id, "template_id required" + assert context.third_party_id, "third_party_id required" + tppd = live_client.templates.create_third_party_pers_data({ + "template_id": context.template_id, + "third_party_id": context.third_party_id, + "third_party_function_code": data.THIRD_PARTY_FUNC_RECIPIENT, + }) + context.result = tppd + print( + f"[step 10a] third party RECIPIENT assigned: " + f"third_party_id={tppd.third_party_id} functionCode={tppd.third_party_function_code}" + ) + + +@then("the third party recipient assignment should succeed") +def third_party_recipient_assigned(context: ConsentScenarioContext) -> None: + assert context.result.template_id == context.template_id, ( + f"Expected template_id={context.template_id} but got {context.result.template_id}" + ) + assert context.result.third_party_id == context.third_party_id, ( + f"Expected third_party_id={context.third_party_id} but got {context.result.third_party_id}" + ) + + +@when('I assign the third party as a "SOURCE" to the template') +def assign_third_party_source(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + assert context.template_id, "template_id required" + assert context.third_party_id, "third_party_id required" + tppd = live_client.templates.create_third_party_pers_data({ + "template_id": context.template_id, + "third_party_id": context.third_party_id, + "third_party_function_code": data.THIRD_PARTY_FUNC_SOURCE, + }) + context.result = tppd + print( + f"[step 10b] third party SOURCE assigned: " + f"third_party_id={tppd.third_party_id} functionCode={tppd.third_party_function_code}" + ) + + +@then("the third party source assignment should succeed") +def third_party_source_assigned(context: ConsentScenarioContext) -> None: + assert context.result.template_id == context.template_id, ( + f"Expected template_id={context.template_id} but got {context.result.template_id}" + ) + assert context.result.third_party_id == context.third_party_id, ( + f"Expected third_party_id={context.third_party_id} but got {context.result.third_party_id}" + ) + + +@when("I activate the template") +def activate_template(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + assert context.template_id, "template_id required before activation" + activated = live_client.templates.set_template_active(context.template_id) + context.result = activated + print( + f"[step 11] template activated: id={activated.template_id} " + f"status={activated.lifecycle_status_code}" + ) + + +@then('the template lifecycle status should be "active"') +def template_lifecycle_active(context: ConsentScenarioContext) -> None: + assert context.result.lifecycle_status_code == data.LIFECYCLE_ACTIVE, ( + f"Expected lifecycle_status_code='{data.LIFECYCLE_ACTIVE}' (active) " + f"but got '{context.result.lifecycle_status_code}'" + ) + + +# --------------------------------------------------------------------------- +# Scenario 4 — Consent record +# --------------------------------------------------------------------------- + +@when('I create a consent from the template for data subject "DS-IT-CREATION-001"') +def create_consent_record(context: ConsentScenarioContext, live_client: ConsentClient) -> None: + assert context.template_id, "template_id required — scenario 3 must pass first" + request = CreateConsentRequest( + data_subject_id=data.DATA_SUBJECT_ID, + template_name=data.TEMPLATE_NAME, + language_code=data.CONSENT_LANG, + data_subject_type_name=data.DATA_SUBJECT_TYPE, + jurisdiction_code=data.JURISDICTION_CODE, + ) + consents = live_client.consents.create_consent_from_template(request) + context.consent_ids = [c.consent_id for c in consents] + print(f"[step 12] {len(consents)} consent record(s) returned: ids={context.consent_ids}") + + +@then("at least one consent record should be returned") +def at_least_one_consent(context: ConsentScenarioContext) -> None: + assert len(context.consent_ids) >= 1, ( + f"Expected at least one consent record but got {len(context.consent_ids)}" + ) diff --git a/tests/core/unit/dpi_ng/__init__.py b/tests/core/unit/dpi_ng/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/dpi_ng/consent/__init__.py b/tests/core/unit/dpi_ng/consent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/dpi_ng/consent/unit/__init__.py b/tests/core/unit/dpi_ng/consent/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/dpi_ng/consent/unit/conftest.py b/tests/core/unit/dpi_ng/consent/unit/conftest.py new file mode 100644 index 00000000..f836ed27 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/conftest.py @@ -0,0 +1,10 @@ +"""Unit-test fixtures — auto-marks every test in tests/unit/ with `unit`.""" + +from __future__ import annotations + +import pytest + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + for item in items: + item.add_marker(pytest.mark.unit) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/__init__.py b/tests/core/unit/dpi_ng/consent/unit/entities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py b/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py new file mode 100644 index 00000000..7265ad5f --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py @@ -0,0 +1,171 @@ +"""Shared fixtures and entity specs for all entity unit tests.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from odata.entity import declarative_base + +CONSENT_ENTITY_SPECS = { + "Consent": { + "collection": "consents", + "pk": ["consent_id"], + "bool": ["purpose_sensitive_data_flag", "third_party_sensitive_data_flag"], + "int": [], + }, +} + +CONFIGURATION_ENTITY_SPECS = { + "ThirdParty": { + "collection": "thirdParties", + "pk": ["third_party_id"], + "bool": [], + "int": [], + }, + "Jurisdiction": { + "collection": "jurisdictions", + "pk": ["jurisdiction_id"], + "bool": [], + "int": [], + }, + "JurisdictionText": { + "collection": "jurisdictionTexts", + "pk": ["jurisdiction_id", "language_code"], + "bool": [], + "int": [], + }, + "Language": { + "collection": "languages", + "pk": ["language_code"], + "bool": [], + "int": [], + }, + "LanguageDescription": { + "collection": "languageDescriptions", + "pk": ["language_code"], + "bool": [], + "int": [], + }, + "SourceInfo": { + "collection": "sourceInfos", + "pk": ["source_id"], + "bool": [], + "int": [], + }, + "Controller": { + "collection": "controllers", + "pk": ["controller_id"], + "bool": [], + "int": [], + }, + "DataSubjectType": { + "collection": "dataSubjectTypes", + "pk": ["data_subject_type_id"], + "bool": [], + "int": [], + }, + "Application": { + "collection": "applications", + "pk": ["application_id"], + "bool": [], + "int": [], + }, + "MasterDataSource": { + "collection": "masterDataSources", + "pk": ["master_data_source_id"], + "bool": [], + "int": [], + }, + "OutboundChannelType": { + "collection": "outboundChannelTypes", + "pk": ["outbound_channel_type_id"], + "bool": [], + "int": [], + }, +} + +PURPOSE_ENTITY_SPECS = { + "ConsentPurpose": { + "collection": "consentPurposes", + "pk": ["purpose_id"], + "bool": ["sensitive_data_flag"], + "int": [], + }, + "ConsentPurposeText": { + "collection": "consentPurposeTexts", + "pk": ["purpose_id", "type_code", "language_code"], + "bool": [], + "int": [], + }, +} + +RETENTION_ENTITY_SPECS = { + "ConsentRetentionRule": { + "collection": "consentRetentionRules", + "pk": ["rule_id"], + "bool": [], + "int": ["retention_years", "retention_months", "retention_days"], + }, +} + +TEMPLATE_ENTITY_SPECS = { + "ConsentTemplate": { + "collection": "consentTemplates", + "pk": ["template_id"], + "bool": [], + "int": [], + }, + "ConsentTemplateText": { + "collection": "consentTemplateTexts", + "pk": ["template_id", "type_code", "language_code"], + "bool": [], + "int": [], + }, + "TemplateThirdPartyPersData": { + "collection": "templateThirdPartyPersDatas", + "pk": ["template_id", "third_party_id"], + "bool": ["sensitive_data_flag"], + "int": [], + }, +} + + +def _make_service(): + svc = MagicMock() + svc.Entity = declarative_base() + return svc + + +def entity_by_name(entities_tuple, name): + return next(c for c in entities_tuple if c.__name__ == name) + + +@pytest.fixture(scope="module") +def consent_entities(): + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent import _make_entities as make_entities + return make_entities(_make_service()) + + +@pytest.fixture(scope="module") +def config_entities(): + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_configuration import _make_entities as make_entities + return make_entities(_make_service()) + + +@pytest.fixture(scope="module") +def purpose_entities(): + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_purpose import _make_entities as make_entities + return make_entities(_make_service()) + + +@pytest.fixture(scope="module") +def retention_entities(): + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_retention import _make_entities as make_entities + return make_entities(_make_service()) + + +@pytest.fixture(scope="module") +def template_entities(): + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_template import _make_entities as make_entities + return make_entities(_make_service()) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py new file mode 100644 index 00000000..1b63a822 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py @@ -0,0 +1,36 @@ +"""Unit tests for the 11 entity classes produced by consent_configuration.make_entities.""" + +from __future__ import annotations + +import pytest +from odata.property import BooleanProperty + +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import CONFIGURATION_ENTITY_SPECS, entity_by_name + + +def test_make_entities_returns_all_classes(config_entities): + assert len(config_entities) == 11 + + +@pytest.mark.parametrize("name,spec", CONFIGURATION_ENTITY_SPECS.items()) +def test_collection_name(config_entities, name, spec): + cls = entity_by_name(config_entities, name) + assert cls.__odata_collection__ == spec["collection"] + + +@pytest.mark.parametrize("name,spec", CONFIGURATION_ENTITY_SPECS.items()) +def test_pk_fields_marked(config_entities, name, spec): + cls = entity_by_name(config_entities, name) + for pk_field in spec["pk"]: + prop = getattr(cls, pk_field) + assert prop.primary_key is True + + +@pytest.mark.parametrize( + "name,spec", + [(n, s) for n, s in CONFIGURATION_ENTITY_SPECS.items() if s["bool"]], +) +def test_bool_fields(config_entities, name, spec): + cls = entity_by_name(config_entities, name) + for field in spec["bool"]: + assert isinstance(getattr(cls, field), BooleanProperty) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py new file mode 100644 index 00000000..c6ed1521 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py @@ -0,0 +1,64 @@ +"""Unit tests for the entity class produced by consent._make_entities.""" + +from __future__ import annotations + +import pytest +from odata.property import BooleanProperty, StringProperty, UUIDProperty + +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import CONSENT_ENTITY_SPECS, entity_by_name + + +def test_make_entities_returns_all_classes(consent_entities): + assert len(consent_entities) == 1 + + +@pytest.mark.parametrize("name,spec", CONSENT_ENTITY_SPECS.items()) +def test_collection_name(consent_entities, name, spec): + cls = entity_by_name(consent_entities, name) + assert cls.__odata_collection__ == spec["collection"] + + +@pytest.mark.parametrize("name,spec", CONSENT_ENTITY_SPECS.items()) +def test_pk_fields_marked(consent_entities, name, spec): + cls = entity_by_name(consent_entities, name) + for pk_field in spec["pk"]: + prop = getattr(cls, pk_field) + assert prop.primary_key is True + + +@pytest.mark.parametrize( + "name,spec", + [(n, s) for n, s in CONSENT_ENTITY_SPECS.items() if s["bool"]], +) +def test_bool_fields(consent_entities, name, spec): + cls = entity_by_name(consent_entities, name) + for field in spec["bool"]: + assert isinstance(getattr(cls, field), BooleanProperty) + + +class TestConsentEntity: + def test_consent_id_is_uuid_pk(self, consent_entities): + cls = entity_by_name(consent_entities, "Consent") + assert isinstance(cls.consent_id, UUIDProperty) + assert cls.consent_id.primary_key is True + + def test_consent_has_single_pk(self, consent_entities): + cls = entity_by_name(consent_entities, "Consent") + pk_props = [ + name for name, val in vars(cls).items() + if isinstance(val, UUIDProperty) and getattr(val, "primary_key", False) + ] + assert pk_props == ["consent_id"] + + def test_consent_purpose_sensitive_data_flag_is_boolean(self, consent_entities): + cls = entity_by_name(consent_entities, "Consent") + assert isinstance(cls.purpose_sensitive_data_flag, BooleanProperty) + + def test_consent_third_party_sensitive_data_flag_is_boolean(self, consent_entities): + cls = entity_by_name(consent_entities, "Consent") + assert isinstance(cls.third_party_sensitive_data_flag, BooleanProperty) + + def test_consent_tenant_is_string(self, consent_entities): + cls = entity_by_name(consent_entities, "Consent") + assert isinstance(cls.tenant, StringProperty) + diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py new file mode 100644 index 00000000..6958de09 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py @@ -0,0 +1,36 @@ +"""Unit tests for the 3 entity classes produced by consent_purpose.make_entities.""" + +from __future__ import annotations + +import pytest +from odata.property import BooleanProperty + +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import PURPOSE_ENTITY_SPECS, entity_by_name + + +def test_make_entities_returns_all_classes(purpose_entities): + assert len(purpose_entities) == 2 + + +@pytest.mark.parametrize("name,spec", PURPOSE_ENTITY_SPECS.items()) +def test_collection_name(purpose_entities, name, spec): + cls = entity_by_name(purpose_entities, name) + assert cls.__odata_collection__ == spec["collection"] + + +@pytest.mark.parametrize("name,spec", PURPOSE_ENTITY_SPECS.items()) +def test_pk_fields_marked(purpose_entities, name, spec): + cls = entity_by_name(purpose_entities, name) + for pk_field in spec["pk"]: + prop = getattr(cls, pk_field) + assert prop.primary_key is True + + +@pytest.mark.parametrize( + "name,spec", + [(n, s) for n, s in PURPOSE_ENTITY_SPECS.items() if s["bool"]], +) +def test_bool_fields(purpose_entities, name, spec): + cls = entity_by_name(purpose_entities, name) + for field in spec["bool"]: + assert isinstance(getattr(cls, field), BooleanProperty) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py new file mode 100644 index 00000000..fae15b0e --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py @@ -0,0 +1,36 @@ +"""Unit tests for the 1 entity class produced by consent_retention.make_entities.""" + +from __future__ import annotations + +import pytest +from odata.property import IntegerProperty + +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import RETENTION_ENTITY_SPECS, entity_by_name + + +def test_make_entities_returns_all_classes(retention_entities): + assert len(retention_entities) == 1 + + +@pytest.mark.parametrize("name,spec", RETENTION_ENTITY_SPECS.items()) +def test_collection_name(retention_entities, name, spec): + cls = entity_by_name(retention_entities, name) + assert cls.__odata_collection__ == spec["collection"] + + +@pytest.mark.parametrize("name,spec", RETENTION_ENTITY_SPECS.items()) +def test_pk_fields_marked(retention_entities, name, spec): + cls = entity_by_name(retention_entities, name) + for pk_field in spec["pk"]: + prop = getattr(cls, pk_field) + assert prop.primary_key is True + + +@pytest.mark.parametrize( + "name,spec", + [(n, s) for n, s in RETENTION_ENTITY_SPECS.items() if s["int"]], +) +def test_integer_fields(retention_entities, name, spec): + cls = entity_by_name(retention_entities, name) + for field in spec["int"]: + assert isinstance(getattr(cls, field), IntegerProperty) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py new file mode 100644 index 00000000..e6fec875 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py @@ -0,0 +1,46 @@ +"""Unit tests for the 3 entity classes produced by consent_template.make_entities.""" + +from __future__ import annotations + +import pytest +from odata.property import BooleanProperty + +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import TEMPLATE_ENTITY_SPECS, entity_by_name + + +def test_make_entities_returns_all_classes(template_entities): + assert len(template_entities) == 3 + + +@pytest.mark.parametrize("name,spec", TEMPLATE_ENTITY_SPECS.items()) +def test_collection_name(template_entities, name, spec): + cls = entity_by_name(template_entities, name) + assert cls.__odata_collection__ == spec["collection"] + + +@pytest.mark.parametrize("name,spec", TEMPLATE_ENTITY_SPECS.items()) +def test_pk_fields_marked(template_entities, name, spec): + cls = entity_by_name(template_entities, name) + for pk_field in spec["pk"]: + prop = getattr(cls, pk_field) + assert prop.primary_key is True + + +@pytest.mark.parametrize( + "name,spec", + [(n, s) for n, s in TEMPLATE_ENTITY_SPECS.items() if s["bool"]], +) +def test_bool_fields(template_entities, name, spec): + cls = entity_by_name(template_entities, name) + for field in spec["bool"]: + assert isinstance(getattr(cls, field), BooleanProperty) + + +class TestTemplateThirdPartyPersDataCompositeKey: + def test_template_id_is_pk(self, template_entities): + cls = entity_by_name(template_entities, "TemplateThirdPartyPersData") + assert cls.template_id.primary_key is True + + def test_third_party_id_is_pk(self, template_entities): + cls = entity_by_name(template_entities, "TemplateThirdPartyPersData") + assert cls.third_party_id.primary_key is True diff --git a/tests/core/unit/dpi_ng/consent/unit/services/__init__.py b/tests/core/unit/dpi_ng/consent/unit/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/dpi_ng/consent/unit/services/conftest.py b/tests/core/unit/dpi_ng/consent/unit/services/conftest.py new file mode 100644 index 00000000..02881048 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/conftest.py @@ -0,0 +1,53 @@ +"""Shared fixtures for service-layer unit tests.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from sap_cloud_sdk.core.dpi_ng.consent.client import _ODataClient + + +def _make_mock_client(entities_module): + svc = MagicMock() + c = MagicMock(spec=_ODataClient) + c.get_entity_classes.return_value = entities_module._make_entities(svc) + q = MagicMock() + q.all.return_value = [] + q.get.return_value = MagicMock() + q.raw.return_value = q + q.limit.return_value = q + q.offset.return_value = q + c.query.return_value = q + return c + + +@pytest.fixture +def mock_consent_client(): + from sap_cloud_sdk.core.dpi_ng.consent.entities import consent as m + return _make_mock_client(m) + + +@pytest.fixture +def mock_config_client(): + from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_configuration as m + return _make_mock_client(m) + + +@pytest.fixture +def mock_purpose_client(): + from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_purpose as m + return _make_mock_client(m) + + +@pytest.fixture +def mock_retention_client(): + from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_retention as m + return _make_mock_client(m) + + +@pytest.fixture +def mock_template_client(): + from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_template as m + return _make_mock_client(m) diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py new file mode 100644 index 00000000..6e35589f --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py @@ -0,0 +1,239 @@ +"""Unit tests for ConsentConfigurationService.""" + +from __future__ import annotations + +import pytest + + +CRUD_ENTITIES = [ + ( + "ThirdParty", + "third_party_id", + "list_third_parties", + "get_third_party", + "create_third_party", + "update_third_party", + "delete_third_party", + ), + ( + "Jurisdiction", + "jurisdiction_id", + "list_jurisdictions", + "get_jurisdiction", + "create_jurisdiction", + "update_jurisdiction", + "delete_jurisdiction", + ), + ( + "SourceInfo", + "source_id", + "list_source_infos", + "get_source_info", + "create_source_info", + "update_source_info", + "delete_source_info", + ), + ( + "Controller", + "controller_id", + "list_controllers", + "get_controller", + "create_controller", + "update_controller", + "delete_controller", + ), + ( + "DataSubjectType", + "data_subject_type_id", + "list_data_subject_types", + "get_data_subject_type", + "create_data_subject_type", + "update_data_subject_type", + "delete_data_subject_type", + ), + ( + "Application", + "application_id", + "list_applications", + "get_application", + "create_application", + "update_application", + "delete_application", + ), + ( + "MasterDataSource", + "master_data_source_id", + "list_master_data_sources", + "get_master_data_source", + "create_master_data_source", + "update_master_data_source", + "delete_master_data_source", + ), + ( + "OutboundChannelType", + "outbound_channel_type_id", + "list_outbound_channel_types", + "get_outbound_channel_type", + "create_outbound_channel_type", + "update_outbound_channel_type", + "delete_outbound_channel_type", + ), +] + +_PARAM_IDS = [row[0] for row in CRUD_ENTITIES] + + +@pytest.fixture +def svc(mock_config_client): + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_configuration_service import ( + ConsentConfigurationService, + ) + + return ConsentConfigurationService(mock_config_client) + + +# --------------------------------------------------------------------------- +# Parametrized CRUD — Strategy A +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("row", CRUD_ENTITIES, ids=_PARAM_IDS) +def test_list_calls_query(svc, row): + getattr(svc, row[2])() + svc._client.query.assert_called() + + +@pytest.mark.parametrize("row", CRUD_ENTITIES, ids=_PARAM_IDS) +def test_get_calls_query_get(svc, row): + getattr(svc, row[3])("some-id") + svc._client.query.return_value.get.assert_called_with("some-id") + + +@pytest.mark.parametrize("row", CRUD_ENTITIES, ids=_PARAM_IDS) +def test_create_calls_save(svc, row): + getattr(svc, row[4])({"name": "x"}) + svc._client.save.assert_called_once() + + +@pytest.mark.parametrize("row", CRUD_ENTITIES, ids=_PARAM_IDS) +def test_update_calls_save(svc, row): + getattr(svc, row[5])("some-id", {"name": "y"}) + svc._client.save.assert_called_once() + + +@pytest.mark.parametrize("row", CRUD_ENTITIES, ids=_PARAM_IDS) +def test_delete_calls_delete_entity(svc, row): + getattr(svc, row[6])("some-id") + svc._client.delete_entity.assert_called_once() + + +# --------------------------------------------------------------------------- +# Query helper — _apply_query +# --------------------------------------------------------------------------- + + +def test_list_with_filter_kwarg(svc): + svc.list_third_parties(filter="third_party_name eq 'ACME'") + svc._client.query.return_value.raw.assert_called_with( + {"$filter": "third_party_name eq 'ACME'"} + ) + + +def test_list_with_top_kwarg(svc): + svc.list_third_parties(top=10) + svc._client.query.return_value.limit.assert_called_with(10) + + +def test_list_with_skip_kwarg(svc): + svc.list_third_parties(skip=5) + svc._client.query.return_value.offset.assert_called_with(5) + + +def test_list_with_orderby_kwarg(svc): + svc.list_third_parties(orderby="third_party_name asc") + svc._client.query.return_value.raw.assert_called_with( + {"$orderby": "third_party_name asc"} + ) + + +# --------------------------------------------------------------------------- +# JurisdictionText — composite key +# --------------------------------------------------------------------------- + + +class TestJurisdictionText: + def test_list(self, svc): + svc.list_jurisdiction_texts() + svc._client.query.assert_called() + + def test_create(self, svc): + svc.create_jurisdiction_text({"description": "x"}) + svc._client.save.assert_called_once() + + def test_update_composite_key(self, svc): + svc.update_jurisdiction_text("jur-1", "EN", {"description": "y"}) + svc._client.query.return_value.get.assert_called_with( + jurisdictionId="jur-1", languageCode="EN" + ) + + def test_update_calls_save(self, svc): + svc.update_jurisdiction_text("jur-1", "EN", {"description": "y"}) + svc._client.save.assert_called_once() + + def test_delete_composite_key(self, svc): + svc.delete_jurisdiction_text("jur-1", "EN") + svc._client.query.return_value.get.assert_called_with( + jurisdictionId="jur-1", languageCode="EN" + ) + + def test_delete_calls_delete_entity(self, svc): + svc.delete_jurisdiction_text("jur-1", "EN") + svc._client.delete_entity.assert_called_once() + + +# --------------------------------------------------------------------------- +# Language — no create method +# --------------------------------------------------------------------------- + + +class TestLanguage: + def test_list(self, svc): + svc.list_languages() + svc._client.query.assert_called() + + def test_get(self, svc): + svc.get_language("EN") + svc._client.query.return_value.get.assert_called_with("EN") + + +# --------------------------------------------------------------------------- +# LanguageDescription +# --------------------------------------------------------------------------- + + +class TestLanguageDescription: + def test_list(self, svc): + svc.list_language_descriptions() + svc._client.query.assert_called() + + def test_create(self, svc): + svc.create_language_description({"description": "x"}) + svc._client.save.assert_called_once() + + def test_update(self, svc): + svc.update_language_description("EN", {"description": "English"}) + svc._client.save.assert_called_once() + + def test_update_fetches_by_code(self, svc): + svc.update_language_description("FR", {"description": "French"}) + svc._client.query.return_value.get.assert_called_with("FR") + + def test_delete(self, svc): + svc.delete_language_description("EN") + svc._client.delete_entity.assert_called_once() + + def test_delete_fetches_by_code(self, svc): + svc.delete_language_description("ES") + svc._client.query.return_value.get.assert_called_with("ES") + + diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py new file mode 100644 index 00000000..68cb7b7f --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py @@ -0,0 +1,156 @@ +import pytest +from unittest.mock import MagicMock, patch +from sap_cloud_sdk.core.dpi_ng.consent.dtos.consent import ( + CheckConsentExistsResult, + CreateConsentRequest, + WithdrawConsentRequest, +) + + +@pytest.fixture +def svc(mock_consent_client): + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_service import ConsentService + return ConsentService(mock_consent_client) + + +@pytest.fixture +def client(mock_consent_client): + return mock_consent_client + + +@pytest.fixture +def q(client): + return client.query.return_value + + +class TestListAndGet: + def test_list_consents(self, svc, mock_consent_client): + result = svc.list_consents() + mock_consent_client.query.assert_called_with("consentServices", svc.Consent) + assert result == [] + + def test_get_consent(self, svc, mock_consent_client): + q = mock_consent_client.query.return_value + svc.get_consent("some-uuid") + mock_consent_client.query.assert_called_with("consentServices", svc.Consent) + q.get.assert_called_once_with("some-uuid") + + +_MODULE = "sap_cloud_sdk.core.dpi_ng.consent.services.consent_service" + + +class TestCreateConsentFromTemplate: + def test_create_consent_from_template_returns_list(self, svc, mock_consent_client): + mock_consent_client.call_action.return_value = {"value": [{"consent_id": "c-1"}]} + req = CreateConsentRequest( + data_subject_id="ds-1", + template_name="tmpl", + language_code="EN", + data_subject_type_name="Employee", + jurisdiction_code="DE", + ) + with patch(f"{_MODULE}._dict_to_entity", return_value=MagicMock()) as mock_dte: + result = svc.create_consent_from_template(req) + mock_dte.assert_called_once() + mock_consent_client.call_action.assert_called_once_with( + "consentServices", "createConsentFromTemplate", req.to_dict() + ) + assert isinstance(result, list) + assert len(result) == 1 + + def test_create_consent_from_template_none_result(self, svc, mock_consent_client): + mock_consent_client.call_action.return_value = None + req = CreateConsentRequest( + data_subject_id="ds-1", + template_name="tmpl", + language_code="EN", + data_subject_type_name="Employee", + jurisdiction_code="DE", + ) + result = svc.create_consent_from_template(req) + assert result == [] + + def test_create_consent_from_template_bare_dict(self, svc, mock_consent_client): + mock_consent_client.call_action.return_value = {"consent_id": "c-2"} + req = CreateConsentRequest( + data_subject_id="ds-1", + template_name="tmpl", + language_code="EN", + data_subject_type_name="Employee", + jurisdiction_code="DE", + ) + with patch(f"{_MODULE}._dict_to_entity", return_value=MagicMock()) as mock_dte: + result = svc.create_consent_from_template(req) + mock_dte.assert_called_once() + assert isinstance(result, list) + assert len(result) == 1 + + +class TestDeleteConsent: + def test_delete_consent(self, svc, client, q): + svc.delete_consent("c-1") + q.get.assert_called_with("c-1") + client.delete_entity.assert_called_once_with(q.get.return_value) + + +class TestWithdrawAndTerminate: + def test_withdraw_consent(self, svc, mock_consent_client): + mock_consent_client.call_action.return_value = None + req = WithdrawConsentRequest(consent_id="c-1", withdrawn_by="user-1") + svc.withdraw_consent(req) + mock_consent_client.call_action.assert_called_once_with( + "consentServices", "withdrawConsent", req.to_dict() + ) + + def test_terminate_consent(self, svc, mock_consent_client): + mock_consent_client.call_action.return_value = None + req = WithdrawConsentRequest(consent_id="c-1", withdrawn_by="user-1") + svc.terminate_consent(req) + mock_consent_client.call_action.assert_called_once_with( + "consentServices", "terminateConsent", req.to_dict() + ) + + +class TestCheckConsentExists: + def test_check_consent_exists(self, svc, mock_consent_client): + mock_consent_client.call_action.return_value = { + "consentId": "c-1", "consentExists": True + } + result = svc.check_consent_exists("ds-1", "tmpl-1") + mock_consent_client.call_action.assert_called_once_with( + "consentServices", "checkConsentExists", + {"dataSubjectId": "ds-1", "templateId": "tmpl-1"}, + ) + assert isinstance(result, CheckConsentExistsResult) + assert result.consent_exists is True + + +class TestQueryKwargs: + def test_query_filter_kwarg(self, svc, mock_consent_client): + q = mock_consent_client.query.return_value + svc.list_consents(filter="x eq 'y'") + q.raw.assert_called_with({"$filter": "x eq 'y'"}) + + def test_query_top_skip(self, svc, mock_consent_client): + q = mock_consent_client.query.return_value + svc.list_consents(top=10, skip=5) + q.limit.assert_called_with(10) + q.offset.assert_called_with(5) + + def test_query_orderby(self, svc, mock_consent_client): + q = mock_consent_client.query.return_value + svc.list_consents(orderby="changedAt desc") + q.raw.assert_called_with({"$orderby": "changedAt desc"}) + + +class TestDictToEntity: + def test_wraps_data_as_persisted_entity(self): + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_service import _dict_to_entity + entity_cls = MagicMock() + entity = entity_cls.return_value + entity.__odata__ = MagicMock() + data = {"consentId": "c-1", "status": "active"} + result = _dict_to_entity(entity_cls, data) + entity.__odata__.update.assert_called_once_with(data) + assert entity.__odata__.persisted is True + assert result is entity diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py new file mode 100644 index 00000000..4f98e1d5 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py @@ -0,0 +1,138 @@ +"""Unit tests for ConsentPurposeService.""" + +from __future__ import annotations + +import pytest + +_SVC = "consentPurposeExternalServices" + + +@pytest.fixture +def svc(mock_purpose_client): + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_purpose_service import ConsentPurposeService + return ConsentPurposeService(mock_purpose_client) + + +@pytest.fixture +def client(mock_purpose_client): + return mock_purpose_client + + +@pytest.fixture +def q(client): + return client.query.return_value + + +class TestListPurposes: + def test_list_purposes(self, svc, client, q): + result = svc.list_purposes() + client.query.assert_called_with(_SVC, svc.ConsentPurpose) + q.all.assert_called_once() + assert result == q.all.return_value + + def test_query_filter(self, svc, client, q): + svc.list_purposes(filter="purpose_name eq 'test'") + q.raw.assert_called_with({"$filter": "purpose_name eq 'test'"}) + + def test_query_top_skip(self, svc, client, q): + svc.list_purposes(top=5, skip=2) + q.limit.assert_called_with(5) + q.offset.assert_called_with(2) + + +class TestGetPurpose: + def test_get_purpose(self, svc, client, q): + result = svc.get_purpose("pid") + client.query.assert_called_with(_SVC, svc.ConsentPurpose) + q.get.assert_called_with("pid") + assert result == q.get.return_value + + +class TestCreatePurpose: + def test_create_purpose(self, svc, client): + body = {"purpose_name": "My Purpose", "lifecycle_status_code": "1"} + svc.create_purpose(body) + client.save.assert_called_once() + + +class TestUpdatePurpose: + def test_update_purpose(self, svc, client, q): + body = {"purpose_name": "Updated"} + svc.update_purpose("pid", body) + q.get.assert_called_with("pid") + client.save.assert_called_once() + + +class TestDeletePurpose: + def test_delete_purpose(self, svc, client, q): + svc.delete_purpose("pid") + q.get.assert_called_with("pid") + client.delete_entity.assert_called_once_with(q.get.return_value) + + +class TestSetPurposeActive: + def test_set_purpose_active_calls_action(self, svc, client): + svc.set_purpose_active("pid") + client.call_action.assert_called_once_with( + _SVC, + "consentPurposeSetConsentPurposeToActive", + {"purposeId": "pid"}, + ) + + def test_set_purpose_active_returns_refreshed_entity(self, svc, client, q): + svc.set_purpose_active("pid") + q.get.assert_called_with("pid") + + +class TestSetPurposeInactive: + def test_set_purpose_inactive_calls_action(self, svc, client): + svc.set_purpose_inactive("pid") + client.call_action.assert_called_once_with( + _SVC, + "consentPurposeSetConsentPurposeToInactive", + {"purposeId": "pid"}, + ) + + def test_set_purpose_inactive_returns_refreshed_entity(self, svc, client, q): + svc.set_purpose_inactive("pid") + q.get.assert_called_with("pid") + + +class TestListPurposeTexts: + def test_list_purpose_texts(self, svc, client, q): + result = svc.list_purpose_texts() + client.query.assert_called_with(_SVC, svc.ConsentPurposeText) + q.all.assert_called_once() + assert result == q.all.return_value + + +class TestGetPurposeText: + def test_get_purpose_text_composite_key(self, svc, client, q): + result = svc.get_purpose_text("pid", "tc", "EN") + client.query.assert_called_with(_SVC, svc.ConsentPurposeText) + q.get.assert_called_with(purposeId="pid", typeCode="tc", languageCode="EN") + assert result == q.get.return_value + + +class TestCreatePurposeText: + def test_create_purpose_text(self, svc, client): + body = {"purpose_id": "pid", "type_code": "tc", "language_code": "EN", "text": "hello"} + svc.create_purpose_text(body) + client.save.assert_called_once() + + +class TestUpdatePurposeText: + def test_update_purpose_text_composite_key(self, svc, client, q): + body = {"text": "updated"} + svc.update_purpose_text("pid", "tc", "EN", body) + q.get.assert_called_with(purposeId="pid", typeCode="tc", languageCode="EN") + client.save.assert_called_once() + + +class TestDeletePurposeText: + def test_delete_purpose_text_composite_key(self, svc, client, q): + svc.delete_purpose_text("pid", "tc", "EN") + q.get.assert_called_with(purposeId="pid", typeCode="tc", languageCode="EN") + client.delete_entity.assert_called_once_with(q.get.return_value) + + diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py new file mode 100644 index 00000000..3eea99ec --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py @@ -0,0 +1,97 @@ +"""Unit tests for ConsentRetentionService.""" + +from __future__ import annotations + +import pytest + +_SVC = "consentRetentionExternalServices" + + +@pytest.fixture +def svc(mock_retention_client): + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_retention_service import ConsentRetentionService + return ConsentRetentionService(mock_retention_client) + + +@pytest.fixture +def client(mock_retention_client): + return mock_retention_client + + +@pytest.fixture +def q(client): + return client.query.return_value + + +class TestListRules: + def test_list_rules(self, svc, client, q): + result = svc.list_rules() + client.query.assert_called_with(_SVC, svc.ConsentRetentionRule) + q.all.assert_called_once() + assert result == q.all.return_value + + def test_query_filter(self, svc, client, q): + svc.list_rules(filter="lifecycle_status_code eq '1'") + q.raw.assert_called_with({"$filter": "lifecycle_status_code eq '1'"}) + + def test_query_top(self, svc, client, q): + svc.list_rules(top=3) + q.limit.assert_called_with(3) + + +class TestGetRule: + def test_get_rule(self, svc, client, q): + result = svc.get_rule("rid") + client.query.assert_called_with(_SVC, svc.ConsentRetentionRule) + q.get.assert_called_with("rid") + assert result == q.get.return_value + + +class TestCreateRule: + def test_create_rule(self, svc, client): + body = {"rule_name": "Rule A", "retention_years": 2} + svc.create_rule(body) + client.save.assert_called_once() + + +class TestUpdateRule: + def test_update_rule(self, svc, client, q): + body = {"retention_years": 5} + svc.update_rule("rid", body) + q.get.assert_called_with("rid") + client.save.assert_called_once() + + +class TestDeleteRule: + def test_delete_rule(self, svc, client, q): + svc.delete_rule("rid") + q.get.assert_called_with("rid") + client.delete_entity.assert_called_once_with(q.get.return_value) + + +class TestSetRuleActive: + def test_set_rule_active(self, svc, client): + svc.set_rule_active("rid") + client.call_action.assert_called_once_with( + _SVC, + "consentRetentionRuleSetConsentRetentionToActive", + {"ruleId": "rid"}, + ) + + def test_set_rule_active_returns_refreshed(self, svc, client, q): + svc.set_rule_active("rid") + q.get.assert_called_with("rid") + + +class TestSetRuleInactive: + def test_set_rule_inactive(self, svc, client): + svc.set_rule_inactive("rid") + client.call_action.assert_called_once_with( + _SVC, + "consentRetentionRuleSetConsentRetentionToInactive", + {"ruleId": "rid"}, + ) + + def test_set_rule_inactive_returns_refreshed(self, svc, client, q): + svc.set_rule_inactive("rid") + q.get.assert_called_with("rid") diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py new file mode 100644 index 00000000..4447bcd1 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py @@ -0,0 +1,212 @@ +"""Unit tests for ConsentTemplateService.""" + +from __future__ import annotations + +import pytest + + +_SVC = "consentTemplateExternalServices" + + +@pytest.fixture +def svc(mock_template_client): + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_template_service import ConsentTemplateService + return ConsentTemplateService(mock_template_client) + + +# --------------------------------------------------------------------------- +# ConsentTemplate CRUD +# --------------------------------------------------------------------------- + +class TestConsentTemplateCRUD: + def test_list_templates(self, svc, mock_template_client): + result = svc.list_templates() + mock_template_client.query.assert_called_with(_SVC, svc.ConsentTemplate) + mock_template_client.query.return_value.all.assert_called_once() + assert result == [] + + def test_get_template(self, svc, mock_template_client): + q = mock_template_client.query.return_value + returned = svc.get_template("tid") + mock_template_client.query.assert_called_with(_SVC, svc.ConsentTemplate) + q.get.assert_called_with("tid") + assert returned is q.get.return_value + + def test_create_template(self, svc, mock_template_client): + svc.create_template({"template_name": "T1", "jurisdiction_code": "DE"}) + mock_template_client.save.assert_called_once() + + def test_create_template_sets_fields(self, svc, mock_template_client): + svc.create_template({"template_name": "T1"}) + call_arg = mock_template_client.save.call_args[0][0] + assert call_arg.template_name == "T1" + + def test_update_template(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.update_template("tid", {"template_name": "updated"}) + q.get.assert_called_with("tid") + mock_template_client.save.assert_called_once() + + def test_update_template_applies_fields(self, svc, mock_template_client): + entity = mock_template_client.query.return_value.get.return_value + svc.update_template("tid", {"template_name": "new-name"}) + assert entity.template_name == "new-name" + + def test_delete_template(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.delete_template("tid") + q.get.assert_called_with("tid") + mock_template_client.delete_entity.assert_called_once_with(q.get.return_value) + + +# --------------------------------------------------------------------------- +# Lifecycle actions +# --------------------------------------------------------------------------- + +class TestTemplateLifecycleActions: + def test_set_template_active_calls_action(self, svc, mock_template_client): + svc.set_template_active("tid") + mock_template_client.call_action.assert_called_once_with( + _SVC, + "consentTemplateSetConsentTemplateToActive", + {"templateId": "tid"}, + ) + + def test_set_template_inactive_calls_action(self, svc, mock_template_client): + svc.set_template_inactive("tid") + mock_template_client.call_action.assert_called_once_with( + _SVC, + "consentTemplateSetConsentTemplateToInactive", + {"templateId": "tid"}, + ) + + def test_set_template_active_returns_refreshed(self, svc, mock_template_client): + q = mock_template_client.query.return_value + result = svc.set_template_active("tid") + q.get.assert_called_with("tid") + assert result is q.get.return_value + + def test_set_template_inactive_returns_refreshed(self, svc, mock_template_client): + q = mock_template_client.query.return_value + result = svc.set_template_inactive("tid") + q.get.assert_called_with("tid") + assert result is q.get.return_value + + +# --------------------------------------------------------------------------- +# ConsentTemplateText (composite key: template_id + type_code + language_code) +# --------------------------------------------------------------------------- + +class TestConsentTemplateText: + def test_list_template_texts(self, svc, mock_template_client): + result = svc.list_template_texts() + mock_template_client.query.assert_called_with(_SVC, svc.ConsentTemplateText) + mock_template_client.query.return_value.all.assert_called_once() + assert result == [] + + def test_get_template_text_composite_key(self, svc, mock_template_client): + q = mock_template_client.query.return_value + result = svc.get_template_text("tid", "tc", "EN") + mock_template_client.query.assert_called_with(_SVC, svc.ConsentTemplateText) + q.get.assert_called_with(templateId="tid", typeCode="tc", languageCode="EN") + assert result is q.get.return_value + + def test_create_template_text(self, svc, mock_template_client): + svc.create_template_text({"text": "hello", "language_code": "EN"}) + mock_template_client.save.assert_called_once() + + def test_create_template_text_sets_fields(self, svc, mock_template_client): + svc.create_template_text({"text": "hello"}) + call_arg = mock_template_client.save.call_args[0][0] + assert call_arg.text == "hello" + + def test_update_template_text_composite_key(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.update_template_text("tid", "tc", "EN", {"text": "updated"}) + q.get.assert_called_with(templateId="tid", typeCode="tc", languageCode="EN") + mock_template_client.save.assert_called_once() + + def test_update_template_text_applies_fields(self, svc, mock_template_client): + entity = mock_template_client.query.return_value.get.return_value + svc.update_template_text("tid", "tc", "EN", {"text": "new text"}) + assert entity.text == "new text" + + def test_delete_template_text_composite_key(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.delete_template_text("tid", "tc", "EN") + q.get.assert_called_with(templateId="tid", typeCode="tc", languageCode="EN") + mock_template_client.delete_entity.assert_called_once_with(q.get.return_value) + + +# --------------------------------------------------------------------------- +# TemplateThirdPartyPersData (composite key: template_id + third_party_id) +# --------------------------------------------------------------------------- + +class TestTemplateThirdPartyPersData: + def test_list_third_party_pers_data(self, svc, mock_template_client): + result = svc.list_third_party_pers_data() + mock_template_client.query.assert_called_with(_SVC, svc.TemplateThirdPartyPersData) + mock_template_client.query.return_value.all.assert_called_once() + assert result == [] + + def test_get_third_party_pers_data_composite_key(self, svc, mock_template_client): + q = mock_template_client.query.return_value + result = svc.get_third_party_pers_data("tid", "tpid") + mock_template_client.query.assert_called_with(_SVC, svc.TemplateThirdPartyPersData) + q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") + assert result is q.get.return_value + + def test_create_third_party_pers_data(self, svc, mock_template_client): + svc.create_third_party_pers_data({"third_party_function_code": "PROCESSOR"}) + mock_template_client.save.assert_called_once() + + def test_create_third_party_pers_data_sets_fields(self, svc, mock_template_client): + svc.create_third_party_pers_data({"third_party_function_code": "PROCESSOR"}) + call_arg = mock_template_client.save.call_args[0][0] + assert call_arg.third_party_function_code == "PROCESSOR" + + def test_update_third_party_pers_data_composite_key(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.update_third_party_pers_data("tid", "tpid", {"third_party_function_code": "CONTROLLER"}) + q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") + mock_template_client.save.assert_called_once() + + def test_update_third_party_pers_data_applies_fields(self, svc, mock_template_client): + entity = mock_template_client.query.return_value.get.return_value + svc.update_third_party_pers_data("tid", "tpid", {"third_party_function_code": "CONTROLLER"}) + assert entity.third_party_function_code == "CONTROLLER" + + def test_delete_third_party_pers_data_composite_key(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.delete_third_party_pers_data("tid", "tpid") + q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") + mock_template_client.delete_entity.assert_called_once_with(q.get.return_value) + + +# --------------------------------------------------------------------------- +# Query parameter forwarding (_apply_query) on list_templates +# --------------------------------------------------------------------------- + +class TestQueryParams: + def test_query_filter(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.list_templates(filter="lifecycle_status_code eq '1'") + q.raw.assert_called_with({"$filter": "lifecycle_status_code eq '1'"}) + + def test_query_top_skip(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.list_templates(top=10, skip=5) + q.limit.assert_called_with(10) + q.offset.assert_called_with(5) + + def test_query_orderby(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.list_templates(orderby="template_name asc") + q.raw.assert_called_with({"$orderby": "template_name asc"}) + + def test_query_no_params_skips_raw(self, svc, mock_template_client): + q = mock_template_client.query.return_value + svc.list_templates() + q.raw.assert_not_called() + q.limit.assert_not_called() + q.offset.assert_not_called() diff --git a/tests/core/unit/dpi_ng/consent/unit/test_auth.py b/tests/core/unit/dpi_ng/consent/unit/test_auth.py new file mode 100644 index 00000000..5e443ac6 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_auth.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from sap_cloud_sdk.core.dpi_ng.consent.auth import ( + BearerTokenAuth, + ClientCertificateAuth, + ClientCredentialsAuth, + _OAuth2Flow, +) + + +def _mock_session() -> MagicMock: + session = MagicMock(spec=requests.Session) + session.headers = {} + return session + + +def _mock_post_response(access_token: str = "tok", expires_in: int = 3600) -> MagicMock: + resp = MagicMock() + resp.json.return_value = {"access_token": access_token, "expires_in": expires_in} + resp.raise_for_status.return_value = None + resp.status_code = 200 + return resp + + +class TestBearerTokenAuth: + def test_empty_token_raises(self): + with pytest.raises(ValueError, match="token must not be empty"): + BearerTokenAuth("") + + def test_apply_sets_authorization_header(self): + auth = BearerTokenAuth("my-token") + session = _mock_session() + auth.apply(session) + assert session.headers["Authorization"] == "Bearer my-token" + + +class TestClientCredentialsAuth: + def test_empty_token_url_raises(self): + with pytest.raises(ValueError, match="token_url, client_id, and client_secret are all required"): + ClientCredentialsAuth("", "cid", "secret") + + def test_empty_client_id_raises(self): + with pytest.raises(ValueError, match="token_url, client_id, and client_secret are all required"): + ClientCredentialsAuth("https://token.url", "", "secret") + + def test_empty_client_secret_raises(self): + with pytest.raises(ValueError, match="token_url, client_id, and client_secret are all required"): + ClientCredentialsAuth("https://token.url", "cid", "") + + def test_apply_sets_session_auth_to_oauth2_flow(self): + auth = ClientCredentialsAuth("https://token.url", "cid", "secret") + session = _mock_session() + auth.apply(session) + assert isinstance(session.auth, _OAuth2Flow) + + +class TestClientCertificateAuth: + def test_empty_cert_file_raises(self): + with pytest.raises(ValueError, match="cert_file and key_file are required"): + ClientCertificateAuth("", "key.pem") + + def test_empty_key_file_raises(self): + with pytest.raises(ValueError, match="cert_file and key_file are required"): + ClientCertificateAuth("cert.pem", "") + + def test_apply_sets_session_cert(self): + auth = ClientCertificateAuth("cert.pem", "key.pem") + session = _mock_session() + auth.apply(session) + assert session.cert == ("cert.pem", "key.pem") + + def test_apply_with_ca_file_sets_session_verify(self): + auth = ClientCertificateAuth("cert.pem", "key.pem", ca_file="ca.pem") + session = _mock_session() + auth.apply(session) + assert session.verify == "ca.pem" + + def test_apply_without_ca_file_does_not_set_session_verify(self): + auth = ClientCertificateAuth("cert.pem", "key.pem") + session = _mock_session() + auth.apply(session) + assigned_attrs = [str(c) for c in session.mock_calls] + assert not any("verify" in call for call in assigned_attrs) + + +class TestOAuth2Flow: + def test_first_call_fetches_token(self): + flow = _OAuth2Flow("https://token.url", "cid", "secret") + req = MagicMock() + req.headers = {} + with patch( + "sap_cloud_sdk.core.dpi_ng.consent.auth.requests.post", + return_value=_mock_post_response(), + ) as mock_post: + flow(req) + mock_post.assert_called_once() + + def test_call_injects_bearer_header(self): + flow = _OAuth2Flow("https://token.url", "cid", "secret") + req = MagicMock() + req.headers = {} + with patch( + "sap_cloud_sdk.core.dpi_ng.consent.auth.requests.post", + return_value=_mock_post_response("my-access-token"), + ): + result = flow(req) + assert result.headers["Authorization"] == "Bearer my-access-token" + + def test_second_call_reuses_cached_token(self): + flow = _OAuth2Flow("https://token.url", "cid", "secret") + req1 = MagicMock() + req1.headers = {} + req2 = MagicMock() + req2.headers = {} + with patch( + "sap_cloud_sdk.core.dpi_ng.consent.auth.requests.post", + return_value=_mock_post_response(), + ) as mock_post: + flow(req1) + flow(req2) + mock_post.assert_called_once() + + def test_expired_token_triggers_refresh(self): + flow = _OAuth2Flow("https://token.url", "cid", "secret") + flow._access_token = "old-token" + flow._expires_at = 0.0 + req = MagicMock() + req.headers = {} + with patch( + "sap_cloud_sdk.core.dpi_ng.consent.auth.requests.post", + return_value=_mock_post_response("new-token"), + ) as mock_post: + flow(req) + mock_post.assert_called_once() + assert req.headers["Authorization"] == "Bearer new-token" + + def test_fetch_token_raises_on_non_200(self): + flow = _OAuth2Flow("https://token.url", "cid", "secret") + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = requests.HTTPError("401 Unauthorized") + error_resp.status_code = 401 + req = MagicMock() + req.headers = {} + with patch( + "sap_cloud_sdk.core.dpi_ng.consent.auth.requests.post", + return_value=error_resp, + ): + with pytest.raises(requests.HTTPError): + flow(req) diff --git a/tests/core/unit/dpi_ng/consent/unit/test_config.py b/tests/core/unit/dpi_ng/consent/unit/test_config.py new file mode 100644 index 00000000..cb243b4a --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_config.py @@ -0,0 +1,117 @@ +import pytest +from unittest.mock import MagicMock + +from sap_cloud_sdk.core.dpi_ng.consent.auth import AuthProvider +from sap_cloud_sdk.core.dpi_ng.consent.config import ConsentSDKConfig + + +def valid_auth(): + return MagicMock(spec=AuthProvider) + + +class TestValidConstruction: + def test_https_url_accepted(self): + cfg = ConsentSDKConfig(base_url="https://example.com", auth=valid_auth()) + assert cfg.base_url == "https://example.com" + + def test_http_url_accepted(self): + cfg = ConsentSDKConfig(base_url="http://example.com", auth=valid_auth()) + assert cfg.base_url == "http://example.com" + + def test_url_with_path_accepted(self): + cfg = ConsentSDKConfig( + base_url="https://consent.cfapps.eu10.hana.ondemand.com", + auth=valid_auth(), + ) + assert "hana.ondemand.com" in cfg.base_url + + def test_trailing_slash_stripped(self): + cfg = ConsentSDKConfig(base_url="https://example.com/", auth=valid_auth()) + assert cfg.base_url == "https://example.com" + + def test_multiple_trailing_slashes_stripped(self): + cfg = ConsentSDKConfig(base_url="https://example.com///", auth=valid_auth()) + assert cfg.base_url == "https://example.com" + + def test_auth_stored(self): + auth = valid_auth() + cfg = ConsentSDKConfig(base_url="https://example.com", auth=auth) + assert cfg.auth is auth + + +class TestDefaults: + def test_timeout_default(self): + cfg = ConsentSDKConfig(base_url="https://example.com", auth=valid_auth()) + assert cfg.timeout == 30.0 + + def test_verify_ssl_default(self): + cfg = ConsentSDKConfig(base_url="https://example.com", auth=valid_auth()) + assert cfg.verify_ssl is True + + def test_service_path_default(self): + cfg = ConsentSDKConfig(base_url="https://example.com", auth=valid_auth()) + assert cfg.service_path == "/sap/cp/kernel/dpi/consent/odata/v4" + + +class TestCustomValues: + def test_custom_timeout_stored(self): + cfg = ConsentSDKConfig( + base_url="https://example.com", auth=valid_auth(), timeout=60.0 + ) + assert cfg.timeout == 60.0 + + def test_verify_ssl_false_stored(self): + cfg = ConsentSDKConfig( + base_url="https://example.com", auth=valid_auth(), verify_ssl=False + ) + assert cfg.verify_ssl is False + + def test_custom_service_path_stored(self): + cfg = ConsentSDKConfig( + base_url="https://example.com", + auth=valid_auth(), + service_path="/custom/path", + ) + assert cfg.service_path == "/custom/path" + + +class TestInvalidBaseUrl: + def test_empty_string_raises(self): + with pytest.raises(ValueError, match="base_url must be a valid HTTP"): + ConsentSDKConfig(base_url="", auth=valid_auth()) + + def test_plain_string_raises(self): + with pytest.raises(ValueError, match="base_url must be a valid HTTP"): + ConsentSDKConfig(base_url="not-a-url", auth=valid_auth()) + + def test_ftp_scheme_raises(self): + with pytest.raises(ValueError, match="base_url must be a valid HTTP"): + ConsentSDKConfig(base_url="ftp://example.com", auth=valid_auth()) + + def test_missing_scheme_raises(self): + with pytest.raises(ValueError, match="base_url must be a valid HTTP"): + ConsentSDKConfig(base_url="example.com", auth=valid_auth()) + + def test_whitespace_only_raises(self): + with pytest.raises(ValueError, match="base_url must be a valid HTTP"): + ConsentSDKConfig(base_url=" ", auth=valid_auth()) + + +class TestInvalidAuth: + def test_none_auth_raises(self): + with pytest.raises(ValueError, match="auth must be an AuthProvider"): + ConsentSDKConfig(base_url="https://example.com", auth=None) # ty: ignore[invalid-argument-type] + + def test_string_auth_raises(self): + with pytest.raises(ValueError, match="auth must be an AuthProvider"): + ConsentSDKConfig(base_url="https://example.com", auth="Bearer token123") # ty: ignore[invalid-argument-type] + + def test_dict_auth_raises(self): + with pytest.raises(ValueError, match="auth must be an AuthProvider"): + ConsentSDKConfig( + base_url="https://example.com", auth={"token": "abc"} # ty: ignore[invalid-argument-type] + ) + + def test_plain_object_auth_raises(self): + with pytest.raises(ValueError, match="auth must be an AuthProvider"): + ConsentSDKConfig(base_url="https://example.com", auth=object()) # ty: ignore[invalid-argument-type] diff --git a/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py b/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py new file mode 100644 index 00000000..741f241a --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py @@ -0,0 +1,155 @@ +"""Unit tests for ConsentClient and create_client.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.core.dpi_ng.consent import ( + BearerTokenAuth, + ClientCreationError, + ConsentClient, + ConsentSDKConfig, + create_client, +) +from sap_cloud_sdk.core.dpi_ng.consent.services import ( + ConsentConfigurationService, + ConsentPurposeService, + ConsentRetentionService, + ConsentService, + ConsentTemplateService, +) + + +def _entity_side_effect(svc_name: str) -> tuple: + sizes = { + "consentServices": 1, + "consentPurposeExternalServices": 2, + "consentTemplateExternalServices": 3, + "consentRetentionExternalServices": 1, + "consentConfigurationExternalServices": 11, + } + return tuple(MagicMock() for _ in range(sizes[svc_name])) + + +def _setup_mock(MockODataClient: MagicMock) -> MagicMock: + mock_instance = MagicMock() + MockODataClient.return_value = mock_instance + mock_instance.get_entity_classes.side_effect = _entity_side_effect + return mock_instance + + +@pytest.fixture +def auth() -> BearerTokenAuth: + return BearerTokenAuth("test-token") + + +class TestCreateClient: + def test_with_config(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + _setup_mock(Mock) + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + client = create_client(config=config) + assert isinstance(client, ConsentClient) + + def test_with_kwargs(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + _setup_mock(Mock) + client = create_client(base_url="https://example.com", auth=auth) + assert isinstance(client, ConsentClient) + + def test_missing_base_url_raises(self, auth: BearerTokenAuth) -> None: + with pytest.raises(ClientCreationError, match="base_url"): + create_client(auth=auth) + + def test_missing_auth_raises(self) -> None: + with pytest.raises(ClientCreationError, match="auth"): + create_client(base_url="https://example.com") + + def test_invalid_url_raises(self, auth: BearerTokenAuth) -> None: + with pytest.raises(ClientCreationError): + create_client(base_url="not-a-url", auth=auth) + + def test_missing_both_raises(self) -> None: + with pytest.raises(ClientCreationError, match="base_url"): + create_client() + + def test_returns_consent_client_instance(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + _setup_mock(Mock) + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + result = create_client(config=config) + assert type(result).__name__ == "ConsentClient" + + +class TestConsentClientAttributes: + def test_service_types(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + _setup_mock(Mock) + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + client = ConsentClient(config) + assert isinstance(client.consents, ConsentService) + assert isinstance(client.purposes, ConsentPurposeService) + assert isinstance(client.templates, ConsentTemplateService) + assert isinstance(client.retention, ConsentRetentionService) + assert isinstance(client.configuration, ConsentConfigurationService) + + def test_odata_client_instantiated(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + _setup_mock(Mock) + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + ConsentClient(config) + Mock.assert_called_once_with(config) + + def test_all_five_services_present(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + _setup_mock(Mock) + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + client = ConsentClient(config) + for attr in ("consents", "purposes", "templates", "retention", "configuration"): + assert hasattr(client, attr) + + +class TestConsentClientLifecycle: + def test_context_manager_closes(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + mock_instance = MagicMock() + Mock.return_value = mock_instance + mock_instance.get_entity_classes.side_effect = _entity_side_effect + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + with ConsentClient(config): + pass + mock_instance.close.assert_called_once() + + def test_close_delegates_to_odata(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + mock_instance = MagicMock() + Mock.return_value = mock_instance + mock_instance.get_entity_classes.side_effect = _entity_side_effect + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + client = ConsentClient(config) + client.close() + mock_instance.close.assert_called_once() + + def test_context_manager_returns_client(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + mock_instance = MagicMock() + Mock.return_value = mock_instance + mock_instance.get_entity_classes.side_effect = _entity_side_effect + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + with ConsentClient(config) as client: + assert isinstance(client, ConsentClient) + + def test_close_called_even_on_exception(self, auth: BearerTokenAuth) -> None: + with patch("sap_cloud_sdk.core.dpi_ng.consent.client._ODataClient") as Mock: + mock_instance = MagicMock() + Mock.return_value = mock_instance + mock_instance.get_entity_classes.side_effect = _entity_side_effect + config = ConsentSDKConfig(base_url="https://example.com", auth=auth) + try: + with ConsentClient(config): + raise RuntimeError("boom") + except RuntimeError: + pass + mock_instance.close.assert_called_once() diff --git a/tests/core/unit/dpi_ng/consent/unit/test_dtos.py b/tests/core/unit/dpi_ng/consent/unit/test_dtos.py new file mode 100644 index 00000000..7ea33e11 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_dtos.py @@ -0,0 +1,118 @@ +"""Unit tests for dtos/consent.py — pure dataclass serialisation and from_dict.""" + +from __future__ import annotations + + +from sap_cloud_sdk.core.dpi_ng.consent.dtos.consent import ( + CheckConsentExistsResult, + CreateConsentRequest, + WithdrawConsentRequest, +) + + +class TestCreateConsentRequest: + def test_to_dict_includes_required_fields(self): + req = CreateConsentRequest( + data_subject_id="ds-1", + template_name="tmpl", + language_code="EN", + data_subject_type_name="Customer", + jurisdiction_code="DE", + ) + result = req.to_dict() + assert result["dataSubjectId"] == "ds-1" + assert result["templateName"] == "tmpl" + assert result["languageCode"] == "EN" + assert result["dataSubjectTypeName"] == "Customer" + assert result["jurisdictionCode"] == "DE" + + def test_to_dict_omits_none_optional_fields(self): + req = CreateConsentRequest( + data_subject_id="ds-1", + template_name="tmpl", + language_code="EN", + data_subject_type_name="Customer", + jurisdiction_code="DE", + ) + result = req.to_dict() + optional_keys = [ + "dataSubjectDescription", + "outboundChannelTypeName", + "outboundChannel", + "validFrom", + "applicationTemplateId", + "controllerName", + "grantedBy", + "grantedAt", + "submissionSite", + ] + for key in optional_keys: + assert key not in result + + def test_to_dict_includes_optional_fields_when_set(self): + req = CreateConsentRequest( + data_subject_id="ds-1", + template_name="tmpl", + language_code="EN", + data_subject_type_name="Customer", + jurisdiction_code="DE", + data_subject_description="A customer", + outbound_channel_type_name="Email", + outbound_channel="channel-x", + valid_from="2024-01-01", + application_template_id="app-tmpl-99", + controller_name="Controller A", + granted_by="admin", + granted_at="2024-01-01T00:00:00Z", + submission_site="site-1", + ) + result = req.to_dict() + assert result["dataSubjectDescription"] == "A customer" + assert result["outboundChannelTypeName"] == "Email" + assert result["outboundChannel"] == "channel-x" + assert result["validFrom"] == "2024-01-01" + assert result["applicationTemplateId"] == "app-tmpl-99" + assert result["controllerName"] == "Controller A" + assert result["grantedBy"] == "admin" + assert result["grantedAt"] == "2024-01-01T00:00:00Z" + assert result["submissionSite"] == "site-1" + + +class TestWithdrawConsentRequest: + def test_to_dict_includes_required_fields_and_omits_none(self): + req = WithdrawConsentRequest(consent_id="c-1", withdrawn_by="user-a") + result = req.to_dict() + assert result["consentId"] == "c-1" + assert result["withdrawnBy"] == "user-a" + assert "withdrawnAt" not in result + + def test_to_dict_includes_withdrawn_at_when_set(self): + req = WithdrawConsentRequest( + consent_id="c-1", + withdrawn_by="user-a", + withdrawn_at="2024-06-01T12:00:00Z", + ) + result = req.to_dict() + assert result["withdrawnAt"] == "2024-06-01T12:00:00Z" + + +class TestCheckConsentExistsResult: + def test_from_dict_round_trip(self): + data = {"consentId": "c-99", "consentExists": True} + result = CheckConsentExistsResult.from_dict(data) + assert result.consent_id == "c-99" + assert result.consent_exists is True + + def test_from_dict_consent_not_exists(self): + data = {"consentId": "c-00", "consentExists": False} + result = CheckConsentExistsResult.from_dict(data) + assert result.consent_exists is False + + def test_from_dict_empty_dict_yields_none_fields(self): + result = CheckConsentExistsResult.from_dict({}) + assert result.consent_id is None + assert result.consent_exists is None + + def test_from_dict_returns_check_consent_exists_result_instance(self): + result = CheckConsentExistsResult.from_dict({"consentId": "c-1", "consentExists": True}) + assert isinstance(result, CheckConsentExistsResult) diff --git a/tests/core/unit/dpi_ng/consent/unit/test_exceptions.py b/tests/core/unit/dpi_ng/consent/unit/test_exceptions.py new file mode 100644 index 00000000..a91a65f7 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_exceptions.py @@ -0,0 +1,108 @@ +import pytest + +from sap_cloud_sdk.core.dpi_ng.consent.exceptions import ( + AuthenticationError, + AuthorizationError, + ClientCreationError, + ConflictError, + ConsentSDKError, + NotFoundError, + ODataError, + ValidationError, +) + + +class TestHierarchy: + def test_client_creation_error_is_sdk_error(self): + assert issubclass(ClientCreationError, ConsentSDKError) + + def test_authentication_error_is_sdk_error(self): + assert issubclass(AuthenticationError, ConsentSDKError) + + def test_authorization_error_is_sdk_error(self): + assert issubclass(AuthorizationError, ConsentSDKError) + + def test_validation_error_is_sdk_error(self): + assert issubclass(ValidationError, ConsentSDKError) + + def test_not_found_error_is_sdk_error(self): + assert issubclass(NotFoundError, ConsentSDKError) + + def test_conflict_error_is_sdk_error(self): + assert issubclass(ConflictError, ConsentSDKError) + + def test_odata_error_is_sdk_error(self): + assert issubclass(ODataError, ConsentSDKError) + + def test_all_subclasses_are_exceptions(self): + for cls in ( + ConsentSDKError, + ClientCreationError, + AuthenticationError, + AuthorizationError, + ValidationError, + NotFoundError, + ConflictError, + ODataError, + ): + assert issubclass(cls, Exception) + + +class TestConsentSDKError: + def test_message_stored(self): + exc = ConsentSDKError("something went wrong") + assert str(exc) == "something went wrong" + + def test_odata_error_stored_when_provided(self): + payload = {"code": "DPI-001", "message": "bad input"} + exc = ConsentSDKError("fail", odata_error=payload) + assert exc.odata_error == payload + + def test_odata_error_defaults_to_empty_dict(self): + exc = ConsentSDKError("fail") + assert exc.odata_error == {} + + def test_odata_error_none_becomes_empty_dict(self): + exc = ConsentSDKError("fail", odata_error=None) + assert exc.odata_error == {} + + def test_raise_and_catch(self): + with pytest.raises(ConsentSDKError, match="something went wrong"): + raise ConsentSDKError("something went wrong") + + def test_subclass_caught_as_sdk_error(self): + with pytest.raises(ConsentSDKError): + raise AuthenticationError("token rejected") + + +class TestODataError: + def test_status_code_stored(self): + exc = ODataError("server error", status_code=500) + assert exc.status_code == 500 + + def test_message_and_status_code(self): + exc = ODataError("not found", status_code=404) + assert str(exc) == "not found" + assert exc.status_code == 404 + + def test_odata_error_payload_stored(self): + payload = {"code": "DPI-404", "message": "resource not found"} + exc = ODataError("not found", status_code=404, odata_error=payload) + assert exc.odata_error == payload + + def test_odata_error_defaults_to_empty_dict(self): + exc = ODataError("error", status_code=500) + assert exc.odata_error == {} + + def test_raise_and_catch_as_odata_error(self): + with pytest.raises(ODataError, match="server error"): + raise ODataError("server error", status_code=500) + + def test_raise_and_catch_as_sdk_error(self): + with pytest.raises(ConsentSDKError): + raise ODataError("server error", status_code=500) + + def test_various_status_codes_stored(self): + for code in (400, 401, 403, 404, 409, 422, 500, 503): + exc = ODataError("err", status_code=code) + assert exc.status_code == code diff --git a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py new file mode 100644 index 00000000..1cfd12cd --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py @@ -0,0 +1,310 @@ +"""Unit tests for client.py — _ODataClient._raise_for_status, call_action, context manager.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.core.dpi_ng.consent.auth import AuthProvider +from sap_cloud_sdk.core.dpi_ng.consent.client import _ODataClient +from sap_cloud_sdk.core.dpi_ng.consent.config import ConsentSDKConfig +from sap_cloud_sdk.core.dpi_ng.consent.exceptions import ( + AuthenticationError, + AuthorizationError, + ConflictError, + NotFoundError, + ODataError, + ValidationError, +) + + +def _mock_response(status_code, json_body=None, text="", content=b"body"): + resp = MagicMock() + resp.status_code = status_code + resp.text = text + resp.content = content + if json_body is not None: + resp.json.return_value = json_body + else: + resp.json.side_effect = ValueError("no json") + return resp + + +def _make_config(): + auth = MagicMock(spec=AuthProvider) + return ConsentSDKConfig(base_url="https://consent.example.com", auth=auth) + + +class TestRaiseForStatus: + def test_200_does_not_raise(self): + resp = _mock_response(200, json_body={}, text="ok", content=b"ok") + _ODataClient._raise_for_status(resp) + + def test_401_raises_authentication_error(self): + resp = _mock_response( + 401, + json_body={"error": {"message": "Unauthorized"}}, + text="Unauthorized", + ) + with pytest.raises(AuthenticationError, match="Unauthorized"): + _ODataClient._raise_for_status(resp) + + def test_403_raises_authorization_error(self): + resp = _mock_response( + 403, + json_body={"error": {"message": "Forbidden"}}, + text="Forbidden", + ) + with pytest.raises(AuthorizationError, match="Forbidden"): + _ODataClient._raise_for_status(resp) + + def test_404_raises_not_found_error(self): + resp = _mock_response( + 404, + json_body={"error": {"message": "Not Found"}}, + text="Not Found", + ) + with pytest.raises(NotFoundError, match="Not Found"): + _ODataClient._raise_for_status(resp) + + def test_409_raises_conflict_error(self): + resp = _mock_response( + 409, + json_body={"error": {"message": "Conflict"}}, + text="Conflict", + ) + with pytest.raises(ConflictError, match="Conflict"): + _ODataClient._raise_for_status(resp) + + def test_400_raises_validation_error(self): + resp = _mock_response( + 400, + json_body={"error": {"message": "Bad Request"}}, + text="Bad Request", + ) + with pytest.raises(ValidationError, match="Bad Request"): + _ODataClient._raise_for_status(resp) + + def test_422_raises_validation_error(self): + resp = _mock_response( + 422, + json_body={"error": {"message": "Unprocessable"}}, + text="Unprocessable", + ) + with pytest.raises(ValidationError, match="Unprocessable"): + _ODataClient._raise_for_status(resp) + + def test_500_raises_odata_error_with_status_code(self): + resp = _mock_response( + 500, + json_body={"error": {"message": "Server Error"}}, + text="Server Error", + ) + with pytest.raises(ODataError) as exc_info: + _ODataClient._raise_for_status(resp) + assert exc_info.value.status_code == 500 + + def test_odata_error_message_extracted_from_body(self): + resp = _mock_response( + 401, + json_body={"error": {"message": "Token expired"}}, + text="raw text", + ) + with pytest.raises(AuthenticationError, match="Token expired"): + _ODataClient._raise_for_status(resp) + + def test_details_appended_to_message(self): + resp = _mock_response( + 400, + json_body={ + "error": { + "message": "Validation failed", + "details": [{"target": "name", "message": "too long"}], + } + }, + text="Validation failed", + ) + with pytest.raises(ValidationError, match="name: too long"): + _ODataClient._raise_for_status(resp) + + def test_non_json_body_falls_back_to_resp_text(self): + resp = _mock_response(403, text="plain text error", content=b"plain text error") + resp.json.side_effect = ValueError("not json") + with pytest.raises(AuthorizationError, match="plain text error"): + _ODataClient._raise_for_status(resp) + + def test_odata_error_stores_odata_error_dict(self): + odata_payload = {"message": "Unauthorized", "code": "AUTH_001"} + resp = _mock_response( + 401, + json_body={"error": odata_payload}, + text="Unauthorized", + ) + with pytest.raises(AuthenticationError) as exc_info: + _ODataClient._raise_for_status(resp) + assert exc_info.value.odata_error == odata_payload + + +@patch("sap_cloud_sdk.core.dpi_ng.consent.client.ODataService") +@patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") +class TestCallAction: + def test_returns_parsed_json_on_200(self, mock_session_cls, mock_odata_svc_cls): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_svc_instance = MagicMock() + mock_svc_instance.url = "https://consent.example.com/sap/cp/kernel/dpi/consent/odata/v4/consentServices/" + mock_odata_svc_cls.return_value = mock_svc_instance + + post_resp = MagicMock() + post_resp.status_code = 200 + post_resp.content = b'{"value": "ok"}' + post_resp.json.return_value = {"value": "ok"} + mock_session.post.return_value = post_resp + + config = _make_config() + client = _ODataClient(config) + result = client.call_action("consentServices", "myAction", body={"key": "val"}) + + assert result == {"value": "ok"} + mock_session.post.assert_called_once() + + def test_returns_none_on_204(self, mock_session_cls, mock_odata_svc_cls): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_svc_instance = MagicMock() + mock_svc_instance.url = "https://consent.example.com/sap/cp/kernel/dpi/consent/odata/v4/consentServices/" + mock_odata_svc_cls.return_value = mock_svc_instance + + post_resp = MagicMock() + post_resp.status_code = 204 + post_resp.content = b"" + mock_session.post.return_value = post_resp + + config = _make_config() + client = _ODataClient(config) + result = client.call_action("consentServices", "myAction") + + assert result is None + + def test_returns_none_on_empty_content(self, mock_session_cls, mock_odata_svc_cls): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_svc_instance = MagicMock() + mock_svc_instance.url = "https://consent.example.com/sap/cp/kernel/dpi/consent/odata/v4/consentServices/" + mock_odata_svc_cls.return_value = mock_svc_instance + + post_resp = MagicMock() + post_resp.status_code = 200 + post_resp.content = b"" + mock_session.post.return_value = post_resp + + config = _make_config() + client = _ODataClient(config) + result = client.call_action("consentServices", "myAction") + + assert result is None + + def test_raises_on_4xx(self, mock_session_cls, mock_odata_svc_cls): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_svc_instance = MagicMock() + mock_svc_instance.url = "https://consent.example.com/sap/cp/kernel/dpi/consent/odata/v4/consentServices/" + mock_odata_svc_cls.return_value = mock_svc_instance + + post_resp = MagicMock() + post_resp.status_code = 404 + post_resp.content = b"Not Found" + post_resp.text = "Not Found" + post_resp.json.return_value = {"error": {"message": "Resource not found"}} + mock_session.post.return_value = post_resp + + config = _make_config() + client = _ODataClient(config) + with pytest.raises(NotFoundError, match="Resource not found"): + client.call_action("consentServices", "missingAction") + + +@patch("sap_cloud_sdk.core.dpi_ng.consent.client.ODataService") +@patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") +class TestOrmMethods: + def test_get_entity_classes_calls_factory_on_cache_miss(self, mock_session_cls, mock_odata_svc_cls): + mock_session_cls.return_value = MagicMock() + mock_svc = MagicMock() + mock_odata_svc_cls.return_value = mock_svc + mock_entities = (MagicMock(),) + mock_factory = MagicMock(return_value=mock_entities) + config = _make_config() + client = _ODataClient(config) + with patch.dict("sap_cloud_sdk.core.dpi_ng.consent.client._ENTITY_FACTORIES", {"testSvc": mock_factory}): + result = client.get_entity_classes("testSvc") + mock_factory.assert_called_once_with(mock_svc) + assert result is mock_entities + + def test_get_entity_classes_returns_cached_on_second_call(self, mock_session_cls, mock_odata_svc_cls): + mock_session_cls.return_value = MagicMock() + mock_odata_svc_cls.return_value = MagicMock() + mock_factory = MagicMock(return_value=(MagicMock(),)) + config = _make_config() + client = _ODataClient(config) + with patch.dict("sap_cloud_sdk.core.dpi_ng.consent.client._ENTITY_FACTORIES", {"testSvc": mock_factory}): + client.get_entity_classes("testSvc") + client.get_entity_classes("testSvc") + mock_factory.assert_called_once() + + def test_query_delegates_to_odata_service(self, mock_session_cls, mock_odata_svc_cls): + mock_session_cls.return_value = MagicMock() + mock_svc = MagicMock() + mock_odata_svc_cls.return_value = mock_svc + entity_cls = MagicMock() + config = _make_config() + client = _ODataClient(config) + result = client.query("consentServices", entity_cls) + mock_svc.query.assert_called_once_with(entity_cls) + assert result is mock_svc.query.return_value + + def test_save_delegates_to_entity_odata_service(self, mock_session_cls, mock_odata_svc_cls): + mock_session_cls.return_value = MagicMock() + mock_odata_svc_cls.return_value = MagicMock() + entity = MagicMock() + entity.__odata_service__ = MagicMock() + config = _make_config() + client = _ODataClient(config) + client.save(entity) + entity.__odata_service__.save.assert_called_once_with(entity) + + def test_delete_entity_delegates_to_entity_odata_service(self, mock_session_cls, mock_odata_svc_cls): + mock_session_cls.return_value = MagicMock() + mock_odata_svc_cls.return_value = MagicMock() + entity = MagicMock() + entity.__odata_service__ = MagicMock() + config = _make_config() + client = _ODataClient(config) + client.delete_entity(entity) + entity.__odata_service__.delete.assert_called_once_with(entity) + + +@patch("sap_cloud_sdk.core.dpi_ng.consent.client.ODataService") +@patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") +class TestContextManager: + def test_enter_returns_self(self, mock_session_cls, mock_odata_svc_cls): + mock_session_cls.return_value = MagicMock() + config = _make_config() + client = _ODataClient(config) + assert client.__enter__() is client + + def test_exit_calls_session_close(self, mock_session_cls, mock_odata_svc_cls): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + config = _make_config() + client = _ODataClient(config) + client.__exit__(None, None, None) + mock_session.close.assert_called_once() + + def test_context_manager_closes_on_exit(self, mock_session_cls, mock_odata_svc_cls): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + config = _make_config() + with _ODataClient(config) as client: + assert isinstance(client, _ODataClient) + mock_session.close.assert_called_once() From d2751d171ad55bf2915e8a888452708274b85f58 Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 12:51:07 +0530 Subject: [PATCH 02/27] chore: bump version to 0.29.0 and update dependencies --- README.md | 4 +++- pyproject.toml | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index da768b4b..372af584 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## About this project -This SDK provides consistent interfaces for interacting with foundational services such as object storage, destination management, audit logging, data anonymization, telemetry, and secure credential handling. +This SDK provides consistent interfaces for interacting with foundational services such as object storage, destination management, audit logging, data anonymization, data privacy integration, telemetry, and secure credential handling. The Python SDK offers a clean, type-safe API following Python best practices while maintaining compatibility with the SAP Application Foundation ecosystem. @@ -24,6 +24,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi - **Secret Resolver** - **Telemetry & Observability** - **Data Anonymization Service** +- **Data Privacy Integration Service** - **Print Service** ## Requirements and Setup @@ -80,6 +81,7 @@ Each module has comprehensive usage guides: - [Telemetry](src/sap_cloud_sdk/core/telemetry/user-guide.md) - [Print](src/sap_cloud_sdk/print/user-guide.md) - [Data Anonymization](src/sap_cloud_sdk/core/data_anonymization/user-guide.md) +- [Data Privacy Integration](src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md) ## Support, Feedback, Contributing diff --git a/pyproject.toml b/pyproject.toml index b50b26ad..2c6c025f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "opentelemetry-api>=1.42.1", "opentelemetry-sdk>=1.42.1", "mcp>=1.1.0", + "python-odata>=0.7.0", ] [project.optional-dependencies] @@ -60,6 +61,7 @@ dev = [ "langchain-community>=0.3.0", "litellm>=1.40.0", "langchain-litellm>=0.6.6", + "responses>=0.25", ] [tool.pytest.ini_options] From ec48be9f27fda776faf006e165d7fb78e31a3af0 Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 14:04:30 +0530 Subject: [PATCH 03/27] feat(dpi_ng): add telemetry instrumentation to dpi_ng/consent module --- .../core/dpi_ng/consent/__init__.py | 32 ++++-- .../services/consent_configuration_service.py | 60 ++++++++++- .../services/consent_purpose_service.py | 22 +++- .../services/consent_retention_service.py | 17 ++- .../consent/services/consent_service.py | 17 ++- .../services/consent_template_service.py | 27 ++++- src/sap_cloud_sdk/core/telemetry/module.py | 1 + src/sap_cloud_sdk/core/telemetry/operation.py | 101 ++++++++++++++++++ 8 files changed, 265 insertions(+), 12 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py index ea006e18..f7b6d56b 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py @@ -15,6 +15,8 @@ Access fields as attributes: ``entity.purpose_name``, ``entity.consent_id``, etc. """ +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + from .auth import ( AuthProvider, BearerTokenAuth, @@ -58,17 +60,31 @@ class ConsentClient: - ``client.configuration`` - reference data CRUD (consentConfigurationExternalServices) """ - def __init__(self, config: ConsentSDKConfig) -> None: + def __init__( + self, + config: ConsentSDKConfig, + *, + _telemetry_source: Module | None = None, + ) -> None: """Initialise all service clients from the given config.""" from .client import _ODataClient + self._telemetry_source = _telemetry_source self._odata = _ODataClient(config) - self.consents: ConsentService = ConsentService(self._odata) - self.purposes: ConsentPurposeService = ConsentPurposeService(self._odata) - self.templates: ConsentTemplateService = ConsentTemplateService(self._odata) - self.retention: ConsentRetentionService = ConsentRetentionService(self._odata) + self.consents: ConsentService = ConsentService( + self._odata, _telemetry_source=_telemetry_source + ) + self.purposes: ConsentPurposeService = ConsentPurposeService( + self._odata, _telemetry_source=_telemetry_source + ) + self.templates: ConsentTemplateService = ConsentTemplateService( + self._odata, _telemetry_source=_telemetry_source + ) + self.retention: ConsentRetentionService = ConsentRetentionService( + self._odata, _telemetry_source=_telemetry_source + ) self.configuration: ConsentConfigurationService = ConsentConfigurationService( - self._odata + self._odata, _telemetry_source=_telemetry_source ) def close(self) -> None: @@ -84,6 +100,7 @@ def __exit__(self, *_: object) -> None: self.close() +@record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CLIENT) def create_client( config: ConsentSDKConfig | None = None, *, @@ -91,6 +108,7 @@ def create_client( auth: AuthProvider | None = None, timeout: float = 30.0, verify_ssl: bool = True, + _telemetry_source: Module | None = None, ) -> ConsentClient: """Instantiate a ConsentClient from a config object or individual keyword arguments. @@ -116,7 +134,7 @@ def create_client( timeout=timeout, verify_ssl=verify_ssl, ) - return ConsentClient(config) + return ConsentClient(config, _telemetry_source=_telemetry_source) except (ValueError, TypeError) as exc: raise ClientCreationError(str(exc)) from exc diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py index a14840c7..86b004ce 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py @@ -5,6 +5,8 @@ import logging from typing import Any +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + from ..client import _ODataClient from ._query import _apply_query @@ -16,10 +18,16 @@ class ConsentConfigurationService: """Client for consentConfigurationExternalServices - CRUD on reference/configuration data.""" - def __init__(self, client: _ODataClient) -> None: + def __init__( + self, + client: _ODataClient, + *, + _telemetry_source: Module | None = None, + ) -> None: """Bind entity classes from the consentConfigurationExternalServices endpoint.""" logger.info("Invoked ConsentConfigurationService.__init__") self._client = client + self._telemetry_source = _telemetry_source ( self.ThirdParty, self.Jurisdiction, @@ -37,6 +45,7 @@ def __init__(self, client: _ODataClient) -> None: # ------ thirdParties ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_THIRD_PARTIES) def list_third_parties(self, **query: Any) -> list[Any]: """Return all third-party records, optionally filtered/paged via OData query kwargs.""" logger.info("Invoked ConsentConfigurationService.list_third_parties") @@ -44,6 +53,7 @@ def list_third_parties(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_third_parties") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_THIRD_PARTY) def get_third_party(self, third_party_id: str) -> Any: """Return a single ThirdParty entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_third_party") @@ -51,6 +61,7 @@ def get_third_party(self, third_party_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_third_party") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY) def create_third_party(self, body: dict[str, Any]) -> Any: """Create a new ThirdParty entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_third_party") @@ -61,6 +72,7 @@ def create_third_party(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_third_party") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY) def update_third_party(self, third_party_id: str, body: dict[str, Any]) -> Any: """Fetch a ThirdParty by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentConfigurationService.update_third_party") @@ -71,6 +83,7 @@ def update_third_party(self, third_party_id: str, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.update_third_party") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY) def delete_third_party(self, third_party_id: str) -> None: """Delete a ThirdParty entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_third_party") @@ -80,6 +93,7 @@ def delete_third_party(self, third_party_id: str) -> None: # ------ jurisdictions ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_JURISDICTIONS) def list_jurisdictions(self, **query: Any) -> list[Any]: """Return all jurisdiction records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_jurisdictions") @@ -87,6 +101,7 @@ def list_jurisdictions(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_jurisdictions") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_JURISDICTION) def get_jurisdiction(self, jurisdiction_id: str) -> Any: """Return a single Jurisdiction entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_jurisdiction") @@ -94,6 +109,7 @@ def get_jurisdiction(self, jurisdiction_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_jurisdiction") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_JURISDICTION) def create_jurisdiction(self, body: dict[str, Any]) -> Any: """Create a new Jurisdiction entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_jurisdiction") @@ -104,6 +120,7 @@ def create_jurisdiction(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_jurisdiction") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_JURISDICTION) def update_jurisdiction(self, jurisdiction_id: str, body: dict[str, Any]) -> Any: """Fetch a Jurisdiction by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentConfigurationService.update_jurisdiction") @@ -114,6 +131,7 @@ def update_jurisdiction(self, jurisdiction_id: str, body: dict[str, Any]) -> Any logger.info("Exiting ConsentConfigurationService.update_jurisdiction") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_JURISDICTION) def delete_jurisdiction(self, jurisdiction_id: str) -> None: """Delete a Jurisdiction entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_jurisdiction") @@ -123,6 +141,7 @@ def delete_jurisdiction(self, jurisdiction_id: str) -> None: # ------ jurisdictionTexts ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_JURISDICTION_TEXTS) def list_jurisdiction_texts(self, **query: Any) -> list[Any]: """Return all jurisdiction text records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_jurisdiction_texts") @@ -132,6 +151,7 @@ def list_jurisdiction_texts(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_jurisdiction_texts") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_JURISDICTION_TEXT) def create_jurisdiction_text(self, body: dict[str, Any]) -> Any: """Create a new JurisdictionText entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_jurisdiction_text") @@ -142,6 +162,7 @@ def create_jurisdiction_text(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_jurisdiction_text") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_JURISDICTION_TEXT) def update_jurisdiction_text( self, jurisdiction_id: str, language_code: str, body: dict[str, Any] ) -> Any: @@ -156,6 +177,7 @@ def update_jurisdiction_text( logger.info("Exiting ConsentConfigurationService.update_jurisdiction_text") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_JURISDICTION_TEXT) def delete_jurisdiction_text( self, jurisdiction_id: str, language_code: str ) -> None: @@ -169,6 +191,7 @@ def delete_jurisdiction_text( # ------ languages ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_LANGUAGES) def list_languages(self, **query: Any) -> list[Any]: """Return all language reference records.""" logger.info("Invoked ConsentConfigurationService.list_languages") @@ -176,6 +199,7 @@ def list_languages(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_languages") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_LANGUAGE) def get_language(self, language_code: str) -> Any: """Return a single Language entity by its code.""" logger.info("Invoked ConsentConfigurationService.get_language") @@ -185,6 +209,7 @@ def get_language(self, language_code: str) -> Any: # ------ languageDescriptions ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_LANGUAGE_DESCRIPTIONS) def list_language_descriptions(self, **query: Any) -> list[Any]: """Return all language description records.""" logger.info("Invoked ConsentConfigurationService.list_language_descriptions") @@ -194,6 +219,7 @@ def list_language_descriptions(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_language_descriptions") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_LANGUAGE_DESCRIPTION) def create_language_description(self, body: dict[str, Any]) -> Any: """Create a new LanguageDescription entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_language_description") @@ -204,6 +230,7 @@ def create_language_description(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_language_description") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_LANGUAGE_DESCRIPTION) def update_language_description( self, language_code: str, body: dict[str, Any] ) -> Any: @@ -216,6 +243,7 @@ def update_language_description( logger.info("Exiting ConsentConfigurationService.update_language_description") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_LANGUAGE_DESCRIPTION) def delete_language_description(self, language_code: str) -> None: """Delete a LanguageDescription entity by its language code.""" logger.info("Invoked ConsentConfigurationService.delete_language_description") @@ -225,6 +253,7 @@ def delete_language_description(self, language_code: str) -> None: # ------ sourceInfos ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_SOURCE_INFOS) def list_source_infos(self, **query: Any) -> list[Any]: """Return all source info records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_source_infos") @@ -232,6 +261,7 @@ def list_source_infos(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_source_infos") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_SOURCE_INFO) def get_source_info(self, source_id: str) -> Any: """Return a single SourceInfo entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_source_info") @@ -239,6 +269,7 @@ def get_source_info(self, source_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_source_info") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_SOURCE_INFO) def create_source_info(self, body: dict[str, Any]) -> Any: """Create a new SourceInfo entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_source_info") @@ -249,6 +280,7 @@ def create_source_info(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_source_info") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_SOURCE_INFO) def update_source_info(self, source_id: str, body: dict[str, Any]) -> Any: """Fetch a SourceInfo by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentConfigurationService.update_source_info") @@ -259,6 +291,7 @@ def update_source_info(self, source_id: str, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.update_source_info") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_SOURCE_INFO) def delete_source_info(self, source_id: str) -> None: """Delete a SourceInfo entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_source_info") @@ -268,6 +301,7 @@ def delete_source_info(self, source_id: str) -> None: # ------ controllers ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_CONTROLLERS) def list_controllers(self, **query: Any) -> list[Any]: """Return all controller records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_controllers") @@ -275,6 +309,7 @@ def list_controllers(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_controllers") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_CONTROLLER) def get_controller(self, controller_id: str) -> Any: """Return a single Controller entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_controller") @@ -282,6 +317,7 @@ def get_controller(self, controller_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_controller") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CONTROLLER) def create_controller(self, body: dict[str, Any]) -> Any: """Create a new Controller entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_controller") @@ -292,6 +328,7 @@ def create_controller(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_controller") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_CONTROLLER) def update_controller(self, controller_id: str, body: dict[str, Any]) -> Any: """Fetch a Controller by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentConfigurationService.update_controller") @@ -302,6 +339,7 @@ def update_controller(self, controller_id: str, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.update_controller") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_CONTROLLER) def delete_controller(self, controller_id: str) -> None: """Delete a Controller entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_controller") @@ -311,6 +349,7 @@ def delete_controller(self, controller_id: str) -> None: # ------ dataSubjectTypes ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_DATA_SUBJECT_TYPES) def list_data_subject_types(self, **query: Any) -> list[Any]: """Return all data subject type records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_data_subject_types") @@ -320,6 +359,7 @@ def list_data_subject_types(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_data_subject_types") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_DATA_SUBJECT_TYPE) def get_data_subject_type(self, data_subject_type_id: str) -> Any: """Return a single DataSubjectType entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_data_subject_type") @@ -329,6 +369,7 @@ def get_data_subject_type(self, data_subject_type_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_data_subject_type") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_DATA_SUBJECT_TYPE) def create_data_subject_type(self, body: dict[str, Any]) -> Any: """Create a new DataSubjectType entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_data_subject_type") @@ -339,6 +380,7 @@ def create_data_subject_type(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_data_subject_type") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_DATA_SUBJECT_TYPE) def update_data_subject_type( self, data_subject_type_id: str, body: dict[str, Any] ) -> Any: @@ -353,6 +395,7 @@ def update_data_subject_type( logger.info("Exiting ConsentConfigurationService.update_data_subject_type") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_DATA_SUBJECT_TYPE) def delete_data_subject_type(self, data_subject_type_id: str) -> None: """Delete a DataSubjectType entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_data_subject_type") @@ -364,6 +407,7 @@ def delete_data_subject_type(self, data_subject_type_id: str) -> None: # ------ applications ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_APPLICATIONS) def list_applications(self, **query: Any) -> list[Any]: """Return all application records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_applications") @@ -371,6 +415,7 @@ def list_applications(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_applications") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_APPLICATION) def get_application(self, application_id: str) -> Any: """Return a single Application entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_application") @@ -378,6 +423,7 @@ def get_application(self, application_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_application") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_APPLICATION) def create_application(self, body: dict[str, Any]) -> Any: """Create a new Application entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_application") @@ -388,6 +434,7 @@ def create_application(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_application") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_APPLICATION) def update_application(self, application_id: str, body: dict[str, Any]) -> Any: """Fetch an Application by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentConfigurationService.update_application") @@ -398,6 +445,7 @@ def update_application(self, application_id: str, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.update_application") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_APPLICATION) def delete_application(self, application_id: str) -> None: """Delete an Application entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_application") @@ -407,6 +455,7 @@ def delete_application(self, application_id: str) -> None: # ------ masterDataSources ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_MASTER_DATA_SOURCES) def list_master_data_sources(self, **query: Any) -> list[Any]: """Return all master data source records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_master_data_sources") @@ -416,6 +465,7 @@ def list_master_data_sources(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_master_data_sources") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_MASTER_DATA_SOURCE) def get_master_data_source(self, master_data_source_id: str) -> Any: """Return a single MasterDataSource entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_master_data_source") @@ -425,6 +475,7 @@ def get_master_data_source(self, master_data_source_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_master_data_source") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_MASTER_DATA_SOURCE) def create_master_data_source(self, body: dict[str, Any]) -> Any: """Create a new MasterDataSource entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_master_data_source") @@ -435,6 +486,7 @@ def create_master_data_source(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_master_data_source") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_MASTER_DATA_SOURCE) def update_master_data_source( self, master_data_source_id: str, body: dict[str, Any] ) -> Any: @@ -449,6 +501,7 @@ def update_master_data_source( logger.info("Exiting ConsentConfigurationService.update_master_data_source") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_MASTER_DATA_SOURCE) def delete_master_data_source(self, master_data_source_id: str) -> None: """Delete a MasterDataSource entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_master_data_source") @@ -460,6 +513,7 @@ def delete_master_data_source(self, master_data_source_id: str) -> None: # ------ outboundChannelTypes ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_OUTBOUND_CHANNEL_TYPES) def list_outbound_channel_types(self, **query: Any) -> list[Any]: """Return all outbound channel type records, optionally filtered/paged.""" logger.info("Invoked ConsentConfigurationService.list_outbound_channel_types") @@ -469,6 +523,7 @@ def list_outbound_channel_types(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentConfigurationService.list_outbound_channel_types") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_OUTBOUND_CHANNEL_TYPE) def get_outbound_channel_type(self, outbound_channel_type_id: str) -> Any: """Return a single OutboundChannelType entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.get_outbound_channel_type") @@ -478,6 +533,7 @@ def get_outbound_channel_type(self, outbound_channel_type_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_outbound_channel_type") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_OUTBOUND_CHANNEL_TYPE) def create_outbound_channel_type(self, body: dict[str, Any]) -> Any: """Create a new OutboundChannelType entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_outbound_channel_type") @@ -488,6 +544,7 @@ def create_outbound_channel_type(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_outbound_channel_type") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_OUTBOUND_CHANNEL_TYPE) def update_outbound_channel_type( self, outbound_channel_type_id: str, body: dict[str, Any] ) -> Any: @@ -502,6 +559,7 @@ def update_outbound_channel_type( logger.info("Exiting ConsentConfigurationService.update_outbound_channel_type") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_OUTBOUND_CHANNEL_TYPE) def delete_outbound_channel_type(self, outbound_channel_type_id: str) -> None: """Delete an OutboundChannelType entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_outbound_channel_type") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py index 83a54e25..53ce65d4 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py @@ -5,6 +5,8 @@ import logging from typing import Any +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + from ..client import _ODataClient from ._query import _apply_query @@ -16,10 +18,16 @@ class ConsentPurposeService: """Client for consentPurposeExternalServices - CRUD on purposes and their texts.""" - def __init__(self, client: _ODataClient) -> None: + def __init__( + self, + client: _ODataClient, + *, + _telemetry_source: Module | None = None, + ) -> None: """Bind entity classes from the consentPurposeExternalServices endpoint.""" logger.info("Invoked ConsentPurposeService.__init__") self._client = client + self._telemetry_source = _telemetry_source ( self.ConsentPurpose, self.ConsentPurposeText, @@ -28,6 +36,7 @@ def __init__(self, client: _ODataClient) -> None: # ------ consentPurposes ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_PURPOSES) def list_purposes(self, **query: Any) -> list[Any]: """Return all consent purposes, optionally filtered/paged via OData query kwargs.""" logger.info("Invoked ConsentPurposeService.list_purposes") @@ -37,6 +46,7 @@ def list_purposes(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentPurposeService.list_purposes") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_PURPOSE) def get_purpose(self, purpose_id: str) -> Any: """Return a single ConsentPurpose entity by its UUID.""" logger.info("Invoked ConsentPurposeService.get_purpose") @@ -44,6 +54,7 @@ def get_purpose(self, purpose_id: str) -> Any: logger.info("Exiting ConsentPurposeService.get_purpose") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_PURPOSE) def create_purpose(self, body: dict[str, Any]) -> Any: """Create a new ConsentPurpose entity and return it.""" logger.info("Invoked ConsentPurposeService.create_purpose") @@ -54,6 +65,7 @@ def create_purpose(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentPurposeService.create_purpose") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_PURPOSE) def update_purpose(self, purpose_id: str, body: dict[str, Any]) -> Any: """Fetch a ConsentPurpose by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentPurposeService.update_purpose") @@ -64,6 +76,7 @@ def update_purpose(self, purpose_id: str, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentPurposeService.update_purpose") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_PURPOSE) def delete_purpose(self, purpose_id: str) -> None: """Delete a ConsentPurpose by its UUID.""" logger.info("Invoked ConsentPurposeService.delete_purpose") @@ -73,6 +86,7 @@ def delete_purpose(self, purpose_id: str) -> None: # ------ lifecycle actions ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_PURPOSE_ACTIVE) def set_purpose_active(self, purpose_id: str) -> Any: """Activate a consent purpose and return the refreshed entity.""" logger.info("Invoked ConsentPurposeService.set_purpose_active") @@ -83,6 +97,7 @@ def set_purpose_active(self, purpose_id: str) -> Any: logger.info("Exiting ConsentPurposeService.set_purpose_active") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_PURPOSE_INACTIVE) def set_purpose_inactive(self, purpose_id: str) -> Any: """Deactivate a consent purpose and return the refreshed entity.""" logger.info("Invoked ConsentPurposeService.set_purpose_inactive") @@ -95,6 +110,7 @@ def set_purpose_inactive(self, purpose_id: str) -> Any: # ------ consentPurposeTexts ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_PURPOSE_TEXTS) def list_purpose_texts(self, **query: Any) -> list[Any]: """Return all purpose text records, optionally filtered/paged.""" logger.info("Invoked ConsentPurposeService.list_purpose_texts") @@ -104,6 +120,7 @@ def list_purpose_texts(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentPurposeService.list_purpose_texts") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_PURPOSE_TEXT) def get_purpose_text( self, purpose_id: str, type_code: str, language_code: str ) -> Any: @@ -115,6 +132,7 @@ def get_purpose_text( logger.info("Exiting ConsentPurposeService.get_purpose_text") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_PURPOSE_TEXT) def create_purpose_text(self, body: dict[str, Any]) -> Any: """Create a new ConsentPurposeText entity and return it.""" logger.info("Invoked ConsentPurposeService.create_purpose_text") @@ -125,6 +143,7 @@ def create_purpose_text(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentPurposeService.create_purpose_text") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_PURPOSE_TEXT) def update_purpose_text( self, purpose_id: str, type_code: str, language_code: str, body: dict[str, Any] ) -> Any: @@ -139,6 +158,7 @@ def update_purpose_text( logger.info("Exiting ConsentPurposeService.update_purpose_text") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_PURPOSE_TEXT) def delete_purpose_text( self, purpose_id: str, type_code: str, language_code: str ) -> None: diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py index 508bd834..e1edcbc5 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py @@ -5,6 +5,8 @@ import logging from typing import Any +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + from ..client import _ODataClient from ._query import _apply_query @@ -16,15 +18,22 @@ class ConsentRetentionService: """Client for consentRetentionExternalServices - CRUD on data retention rules.""" - def __init__(self, client: _ODataClient) -> None: + def __init__( + self, + client: _ODataClient, + *, + _telemetry_source: Module | None = None, + ) -> None: """Bind entity classes from the consentRetentionExternalServices endpoint.""" logger.info("Invoked ConsentRetentionService.__init__") self._client = client + self._telemetry_source = _telemetry_source (self.ConsentRetentionRule,) = client.get_entity_classes(_SVC) logger.info("Exiting ConsentRetentionService.__init__") # ------ consentRetentionRules ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_RULES) def list_rules(self, **query: Any) -> list[Any]: """Return all retention rules, optionally filtered/paged via OData query kwargs.""" logger.info("Invoked ConsentRetentionService.list_rules") @@ -34,6 +43,7 @@ def list_rules(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentRetentionService.list_rules") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_RULE) def get_rule(self, rule_id: str) -> Any: """Return a single ConsentRetentionRule entity by its UUID.""" logger.info("Invoked ConsentRetentionService.get_rule") @@ -41,6 +51,7 @@ def get_rule(self, rule_id: str) -> Any: logger.info("Exiting ConsentRetentionService.get_rule") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_RULE) def create_rule(self, body: dict[str, Any]) -> Any: """Create a new ConsentRetentionRule entity and return it.""" logger.info("Invoked ConsentRetentionService.create_rule") @@ -51,6 +62,7 @@ def create_rule(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentRetentionService.create_rule") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_RULE) def update_rule(self, rule_id: str, body: dict[str, Any]) -> Any: """Fetch a ConsentRetentionRule by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentRetentionService.update_rule") @@ -61,6 +73,7 @@ def update_rule(self, rule_id: str, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentRetentionService.update_rule") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_RULE) def delete_rule(self, rule_id: str) -> None: """Delete a ConsentRetentionRule by its UUID.""" logger.info("Invoked ConsentRetentionService.delete_rule") @@ -70,6 +83,7 @@ def delete_rule(self, rule_id: str) -> None: # ------ lifecycle actions ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_RULE_ACTIVE) def set_rule_active(self, rule_id: str) -> Any: """Activate a retention rule and return the refreshed entity.""" logger.info("Invoked ConsentRetentionService.set_rule_active") @@ -80,6 +94,7 @@ def set_rule_active(self, rule_id: str) -> Any: logger.info("Exiting ConsentRetentionService.set_rule_active") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_RULE_INACTIVE) def set_rule_inactive(self, rule_id: str) -> Any: """Deactivate a retention rule and return the refreshed entity.""" logger.info("Invoked ConsentRetentionService.set_rule_inactive") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py index 446bff98..8fa2db69 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py @@ -5,6 +5,8 @@ import logging from typing import Any +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + from ..client import _ODataClient from ..dtos.consent import ( CheckConsentExistsResult, @@ -21,15 +23,22 @@ class ConsentService: """Client for consentServices - consent creation, withdrawal, and reads.""" - def __init__(self, client: _ODataClient) -> None: + def __init__( + self, + client: _ODataClient, + *, + _telemetry_source: Module | None = None, + ) -> None: """Bind entity classes from the consentServices endpoint.""" logger.info("Invoked ConsentService.__init__") self._client = client + self._telemetry_source = _telemetry_source (self.Consent,) = client.get_entity_classes(_SVC) logger.info("Exiting ConsentService.__init__") # ------ consents ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_CONSENTS) def list_consents(self, **query: Any) -> list[Any]: """Return all consents, optionally filtered/paged via OData query kwargs.""" logger.info("Invoked ConsentService.list_consents") @@ -37,6 +46,7 @@ def list_consents(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentService.list_consents") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_CONSENT) def get_consent(self, consent_id: str) -> Any: """Return a single Consent entity by its UUID.""" logger.info("Invoked ConsentService.get_consent") @@ -44,6 +54,7 @@ def get_consent(self, consent_id: str) -> Any: logger.info("Exiting ConsentService.get_consent") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_CONSENT) def delete_consent(self, consent_id: str) -> None: """Delete a Consent entity by its UUID.""" logger.info("Invoked ConsentService.delete_consent") @@ -53,6 +64,7 @@ def delete_consent(self, consent_id: str) -> None: # ------ actions ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CONSENT_FROM_TEMPLATE) def create_consent_from_template(self, request: CreateConsentRequest) -> list[Any]: """Invoke createConsentFromTemplate and return the resulting Consent entities.""" logger.info("Invoked ConsentService.create_consent_from_template") @@ -71,6 +83,7 @@ def create_consent_from_template(self, request: CreateConsentRequest) -> list[An logger.info("Exiting ConsentService.create_consent_from_template") return entities + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_WITHDRAW_CONSENT) def withdraw_consent( self, request: WithdrawConsentRequest ) -> dict[str, Any] | None: @@ -80,6 +93,7 @@ def withdraw_consent( logger.info("Exiting ConsentService.withdraw_consent") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_TERMINATE_CONSENT) def terminate_consent( self, request: WithdrawConsentRequest ) -> dict[str, Any] | None: @@ -89,6 +103,7 @@ def terminate_consent( logger.info("Exiting ConsentService.terminate_consent") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CHECK_CONSENT_EXISTS) def check_consent_exists( self, data_subject_id: str, template_id: str ) -> CheckConsentExistsResult: diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py index 6eee1871..50a1aaf4 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py @@ -5,6 +5,8 @@ import logging from typing import Any +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + from ..client import _ODataClient from ._query import _apply_query @@ -16,10 +18,16 @@ class ConsentTemplateService: """Client for consentTemplateExternalServices - CRUD on templates and related entities.""" - def __init__(self, client: _ODataClient) -> None: + def __init__( + self, + client: _ODataClient, + *, + _telemetry_source: Module | None = None, + ) -> None: """Bind entity classes from the consentTemplateExternalServices endpoint.""" logger.info("Invoked ConsentTemplateService.__init__") self._client = client + self._telemetry_source = _telemetry_source ( self.ConsentTemplate, self.ConsentTemplateText, @@ -29,6 +37,7 @@ def __init__(self, client: _ODataClient) -> None: # ------ consentTemplates ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_TEMPLATES) def list_templates(self, **query: Any) -> list[Any]: """Return all consent templates, optionally filtered/paged via OData query kwargs.""" logger.info("Invoked ConsentTemplateService.list_templates") @@ -38,6 +47,7 @@ def list_templates(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentTemplateService.list_templates") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_TEMPLATE) def get_template(self, template_id: str) -> Any: """Return a single ConsentTemplate entity by its UUID.""" logger.info("Invoked ConsentTemplateService.get_template") @@ -45,6 +55,7 @@ def get_template(self, template_id: str) -> Any: logger.info("Exiting ConsentTemplateService.get_template") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_TEMPLATE) def create_template(self, body: dict[str, Any]) -> Any: """Create a new ConsentTemplate entity and return it.""" logger.info("Invoked ConsentTemplateService.create_template") @@ -55,6 +66,7 @@ def create_template(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentTemplateService.create_template") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_TEMPLATE) def update_template(self, template_id: str, body: dict[str, Any]) -> Any: """Fetch a ConsentTemplate by ID, apply field updates, and PATCH it.""" logger.info("Invoked ConsentTemplateService.update_template") @@ -65,6 +77,7 @@ def update_template(self, template_id: str, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentTemplateService.update_template") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_TEMPLATE) def delete_template(self, template_id: str) -> None: """Delete a ConsentTemplate by its UUID.""" logger.info("Invoked ConsentTemplateService.delete_template") @@ -74,6 +87,7 @@ def delete_template(self, template_id: str) -> None: # ------ lifecycle actions ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_TEMPLATE_ACTIVE) def set_template_active(self, template_id: str) -> Any: """Activate a consent template and return the refreshed entity.""" logger.info("Invoked ConsentTemplateService.set_template_active") @@ -86,6 +100,7 @@ def set_template_active(self, template_id: str) -> Any: logger.info("Exiting ConsentTemplateService.set_template_active") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_TEMPLATE_INACTIVE) def set_template_inactive(self, template_id: str) -> Any: """Deactivate a consent template and return the refreshed entity.""" logger.info("Invoked ConsentTemplateService.set_template_inactive") @@ -100,6 +115,7 @@ def set_template_inactive(self, template_id: str) -> Any: # ------ consentTemplateTexts ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_TEMPLATE_TEXTS) def list_template_texts(self, **query: Any) -> list[Any]: """Return all template text records, optionally filtered/paged.""" logger.info("Invoked ConsentTemplateService.list_template_texts") @@ -109,6 +125,7 @@ def list_template_texts(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentTemplateService.list_template_texts") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_TEMPLATE_TEXT) def get_template_text( self, template_id: str, type_code: str, language_code: str ) -> Any: @@ -120,6 +137,7 @@ def get_template_text( logger.info("Exiting ConsentTemplateService.get_template_text") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_TEMPLATE_TEXT) def create_template_text(self, body: dict[str, Any]) -> Any: """Create a new ConsentTemplateText entity and return it.""" logger.info("Invoked ConsentTemplateService.create_template_text") @@ -130,6 +148,7 @@ def create_template_text(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentTemplateService.create_template_text") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_TEMPLATE_TEXT) def update_template_text( self, template_id: str, type_code: str, language_code: str, body: dict[str, Any] ) -> Any: @@ -144,6 +163,7 @@ def update_template_text( logger.info("Exiting ConsentTemplateService.update_template_text") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_TEMPLATE_TEXT) def delete_template_text( self, template_id: str, type_code: str, language_code: str ) -> None: @@ -157,6 +177,7 @@ def delete_template_text( # ------ templateThirdPartyPersDatas ------ + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_THIRD_PARTY_PERS_DATA) def list_third_party_pers_data(self, **query: Any) -> list[Any]: """Return all template third-party personal data records.""" logger.info("Invoked ConsentTemplateService.list_third_party_pers_data") @@ -166,6 +187,7 @@ def list_third_party_pers_data(self, **query: Any) -> list[Any]: logger.info("Exiting ConsentTemplateService.list_third_party_pers_data") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_THIRD_PARTY_PERS_DATA) def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> Any: """Return a single TemplateThirdPartyPersData by its composite key.""" logger.info("Invoked ConsentTemplateService.get_third_party_pers_data") @@ -175,6 +197,7 @@ def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> An logger.info("Exiting ConsentTemplateService.get_third_party_pers_data") return result + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY_PERS_DATA) def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: """Create a new TemplateThirdPartyPersData entity and return it.""" logger.info("Invoked ConsentTemplateService.create_third_party_pers_data") @@ -185,6 +208,7 @@ def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentTemplateService.create_third_party_pers_data") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY_PERS_DATA) def update_third_party_pers_data( self, template_id: str, third_party_id: str, body: dict[str, Any] ) -> Any: @@ -199,6 +223,7 @@ def update_third_party_pers_data( logger.info("Exiting ConsentTemplateService.update_third_party_pers_data") return entity + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY_PERS_DATA) def delete_third_party_pers_data( self, template_id: str, third_party_id: str ) -> None: diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index d1f605f6..223b6ccd 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -14,6 +14,7 @@ class Module(str, Enum): AUDITLOG_NG = "auditlog_ng" DATA_ANONYMIZATION = "data_anonymization" DESTINATION = "destination" + DPI_NG = "dpi_ng" DMS = "dms" EXTENSIBILITY = "extensibility" OBJECTSTORE = "objectstore" diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 6472918c..957ed4ea 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -191,6 +191,107 @@ class Operation(str, Enum): AGENTGATEWAY_LIST_AGENT_CARDS = "list_agent_cards" AGENTGATEWAY_GET_IAS_CLIENT_ID = "get_ias_client_id" + # DPI NG Consent Operations + DPI_NG_CONSENT_CREATE_CLIENT = "consent_create_client" + # ConsentService + DPI_NG_CONSENT_LIST_CONSENTS = "consent_list_consents" + DPI_NG_CONSENT_GET_CONSENT = "consent_get_consent" + DPI_NG_CONSENT_DELETE_CONSENT = "consent_delete_consent" + DPI_NG_CONSENT_CREATE_CONSENT_FROM_TEMPLATE = "consent_create_consent_from_template" + DPI_NG_CONSENT_WITHDRAW_CONSENT = "consent_withdraw_consent" + DPI_NG_CONSENT_TERMINATE_CONSENT = "consent_terminate_consent" + DPI_NG_CONSENT_CHECK_CONSENT_EXISTS = "consent_check_consent_exists" + # ConsentPurposeService + DPI_NG_CONSENT_LIST_PURPOSES = "consent_list_purposes" + DPI_NG_CONSENT_GET_PURPOSE = "consent_get_purpose" + DPI_NG_CONSENT_CREATE_PURPOSE = "consent_create_purpose" + DPI_NG_CONSENT_UPDATE_PURPOSE = "consent_update_purpose" + DPI_NG_CONSENT_DELETE_PURPOSE = "consent_delete_purpose" + DPI_NG_CONSENT_SET_PURPOSE_ACTIVE = "consent_set_purpose_active" + DPI_NG_CONSENT_SET_PURPOSE_INACTIVE = "consent_set_purpose_inactive" + DPI_NG_CONSENT_LIST_PURPOSE_TEXTS = "consent_list_purpose_texts" + DPI_NG_CONSENT_GET_PURPOSE_TEXT = "consent_get_purpose_text" + DPI_NG_CONSENT_CREATE_PURPOSE_TEXT = "consent_create_purpose_text" + DPI_NG_CONSENT_UPDATE_PURPOSE_TEXT = "consent_update_purpose_text" + DPI_NG_CONSENT_DELETE_PURPOSE_TEXT = "consent_delete_purpose_text" + # ConsentTemplateService + DPI_NG_CONSENT_LIST_TEMPLATES = "consent_list_templates" + DPI_NG_CONSENT_GET_TEMPLATE = "consent_get_template" + DPI_NG_CONSENT_CREATE_TEMPLATE = "consent_create_template" + DPI_NG_CONSENT_UPDATE_TEMPLATE = "consent_update_template" + DPI_NG_CONSENT_DELETE_TEMPLATE = "consent_delete_template" + DPI_NG_CONSENT_SET_TEMPLATE_ACTIVE = "consent_set_template_active" + DPI_NG_CONSENT_SET_TEMPLATE_INACTIVE = "consent_set_template_inactive" + DPI_NG_CONSENT_LIST_TEMPLATE_TEXTS = "consent_list_template_texts" + DPI_NG_CONSENT_GET_TEMPLATE_TEXT = "consent_get_template_text" + DPI_NG_CONSENT_CREATE_TEMPLATE_TEXT = "consent_create_template_text" + DPI_NG_CONSENT_UPDATE_TEMPLATE_TEXT = "consent_update_template_text" + DPI_NG_CONSENT_DELETE_TEMPLATE_TEXT = "consent_delete_template_text" + DPI_NG_CONSENT_LIST_THIRD_PARTY_PERS_DATA = "consent_list_third_party_pers_data" + DPI_NG_CONSENT_GET_THIRD_PARTY_PERS_DATA = "consent_get_third_party_pers_data" + DPI_NG_CONSENT_CREATE_THIRD_PARTY_PERS_DATA = "consent_create_third_party_pers_data" + DPI_NG_CONSENT_UPDATE_THIRD_PARTY_PERS_DATA = "consent_update_third_party_pers_data" + DPI_NG_CONSENT_DELETE_THIRD_PARTY_PERS_DATA = "consent_delete_third_party_pers_data" + # ConsentRetentionService + DPI_NG_CONSENT_LIST_RULES = "consent_list_rules" + DPI_NG_CONSENT_GET_RULE = "consent_get_rule" + DPI_NG_CONSENT_CREATE_RULE = "consent_create_rule" + DPI_NG_CONSENT_UPDATE_RULE = "consent_update_rule" + DPI_NG_CONSENT_DELETE_RULE = "consent_delete_rule" + DPI_NG_CONSENT_SET_RULE_ACTIVE = "consent_set_rule_active" + DPI_NG_CONSENT_SET_RULE_INACTIVE = "consent_set_rule_inactive" + # ConsentConfigurationService + DPI_NG_CONSENT_LIST_THIRD_PARTIES = "consent_list_third_parties" + DPI_NG_CONSENT_GET_THIRD_PARTY = "consent_get_third_party" + DPI_NG_CONSENT_CREATE_THIRD_PARTY = "consent_create_third_party" + DPI_NG_CONSENT_UPDATE_THIRD_PARTY = "consent_update_third_party" + DPI_NG_CONSENT_DELETE_THIRD_PARTY = "consent_delete_third_party" + DPI_NG_CONSENT_LIST_JURISDICTIONS = "consent_list_jurisdictions" + DPI_NG_CONSENT_GET_JURISDICTION = "consent_get_jurisdiction" + DPI_NG_CONSENT_CREATE_JURISDICTION = "consent_create_jurisdiction" + DPI_NG_CONSENT_UPDATE_JURISDICTION = "consent_update_jurisdiction" + DPI_NG_CONSENT_DELETE_JURISDICTION = "consent_delete_jurisdiction" + DPI_NG_CONSENT_LIST_JURISDICTION_TEXTS = "consent_list_jurisdiction_texts" + DPI_NG_CONSENT_CREATE_JURISDICTION_TEXT = "consent_create_jurisdiction_text" + DPI_NG_CONSENT_UPDATE_JURISDICTION_TEXT = "consent_update_jurisdiction_text" + DPI_NG_CONSENT_DELETE_JURISDICTION_TEXT = "consent_delete_jurisdiction_text" + DPI_NG_CONSENT_LIST_LANGUAGES = "consent_list_languages" + DPI_NG_CONSENT_GET_LANGUAGE = "consent_get_language" + DPI_NG_CONSENT_LIST_LANGUAGE_DESCRIPTIONS = "consent_list_language_descriptions" + DPI_NG_CONSENT_CREATE_LANGUAGE_DESCRIPTION = "consent_create_language_description" + DPI_NG_CONSENT_UPDATE_LANGUAGE_DESCRIPTION = "consent_update_language_description" + DPI_NG_CONSENT_DELETE_LANGUAGE_DESCRIPTION = "consent_delete_language_description" + DPI_NG_CONSENT_LIST_SOURCE_INFOS = "consent_list_source_infos" + DPI_NG_CONSENT_GET_SOURCE_INFO = "consent_get_source_info" + DPI_NG_CONSENT_CREATE_SOURCE_INFO = "consent_create_source_info" + DPI_NG_CONSENT_UPDATE_SOURCE_INFO = "consent_update_source_info" + DPI_NG_CONSENT_DELETE_SOURCE_INFO = "consent_delete_source_info" + DPI_NG_CONSENT_LIST_CONTROLLERS = "consent_list_controllers" + DPI_NG_CONSENT_GET_CONTROLLER = "consent_get_controller" + DPI_NG_CONSENT_CREATE_CONTROLLER = "consent_create_controller" + DPI_NG_CONSENT_UPDATE_CONTROLLER = "consent_update_controller" + DPI_NG_CONSENT_DELETE_CONTROLLER = "consent_delete_controller" + DPI_NG_CONSENT_LIST_DATA_SUBJECT_TYPES = "consent_list_data_subject_types" + DPI_NG_CONSENT_GET_DATA_SUBJECT_TYPE = "consent_get_data_subject_type" + DPI_NG_CONSENT_CREATE_DATA_SUBJECT_TYPE = "consent_create_data_subject_type" + DPI_NG_CONSENT_UPDATE_DATA_SUBJECT_TYPE = "consent_update_data_subject_type" + DPI_NG_CONSENT_DELETE_DATA_SUBJECT_TYPE = "consent_delete_data_subject_type" + DPI_NG_CONSENT_LIST_APPLICATIONS = "consent_list_applications" + DPI_NG_CONSENT_GET_APPLICATION = "consent_get_application" + DPI_NG_CONSENT_CREATE_APPLICATION = "consent_create_application" + DPI_NG_CONSENT_UPDATE_APPLICATION = "consent_update_application" + DPI_NG_CONSENT_DELETE_APPLICATION = "consent_delete_application" + DPI_NG_CONSENT_LIST_MASTER_DATA_SOURCES = "consent_list_master_data_sources" + DPI_NG_CONSENT_GET_MASTER_DATA_SOURCE = "consent_get_master_data_source" + DPI_NG_CONSENT_CREATE_MASTER_DATA_SOURCE = "consent_create_master_data_source" + DPI_NG_CONSENT_UPDATE_MASTER_DATA_SOURCE = "consent_update_master_data_source" + DPI_NG_CONSENT_DELETE_MASTER_DATA_SOURCE = "consent_delete_master_data_source" + DPI_NG_CONSENT_LIST_OUTBOUND_CHANNEL_TYPES = "consent_list_outbound_channel_types" + DPI_NG_CONSENT_GET_OUTBOUND_CHANNEL_TYPE = "consent_get_outbound_channel_type" + DPI_NG_CONSENT_CREATE_OUTBOUND_CHANNEL_TYPE = "consent_create_outbound_channel_type" + DPI_NG_CONSENT_UPDATE_OUTBOUND_CHANNEL_TYPE = "consent_update_outbound_channel_type" + DPI_NG_CONSENT_DELETE_OUTBOUND_CHANNEL_TYPE = "consent_delete_outbound_channel_type" + # Agent Memory Operations AGENT_MEMORY_ADD_MEMORY = "add_memory" AGENT_MEMORY_GET_MEMORY = "get_memory" From 86a978d3bd1a5502b65f74fcc1867e2afcbe61bb Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 14:33:57 +0530 Subject: [PATCH 04/27] test(dpi_ng): add telemetry unit tests for consent module operations --- tests/core/unit/telemetry/test_module.py | 7 +- tests/core/unit/telemetry/test_operation.py | 105 +++++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index ccc5d1dc..5a4012a1 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -15,6 +15,7 @@ def test_module_values(self): assert Module.AUDITLOG_NG.value == "auditlog_ng" assert Module.DATA_ANONYMIZATION.value == "data_anonymization" assert Module.DESTINATION.value == "destination" + assert Module.DPI_NG.value == "dpi_ng" assert Module.OBJECTSTORE.value == "objectstore" assert Module.DMS.value == "dms" assert Module.PRINT.value == "print" @@ -26,6 +27,7 @@ def test_module_str_representation(self): assert str(Module.AUDITLOG_NG) == "auditlog_ng" assert str(Module.DATA_ANONYMIZATION) == "data_anonymization" assert str(Module.DESTINATION) == "destination" + assert str(Module.DPI_NG) == "dpi_ng" assert str(Module.OBJECTSTORE) == "objectstore" assert str(Module.DMS) == "dms" assert str(Module.PRINT) == "print" @@ -37,6 +39,7 @@ def test_module_is_string_enum(self): assert isinstance(Module.AUDITLOG_NG, str) assert isinstance(Module.DATA_ANONYMIZATION, str) assert isinstance(Module.DESTINATION, str) + assert isinstance(Module.DPI_NG, str) assert isinstance(Module.DMS, str) def test_module_equality(self): @@ -55,7 +58,7 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 13 + assert len(all_modules) == 14 assert Module.ADMS in all_modules assert Module.AGENT_MEMORY in all_modules assert Module.AGENTGATEWAY in all_modules @@ -65,6 +68,7 @@ def test_all_modules_present(self): assert Module.DATA_ANONYMIZATION in all_modules assert Module.DESTINATION in all_modules assert Module.DMS in all_modules + assert Module.DPI_NG in all_modules assert Module.EXTENSIBILITY in all_modules assert Module.OBJECTSTORE in all_modules assert Module.PRINT in all_modules @@ -78,6 +82,7 @@ def test_module_iteration(self): assert "auditlog_ng" in module_values assert "data_anonymization" in module_values assert "destination" in module_values + assert "dpi_ng" in module_values assert "objectstore" in module_values assert "dms" in module_values assert "extensibility" in module_values diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index 8db63ca3..cbe41cc4 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -163,6 +163,103 @@ def test_print_operations(self): assert Operation.PRINT_UPLOAD_DOCUMENT.value == "upload_document" assert Operation.PRINT_CREATE_TASK.value == "create_print_task" + def test_dpi_ng_consent_operations(self): + """Test DPI NG Consent operation values.""" + assert Operation.DPI_NG_CONSENT_CREATE_CLIENT.value == "consent_create_client" + assert Operation.DPI_NG_CONSENT_LIST_CONSENTS.value == "consent_list_consents" + assert Operation.DPI_NG_CONSENT_GET_CONSENT.value == "consent_get_consent" + assert Operation.DPI_NG_CONSENT_DELETE_CONSENT.value == "consent_delete_consent" + assert Operation.DPI_NG_CONSENT_CREATE_CONSENT_FROM_TEMPLATE.value == "consent_create_consent_from_template" + assert Operation.DPI_NG_CONSENT_WITHDRAW_CONSENT.value == "consent_withdraw_consent" + assert Operation.DPI_NG_CONSENT_TERMINATE_CONSENT.value == "consent_terminate_consent" + assert Operation.DPI_NG_CONSENT_CHECK_CONSENT_EXISTS.value == "consent_check_consent_exists" + assert Operation.DPI_NG_CONSENT_LIST_PURPOSES.value == "consent_list_purposes" + assert Operation.DPI_NG_CONSENT_GET_PURPOSE.value == "consent_get_purpose" + assert Operation.DPI_NG_CONSENT_CREATE_PURPOSE.value == "consent_create_purpose" + assert Operation.DPI_NG_CONSENT_UPDATE_PURPOSE.value == "consent_update_purpose" + assert Operation.DPI_NG_CONSENT_DELETE_PURPOSE.value == "consent_delete_purpose" + assert Operation.DPI_NG_CONSENT_SET_PURPOSE_ACTIVE.value == "consent_set_purpose_active" + assert Operation.DPI_NG_CONSENT_SET_PURPOSE_INACTIVE.value == "consent_set_purpose_inactive" + assert Operation.DPI_NG_CONSENT_LIST_PURPOSE_TEXTS.value == "consent_list_purpose_texts" + assert Operation.DPI_NG_CONSENT_GET_PURPOSE_TEXT.value == "consent_get_purpose_text" + assert Operation.DPI_NG_CONSENT_CREATE_PURPOSE_TEXT.value == "consent_create_purpose_text" + assert Operation.DPI_NG_CONSENT_UPDATE_PURPOSE_TEXT.value == "consent_update_purpose_text" + assert Operation.DPI_NG_CONSENT_DELETE_PURPOSE_TEXT.value == "consent_delete_purpose_text" + assert Operation.DPI_NG_CONSENT_LIST_TEMPLATES.value == "consent_list_templates" + assert Operation.DPI_NG_CONSENT_GET_TEMPLATE.value == "consent_get_template" + assert Operation.DPI_NG_CONSENT_CREATE_TEMPLATE.value == "consent_create_template" + assert Operation.DPI_NG_CONSENT_UPDATE_TEMPLATE.value == "consent_update_template" + assert Operation.DPI_NG_CONSENT_DELETE_TEMPLATE.value == "consent_delete_template" + assert Operation.DPI_NG_CONSENT_SET_TEMPLATE_ACTIVE.value == "consent_set_template_active" + assert Operation.DPI_NG_CONSENT_SET_TEMPLATE_INACTIVE.value == "consent_set_template_inactive" + assert Operation.DPI_NG_CONSENT_LIST_TEMPLATE_TEXTS.value == "consent_list_template_texts" + assert Operation.DPI_NG_CONSENT_GET_TEMPLATE_TEXT.value == "consent_get_template_text" + assert Operation.DPI_NG_CONSENT_CREATE_TEMPLATE_TEXT.value == "consent_create_template_text" + assert Operation.DPI_NG_CONSENT_UPDATE_TEMPLATE_TEXT.value == "consent_update_template_text" + assert Operation.DPI_NG_CONSENT_DELETE_TEMPLATE_TEXT.value == "consent_delete_template_text" + assert Operation.DPI_NG_CONSENT_LIST_THIRD_PARTY_PERS_DATA.value == "consent_list_third_party_pers_data" + assert Operation.DPI_NG_CONSENT_GET_THIRD_PARTY_PERS_DATA.value == "consent_get_third_party_pers_data" + assert Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY_PERS_DATA.value == "consent_create_third_party_pers_data" + assert Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY_PERS_DATA.value == "consent_update_third_party_pers_data" + assert Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY_PERS_DATA.value == "consent_delete_third_party_pers_data" + assert Operation.DPI_NG_CONSENT_LIST_RULES.value == "consent_list_rules" + assert Operation.DPI_NG_CONSENT_GET_RULE.value == "consent_get_rule" + assert Operation.DPI_NG_CONSENT_CREATE_RULE.value == "consent_create_rule" + assert Operation.DPI_NG_CONSENT_UPDATE_RULE.value == "consent_update_rule" + assert Operation.DPI_NG_CONSENT_DELETE_RULE.value == "consent_delete_rule" + assert Operation.DPI_NG_CONSENT_SET_RULE_ACTIVE.value == "consent_set_rule_active" + assert Operation.DPI_NG_CONSENT_SET_RULE_INACTIVE.value == "consent_set_rule_inactive" + assert Operation.DPI_NG_CONSENT_LIST_THIRD_PARTIES.value == "consent_list_third_parties" + assert Operation.DPI_NG_CONSENT_GET_THIRD_PARTY.value == "consent_get_third_party" + assert Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY.value == "consent_create_third_party" + assert Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY.value == "consent_update_third_party" + assert Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY.value == "consent_delete_third_party" + assert Operation.DPI_NG_CONSENT_LIST_JURISDICTIONS.value == "consent_list_jurisdictions" + assert Operation.DPI_NG_CONSENT_GET_JURISDICTION.value == "consent_get_jurisdiction" + assert Operation.DPI_NG_CONSENT_CREATE_JURISDICTION.value == "consent_create_jurisdiction" + assert Operation.DPI_NG_CONSENT_UPDATE_JURISDICTION.value == "consent_update_jurisdiction" + assert Operation.DPI_NG_CONSENT_DELETE_JURISDICTION.value == "consent_delete_jurisdiction" + assert Operation.DPI_NG_CONSENT_LIST_JURISDICTION_TEXTS.value == "consent_list_jurisdiction_texts" + assert Operation.DPI_NG_CONSENT_CREATE_JURISDICTION_TEXT.value == "consent_create_jurisdiction_text" + assert Operation.DPI_NG_CONSENT_UPDATE_JURISDICTION_TEXT.value == "consent_update_jurisdiction_text" + assert Operation.DPI_NG_CONSENT_DELETE_JURISDICTION_TEXT.value == "consent_delete_jurisdiction_text" + assert Operation.DPI_NG_CONSENT_LIST_LANGUAGES.value == "consent_list_languages" + assert Operation.DPI_NG_CONSENT_GET_LANGUAGE.value == "consent_get_language" + assert Operation.DPI_NG_CONSENT_LIST_LANGUAGE_DESCRIPTIONS.value == "consent_list_language_descriptions" + assert Operation.DPI_NG_CONSENT_CREATE_LANGUAGE_DESCRIPTION.value == "consent_create_language_description" + assert Operation.DPI_NG_CONSENT_UPDATE_LANGUAGE_DESCRIPTION.value == "consent_update_language_description" + assert Operation.DPI_NG_CONSENT_DELETE_LANGUAGE_DESCRIPTION.value == "consent_delete_language_description" + assert Operation.DPI_NG_CONSENT_LIST_SOURCE_INFOS.value == "consent_list_source_infos" + assert Operation.DPI_NG_CONSENT_GET_SOURCE_INFO.value == "consent_get_source_info" + assert Operation.DPI_NG_CONSENT_CREATE_SOURCE_INFO.value == "consent_create_source_info" + assert Operation.DPI_NG_CONSENT_UPDATE_SOURCE_INFO.value == "consent_update_source_info" + assert Operation.DPI_NG_CONSENT_DELETE_SOURCE_INFO.value == "consent_delete_source_info" + assert Operation.DPI_NG_CONSENT_LIST_CONTROLLERS.value == "consent_list_controllers" + assert Operation.DPI_NG_CONSENT_GET_CONTROLLER.value == "consent_get_controller" + assert Operation.DPI_NG_CONSENT_CREATE_CONTROLLER.value == "consent_create_controller" + assert Operation.DPI_NG_CONSENT_UPDATE_CONTROLLER.value == "consent_update_controller" + assert Operation.DPI_NG_CONSENT_DELETE_CONTROLLER.value == "consent_delete_controller" + assert Operation.DPI_NG_CONSENT_LIST_DATA_SUBJECT_TYPES.value == "consent_list_data_subject_types" + assert Operation.DPI_NG_CONSENT_GET_DATA_SUBJECT_TYPE.value == "consent_get_data_subject_type" + assert Operation.DPI_NG_CONSENT_CREATE_DATA_SUBJECT_TYPE.value == "consent_create_data_subject_type" + assert Operation.DPI_NG_CONSENT_UPDATE_DATA_SUBJECT_TYPE.value == "consent_update_data_subject_type" + assert Operation.DPI_NG_CONSENT_DELETE_DATA_SUBJECT_TYPE.value == "consent_delete_data_subject_type" + assert Operation.DPI_NG_CONSENT_LIST_APPLICATIONS.value == "consent_list_applications" + assert Operation.DPI_NG_CONSENT_GET_APPLICATION.value == "consent_get_application" + assert Operation.DPI_NG_CONSENT_CREATE_APPLICATION.value == "consent_create_application" + assert Operation.DPI_NG_CONSENT_UPDATE_APPLICATION.value == "consent_update_application" + assert Operation.DPI_NG_CONSENT_DELETE_APPLICATION.value == "consent_delete_application" + assert Operation.DPI_NG_CONSENT_LIST_MASTER_DATA_SOURCES.value == "consent_list_master_data_sources" + assert Operation.DPI_NG_CONSENT_GET_MASTER_DATA_SOURCE.value == "consent_get_master_data_source" + assert Operation.DPI_NG_CONSENT_CREATE_MASTER_DATA_SOURCE.value == "consent_create_master_data_source" + assert Operation.DPI_NG_CONSENT_UPDATE_MASTER_DATA_SOURCE.value == "consent_update_master_data_source" + assert Operation.DPI_NG_CONSENT_DELETE_MASTER_DATA_SOURCE.value == "consent_delete_master_data_source" + assert Operation.DPI_NG_CONSENT_LIST_OUTBOUND_CHANNEL_TYPES.value == "consent_list_outbound_channel_types" + assert Operation.DPI_NG_CONSENT_GET_OUTBOUND_CHANNEL_TYPE.value == "consent_get_outbound_channel_type" + assert Operation.DPI_NG_CONSENT_CREATE_OUTBOUND_CHANNEL_TYPE.value == "consent_create_outbound_channel_type" + assert Operation.DPI_NG_CONSENT_UPDATE_OUTBOUND_CHANNEL_TYPE.value == "consent_update_outbound_channel_type" + assert Operation.DPI_NG_CONSENT_DELETE_OUTBOUND_CHANNEL_TYPE.value == "consent_delete_outbound_channel_type" + def test_operation_str_representation(self): """Test that Operation enum converts to string correctly.""" assert str(Operation.AUDITLOG_LOG) == "log" @@ -170,6 +267,8 @@ def test_operation_str_representation(self): assert str(Operation.DESTINATION_GET_INSTANCE_DESTINATION) == "get_instance_destination" assert str(Operation.OBJECTSTORE_PUT_OBJECT) == "put_object" assert str(Operation.AICORE_AUTO_INSTRUMENT) == "auto_instrument" + assert str(Operation.DPI_NG_CONSENT_CREATE_CLIENT) == "consent_create_client" + assert str(Operation.DPI_NG_CONSENT_LIST_CONSENTS) == "consent_list_consents" def test_operation_is_string_enum(self): """Test that Operation enum inherits from str.""" @@ -177,6 +276,7 @@ def test_operation_is_string_enum(self): assert isinstance(Operation.DATA_ANONYMIZATION_ANONYMIZE_TEXT, str) assert isinstance(Operation.DESTINATION_CREATE_DESTINATION, str) assert isinstance(Operation.OBJECTSTORE_GET_OBJECT, str) + assert isinstance(Operation.DPI_NG_CONSENT_CREATE_CLIENT, str) def test_operation_equality(self): """Test Operation enum equality comparisons.""" @@ -210,11 +310,12 @@ def test_operation_iteration(self): assert any("OBJECTSTORE" in op.name for op in all_operations) assert any("AICORE" in op.name for op in all_operations) assert any("PRINT" in op.name for op in all_operations) + assert any("DPI_NG" in op.name for op in all_operations) def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 4 aicore + 23 dms + 6 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print = 154 - assert len(all_operations) == 154 + # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng = 248 + assert len(all_operations) == 248 From f1d66193b1efe4f30275e2e14daf7df4d5b1a9f3 Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 14:53:04 +0530 Subject: [PATCH 05/27] feat(dpi_ng/consent): add DPI_NG_CONSENT_* env vars for integration tests --- .env_integration_tests.example | 19 +++++++++++++++++++ .../integration/dpi_ng/consent/conftest.py | 18 +++++++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.env_integration_tests.example b/.env_integration_tests.example index e8c7a779..77193d28 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -57,6 +57,25 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_TENANT_SUBDOMAIN=your-subscriber-tenant-subdom CLOUD_SDK_CFG_SDM_DEFAULT_URI=https://your-sdm-api-uri-here CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone"}' +# DPI NG - CONSENT +# Required for all consent integration tests +DPI_NG_CONSENT_BASE_URL=https://your-consent-service-host + +# Pick ONE of the three auth methods below: + +# Option A: Static Bearer Token +DPI_NG_CONSENT_BEARER_TOKEN=your-bearer-token-here + +# Option B: OAuth 2.0 Client Credentials +DPI_NG_CONSENT_TOKEN_URL=https://your-auth-host/oauth/token +DPI_NG_CONSENT_CLIENT_ID=your-client-id +DPI_NG_CONSENT_CLIENT_SECRET=your-client-secret + +# Option C: Mutual TLS (mTLS) +DPI_NG_CONSENT_CERT_FILE=/path/to/client.crt +DPI_NG_CONSENT_KEY_FILE=/path/to/client.key +DPI_NG_CONSENT_CA_FILE=/path/to/ca.crt # optional + # OBJECT STORE CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_HOST=your-objectstore-host-here CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_ACCESS_KEY_ID=your-access-key-id-here diff --git a/tests/core/integration/dpi_ng/consent/conftest.py b/tests/core/integration/dpi_ng/consent/conftest.py index 3e54fb8b..5a4c8162 100644 --- a/tests/core/integration/dpi_ng/consent/conftest.py +++ b/tests/core/integration/dpi_ng/consent/conftest.py @@ -25,17 +25,17 @@ def _resolve_auth() -> AuthProvider | None: - if token := os.getenv("CONSENT_BEARER_TOKEN"): + if token := os.getenv("DPI_NG_CONSENT_BEARER_TOKEN"): return BearerTokenAuth(token) - token_url = os.getenv("CONSENT_TOKEN_URL") - client_id = os.getenv("CONSENT_CLIENT_ID") - client_secret = os.getenv("CONSENT_CLIENT_SECRET") + token_url = os.getenv("DPI_NG_CONSENT_TOKEN_URL") + client_id = os.getenv("DPI_NG_CONSENT_CLIENT_ID") + client_secret = os.getenv("DPI_NG_CONSENT_CLIENT_SECRET") if token_url and client_id and client_secret: return ClientCredentialsAuth(token_url=token_url, client_id=client_id, client_secret=client_secret) - cert_file = os.getenv("CONSENT_CERT_FILE") - key_file = os.getenv("CONSENT_KEY_FILE") + cert_file = os.getenv("DPI_NG_CONSENT_CERT_FILE") + key_file = os.getenv("DPI_NG_CONSENT_KEY_FILE") if cert_file and key_file: - return ClientCertificateAuth(cert_file=cert_file, key_file=key_file, ca_file=os.getenv("CONSENT_CA_FILE")) + return ClientCertificateAuth(cert_file=cert_file, key_file=key_file, ca_file=os.getenv("DPI_NG_CONSENT_CA_FILE")) return None @@ -53,10 +53,10 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item def live_client() -> Iterator[ConsentClient]: if _ENV_FILE.exists(): load_dotenv(_ENV_FILE, override=True) - base_url = os.getenv("CONSENT_BASE_URL", "") + base_url = os.getenv("DPI_NG_CONSENT_BASE_URL", "") auth = _resolve_auth() if not base_url or auth is None: - pytest.skip("No integration credentials in .env — set CONSENT_BASE_URL plus one auth flow") + pytest.skip("No integration credentials in .env — set DPI_NG_CONSENT_BASE_URL plus one auth flow") with create_client(base_url=base_url, auth=auth) as client: yield client From 4748469086ccf604b22bffbad5b47afd209d7bc9 Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 15:02:20 +0530 Subject: [PATCH 06/27] docs(dpi_ng/consent): add integration test setup guide to INTEGRATION_TESTS.md --- docs/INTEGRATION_TESTS.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index 133247f4..a6f76128 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -141,6 +141,41 @@ CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-c **Note**: The test fixture automatically onboards test repositories (standard and version-enabled) at session start and cleans them up on teardown. No pre-existing repositories are required. +### DPI NG Consent Integration Tests + +For DPI NG Consent integration tests, configure the following variables in `.env_integration_tests`: + +```bash +# DPI NG Consent Configuration +DPI_NG_CONSENT_BASE_URL=https://your-consent-service-host +``` + +Authentication is configurable via one of three methods: + +**Option A — Static Bearer Token** + +```bash +DPI_NG_CONSENT_BEARER_TOKEN=your-bearer-token-here +``` + +**Option B — OAuth 2.0 Client Credentials** + +```bash +DPI_NG_CONSENT_TOKEN_URL=https://your-auth-host/oauth/token +DPI_NG_CONSENT_CLIENT_ID=your-client-id +DPI_NG_CONSENT_CLIENT_SECRET=your-client-secret +``` + +**Option C — Mutual TLS (mTLS)** + +```bash +DPI_NG_CONSENT_CERT_FILE=/path/to/client.crt +DPI_NG_CONSENT_KEY_FILE=/path/to/client.key +DPI_NG_CONSENT_CA_FILE=/path/to/ca.crt # optional +``` + +Tests are skipped automatically when `DPI_NG_CONSENT_BASE_URL` or all auth variables are missing. + ### ObjectStore Integration Tests For ObjectStore integration tests, configure the following variables in `.env_integration_tests`: @@ -168,6 +203,7 @@ uv run pytest tests/core/integration/auditlog -v uv run pytest tests/core/integration/data_anonymization -v uv run pytest tests/destination/integration/ -v uv run pytest tests/dms/integration/ -v +uv run pytest tests/core/integration/dpi_ng/consent/ -v uv run pytest tests/objectstore/integration/ -v ``` From 41921ab5daa68b157742fb49d12f51dadae0c812 Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 15:12:22 +0530 Subject: [PATCH 07/27] chore(dpi_ng/consent): align integration test env vars to CLOUD_SDK_CFG_* convention --- .env_integration_tests.example | 18 ++++++++---------- docs/INTEGRATION_TESTS.md | 18 +++++++++--------- .../integration/dpi_ng/consent/conftest.py | 18 +++++++++--------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/.env_integration_tests.example b/.env_integration_tests.example index 77193d28..18e614b2 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -59,22 +59,20 @@ CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-c # DPI NG - CONSENT # Required for all consent integration tests -DPI_NG_CONSENT_BASE_URL=https://your-consent-service-host +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL=https://your-consent-service-host # Pick ONE of the three auth methods below: # Option A: Static Bearer Token -DPI_NG_CONSENT_BEARER_TOKEN=your-bearer-token-here - +# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN=your-bearer-token-here # Option B: OAuth 2.0 Client Credentials -DPI_NG_CONSENT_TOKEN_URL=https://your-auth-host/oauth/token -DPI_NG_CONSENT_CLIENT_ID=your-client-id -DPI_NG_CONSENT_CLIENT_SECRET=your-client-secret - +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL=https://your-auth-host/oauth/token +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID=your-client-id +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET=your-client-secret # Option C: Mutual TLS (mTLS) -DPI_NG_CONSENT_CERT_FILE=/path/to/client.crt -DPI_NG_CONSENT_KEY_FILE=/path/to/client.key -DPI_NG_CONSENT_CA_FILE=/path/to/ca.crt # optional +# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE=/path/to/client.crt +# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE=/path/to/client.key +# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE=/path/to/ca.crt # optional # OBJECT STORE CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_HOST=your-objectstore-host-here diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index a6f76128..779d9e79 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -147,7 +147,7 @@ For DPI NG Consent integration tests, configure the following variables in `.env ```bash # DPI NG Consent Configuration -DPI_NG_CONSENT_BASE_URL=https://your-consent-service-host +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL=https://your-consent-service-host ``` Authentication is configurable via one of three methods: @@ -155,26 +155,26 @@ Authentication is configurable via one of three methods: **Option A — Static Bearer Token** ```bash -DPI_NG_CONSENT_BEARER_TOKEN=your-bearer-token-here +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN=your-bearer-token-here ``` **Option B — OAuth 2.0 Client Credentials** ```bash -DPI_NG_CONSENT_TOKEN_URL=https://your-auth-host/oauth/token -DPI_NG_CONSENT_CLIENT_ID=your-client-id -DPI_NG_CONSENT_CLIENT_SECRET=your-client-secret +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL=https://your-auth-host/oauth/token +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID=your-client-id +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET=your-client-secret ``` **Option C — Mutual TLS (mTLS)** ```bash -DPI_NG_CONSENT_CERT_FILE=/path/to/client.crt -DPI_NG_CONSENT_KEY_FILE=/path/to/client.key -DPI_NG_CONSENT_CA_FILE=/path/to/ca.crt # optional +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE=/path/to/client.crt +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE=/path/to/client.key +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE=/path/to/ca.crt # optional ``` -Tests are skipped automatically when `DPI_NG_CONSENT_BASE_URL` or all auth variables are missing. +Tests are skipped automatically when `CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL` or all auth variables are missing. ### ObjectStore Integration Tests diff --git a/tests/core/integration/dpi_ng/consent/conftest.py b/tests/core/integration/dpi_ng/consent/conftest.py index 5a4c8162..adce8f2c 100644 --- a/tests/core/integration/dpi_ng/consent/conftest.py +++ b/tests/core/integration/dpi_ng/consent/conftest.py @@ -25,17 +25,17 @@ def _resolve_auth() -> AuthProvider | None: - if token := os.getenv("DPI_NG_CONSENT_BEARER_TOKEN"): + if token := os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN"): return BearerTokenAuth(token) - token_url = os.getenv("DPI_NG_CONSENT_TOKEN_URL") - client_id = os.getenv("DPI_NG_CONSENT_CLIENT_ID") - client_secret = os.getenv("DPI_NG_CONSENT_CLIENT_SECRET") + token_url = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL") + client_id = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID") + client_secret = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET") if token_url and client_id and client_secret: return ClientCredentialsAuth(token_url=token_url, client_id=client_id, client_secret=client_secret) - cert_file = os.getenv("DPI_NG_CONSENT_CERT_FILE") - key_file = os.getenv("DPI_NG_CONSENT_KEY_FILE") + cert_file = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE") + key_file = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE") if cert_file and key_file: - return ClientCertificateAuth(cert_file=cert_file, key_file=key_file, ca_file=os.getenv("DPI_NG_CONSENT_CA_FILE")) + return ClientCertificateAuth(cert_file=cert_file, key_file=key_file, ca_file=os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE")) return None @@ -53,10 +53,10 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item def live_client() -> Iterator[ConsentClient]: if _ENV_FILE.exists(): load_dotenv(_ENV_FILE, override=True) - base_url = os.getenv("DPI_NG_CONSENT_BASE_URL", "") + base_url = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL", "") auth = _resolve_auth() if not base_url or auth is None: - pytest.skip("No integration credentials in .env — set DPI_NG_CONSENT_BASE_URL plus one auth flow") + pytest.skip("No integration credentials in .env — set CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL plus one auth flow") with create_client(base_url=base_url, auth=auth) as client: yield client From 69a353d7217d1b1c6cdefc6525c97c0bc2f52977 Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 15:15:33 +0530 Subject: [PATCH 08/27] docs(dpi_ng/consent): replace em dashes with hyphens in integration test auth option headings --- docs/INTEGRATION_TESTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index 779d9e79..a2266bef 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -152,13 +152,13 @@ CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL=https://your-consent-service-host Authentication is configurable via one of three methods: -**Option A — Static Bearer Token** +**Option A - Static Bearer Token** ```bash CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN=your-bearer-token-here ``` -**Option B — OAuth 2.0 Client Credentials** +**Option B - OAuth 2.0 Client Credentials** ```bash CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL=https://your-auth-host/oauth/token @@ -166,7 +166,7 @@ CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID=your-client-id CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET=your-client-secret ``` -**Option C — Mutual TLS (mTLS)** +**Option C - Mutual TLS (mTLS)** ```bash CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE=/path/to/client.crt From ea0c2b181c61a474c337d8347e8ca20adf84386a Mon Sep 17 00:00:00 2001 From: N Date: Wed, 24 Jun 2026 15:26:45 +0530 Subject: [PATCH 09/27] docs(dpi_ng/consent): rewrite user guide with full API examples --- .../core/dpi_ng/consent/user-guide.md | 867 ++++++++++++++++-- 1 file changed, 766 insertions(+), 101 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md index 9b133c59..e814b261 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -1,45 +1,98 @@ -# Consent SDK - User Guide +# Consent User Guide + +This module provides a unified API for managing consents, purposes, templates, +retention rules, and configuration reference data exposed by the DPI V2 Consent +Repository OData service. The module contains a factory function, typed request +and result dataclasses, an OData transport abstraction, and operation-level +telemetry so developers can focus on business logic while satisfying data-privacy +requirements. Telemetry is limited to aggregate operation metrics and excludes +request and response payloads. ## Installation +The consent module is part of the SAP Cloud SDK for Python and is available +automatically once the SDK is installed. + ```bash pip install sap-cloud-sdk ``` -Requires Python 3.11+. The SDK depends on [python-odata](https://pypi.org/project/python-odata/) for entity modelling and OData query building. +Requires Python 3.11+. ---- +## Import + +Import what you need explicitly: + +```python +from sap_cloud_sdk.core.dpi_ng.consent import ( + create_client, + ConsentSDKConfig, + ClientCredentialsAuth, + CreateConsentRequest, + WithdrawConsentRequest, + CheckConsentExistsResult, +) +``` + +Or use a star import for convenience: + +```python +from sap_cloud_sdk.core.dpi_ng.consent import * +``` + +## Quick Start -## Quick start +Pass a `ConsentSDKConfig` with `ClientCredentialsAuth` when OAuth2 client +credentials are available. The auth provider fetches and refreshes bearer tokens +automatically. ```python -from sap_cloud_sdk.core.dpi_ng.consent import create_client, BearerTokenAuth +from sap_cloud_sdk.core.dpi_ng.consent import ( + create_client, + ConsentSDKConfig, + ClientCredentialsAuth, +) -with create_client( +config = ConsentSDKConfig( base_url="https://", - auth=BearerTokenAuth(""), -) as client: - # List consents with an OData filter - consents = client.consents.list_consents(filter="lifecycle_status_code eq '1'") + auth=ClientCredentialsAuth( + token_url="https:///oauth/token", + client_id="", + client_secret="", + ), +) + +with create_client(config=config) as client: + consents = client.consents.list_consents(filter="lifecycleStatusCode eq '1'") for c in consents: print(c.consent_id, c.lifecycle_status_code) ``` ---- - ## Authentication -Pass one of the built-in `AuthProvider` implementations to `create_client`: +Pass a `ClientCredentialsAuth` instance to `ConsentSDKConfig`. The provider +performs the OAuth2 `client_credentials` flow against the given token URL and +refreshes the token transparently 60 seconds before it expires. -| Class | When to use | -|---|---| -| `BearerTokenAuth(token)` | You hold a pre-fetched token and manage refresh yourself | -| `ClientCredentialsAuth(token_url, client_id, client_secret)` | OAuth2 client credentials; token is fetched and refreshed automatically | -| `ClientCertificateAuth(cert_file, key_file, ca_file=None)` | Mutual TLS with a client certificate and key | +```python +from sap_cloud_sdk.core.dpi_ng.consent import ( + ConsentSDKConfig, + ClientCredentialsAuth, +) -Custom auth providers implement `AuthProvider.apply(session: requests.Session)`. +config = ConsentSDKConfig( + base_url="https://", + auth=ClientCredentialsAuth( + token_url="https:///oauth/token", + client_id="", + client_secret="", + ), +) +``` ---- +All three fields - `token_url`, `client_id`, and `client_secret` - are required. +Passing an empty string for any of them raises a `ValueError` during +construction. ## Client structure @@ -48,136 +101,706 @@ Custom auth providers implement `AuthProvider.apply(session: requests.Session)`. | Attribute | OData service | Purpose | |---|---|---| | `client.consents` | `consentServices` | Consent creation, withdrawal, termination, reads | -| `client.purposes` | `consentPurposeExternalServices` | Purpose CRUD and lifecycle | -| `client.templates` | `consentTemplateExternalServices` | Template CRUD, lifecycle, third-party assignment | +| `client.purposes` | `consentPurposeExternalServices` | Purpose CRUD, lifecycle, and purpose texts | +| `client.templates` | `consentTemplateExternalServices` | Template CRUD, lifecycle, template texts, third-party data | | `client.retention` | `consentRetentionExternalServices` | Retention rule CRUD and lifecycle | -| `client.configuration` | `consentConfigurationExternalServices` | Reference data (controllers, applications, jurisdictions, ...) | +| `client.configuration` | `consentConfigurationExternalServices` | Reference data CRUD (controllers, applications, jurisdictions, ...) | + +## Operations + +All `list_*` methods accept OData query kwargs: + +| Kwarg | OData option | Example | +|---|---|---| +| `filter` | `$filter` | `filter="lifecycleStatusCode eq '2'"` | +| `top` | `$top` | `top=10` | +| `skip` | `$skip` | `skip=20` | +| `orderby` | `$orderby` | `orderby="changedAt desc"` | + +### Consents (`client.consents`) + +#### List consents + +```python +consents = client.consents.list_consents( + filter="lifecycleStatusCode eq '1'", + top=50, +) +for c in consents: + print(c.consent_id, c.lifecycle_status_code) +``` + +#### Get a consent by ID + +```python +consent = client.consents.get_consent("3fa85f64-5717-4562-b3fc-2c963f66afa6") +print(consent.consent_id, consent.data_subject_id) +``` + +#### Delete a consent + +```python +client.consents.delete_consent("3fa85f64-5717-4562-b3fc-2c963f66afa6") +``` + +#### Create a consent from a template + +```python +from sap_cloud_sdk.core.dpi_ng.consent import CreateConsentRequest + +request = CreateConsentRequest( + data_subject_id="user@example.com", + template_name="", + language_code="EN", + data_subject_type_name="", + jurisdiction_code="", +) +consents = client.consents.create_consent_from_template(request) +for c in consents: + print(c.consent_id) +``` + +**Required fields:** +- `data_subject_id` - identifier of the data subject. +- `template_name` - name of the consent template to use. +- `language_code` - language code for the consent text (e.g. `"EN"`). +- `data_subject_type_name` - type classification for the data subject. +- `jurisdiction_code` - jurisdiction under which the consent is recorded. + +**Optional fields:** +- `data_subject_description` - human-readable description of the data subject. +- `outbound_channel_type_name` - outbound channel type name. +- `outbound_channel` - outbound channel identifier. +- `valid_from` - ISO 8601 date from which the consent is valid. +- `application_template_id` - application-specific template reference. +- `controller_name` - name of the data controller. +- `granted_by` - actor who granted the consent. +- `granted_at` - ISO 8601 timestamp of consent grant. +- `submission_site` - site or URL where the consent was submitted. + +#### Withdraw a consent + +```python +from sap_cloud_sdk.core.dpi_ng.consent import WithdrawConsentRequest + +client.consents.withdraw_consent( + WithdrawConsentRequest( + consent_id="3fa85f64-5717-4562-b3fc-2c963f66afa6", + withdrawn_by="user@example.com", + ) +) +``` + +#### Terminate a consent + +```python +client.consents.terminate_consent( + WithdrawConsentRequest( + consent_id="3fa85f64-5717-4562-b3fc-2c963f66afa6", + withdrawn_by="contract-end-process", + ) +) +``` + +`WithdrawConsentRequest.withdrawn_at` is optional - omit it to let the service +record the current timestamp. + +#### Check whether a consent exists + +```python +result = client.consents.check_consent_exists( + data_subject_id="user@example.com", + template_id="", +) +if result.consent_exists: + print("Consent found:", result.consent_id) +``` + +`check_consent_exists` returns a `CheckConsentExistsResult` with two fields: +`consent_exists` (`bool | None`) and `consent_id` (`str | None`). --- -## Entity model (python-odata) +### Purposes (`client.purposes`) -Entity classes are defined using [python-odata](https://pypi.org/project/python-odata/) descriptors and live under `entities/`. Each `make_entities(Service)` factory binds the classes to a specific `ODataService` instance so that query and save operations go to the right endpoint. +#### List purposes -### Property types +```python +purposes = client.purposes.list_purposes( + filter="lifecycleStatusCode eq '2'", + top=25, +) +for p in purposes: + print(p.purpose_id, p.purpose_name) +``` -| Descriptor | OData type | Python type | -|---|---|---| -| `StringProperty` | `Edm.String` | `str` | -| `UUIDProperty` | `Edm.Guid` | `uuid.UUID` / `str` | -| `BooleanProperty` | `Edm.Boolean` | `bool` | -| `DatetimeProperty` | `Edm.DateTimeOffset` | `datetime` | -| `IntegerProperty` | `Edm.Int32` | `int` | +#### Get a purpose by ID + +```python +purpose = client.purposes.get_purpose("") +print(purpose.purpose_name, purpose.sensitive_data_flag) +``` + +#### Create a purpose + +```python +purpose = client.purposes.create_purpose({ + "purpose_name": "Marketing Emails", + "sensitive_data_flag": False, +}) +print(purpose.purpose_id) +``` + +#### Update a purpose + +```python +purpose = client.purposes.update_purpose( + "", + {"purpose_name": "Marketing Emails - Updated"}, +) +``` + +#### Delete a purpose + +```python +client.purposes.delete_purpose("") +``` + +#### Set a purpose active / inactive + +```python +client.purposes.set_purpose_active("") +client.purposes.set_purpose_inactive("") +``` -Properties declared with `primary_key=True` form the entity key used by `GET` and `DELETE` requests. +Both methods return the refreshed entity after invoking the lifecycle action. + +#### List purpose texts + +```python +texts = client.purposes.list_purpose_texts( + filter="purposeId eq ''" +) +``` + +#### Get a purpose text + +Purpose texts have a composite key of `purpose_id`, `type_code`, and +`language_code`. + +```python +text = client.purposes.get_purpose_text( + purpose_id="", + type_code="SHORT", + language_code="EN", +) +print(text.text_value) +``` + +#### Create a purpose text + +```python +text = client.purposes.create_purpose_text({ + "purpose_id": "", + "type_code": "SHORT", + "language_code": "EN", + "text_value": "We use your email to send marketing updates.", +}) +``` + +#### Update a purpose text + +```python +text = client.purposes.update_purpose_text( + purpose_id="", + type_code="SHORT", + language_code="EN", + body={"text_value": "Updated marketing email description."}, +) +``` -### Reading a field +#### Delete a purpose text ```python -purpose = client.purposes.list_purposes()[0] -print(purpose.purpose_id) # uuid.UUID -print(purpose.purpose_name) # str -print(purpose.sensitive_data_flag) # bool +client.purposes.delete_purpose_text( + purpose_id="", + type_code="SHORT", + language_code="EN", +) ``` --- -## Querying +### Templates (`client.templates`) -All `list_*` methods accept OData query kwargs: +#### List templates -| Kwarg | OData option | Example | -|---|---|---| -| `filter` | `$filter` | `filter="lifecycle_status_code eq '2'"` | -| `top` | `$top` | `top=10` | -| `skip` | `$skip` | `skip=20` | -| `orderby` | `$orderby` | `orderby="changed_at desc"` | +```python +templates = client.templates.list_templates( + filter="lifecycleStatusCode eq '2'" +) +``` + +#### Get a template by ID ```python -# First page of active purposes -active = client.purposes.list_purposes(filter="lifecycle_status_code eq '2'", top=50) +template = client.templates.get_template("") +print(template.template_name) +``` -# A single consent by UUID -consent = client.consents.get_consent("3fa85f64-5717-4562-b3fc-2c963f66afa6") +#### Create a template + +```python +template = client.templates.create_template({ + "template_name": "GDPR-Marketing-2024", + "jurisdiction_code": "EU", +}) +print(template.template_id) +``` + +#### Update a template + +```python +template = client.templates.update_template( + "", + {"template_name": "GDPR-Marketing-2025"}, +) +``` + +#### Delete a template + +```python +client.templates.delete_template("") +``` + +#### Set a template active / inactive + +```python +client.templates.set_template_active("") +client.templates.set_template_inactive("") +``` + +#### List template texts + +```python +texts = client.templates.list_template_texts( + filter="templateId eq ''" +) +``` + +#### Get a template text + +Template texts have a composite key of `template_id`, `type_code`, and +`language_code`. + +```python +text = client.templates.get_template_text( + template_id="", + type_code="LONG", + language_code="EN", +) +``` + +#### Create a template text + +```python +text = client.templates.create_template_text({ + "template_id": "", + "type_code": "LONG", + "language_code": "EN", + "text_value": "Full consent statement in English...", +}) +``` + +#### Update a template text + +```python +text = client.templates.update_template_text( + template_id="", + type_code="LONG", + language_code="EN", + body={"text_value": "Revised consent statement."}, +) +``` + +#### Delete a template text + +```python +client.templates.delete_template_text( + template_id="", + type_code="LONG", + language_code="EN", +) +``` + +#### List third-party personal data assignments + +```python +records = client.templates.list_third_party_pers_data( + filter="templateId eq ''" +) +``` + +#### Get a third-party personal data record + +The composite key is `template_id` and `third_party_id`. + +```python +record = client.templates.get_third_party_pers_data( + template_id="", + third_party_id="", +) +``` + +#### Create a third-party personal data record + +```python +record = client.templates.create_third_party_pers_data({ + "template_id": "", + "third_party_id": "", +}) +``` + +#### Update a third-party personal data record + +```python +record = client.templates.update_third_party_pers_data( + template_id="", + third_party_id="", + body={"description": "Updated description"}, +) +``` + +#### Delete a third-party personal data record + +```python +client.templates.delete_third_party_pers_data( + template_id="", + third_party_id="", +) ``` --- -## Creating and saving entities +### Retention rules (`client.retention`) -Entities are created by instantiating the class, setting attributes, and calling `save` (which issues a `POST` for new entities and a `PATCH` for dirty existing entities). +#### List retention rules ```python -# Create a new controller via configuration service -ctrl = client.configuration.create_controller({ - "controller_name": "AB Corp", - "description": "Main data controller", +rules = client.retention.list_rules( + filter="lifecycleStatusCode eq '2'" +) +``` + +#### Get a retention rule by ID + +```python +rule = client.retention.get_rule("") +print(rule.retention_period) +``` + +#### Create a retention rule + +```python +rule = client.retention.create_rule({ + "rule_name": "7-year-financial", + "retention_period": 84, }) -print(ctrl.controller_id) # UUID assigned by the service +print(rule.rule_id) +``` + +#### Update a retention rule + +```python +rule = client.retention.update_rule( + "", + {"retention_period": 96}, +) ``` -Internally the service calls `ODataClient.save(entity)`, which delegates to `entity.__odata_service__.save(entity)`. +#### Delete a retention rule + +```python +client.retention.delete_rule("") +``` + +#### Set a retention rule active / inactive + +```python +client.retention.set_rule_active("") +client.retention.set_rule_inactive("") +``` --- -## OData actions +### Configuration reference data (`client.configuration`) -Actions that are not standard CRUD are called via `ODataClient.call_action` and are exposed as named methods. +The configuration service manages all reference data that other entities reference +by ID or code. -### Consent creation from a template +#### Third parties ```python -from sap_cloud_sdk.core.dpi_ng.consent import CreateConsentRequest +# List +third_parties = client.configuration.list_third_parties() -request = CreateConsentRequest( - data_subject_id="user@example.com", - template_name="", +# Get +tp = client.configuration.get_third_party("") + +# Create +tp = client.configuration.create_third_party({ + "third_party_name": "Analytics Corp", + "description": "Third-party analytics provider", +}) +print(tp.third_party_id) + +# Update +tp = client.configuration.update_third_party( + "", + {"description": "Updated analytics provider description"}, +) + +# Delete +client.configuration.delete_third_party("") +``` + +#### Jurisdictions + +```python +# List +jurisdictions = client.configuration.list_jurisdictions() + +# Get +jurisdiction = client.configuration.get_jurisdiction("") + +# Create +jurisdiction = client.configuration.create_jurisdiction({ + "jurisdiction_code": "EU", + "jurisdiction_name": "European Union", +}) + +# Update +jurisdiction = client.configuration.update_jurisdiction( + "", + {"jurisdiction_name": "EU - GDPR"}, +) + +# Delete +client.configuration.delete_jurisdiction("") +``` + +#### Jurisdiction texts + +Jurisdiction texts have a composite key of `jurisdiction_id` and `language_code`. + +```python +# List +texts = client.configuration.list_jurisdiction_texts() + +# Create +text = client.configuration.create_jurisdiction_text({ + "jurisdiction_id": "", + "language_code": "EN", + "text_value": "European Union General Data Protection Regulation", +}) + +# Update +text = client.configuration.update_jurisdiction_text( + jurisdiction_id="", + language_code="EN", + body={"text_value": "EU GDPR - Revised"}, +) + +# Delete +client.configuration.delete_jurisdiction_text( + jurisdiction_id="", language_code="EN", - data_subject_type_name="", - jurisdiction_code="", ) -consents = client.consents.create_consent_from_template(request) ``` -### Async consent creation +#### Languages ```python -result = client.consents.create_consent_from_template_async(request) -# result.request_id, result.status +# List +languages = client.configuration.list_languages() -# Poll until done -status = client.consents.get_async_consent_status(result.request_id) +# Get +language = client.configuration.get_language("EN") +print(language.language_code) ``` -### Withdraw / terminate +Languages are read-only reference data - create and delete are not exposed. + +#### Language descriptions ```python -from sap_cloud_sdk.core.dpi_ng.consent import WithdrawConsentRequest +# List +descriptions = client.configuration.list_language_descriptions() -client.consents.withdraw_consent( - WithdrawConsentRequest(consent_id="", withdrawn_by="User request") +# Create +desc = client.configuration.create_language_description({ + "language_code": "EN", + "description": "English", +}) + +# Update +desc = client.configuration.update_language_description( + language_code="EN", + body={"description": "English (Updated)"}, ) -client.consents.terminate_consent( - WithdrawConsentRequest(consent_id="", withdrawn_by="Contract end") + +# Delete +client.configuration.delete_language_description("EN") +``` + +#### Source infos + +```python +# List +sources = client.configuration.list_source_infos() + +# Get +source = client.configuration.get_source_info("") + +# Create +source = client.configuration.create_source_info({ + "source_name": "CRM System", + "description": "Customer relationship management platform", +}) + +# Update +source = client.configuration.update_source_info( + "", + {"description": "CRM - Updated"}, ) + +# Delete +client.configuration.delete_source_info("") ``` -### Lifecycle transitions (purposes, templates, retention) +#### Controllers ```python -client.purposes.set_purpose_active(purpose_id) -client.purposes.set_purpose_inactive(purpose_id) +# List +controllers = client.configuration.list_controllers() -client.templates.set_template_active(template_id) -client.templates.set_template_inactive(template_id) +# Get +controller = client.configuration.get_controller("") -client.retention.set_rule_active(rule_id) -client.retention.set_rule_inactive(rule_id) +# Create +controller = client.configuration.create_controller({ + "controller_name": "AB Corp", + "description": "Main data controller", +}) +print(controller.controller_id) + +# Update +controller = client.configuration.update_controller( + "", + {"description": "AB Corp - Primary data controller"}, +) + +# Delete +client.configuration.delete_controller("") ``` ---- +#### Data subject types + +```python +# List +types = client.configuration.list_data_subject_types() + +# Get +dst = client.configuration.get_data_subject_type("") + +# Create +dst = client.configuration.create_data_subject_type({ + "data_subject_type_name": "Employee", +}) + +# Update +dst = client.configuration.update_data_subject_type( + "", + {"data_subject_type_name": "Internal Employee"}, +) + +# Delete +client.configuration.delete_data_subject_type("") +``` + +#### Applications + +```python +# List +apps = client.configuration.list_applications() + +# Get +app = client.configuration.get_application("") + +# Create +app = client.configuration.create_application({ + "application_name": "HR Portal", +}) + +# Update +app = client.configuration.update_application( + "", + {"application_name": "HR Portal v2"}, +) + +# Delete +client.configuration.delete_application("") +``` -## Error handling +#### Master data sources + +```python +# List +sources = client.configuration.list_master_data_sources() + +# Get +source = client.configuration.get_master_data_source("") + +# Create +source = client.configuration.create_master_data_source({ + "master_data_source_name": "SAP S/4HANA", +}) + +# Update +source = client.configuration.update_master_data_source( + "", + {"master_data_source_name": "SAP S/4HANA Cloud"}, +) + +# Delete +client.configuration.delete_master_data_source("") +``` + +#### Outbound channel types + +```python +# List +channel_types = client.configuration.list_outbound_channel_types() + +# Get +ct = client.configuration.get_outbound_channel_type("") + +# Create +ct = client.configuration.create_outbound_channel_type({ + "outbound_channel_type_name": "Email", +}) + +# Update +ct = client.configuration.update_outbound_channel_type( + "", + {"outbound_channel_type_name": "Email Newsletter"}, +) + +# Delete +client.configuration.delete_outbound_channel_type("") +``` + +## Error Handling All SDK errors inherit from `ConsentSDKError`. @@ -190,26 +813,68 @@ All SDK errors inherit from `ConsentSDKError`. | `ConflictError` | 409 | | `ODataError` | other 4xx / 5xx | +Always catch `ConsentSDKError` or its subclasses around calls: + ```python -from sap_cloud_sdk.core.dpi_ng.consent import NotFoundError, ValidationError +from sap_cloud_sdk.core.dpi_ng.consent import ( + create_client, + ConsentSDKConfig, + ClientCredentialsAuth, + ConsentSDKError, + NotFoundError, + ValidationError, + AuthenticationError, +) + +config = ConsentSDKConfig( + base_url="https://", + auth=ClientCredentialsAuth( + token_url="https:///oauth/token", + client_id="", + client_secret="", + ), +) try: - consent = client.consents.get_consent(some_id) -except NotFoundError: - print("Consent not found") -except ValidationError as exc: - print("Bad request:", exc) + with create_client(config=config) as client: + consent = client.consents.get_consent("") +except AuthenticationError as e: + # Token fetch failed or token was rejected - check credentials + handle_error(e) +except NotFoundError as e: + # Consent does not exist + handle_error(e) +except ValidationError as e: + # Bad request - check the request fields + handle_error(e) +except ConsentSDKError as e: + # Catch-all for any other SDK error + handle_error(e) ``` ---- - -## Context manager +## Context Manager -Always use the client as a context manager so the underlying `requests.Session` is closed properly: +`ConsentClient` supports the context-manager protocol, which ensures the +underlying `requests.Session` is closed when the block exits: ```python -with create_client(base_url=..., auth=...) as client: - ... +from sap_cloud_sdk.core.dpi_ng.consent import ( + create_client, + ConsentSDKConfig, + ClientCredentialsAuth, +) + +config = ConsentSDKConfig( + base_url="https://", + auth=ClientCredentialsAuth( + token_url="https:///oauth/token", + client_id="", + client_secret="", + ), +) + +with create_client(config=config) as client: + consents = client.consents.list_consents(top=10) # session is closed here ``` From 130048f533857c471b38bef3d7980f617e2cb731 Mon Sep 17 00:00:00 2001 From: N Date: Thu, 25 Jun 2026 11:33:24 +0530 Subject: [PATCH 10/27] fix(dpi_ng/consent): resolve ty type-check errors in auth and client --- src/sap_cloud_sdk/core/dpi_ng/consent/auth.py | 4 +- .../core/dpi_ng/consent/client.py | 11 +- .../services/consent_configuration_service.py | 12 +- .../consent/services/consent_service.py | 4 +- .../services/consent_template_service.py | 12 +- .../integration/dpi_ng/consent/conftest.py | 28 ++- .../integration/dpi_ng/consent/data_bdd.py | 18 +- .../consent/test_consent_creation_bdd.py | 216 ++++++++++++------ .../core/unit/dpi_ng/consent/unit/conftest.py | 4 +- .../dpi_ng/consent/unit/entities/conftest.py | 25 +- .../entities/test_configuration_entities.py | 5 +- .../unit/entities/test_consent_entities.py | 9 +- .../unit/entities/test_purpose_entities.py | 5 +- .../unit/entities/test_retention_entities.py | 5 +- .../unit/entities/test_template_entities.py | 5 +- .../dpi_ng/consent/unit/services/conftest.py | 5 + .../services/test_configuration_service.py | 2 - .../unit/services/test_consent_service.py | 20 +- .../unit/services/test_purpose_service.py | 14 +- .../unit/services/test_retention_service.py | 5 +- .../unit/services/test_template_service.py | 38 ++- .../unit/dpi_ng/consent/unit/test_auth.py | 14 +- .../unit/dpi_ng/consent/unit/test_config.py | 3 +- .../consent/unit/test_consent_client.py | 8 +- .../unit/dpi_ng/consent/unit/test_dtos.py | 4 +- .../dpi_ng/consent/unit/test_odata_client.py | 30 ++- 26 files changed, 354 insertions(+), 152 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py b/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py index 2a4a7dd4..d1951781 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py @@ -111,7 +111,7 @@ def apply(self, session: requests.Session) -> None: logger.info("Invoked ClientCertificateAuth.apply") session.cert = (self._cert_file, self._key_file) if self._ca_file: - session.verify = self._ca_file + session.verify = self._ca_file # ty: ignore[invalid-assignment] logger.debug("Custom CA bundle applied — ca_file=%s", self._ca_file) logger.info("Exiting ClientCertificateAuth.apply") @@ -140,7 +140,7 @@ def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: if self._is_expired(): logger.debug("Token expired or absent — fetching new token") self._fetch_token() - r.headers["Authorization"] = f"Bearer {self._access_token}" + r.headers["Authorization"] = f"Bearer {self._access_token}" # ty: ignore[invalid-assignment] return r def _is_expired(self) -> bool: diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py index 387ecb63..5e16dba0 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -177,7 +177,8 @@ def __exit__(self, *_: Any) -> None: @staticmethod def _raise_for_status(resp: requests.Response) -> None: """Translate 4xx/5xx HTTP responses into typed ConsentSDK exceptions.""" - if resp.status_code < 400: + status_code: int = resp.status_code # ty: ignore[invalid-assignment] + if status_code < 400: return try: body = resp.json() @@ -198,10 +199,8 @@ def _raise_for_status(resp: requests.Response) -> None: odata_error = {} message = resp.text - logger.error( - "HTTP error response — status=%d message=%s", resp.status_code, message - ) - match resp.status_code: + logger.error("HTTP error response — status=%d message=%s", status_code, message) + match status_code: case 401: raise AuthenticationError(message, odata_error) case 403: @@ -213,4 +212,4 @@ def _raise_for_status(resp: requests.Response) -> None: case 400 | 422: raise ValidationError(message, odata_error) case _: - raise ODataError(message, resp.status_code, odata_error) + raise ODataError(message, status_code, odata_error) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py index 86b004ce..a2003a71 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py @@ -533,7 +533,9 @@ def get_outbound_channel_type(self, outbound_channel_type_id: str) -> Any: logger.info("Exiting ConsentConfigurationService.get_outbound_channel_type") return result - @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_OUTBOUND_CHANNEL_TYPE) + @record_metrics( + Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_OUTBOUND_CHANNEL_TYPE + ) def create_outbound_channel_type(self, body: dict[str, Any]) -> Any: """Create a new OutboundChannelType entity and return it.""" logger.info("Invoked ConsentConfigurationService.create_outbound_channel_type") @@ -544,7 +546,9 @@ def create_outbound_channel_type(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentConfigurationService.create_outbound_channel_type") return entity - @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_OUTBOUND_CHANNEL_TYPE) + @record_metrics( + Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_OUTBOUND_CHANNEL_TYPE + ) def update_outbound_channel_type( self, outbound_channel_type_id: str, body: dict[str, Any] ) -> Any: @@ -559,7 +563,9 @@ def update_outbound_channel_type( logger.info("Exiting ConsentConfigurationService.update_outbound_channel_type") return entity - @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_OUTBOUND_CHANNEL_TYPE) + @record_metrics( + Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_OUTBOUND_CHANNEL_TYPE + ) def delete_outbound_channel_type(self, outbound_channel_type_id: str) -> None: """Delete an OutboundChannelType entity by its UUID.""" logger.info("Invoked ConsentConfigurationService.delete_outbound_channel_type") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py index 8fa2db69..ab72e68d 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py @@ -64,7 +64,9 @@ def delete_consent(self, consent_id: str) -> None: # ------ actions ------ - @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CONSENT_FROM_TEMPLATE) + @record_metrics( + Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CONSENT_FROM_TEMPLATE + ) def create_consent_from_template(self, request: CreateConsentRequest) -> list[Any]: """Invoke createConsentFromTemplate and return the resulting Consent entities.""" logger.info("Invoked ConsentService.create_consent_from_template") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py index 50a1aaf4..27671d13 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py @@ -197,7 +197,9 @@ def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> An logger.info("Exiting ConsentTemplateService.get_third_party_pers_data") return result - @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY_PERS_DATA) + @record_metrics( + Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY_PERS_DATA + ) def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: """Create a new TemplateThirdPartyPersData entity and return it.""" logger.info("Invoked ConsentTemplateService.create_third_party_pers_data") @@ -208,7 +210,9 @@ def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: logger.info("Exiting ConsentTemplateService.create_third_party_pers_data") return entity - @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY_PERS_DATA) + @record_metrics( + Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY_PERS_DATA + ) def update_third_party_pers_data( self, template_id: str, third_party_id: str, body: dict[str, Any] ) -> Any: @@ -223,7 +227,9 @@ def update_third_party_pers_data( logger.info("Exiting ConsentTemplateService.update_third_party_pers_data") return entity - @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY_PERS_DATA) + @record_metrics( + Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY_PERS_DATA + ) def delete_third_party_pers_data( self, template_id: str, third_party_id: str ) -> None: diff --git a/tests/core/integration/dpi_ng/consent/conftest.py b/tests/core/integration/dpi_ng/consent/conftest.py index adce8f2c..2637dd07 100644 --- a/tests/core/integration/dpi_ng/consent/conftest.py +++ b/tests/core/integration/dpi_ng/consent/conftest.py @@ -21,21 +21,29 @@ ) # __file__ is at tests/core/integration/dpi_ng/consent/conftest.py — 6 levels up to project root -_ENV_FILE = Path(__file__).parent.parent.parent.parent.parent.parent / ".env_integration_tests" +_ENV_FILE = ( + Path(__file__).parent.parent.parent.parent.parent.parent / ".env_integration_tests" +) def _resolve_auth() -> AuthProvider | None: if token := os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN"): return BearerTokenAuth(token) - token_url = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL") - client_id = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID") + token_url = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL") + client_id = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID") client_secret = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET") if token_url and client_id and client_secret: - return ClientCredentialsAuth(token_url=token_url, client_id=client_id, client_secret=client_secret) + return ClientCredentialsAuth( + token_url=token_url, client_id=client_id, client_secret=client_secret + ) cert_file = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE") - key_file = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE") + key_file = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE") if cert_file and key_file: - return ClientCertificateAuth(cert_file=cert_file, key_file=key_file, ca_file=os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE")) + return ClientCertificateAuth( + cert_file=cert_file, + key_file=key_file, + ca_file=os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE"), + ) return None @@ -43,7 +51,9 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", "integration: mark test as integration test") -def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: for item in items: if "integration" in str(item.fspath): item.add_marker(pytest.mark.integration) @@ -56,7 +66,9 @@ def live_client() -> Iterator[ConsentClient]: base_url = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL", "") auth = _resolve_auth() if not base_url or auth is None: - pytest.skip("No integration credentials in .env — set CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL plus one auth flow") + pytest.skip( + "No integration credentials in .env — set CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL plus one auth flow" + ) with create_client(base_url=base_url, auth=auth) as client: yield client diff --git a/tests/core/integration/dpi_ng/consent/data_bdd.py b/tests/core/integration/dpi_ng/consent/data_bdd.py index 8a86ac48..dbf5011e 100644 --- a/tests/core/integration/dpi_ng/consent/data_bdd.py +++ b/tests/core/integration/dpi_ng/consent/data_bdd.py @@ -5,19 +5,19 @@ import secrets # 6-char hex, unique per pytest session -_RUN_SUFFIX = secrets.token_hex(3) +_RUN_SUFFIX = secrets.token_hex(3) # Domain / lifecycle codes -LIFECYCLE_INITIAL = "1" -LIFECYCLE_ACTIVE = "2" +LIFECYCLE_INITIAL = "1" +LIFECYCLE_ACTIVE = "2" LIFECYCLE_INACTIVE = "3" -CONSENT_MODEL_OPT_IN = "1" +CONSENT_MODEL_OPT_IN = "1" CONSENT_MODEL_OPT_OUT = "2" THIRD_PARTY_FUNC_RECIPIENT = "1" -THIRD_PARTY_FUNC_SOURCE = "2" +THIRD_PARTY_FUNC_SOURCE = "2" PURPOSE_TEXT_TYPE_EXPLANATORY = "01" PURPOSE_TEXT_TYPE_DESCRIPTION = "02" @@ -39,14 +39,14 @@ THIRD_PARTY_NAME = f"absuppliers-india_{_RUN_SUFFIX}" THIRD_PARTY_DESC = "AB Suppliers India Pvt Ltd" -PURPOSE_NAME = f"marketing-lead-purpose_{_RUN_SUFFIX}" -PURPOSE_TEXT_LANG = "en" +PURPOSE_NAME = f"marketing-lead-purpose_{_RUN_SUFFIX}" +PURPOSE_TEXT_LANG = "en" PURPOSE_EXPLANATORY_TEXT = ( "AB Corp processes your personal data for marketing purposes. " "You can withdraw your consent at any time." ) -TEMPLATE_NAME = f"marketing-lead-india_{_RUN_SUFFIX}" +TEMPLATE_NAME = f"marketing-lead-india_{_RUN_SUFFIX}" TEMPLATE_VALIDITY_PERIOD = 100 TEMPLATE_THIRD_PARTY_FUNCS = ( @@ -55,4 +55,4 @@ ) DATA_SUBJECT_ID = "I12345" -CONSENT_LANG = "en" +CONSENT_LANG = "en" diff --git a/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py b/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py index f2471f65..7973f675 100644 --- a/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py +++ b/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py @@ -28,8 +28,11 @@ # Background / shared # --------------------------------------------------------------------------- + @given("a configured consent client") -def configured_consent_client(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def configured_consent_client( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: context.client = live_client print("[step 0] consent client configured") @@ -38,14 +41,21 @@ def configured_consent_client(context: ConsentScenarioContext, live_client: Cons # Scenario 1 — Configuration entities # --------------------------------------------------------------------------- + @when("I create a controller") -def create_controller(context: ConsentScenarioContext, live_client: ConsentClient) -> None: - controller = live_client.configuration.create_controller({ - "controller_name": data.CONTROLLER_NAME, - "description": data.CONTROLLER_DESC, - }) +def create_controller( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: + controller = live_client.configuration.create_controller( + { + "controller_name": data.CONTROLLER_NAME, + "description": data.CONTROLLER_DESC, + } + ) context.controller_id = controller.controller_id - print(f"[step 1] controller created: id={controller.controller_id} name={controller.controller_name}") + print( + f"[step 1] controller created: id={controller.controller_id} name={controller.controller_name}" + ) @then("the controller should have a valid id") @@ -54,13 +64,19 @@ def controller_has_valid_id(context: ConsentScenarioContext) -> None: @when("I create an application") -def create_application(context: ConsentScenarioContext, live_client: ConsentClient) -> None: - app = live_client.configuration.create_application({ - "application_name": data.APP_NAME, - "description": data.APP_DESC, - }) +def create_application( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: + app = live_client.configuration.create_application( + { + "application_name": data.APP_NAME, + "description": data.APP_DESC, + } + ) context.application_id = app.application_id - print(f"[step 2] application created: id={app.application_id} name={app.application_name}") + print( + f"[step 2] application created: id={app.application_id} name={app.application_name}" + ) @then("the application should have a valid id") @@ -69,9 +85,12 @@ def application_has_valid_id(context: ConsentScenarioContext) -> None: @when('I reuse or create a jurisdiction named "India"') -def reuse_or_create_jurisdiction(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def reuse_or_create_jurisdiction( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: existing = [ - j for j in live_client.configuration.list_jurisdictions() + j + for j in live_client.configuration.list_jurisdictions() if j.jurisdiction_code == data.JURISDICTION_CODE ] if existing: @@ -81,9 +100,11 @@ def reuse_or_create_jurisdiction(context: ConsentScenarioContext, live_client: C f"code={jurisdiction.jurisdiction_code}" ) else: - jurisdiction = live_client.configuration.create_jurisdiction({ - "jurisdiction_code": data.JURISDICTION_CODE, - }) + jurisdiction = live_client.configuration.create_jurisdiction( + { + "jurisdiction_code": data.JURISDICTION_CODE, + } + ) print( f"[step 3a] jurisdiction created: id={jurisdiction.jurisdiction_id} " f"code={jurisdiction.jurisdiction_code}" @@ -92,11 +113,13 @@ def reuse_or_create_jurisdiction(context: ConsentScenarioContext, live_client: C context.jurisdiction_id = jurisdiction.jurisdiction_id try: - j_text = live_client.configuration.create_jurisdiction_text({ - "jurisdiction_id": jurisdiction.jurisdiction_id, - "language_code": data.JURISDICTION_LANG, - "description": data.JURISDICTION_TEXT, - }) + j_text = live_client.configuration.create_jurisdiction_text( + { + "jurisdiction_id": jurisdiction.jurisdiction_id, + "language_code": data.JURISDICTION_LANG, + "description": data.JURISDICTION_TEXT, + } + ) print( f"[step 3b] jurisdiction text added: lang={j_text.language_code} " f"text='{j_text.description}'" @@ -114,27 +137,41 @@ def jurisdiction_has_valid_id(context: ConsentScenarioContext) -> None: @when("I create a data subject type") -def create_data_subject_type(context: ConsentScenarioContext, live_client: ConsentClient) -> None: - dst = live_client.configuration.create_data_subject_type({ - "data_subject_type_name": data.DATA_SUBJECT_TYPE, - }) +def create_data_subject_type( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: + dst = live_client.configuration.create_data_subject_type( + { + "data_subject_type_name": data.DATA_SUBJECT_TYPE, + } + ) context.data_subject_type_id = dst.data_subject_type_id - print(f"[step 4] data subject type created: id={dst.data_subject_type_id} name={dst.data_subject_type_name}") + print( + f"[step 4] data subject type created: id={dst.data_subject_type_id} name={dst.data_subject_type_name}" + ) @then("the data subject type should have a valid id") def data_subject_type_has_valid_id(context: ConsentScenarioContext) -> None: - assert context.data_subject_type_id, "data_subject_type_id must be set after creation" + assert context.data_subject_type_id, ( + "data_subject_type_id must be set after creation" + ) @when("I create a third party") -def create_third_party(context: ConsentScenarioContext, live_client: ConsentClient) -> None: - tp = live_client.configuration.create_third_party({ - "third_party_name": data.THIRD_PARTY_NAME, - "formatted_description": data.THIRD_PARTY_DESC, - }) +def create_third_party( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: + tp = live_client.configuration.create_third_party( + { + "third_party_name": data.THIRD_PARTY_NAME, + "formatted_description": data.THIRD_PARTY_DESC, + } + ) context.third_party_id = tp.third_party_id - print(f"[step 5] third party created: id={tp.third_party_id} name={tp.third_party_name}") + print( + f"[step 5] third party created: id={tp.third_party_id} name={tp.third_party_name}" + ) @then("the third party should have a valid id") @@ -146,13 +183,18 @@ def third_party_has_valid_id(context: ConsentScenarioContext) -> None: # Scenario 2 — Consent purpose # --------------------------------------------------------------------------- + @when("I create a purpose") def create_purpose(context: ConsentScenarioContext, live_client: ConsentClient) -> None: - purpose = live_client.purposes.create_purpose({ - "purpose_name": data.PURPOSE_NAME, - }) + purpose = live_client.purposes.create_purpose( + { + "purpose_name": data.PURPOSE_NAME, + } + ) context.purpose_id = purpose.purpose_id - print(f"[step 6] purpose created: id={purpose.purpose_id} name={purpose.purpose_name}") + print( + f"[step 6] purpose created: id={purpose.purpose_id} name={purpose.purpose_name}" + ) @then("the purpose should have a valid id") @@ -161,14 +203,20 @@ def purpose_has_valid_id(context: ConsentScenarioContext) -> None: @when("I add an English explanatory text to the purpose") -def add_purpose_text(context: ConsentScenarioContext, live_client: ConsentClient) -> None: - assert context.purpose_id, "purpose_id must be set (scenario 2 depends on scenario 1 passing)" - p_text = live_client.purposes.create_purpose_text({ - "purpose_id": context.purpose_id, - "language_code": data.PURPOSE_TEXT_LANG, - "type_code": data.PURPOSE_TEXT_TYPE_EXPLANATORY, - "text": data.PURPOSE_EXPLANATORY_TEXT, - }) +def add_purpose_text( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: + assert context.purpose_id, ( + "purpose_id must be set (scenario 2 depends on scenario 1 passing)" + ) + p_text = live_client.purposes.create_purpose_text( + { + "purpose_id": context.purpose_id, + "language_code": data.PURPOSE_TEXT_LANG, + "type_code": data.PURPOSE_TEXT_TYPE_EXPLANATORY, + "text": data.PURPOSE_EXPLANATORY_TEXT, + } + ) context.result = p_text print( f"[step 7] purpose text added: purpose_id={p_text.purpose_id} " @@ -185,7 +233,9 @@ def purpose_text_saved(context: ConsentScenarioContext) -> None: @when("I activate the purpose") -def activate_purpose(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def activate_purpose( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: assert context.purpose_id, "purpose_id must be set before activation" activated = live_client.purposes.set_purpose_active(context.purpose_id) context.result = activated @@ -207,20 +257,25 @@ def purpose_lifecycle_active(context: ConsentScenarioContext) -> None: # Scenario 3 — Consent template # --------------------------------------------------------------------------- + @when("I create a consent template using the purpose, controller, and application") -def create_template(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def create_template( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: assert context.purpose_id, "purpose_id required" assert context.controller_id, "controller_id required" assert context.application_id, "application_id required" - template = live_client.templates.create_template({ - "template_name": data.TEMPLATE_NAME, - "jurisdiction_code": data.JURISDICTION_CODE, - "consent_model_code": data.CONSENT_MODEL_OPT_IN, - "purpose_id": context.purpose_id, - "controller_id": context.controller_id, - "application_id": context.application_id, - "validity_period": data.TEMPLATE_VALIDITY_PERIOD, - }) + template = live_client.templates.create_template( + { + "template_name": data.TEMPLATE_NAME, + "jurisdiction_code": data.JURISDICTION_CODE, + "consent_model_code": data.CONSENT_MODEL_OPT_IN, + "purpose_id": context.purpose_id, + "controller_id": context.controller_id, + "application_id": context.application_id, + "validity_period": data.TEMPLATE_VALIDITY_PERIOD, + } + ) context.template_id = template.template_id print( f"[step 9] template created: id={template.template_id} " @@ -234,14 +289,18 @@ def template_has_valid_id(context: ConsentScenarioContext) -> None: @when('I assign the third party as a "RECIPIENT" to the template') -def assign_third_party_recipient(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def assign_third_party_recipient( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: assert context.template_id, "template_id required" assert context.third_party_id, "third_party_id required" - tppd = live_client.templates.create_third_party_pers_data({ - "template_id": context.template_id, - "third_party_id": context.third_party_id, - "third_party_function_code": data.THIRD_PARTY_FUNC_RECIPIENT, - }) + tppd = live_client.templates.create_third_party_pers_data( + { + "template_id": context.template_id, + "third_party_id": context.third_party_id, + "third_party_function_code": data.THIRD_PARTY_FUNC_RECIPIENT, + } + ) context.result = tppd print( f"[step 10a] third party RECIPIENT assigned: " @@ -260,14 +319,18 @@ def third_party_recipient_assigned(context: ConsentScenarioContext) -> None: @when('I assign the third party as a "SOURCE" to the template') -def assign_third_party_source(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def assign_third_party_source( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: assert context.template_id, "template_id required" assert context.third_party_id, "third_party_id required" - tppd = live_client.templates.create_third_party_pers_data({ - "template_id": context.template_id, - "third_party_id": context.third_party_id, - "third_party_function_code": data.THIRD_PARTY_FUNC_SOURCE, - }) + tppd = live_client.templates.create_third_party_pers_data( + { + "template_id": context.template_id, + "third_party_id": context.third_party_id, + "third_party_function_code": data.THIRD_PARTY_FUNC_SOURCE, + } + ) context.result = tppd print( f"[step 10b] third party SOURCE assigned: " @@ -286,7 +349,9 @@ def third_party_source_assigned(context: ConsentScenarioContext) -> None: @when("I activate the template") -def activate_template(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def activate_template( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: assert context.template_id, "template_id required before activation" activated = live_client.templates.set_template_active(context.template_id) context.result = activated @@ -308,8 +373,11 @@ def template_lifecycle_active(context: ConsentScenarioContext) -> None: # Scenario 4 — Consent record # --------------------------------------------------------------------------- + @when('I create a consent from the template for data subject "DS-IT-CREATION-001"') -def create_consent_record(context: ConsentScenarioContext, live_client: ConsentClient) -> None: +def create_consent_record( + context: ConsentScenarioContext, live_client: ConsentClient +) -> None: assert context.template_id, "template_id required — scenario 3 must pass first" request = CreateConsentRequest( data_subject_id=data.DATA_SUBJECT_ID, @@ -320,7 +388,9 @@ def create_consent_record(context: ConsentScenarioContext, live_client: ConsentC ) consents = live_client.consents.create_consent_from_template(request) context.consent_ids = [c.consent_id for c in consents] - print(f"[step 12] {len(consents)} consent record(s) returned: ids={context.consent_ids}") + print( + f"[step 12] {len(consents)} consent record(s) returned: ids={context.consent_ids}" + ) @then("at least one consent record should be returned") diff --git a/tests/core/unit/dpi_ng/consent/unit/conftest.py b/tests/core/unit/dpi_ng/consent/unit/conftest.py index f836ed27..d38ec885 100644 --- a/tests/core/unit/dpi_ng/consent/unit/conftest.py +++ b/tests/core/unit/dpi_ng/consent/unit/conftest.py @@ -5,6 +5,8 @@ import pytest -def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: for item in items: item.add_marker(pytest.mark.unit) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py b/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py index 7265ad5f..97208dd2 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py @@ -143,29 +143,44 @@ def entity_by_name(entities_tuple, name): @pytest.fixture(scope="module") def consent_entities(): - from sap_cloud_sdk.core.dpi_ng.consent.entities.consent import _make_entities as make_entities + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent import ( + _make_entities as make_entities, + ) + return make_entities(_make_service()) @pytest.fixture(scope="module") def config_entities(): - from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_configuration import _make_entities as make_entities + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_configuration import ( + _make_entities as make_entities, + ) + return make_entities(_make_service()) @pytest.fixture(scope="module") def purpose_entities(): - from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_purpose import _make_entities as make_entities + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_purpose import ( + _make_entities as make_entities, + ) + return make_entities(_make_service()) @pytest.fixture(scope="module") def retention_entities(): - from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_retention import _make_entities as make_entities + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_retention import ( + _make_entities as make_entities, + ) + return make_entities(_make_service()) @pytest.fixture(scope="module") def template_entities(): - from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_template import _make_entities as make_entities + from sap_cloud_sdk.core.dpi_ng.consent.entities.consent_template import ( + _make_entities as make_entities, + ) + return make_entities(_make_service()) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py index 1b63a822..6088a6b9 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py @@ -5,7 +5,10 @@ import pytest from odata.property import BooleanProperty -from tests.core.unit.dpi_ng.consent.unit.entities.conftest import CONFIGURATION_ENTITY_SPECS, entity_by_name +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import ( + CONFIGURATION_ENTITY_SPECS, + entity_by_name, +) def test_make_entities_returns_all_classes(config_entities): diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py index c6ed1521..ba233cb2 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py @@ -5,7 +5,10 @@ import pytest from odata.property import BooleanProperty, StringProperty, UUIDProperty -from tests.core.unit.dpi_ng.consent.unit.entities.conftest import CONSENT_ENTITY_SPECS, entity_by_name +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import ( + CONSENT_ENTITY_SPECS, + entity_by_name, +) def test_make_entities_returns_all_classes(consent_entities): @@ -45,7 +48,8 @@ def test_consent_id_is_uuid_pk(self, consent_entities): def test_consent_has_single_pk(self, consent_entities): cls = entity_by_name(consent_entities, "Consent") pk_props = [ - name for name, val in vars(cls).items() + name + for name, val in vars(cls).items() if isinstance(val, UUIDProperty) and getattr(val, "primary_key", False) ] assert pk_props == ["consent_id"] @@ -61,4 +65,3 @@ def test_consent_third_party_sensitive_data_flag_is_boolean(self, consent_entiti def test_consent_tenant_is_string(self, consent_entities): cls = entity_by_name(consent_entities, "Consent") assert isinstance(cls.tenant, StringProperty) - diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py index 6958de09..8da12750 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py @@ -5,7 +5,10 @@ import pytest from odata.property import BooleanProperty -from tests.core.unit.dpi_ng.consent.unit.entities.conftest import PURPOSE_ENTITY_SPECS, entity_by_name +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import ( + PURPOSE_ENTITY_SPECS, + entity_by_name, +) def test_make_entities_returns_all_classes(purpose_entities): diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py index fae15b0e..bfe02073 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py @@ -5,7 +5,10 @@ import pytest from odata.property import IntegerProperty -from tests.core.unit.dpi_ng.consent.unit.entities.conftest import RETENTION_ENTITY_SPECS, entity_by_name +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import ( + RETENTION_ENTITY_SPECS, + entity_by_name, +) def test_make_entities_returns_all_classes(retention_entities): diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py index e6fec875..c1946f81 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py @@ -5,7 +5,10 @@ import pytest from odata.property import BooleanProperty -from tests.core.unit.dpi_ng.consent.unit.entities.conftest import TEMPLATE_ENTITY_SPECS, entity_by_name +from tests.core.unit.dpi_ng.consent.unit.entities.conftest import ( + TEMPLATE_ENTITY_SPECS, + entity_by_name, +) def test_make_entities_returns_all_classes(template_entities): diff --git a/tests/core/unit/dpi_ng/consent/unit/services/conftest.py b/tests/core/unit/dpi_ng/consent/unit/services/conftest.py index 02881048..54372fc5 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/conftest.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/conftest.py @@ -26,28 +26,33 @@ def _make_mock_client(entities_module): @pytest.fixture def mock_consent_client(): from sap_cloud_sdk.core.dpi_ng.consent.entities import consent as m + return _make_mock_client(m) @pytest.fixture def mock_config_client(): from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_configuration as m + return _make_mock_client(m) @pytest.fixture def mock_purpose_client(): from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_purpose as m + return _make_mock_client(m) @pytest.fixture def mock_retention_client(): from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_retention as m + return _make_mock_client(m) @pytest.fixture def mock_template_client(): from sap_cloud_sdk.core.dpi_ng.consent.entities import consent_template as m + return _make_mock_client(m) diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py index 6e35589f..c106c5b1 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py @@ -235,5 +235,3 @@ def test_delete(self, svc): def test_delete_fetches_by_code(self, svc): svc.delete_language_description("ES") svc._client.query.return_value.get.assert_called_with("ES") - - diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py index 68cb7b7f..5a1553d0 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py @@ -9,7 +9,10 @@ @pytest.fixture def svc(mock_consent_client): - from sap_cloud_sdk.core.dpi_ng.consent.services.consent_service import ConsentService + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_service import ( + ConsentService, + ) + return ConsentService(mock_consent_client) @@ -41,7 +44,9 @@ def test_get_consent(self, svc, mock_consent_client): class TestCreateConsentFromTemplate: def test_create_consent_from_template_returns_list(self, svc, mock_consent_client): - mock_consent_client.call_action.return_value = {"value": [{"consent_id": "c-1"}]} + mock_consent_client.call_action.return_value = { + "value": [{"consent_id": "c-1"}] + } req = CreateConsentRequest( data_subject_id="ds-1", template_name="tmpl", @@ -114,11 +119,13 @@ def test_terminate_consent(self, svc, mock_consent_client): class TestCheckConsentExists: def test_check_consent_exists(self, svc, mock_consent_client): mock_consent_client.call_action.return_value = { - "consentId": "c-1", "consentExists": True + "consentId": "c-1", + "consentExists": True, } result = svc.check_consent_exists("ds-1", "tmpl-1") mock_consent_client.call_action.assert_called_once_with( - "consentServices", "checkConsentExists", + "consentServices", + "checkConsentExists", {"dataSubjectId": "ds-1", "templateId": "tmpl-1"}, ) assert isinstance(result, CheckConsentExistsResult) @@ -145,7 +152,10 @@ def test_query_orderby(self, svc, mock_consent_client): class TestDictToEntity: def test_wraps_data_as_persisted_entity(self): - from sap_cloud_sdk.core.dpi_ng.consent.services.consent_service import _dict_to_entity + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_service import ( + _dict_to_entity, + ) + entity_cls = MagicMock() entity = entity_cls.return_value entity.__odata__ = MagicMock() diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py index 4f98e1d5..55304c1d 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py @@ -9,7 +9,10 @@ @pytest.fixture def svc(mock_purpose_client): - from sap_cloud_sdk.core.dpi_ng.consent.services.consent_purpose_service import ConsentPurposeService + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_purpose_service import ( + ConsentPurposeService, + ) + return ConsentPurposeService(mock_purpose_client) @@ -116,7 +119,12 @@ def test_get_purpose_text_composite_key(self, svc, client, q): class TestCreatePurposeText: def test_create_purpose_text(self, svc, client): - body = {"purpose_id": "pid", "type_code": "tc", "language_code": "EN", "text": "hello"} + body = { + "purpose_id": "pid", + "type_code": "tc", + "language_code": "EN", + "text": "hello", + } svc.create_purpose_text(body) client.save.assert_called_once() @@ -134,5 +142,3 @@ def test_delete_purpose_text_composite_key(self, svc, client, q): svc.delete_purpose_text("pid", "tc", "EN") q.get.assert_called_with(purposeId="pid", typeCode="tc", languageCode="EN") client.delete_entity.assert_called_once_with(q.get.return_value) - - diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py index 3eea99ec..d4d29b89 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py @@ -9,7 +9,10 @@ @pytest.fixture def svc(mock_retention_client): - from sap_cloud_sdk.core.dpi_ng.consent.services.consent_retention_service import ConsentRetentionService + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_retention_service import ( + ConsentRetentionService, + ) + return ConsentRetentionService(mock_retention_client) diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py index 4447bcd1..c5182143 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py @@ -10,7 +10,10 @@ @pytest.fixture def svc(mock_template_client): - from sap_cloud_sdk.core.dpi_ng.consent.services.consent_template_service import ConsentTemplateService + from sap_cloud_sdk.core.dpi_ng.consent.services.consent_template_service import ( + ConsentTemplateService, + ) + return ConsentTemplateService(mock_template_client) @@ -18,6 +21,7 @@ def svc(mock_template_client): # ConsentTemplate CRUD # --------------------------------------------------------------------------- + class TestConsentTemplateCRUD: def test_list_templates(self, svc, mock_template_client): result = svc.list_templates() @@ -63,6 +67,7 @@ def test_delete_template(self, svc, mock_template_client): # Lifecycle actions # --------------------------------------------------------------------------- + class TestTemplateLifecycleActions: def test_set_template_active_calls_action(self, svc, mock_template_client): svc.set_template_active("tid") @@ -97,6 +102,7 @@ def test_set_template_inactive_returns_refreshed(self, svc, mock_template_client # ConsentTemplateText (composite key: template_id + type_code + language_code) # --------------------------------------------------------------------------- + class TestConsentTemplateText: def test_list_template_texts(self, svc, mock_template_client): result = svc.list_template_texts() @@ -142,17 +148,22 @@ def test_delete_template_text_composite_key(self, svc, mock_template_client): # TemplateThirdPartyPersData (composite key: template_id + third_party_id) # --------------------------------------------------------------------------- + class TestTemplateThirdPartyPersData: def test_list_third_party_pers_data(self, svc, mock_template_client): result = svc.list_third_party_pers_data() - mock_template_client.query.assert_called_with(_SVC, svc.TemplateThirdPartyPersData) + mock_template_client.query.assert_called_with( + _SVC, svc.TemplateThirdPartyPersData + ) mock_template_client.query.return_value.all.assert_called_once() assert result == [] def test_get_third_party_pers_data_composite_key(self, svc, mock_template_client): q = mock_template_client.query.return_value result = svc.get_third_party_pers_data("tid", "tpid") - mock_template_client.query.assert_called_with(_SVC, svc.TemplateThirdPartyPersData) + mock_template_client.query.assert_called_with( + _SVC, svc.TemplateThirdPartyPersData + ) q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") assert result is q.get.return_value @@ -165,18 +176,28 @@ def test_create_third_party_pers_data_sets_fields(self, svc, mock_template_clien call_arg = mock_template_client.save.call_args[0][0] assert call_arg.third_party_function_code == "PROCESSOR" - def test_update_third_party_pers_data_composite_key(self, svc, mock_template_client): + def test_update_third_party_pers_data_composite_key( + self, svc, mock_template_client + ): q = mock_template_client.query.return_value - svc.update_third_party_pers_data("tid", "tpid", {"third_party_function_code": "CONTROLLER"}) + svc.update_third_party_pers_data( + "tid", "tpid", {"third_party_function_code": "CONTROLLER"} + ) q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") mock_template_client.save.assert_called_once() - def test_update_third_party_pers_data_applies_fields(self, svc, mock_template_client): + def test_update_third_party_pers_data_applies_fields( + self, svc, mock_template_client + ): entity = mock_template_client.query.return_value.get.return_value - svc.update_third_party_pers_data("tid", "tpid", {"third_party_function_code": "CONTROLLER"}) + svc.update_third_party_pers_data( + "tid", "tpid", {"third_party_function_code": "CONTROLLER"} + ) assert entity.third_party_function_code == "CONTROLLER" - def test_delete_third_party_pers_data_composite_key(self, svc, mock_template_client): + def test_delete_third_party_pers_data_composite_key( + self, svc, mock_template_client + ): q = mock_template_client.query.return_value svc.delete_third_party_pers_data("tid", "tpid") q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") @@ -187,6 +208,7 @@ def test_delete_third_party_pers_data_composite_key(self, svc, mock_template_cli # Query parameter forwarding (_apply_query) on list_templates # --------------------------------------------------------------------------- + class TestQueryParams: def test_query_filter(self, svc, mock_template_client): q = mock_template_client.query.return_value diff --git a/tests/core/unit/dpi_ng/consent/unit/test_auth.py b/tests/core/unit/dpi_ng/consent/unit/test_auth.py index 5e443ac6..28707855 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_auth.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_auth.py @@ -41,15 +41,21 @@ def test_apply_sets_authorization_header(self): class TestClientCredentialsAuth: def test_empty_token_url_raises(self): - with pytest.raises(ValueError, match="token_url, client_id, and client_secret are all required"): + with pytest.raises( + ValueError, match="token_url, client_id, and client_secret are all required" + ): ClientCredentialsAuth("", "cid", "secret") def test_empty_client_id_raises(self): - with pytest.raises(ValueError, match="token_url, client_id, and client_secret are all required"): + with pytest.raises( + ValueError, match="token_url, client_id, and client_secret are all required" + ): ClientCredentialsAuth("https://token.url", "", "secret") def test_empty_client_secret_raises(self): - with pytest.raises(ValueError, match="token_url, client_id, and client_secret are all required"): + with pytest.raises( + ValueError, match="token_url, client_id, and client_secret are all required" + ): ClientCredentialsAuth("https://token.url", "cid", "") def test_apply_sets_session_auth_to_oauth2_flow(self): @@ -109,7 +115,7 @@ def test_call_injects_bearer_header(self): return_value=_mock_post_response("my-access-token"), ): result = flow(req) - assert result.headers["Authorization"] == "Bearer my-access-token" + assert result.headers["Authorization"] == "Bearer my-access-token" # ty: ignore[not-subscriptable] def test_second_call_reuses_cached_token(self): flow = _OAuth2Flow("https://token.url", "cid", "secret") diff --git a/tests/core/unit/dpi_ng/consent/unit/test_config.py b/tests/core/unit/dpi_ng/consent/unit/test_config.py index cb243b4a..4ce312c1 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_config.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_config.py @@ -109,7 +109,8 @@ def test_string_auth_raises(self): def test_dict_auth_raises(self): with pytest.raises(ValueError, match="auth must be an AuthProvider"): ConsentSDKConfig( - base_url="https://example.com", auth={"token": "abc"} # ty: ignore[invalid-argument-type] + base_url="https://example.com", + auth={"token": "abc"}, # ty: ignore[invalid-argument-type] ) def test_plain_object_auth_raises(self): diff --git a/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py b/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py index 741f241a..26832b87 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py @@ -107,7 +107,13 @@ def test_all_five_services_present(self, auth: BearerTokenAuth) -> None: _setup_mock(Mock) config = ConsentSDKConfig(base_url="https://example.com", auth=auth) client = ConsentClient(config) - for attr in ("consents", "purposes", "templates", "retention", "configuration"): + for attr in ( + "consents", + "purposes", + "templates", + "retention", + "configuration", + ): assert hasattr(client, attr) diff --git a/tests/core/unit/dpi_ng/consent/unit/test_dtos.py b/tests/core/unit/dpi_ng/consent/unit/test_dtos.py index 7ea33e11..9fec3950 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_dtos.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_dtos.py @@ -114,5 +114,7 @@ def test_from_dict_empty_dict_yields_none_fields(self): assert result.consent_exists is None def test_from_dict_returns_check_consent_exists_result_instance(self): - result = CheckConsentExistsResult.from_dict({"consentId": "c-1", "consentExists": True}) + result = CheckConsentExistsResult.from_dict( + {"consentId": "c-1", "consentExists": True} + ) assert isinstance(result, CheckConsentExistsResult) diff --git a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py index 1cfd12cd..a9b6e790 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py @@ -228,7 +228,9 @@ def test_raises_on_4xx(self, mock_session_cls, mock_odata_svc_cls): @patch("sap_cloud_sdk.core.dpi_ng.consent.client.ODataService") @patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") class TestOrmMethods: - def test_get_entity_classes_calls_factory_on_cache_miss(self, mock_session_cls, mock_odata_svc_cls): + def test_get_entity_classes_calls_factory_on_cache_miss( + self, mock_session_cls, mock_odata_svc_cls + ): mock_session_cls.return_value = MagicMock() mock_svc = MagicMock() mock_odata_svc_cls.return_value = mock_svc @@ -236,23 +238,33 @@ def test_get_entity_classes_calls_factory_on_cache_miss(self, mock_session_cls, mock_factory = MagicMock(return_value=mock_entities) config = _make_config() client = _ODataClient(config) - with patch.dict("sap_cloud_sdk.core.dpi_ng.consent.client._ENTITY_FACTORIES", {"testSvc": mock_factory}): + with patch.dict( + "sap_cloud_sdk.core.dpi_ng.consent.client._ENTITY_FACTORIES", + {"testSvc": mock_factory}, + ): result = client.get_entity_classes("testSvc") mock_factory.assert_called_once_with(mock_svc) assert result is mock_entities - def test_get_entity_classes_returns_cached_on_second_call(self, mock_session_cls, mock_odata_svc_cls): + def test_get_entity_classes_returns_cached_on_second_call( + self, mock_session_cls, mock_odata_svc_cls + ): mock_session_cls.return_value = MagicMock() mock_odata_svc_cls.return_value = MagicMock() mock_factory = MagicMock(return_value=(MagicMock(),)) config = _make_config() client = _ODataClient(config) - with patch.dict("sap_cloud_sdk.core.dpi_ng.consent.client._ENTITY_FACTORIES", {"testSvc": mock_factory}): + with patch.dict( + "sap_cloud_sdk.core.dpi_ng.consent.client._ENTITY_FACTORIES", + {"testSvc": mock_factory}, + ): client.get_entity_classes("testSvc") client.get_entity_classes("testSvc") mock_factory.assert_called_once() - def test_query_delegates_to_odata_service(self, mock_session_cls, mock_odata_svc_cls): + def test_query_delegates_to_odata_service( + self, mock_session_cls, mock_odata_svc_cls + ): mock_session_cls.return_value = MagicMock() mock_svc = MagicMock() mock_odata_svc_cls.return_value = mock_svc @@ -263,7 +275,9 @@ def test_query_delegates_to_odata_service(self, mock_session_cls, mock_odata_svc mock_svc.query.assert_called_once_with(entity_cls) assert result is mock_svc.query.return_value - def test_save_delegates_to_entity_odata_service(self, mock_session_cls, mock_odata_svc_cls): + def test_save_delegates_to_entity_odata_service( + self, mock_session_cls, mock_odata_svc_cls + ): mock_session_cls.return_value = MagicMock() mock_odata_svc_cls.return_value = MagicMock() entity = MagicMock() @@ -273,7 +287,9 @@ def test_save_delegates_to_entity_odata_service(self, mock_session_cls, mock_oda client.save(entity) entity.__odata_service__.save.assert_called_once_with(entity) - def test_delete_entity_delegates_to_entity_odata_service(self, mock_session_cls, mock_odata_svc_cls): + def test_delete_entity_delegates_to_entity_odata_service( + self, mock_session_cls, mock_odata_svc_cls + ): mock_session_cls.return_value = MagicMock() mock_odata_svc_cls.return_value = MagicMock() entity = MagicMock() From 2bd15b49ad6e2d34fccc57a0d026bfcac15197f5 Mon Sep 17 00:00:00 2001 From: N Date: Thu, 25 Jun 2026 13:20:23 +0530 Subject: [PATCH 11/27] docs(dpi_ng/consent): add and improve docstrings across all public APIs --- .../core/dpi_ng/consent/__init__.py | 31 +- src/sap_cloud_sdk/core/dpi_ng/consent/auth.py | 54 +- .../core/dpi_ng/consent/client.py | 70 +- .../core/dpi_ng/consent/config.py | 9 +- .../core/dpi_ng/consent/dtos/consent.py | 48 +- .../core/dpi_ng/consent/exceptions.py | 23 +- .../core/dpi_ng/consent/services/_query.py | 13 +- .../services/consent_configuration_service.py | 644 ++++++++++++++++-- .../services/consent_purpose_service.py | 161 ++++- .../services/consent_retention_service.py | 90 ++- .../consent/services/consent_service.py | 89 ++- .../services/consent_template_service.py | 229 ++++++- 12 files changed, 1337 insertions(+), 124 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py index f7b6d56b..28ddf2e2 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py @@ -66,7 +66,12 @@ def __init__( *, _telemetry_source: Module | None = None, ) -> None: - """Initialise all service clients from the given config.""" + """Initialise all service clients from the given config. + + Args: + config: Validated ``ConsentSDKConfig`` containing the base URL and auth strategy. + _telemetry_source: Internal parameter; not for end-user use. + """ from .client import _ODataClient self._telemetry_source = _telemetry_source @@ -110,17 +115,29 @@ def create_client( verify_ssl: bool = True, _telemetry_source: Module | None = None, ) -> ConsentClient: - """Instantiate a ConsentClient from a config object or individual keyword arguments. + """Create a ConsentClient with explicit configuration or individual keyword arguments. Args: - config: Pre-built ConsentSDKConfig. When provided, all other kwargs are ignored. + config: Pre-built ``ConsentSDKConfig``. When provided, all other kwargs + are ignored. base_url: Host-only root URL of the consent service (no path). - auth: Authentication strategy (BearerTokenAuth, ClientCredentialsAuth, etc.). - timeout: HTTP request timeout in seconds. - verify_ssl: Verify TLS certificates. + Required when *config* is not provided. + auth: Authentication strategy (``BearerTokenAuth``, + ``ClientCredentialsAuth``, ``ClientCertificateAuth``, etc.). + Required when *config* is not provided. + timeout: HTTP request timeout in seconds. Defaults to ``30.0``. + verify_ssl: Whether to verify TLS certificates. Defaults to ``True``. + _telemetry_source: Internal parameter; not for end-user use. + + Returns: + ConsentClient ready for consent management calls. Raises: - ClientCreationError: If required fields are missing or invalid. + ClientCreationError: If required fields are missing or client creation fails. + + Note: + Telemetry for client creation records only module/operation metadata and + never includes configuration values or processed user content. """ try: if config is None: diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py b/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py index d1951781..61e8831d 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py @@ -38,7 +38,14 @@ class BearerTokenAuth(AuthProvider): """Static bearer token - use when the caller manages token lifecycle externally.""" def __init__(self, token: str) -> None: - """Store the bearer token, raising ValueError if empty.""" + """Store the bearer token. + + Args: + token: A valid bearer token string (e.g. an already-fetched XSUAA access token). + + Raises: + ValueError: If *token* is an empty string. + """ logger.info("Invoked BearerTokenAuth.__init__") if not token: logger.error("token is empty") @@ -47,7 +54,11 @@ def __init__(self, token: str) -> None: logger.info("Exiting BearerTokenAuth.__init__") def apply(self, session: requests.Session) -> None: - """Inject the static bearer token into the session Authorization header.""" + """Set the ``Authorization: Bearer `` header on the session. + + Args: + session: The ``requests.Session`` to configure. + """ logger.info("Invoked BearerTokenAuth.apply") session.headers["Authorization"] = f"Bearer {self._token}" logger.info("Exiting BearerTokenAuth.apply") @@ -61,7 +72,17 @@ class ClientCredentialsAuth(AuthProvider): """ def __init__(self, token_url: str, client_id: str, client_secret: str) -> None: - """Store OAuth2 credentials, raising ValueError if any field is empty.""" + """Store OAuth2 credentials for lazy token fetching. + + Args: + token_url: Full URL of the OAuth2 token endpoint + (e.g. ``https://.authentication.eu10.hana.ondemand.com/oauth/token``). + client_id: OAuth2 client identifier. + client_secret: OAuth2 client secret. + + Raises: + ValueError: If any of *token_url*, *client_id*, or *client_secret* is empty. + """ logger.info("Invoked ClientCredentialsAuth.__init__") if not token_url or not client_id or not client_secret: logger.error("token_url, client_id, or client_secret is empty") @@ -72,7 +93,14 @@ def __init__(self, token_url: str, client_id: str, client_secret: str) -> None: logger.info("Exiting ClientCredentialsAuth.__init__") def apply(self, session: requests.Session) -> None: - """Attach the OAuth2 flow handler to the session so tokens are fetched on demand.""" + """Attach the OAuth2 flow handler to the session. + + Tokens are fetched on the first request and refreshed automatically + 60 seconds before expiry. + + Args: + session: The ``requests.Session`` to configure. + """ logger.info("Invoked ClientCredentialsAuth.apply") session.auth = _OAuth2Flow( self._token_url, self._client_id, self._client_secret @@ -96,7 +124,17 @@ def __init__( key_file: str, ca_file: str | None = None, ) -> None: - """Store mTLS paths, raising ValueError if cert_file or key_file is empty.""" + """Store mTLS file paths. + + Args: + cert_file: Path to the PEM-encoded client certificate file. + key_file: Path to the PEM-encoded client private key file. + ca_file: Path to a PEM-encoded CA certificate file for server + verification. When omitted the system CA bundle is used. + + Raises: + ValueError: If *cert_file* or *key_file* is empty. + """ logger.info("Invoked ClientCertificateAuth.__init__") if not cert_file or not key_file: logger.error("cert_file or key_file is empty") @@ -107,7 +145,11 @@ def __init__( logger.info("Exiting ClientCertificateAuth.__init__") def apply(self, session: requests.Session) -> None: - """Configure the session with the client cert/key pair and optional CA bundle.""" + """Configure the session with the client cert/key pair and optional CA bundle. + + Args: + session: The ``requests.Session`` to configure. + """ logger.info("Invoked ClientCertificateAuth.apply") session.cert = (self._cert_file, self._key_file) if self._ca_file: diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py index 5e16dba0..49eba35a 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -91,7 +91,17 @@ def _get_service(self, service_name: str) -> ODataService: return self._services[service_name] def get_entity_classes(self, service_name: str) -> tuple: - """Return the tuple of entity classes bound to the given service endpoint.""" + """Return the tuple of entity classes bound to the given service endpoint. + + Classes are created once and cached; subsequent calls return the same objects. + + Args: + service_name: OData service identifier (e.g. ``"consentServices"``). + + Returns: + Tuple of python-odata entity classes in the order defined by the + service's ``_make_entities`` factory. + """ logger.info("Invoked ODataClient.get_entity_classes") if service_name not in self._entity_classes: svc = self._get_service(service_name) @@ -106,7 +116,16 @@ def get_entity_classes(self, service_name: str) -> tuple: # ------------------------------------------------------------------ def query(self, service_name: str, entity_cls: type) -> Query: - """Return a Query builder for the given entity class.""" + """Return a Query builder for the given entity class. + + Args: + service_name: OData service identifier (e.g. ``"consentServices"``). + entity_cls: The python-odata entity class to query. + + Returns: + A python-odata ``Query`` instance that can be further filtered, + paged, or executed with ``.all()`` / ``.get()``. + """ logger.info("Invoked ODataClient.query") svc = self._get_service(service_name) result = svc.query(entity_cls) @@ -114,13 +133,21 @@ def query(self, service_name: str, entity_cls: type) -> Query: return result def save(self, entity: Any) -> None: - """POST (new entity) or PATCH (existing entity, dirty fields only).""" + """Persist an entity: POST if new, PATCH dirty fields if already saved. + + Args: + entity: A python-odata entity instance to create or update. + """ logger.info("Invoked ODataClient.save") entity.__odata_service__.save(entity) logger.info("Exiting ODataClient.save") def delete_entity(self, entity: Any) -> None: - """DELETE the entity from the service.""" + """Send a DELETE request for the given entity. + + Args: + entity: A python-odata entity instance to delete. + """ logger.info("Invoked ODataClient.delete_entity") entity.__odata_service__.delete(entity) logger.info("Exiting ODataClient.delete_entity") @@ -136,7 +163,26 @@ def call_action( body: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> dict[str, Any] | None: - """POST an OData action and return the parsed response body, or None for 204.""" + """POST an OData action and return the parsed response body. + + Args: + service: OData service identifier (e.g. ``"consentServices"``). + path: Action name relative to the service root URL + (e.g. ``"createConsentFromTemplate"``). + body: JSON-serialisable request payload. Defaults to ``{}`` when omitted. + params: Optional URL query parameters to append to the request. + + Returns: + Parsed JSON response body as a dict, or ``None`` for HTTP 204 No Content. + + Raises: + AuthenticationError: On HTTP 401. + AuthorizationError: On HTTP 403. + NotFoundError: On HTTP 404. + ConflictError: On HTTP 409. + ValidationError: On HTTP 400 or 422. + ODataError: On any other 4xx or 5xx response. + """ logger.info("Invoked ODataClient.call_action") svc = self._get_service(service) url = f"{svc.url}{path}" @@ -176,7 +222,19 @@ def __exit__(self, *_: Any) -> None: @staticmethod def _raise_for_status(resp: requests.Response) -> None: - """Translate 4xx/5xx HTTP responses into typed ConsentSDK exceptions.""" + """Translate 4xx/5xx HTTP responses into typed ``ConsentSDKError`` subclasses. + + Args: + resp: The ``requests.Response`` to inspect. + + Raises: + AuthenticationError: On HTTP 401. + AuthorizationError: On HTTP 403. + NotFoundError: On HTTP 404. + ConflictError: On HTTP 409. + ValidationError: On HTTP 400 or 422. + ODataError: On any other 4xx or 5xx response. + """ status_code: int = resp.status_code # ty: ignore[invalid-assignment] if status_code < 400: return diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py index 888a4cfc..926b3c98 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py @@ -21,7 +21,7 @@ class ConsentSDKConfig: ClientCertificateAuth, or a custom AuthProvider subclass. timeout: HTTP request timeout in seconds (default 30). verify_ssl: Verify TLS certificates - set False only in local dev. - Ignored when ClientCertificateAuth provides its own ca_file. + Overridden by ``ClientCertificateAuth`` when a custom ``ca_file`` is provided. service_path: OData service base path prefix - do not override unless deploying to a non-standard environment. """ @@ -33,7 +33,12 @@ class ConsentSDKConfig: service_path: str = "/sap/cp/kernel/dpi/consent/odata/v4" def __post_init__(self) -> None: - """Validate base_url format and auth type after dataclass construction.""" + """Validate *base_url* format and *auth* type after dataclass construction. + + Raises: + ValueError: If *base_url* is not a valid HTTP(S) URL, or if *auth* is + not an ``AuthProvider`` instance. + """ logger.info("Invoked ConsentSDKConfig.__post_init__") if not _URL_PATTERN.match(self.base_url): logger.error("Invalid base_url — value=%r", self.base_url) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py index 999fd6e5..ba0cf36f 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py @@ -10,14 +10,27 @@ @dataclass class CheckConsentExistsResult: - """Returned by checkConsentExists action.""" + """Result returned by the ``checkConsentExists`` OData action. + + Attributes: + consent_id: UUID of the matching Consent record, or ``None`` if none was found. + consent_exists: ``True`` if an active consent exists for the queried + data subject and template, ``False`` otherwise. + """ consent_id: str | None = None consent_exists: bool | None = None @classmethod def from_dict(cls, data: dict[str, Any]) -> CheckConsentExistsResult: - """Construct from a raw OData action response dict.""" + """Construct a ``CheckConsentExistsResult`` from a raw OData action response dict. + + Args: + data: Parsed JSON response body from the ``checkConsentExists`` action. + + Returns: + A populated ``CheckConsentExistsResult`` instance. + """ return cls( consent_id=data.get("consentId"), consent_exists=data.get("consentExists") ) @@ -25,7 +38,27 @@ def from_dict(cls, data: dict[str, Any]) -> CheckConsentExistsResult: @dataclass class CreateConsentRequest(_CamelSerializable): - """Input for createConsentFromTemplate / createConsentFromTemplateAsync.""" + """Input DTO for the ``createConsentFromTemplate`` OData action. + + Required fields must be provided; optional fields default to ``None`` and are + omitted from the serialised payload when not set. + + Attributes: + data_subject_id: Identifier of the data subject giving consent. + template_name: Name of the ConsentTemplate to create from. + language_code: BCP-47 language code for the consent text (e.g. ``"en"``). + data_subject_type_name: Name of the DataSubjectType. + jurisdiction_code: Code of the applicable Jurisdiction. + data_subject_description: Optional human-readable description of the data subject. + outbound_channel_type_name: Optional name of the outbound communication channel type. + outbound_channel: Optional identifier of the specific outbound channel. + valid_from: Optional ISO-8601 date string for the consent start date. + application_template_id: Optional application-level template identifier. + controller_name: Optional name of the data controller. + granted_by: Optional identifier of the person who recorded the consent grant. + granted_at: Optional ISO-8601 datetime string when consent was granted. + submission_site: Optional site identifier where consent was collected. + """ data_subject_id: str template_name: str @@ -45,7 +78,14 @@ class CreateConsentRequest(_CamelSerializable): @dataclass class WithdrawConsentRequest(_CamelSerializable): - """Input for withdrawConsent and terminateConsent.""" + """Input DTO for the ``withdrawConsent`` and ``terminateConsent`` OData actions. + + Attributes: + consent_id: UUID of the Consent record to withdraw or terminate. + withdrawn_by: Identifier of the person or system initiating the withdrawal. + withdrawn_at: Optional ISO-8601 datetime string when the withdrawal occurred. + Defaults to the server timestamp when omitted. + """ consent_id: str withdrawn_by: str diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py b/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py index 6d9420ac..cb6026e4 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py @@ -5,7 +5,13 @@ class ConsentSDKError(Exception): """Base exception for all Consent SDK errors.""" def __init__(self, message: str, odata_error: dict | None = None) -> None: - """Store the OData error payload alongside the human-readable message.""" + """Store the error message and optional OData error payload. + + Args: + message: Human-readable error description. + odata_error: Parsed OData ``error`` object from the response body, + if available. Defaults to an empty dict when not provided. + """ super().__init__(message) self.odata_error = odata_error or {} @@ -15,11 +21,11 @@ class ClientCreationError(ConsentSDKError): class AuthenticationError(ConsentSDKError): - """Raised when the bearer token is missing or rejected.""" + """Raised on HTTP 401 - credentials are missing, expired, or rejected by the service.""" class AuthorizationError(ConsentSDKError): - """Raised when the caller lacks the required OData role.""" + """Raised on HTTP 403 - the caller is authenticated but lacks permission for the operation.""" class ValidationError(ConsentSDKError): @@ -35,11 +41,18 @@ class ConflictError(ConsentSDKError): class ODataError(ConsentSDKError): - """Raised for unexpected OData service error responses.""" + """Raised for unexpected OData service error responses (any status not covered by a subclass).""" def __init__( self, message: str, status_code: int, odata_error: dict | None = None ) -> None: - """Store the HTTP status code alongside the OData error payload.""" + """Store the HTTP status code alongside the OData error payload. + + Args: + message: Human-readable error description. + status_code: HTTP status code returned by the service. + odata_error: Parsed OData ``error`` object from the response body, + if available. + """ super().__init__(message, odata_error) self.status_code = status_code diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py index 0b837e09..9fdb780a 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py @@ -6,7 +6,18 @@ def _apply_query(q: Any, params: dict[str, Any]) -> Any: - """Apply OData query options (filter, top, skip, orderby) to a Query builder.""" + """Apply supported OData query options to a Query builder and return it. + + Supported keys: ``filter``, ``top``, ``skip``, ``orderby``. + Unknown keys are silently ignored. + + Args: + q: A python-odata ``Query`` instance to apply options to. + params: Mapping of OData option names to their values. + + Returns: + The Query instance with all supported options applied. + """ if "filter" in params: q = q.raw({"$filter": params["filter"]}) if "top" in params: diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py index a2003a71..f843fb16 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py @@ -47,7 +47,18 @@ def __init__( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_THIRD_PARTIES) def list_third_parties(self, **query: Any) -> list[Any]: - """Return all third-party records, optionally filtered/paged via OData query kwargs.""" + """Return all third-party records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of ThirdParty objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_third_parties") result = _apply_query(self._client.query(_SVC, self.ThirdParty), query).all() logger.info("Exiting ConsentConfigurationService.list_third_parties") @@ -55,7 +66,18 @@ def list_third_parties(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_THIRD_PARTY) def get_third_party(self, third_party_id: str) -> Any: - """Return a single ThirdParty entity by its UUID.""" + """Return a single ThirdParty entity by its UUID. + + Args: + third_party_id: UUID of the ThirdParty to retrieve. + + Returns: + The matching ThirdParty object. + + Raises: + NotFoundError: If no ThirdParty with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_third_party") result = self._client.query(_SVC, self.ThirdParty).get(third_party_id) logger.info("Exiting ConsentConfigurationService.get_third_party") @@ -63,7 +85,18 @@ def get_third_party(self, third_party_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY) def create_third_party(self, body: dict[str, Any]) -> Any: - """Create a new ThirdParty entity and return it.""" + """Create a new ThirdParty entity and return it. + + Args: + body: Dictionary of field names and values for the new ThirdParty. + + Returns: + The newly created ThirdParty object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_third_party") entity = self.ThirdParty() for k, v in body.items(): @@ -74,7 +107,20 @@ def create_third_party(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY) def update_third_party(self, third_party_id: str, body: dict[str, Any]) -> Any: - """Fetch a ThirdParty by ID, apply field updates, and PATCH it.""" + """Fetch a ThirdParty by ID, apply field updates, and PATCH it to the service. + + Args: + third_party_id: UUID of the ThirdParty to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated ThirdParty object. + + Raises: + NotFoundError: If no ThirdParty with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_third_party") entity = self._client.query(_SVC, self.ThirdParty).get(third_party_id) for k, v in body.items(): @@ -85,7 +131,15 @@ def update_third_party(self, third_party_id: str, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY) def delete_third_party(self, third_party_id: str) -> None: - """Delete a ThirdParty entity by its UUID.""" + """Delete a ThirdParty entity by its UUID. + + Args: + third_party_id: UUID of the ThirdParty to delete. + + Raises: + NotFoundError: If no ThirdParty with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_third_party") entity = self._client.query(_SVC, self.ThirdParty).get(third_party_id) self._client.delete_entity(entity) @@ -95,7 +149,18 @@ def delete_third_party(self, third_party_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_JURISDICTIONS) def list_jurisdictions(self, **query: Any) -> list[Any]: - """Return all jurisdiction records, optionally filtered/paged.""" + """Return all jurisdiction records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of Jurisdiction objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_jurisdictions") result = _apply_query(self._client.query(_SVC, self.Jurisdiction), query).all() logger.info("Exiting ConsentConfigurationService.list_jurisdictions") @@ -103,7 +168,18 @@ def list_jurisdictions(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_JURISDICTION) def get_jurisdiction(self, jurisdiction_id: str) -> Any: - """Return a single Jurisdiction entity by its UUID.""" + """Return a single Jurisdiction entity by its UUID. + + Args: + jurisdiction_id: UUID of the Jurisdiction to retrieve. + + Returns: + The matching Jurisdiction object. + + Raises: + NotFoundError: If no Jurisdiction with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_jurisdiction") result = self._client.query(_SVC, self.Jurisdiction).get(jurisdiction_id) logger.info("Exiting ConsentConfigurationService.get_jurisdiction") @@ -111,7 +187,18 @@ def get_jurisdiction(self, jurisdiction_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_JURISDICTION) def create_jurisdiction(self, body: dict[str, Any]) -> Any: - """Create a new Jurisdiction entity and return it.""" + """Create a new Jurisdiction entity and return it. + + Args: + body: Dictionary of field names and values for the new Jurisdiction. + + Returns: + The newly created Jurisdiction object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_jurisdiction") entity = self.Jurisdiction() for k, v in body.items(): @@ -122,7 +209,20 @@ def create_jurisdiction(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_JURISDICTION) def update_jurisdiction(self, jurisdiction_id: str, body: dict[str, Any]) -> Any: - """Fetch a Jurisdiction by ID, apply field updates, and PATCH it.""" + """Fetch a Jurisdiction by ID, apply field updates, and PATCH it to the service. + + Args: + jurisdiction_id: UUID of the Jurisdiction to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated Jurisdiction object. + + Raises: + NotFoundError: If no Jurisdiction with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_jurisdiction") entity = self._client.query(_SVC, self.Jurisdiction).get(jurisdiction_id) for k, v in body.items(): @@ -133,7 +233,15 @@ def update_jurisdiction(self, jurisdiction_id: str, body: dict[str, Any]) -> Any @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_JURISDICTION) def delete_jurisdiction(self, jurisdiction_id: str) -> None: - """Delete a Jurisdiction entity by its UUID.""" + """Delete a Jurisdiction entity by its UUID. + + Args: + jurisdiction_id: UUID of the Jurisdiction to delete. + + Raises: + NotFoundError: If no Jurisdiction with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_jurisdiction") entity = self._client.query(_SVC, self.Jurisdiction).get(jurisdiction_id) self._client.delete_entity(entity) @@ -143,7 +251,18 @@ def delete_jurisdiction(self, jurisdiction_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_JURISDICTION_TEXTS) def list_jurisdiction_texts(self, **query: Any) -> list[Any]: - """Return all jurisdiction text records, optionally filtered/paged.""" + """Return all jurisdiction text records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of JurisdictionText objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_jurisdiction_texts") result = _apply_query( self._client.query(_SVC, self.JurisdictionText), query @@ -153,7 +272,19 @@ def list_jurisdiction_texts(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_JURISDICTION_TEXT) def create_jurisdiction_text(self, body: dict[str, Any]) -> Any: - """Create a new JurisdictionText entity and return it.""" + """Create a new JurisdictionText entity and return it. + + Args: + body: Dictionary of field names and values for the new JurisdictionText. + Must include ``jurisdictionId`` and ``languageCode`` as the composite key. + + Returns: + The newly created JurisdictionText object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_jurisdiction_text") entity = self.JurisdictionText() for k, v in body.items(): @@ -166,7 +297,21 @@ def create_jurisdiction_text(self, body: dict[str, Any]) -> Any: def update_jurisdiction_text( self, jurisdiction_id: str, language_code: str, body: dict[str, Any] ) -> Any: - """Fetch a JurisdictionText by composite key, apply updates, and PATCH it.""" + """Fetch a JurisdictionText by composite key, apply field updates, and PATCH it to the service. + + Args: + jurisdiction_id: UUID of the parent Jurisdiction. + language_code: BCP-47 language code identifying the text entry. + body: Dictionary of field names and values to apply. + + Returns: + The updated JurisdictionText object. + + Raises: + NotFoundError: If no JurisdictionText for the given composite key exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_jurisdiction_text") entity = self._client.query(_SVC, self.JurisdictionText).get( jurisdictionId=jurisdiction_id, languageCode=language_code @@ -181,7 +326,16 @@ def update_jurisdiction_text( def delete_jurisdiction_text( self, jurisdiction_id: str, language_code: str ) -> None: - """Delete a JurisdictionText by its composite key.""" + """Delete a JurisdictionText entity by its composite key. + + Args: + jurisdiction_id: UUID of the parent Jurisdiction. + language_code: BCP-47 language code identifying the text entry to delete. + + Raises: + NotFoundError: If no JurisdictionText for the given composite key exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_jurisdiction_text") entity = self._client.query(_SVC, self.JurisdictionText).get( jurisdictionId=jurisdiction_id, languageCode=language_code @@ -193,7 +347,18 @@ def delete_jurisdiction_text( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_LANGUAGES) def list_languages(self, **query: Any) -> list[Any]: - """Return all language reference records.""" + """Return all language reference records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of Language objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_languages") result = _apply_query(self._client.query(_SVC, self.Language), query).all() logger.info("Exiting ConsentConfigurationService.list_languages") @@ -201,7 +366,18 @@ def list_languages(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_LANGUAGE) def get_language(self, language_code: str) -> Any: - """Return a single Language entity by its code.""" + """Return a single Language entity by its BCP-47 code. + + Args: + language_code: BCP-47 language code of the Language to retrieve. + + Returns: + The matching Language object. + + Raises: + NotFoundError: If no Language with the given code exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_language") result = self._client.query(_SVC, self.Language).get(language_code) logger.info("Exiting ConsentConfigurationService.get_language") @@ -211,7 +387,18 @@ def get_language(self, language_code: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_LANGUAGE_DESCRIPTIONS) def list_language_descriptions(self, **query: Any) -> list[Any]: - """Return all language description records.""" + """Return all language description records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of LanguageDescription objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_language_descriptions") result = _apply_query( self._client.query(_SVC, self.LanguageDescription), query @@ -221,7 +408,19 @@ def list_language_descriptions(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_LANGUAGE_DESCRIPTION) def create_language_description(self, body: dict[str, Any]) -> Any: - """Create a new LanguageDescription entity and return it.""" + """Create a new LanguageDescription entity and return it. + + Args: + body: Dictionary of field names and values for the new LanguageDescription. + Must include ``languageCode`` as the primary key. + + Returns: + The newly created LanguageDescription object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_language_description") entity = self.LanguageDescription() for k, v in body.items(): @@ -234,7 +433,20 @@ def create_language_description(self, body: dict[str, Any]) -> Any: def update_language_description( self, language_code: str, body: dict[str, Any] ) -> Any: - """Fetch a LanguageDescription by code, apply updates, and PATCH it.""" + """Fetch a LanguageDescription by language code, apply field updates, and PATCH it to the service. + + Args: + language_code: BCP-47 language code of the LanguageDescription to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated LanguageDescription object. + + Raises: + NotFoundError: If no LanguageDescription with the given code exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_language_description") entity = self._client.query(_SVC, self.LanguageDescription).get(language_code) for k, v in body.items(): @@ -245,7 +457,15 @@ def update_language_description( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_LANGUAGE_DESCRIPTION) def delete_language_description(self, language_code: str) -> None: - """Delete a LanguageDescription entity by its language code.""" + """Delete a LanguageDescription entity by its language code. + + Args: + language_code: BCP-47 language code of the LanguageDescription to delete. + + Raises: + NotFoundError: If no LanguageDescription with the given code exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_language_description") entity = self._client.query(_SVC, self.LanguageDescription).get(language_code) self._client.delete_entity(entity) @@ -255,7 +475,18 @@ def delete_language_description(self, language_code: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_SOURCE_INFOS) def list_source_infos(self, **query: Any) -> list[Any]: - """Return all source info records, optionally filtered/paged.""" + """Return all source info records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of SourceInfo objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_source_infos") result = _apply_query(self._client.query(_SVC, self.SourceInfo), query).all() logger.info("Exiting ConsentConfigurationService.list_source_infos") @@ -263,7 +494,18 @@ def list_source_infos(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_SOURCE_INFO) def get_source_info(self, source_id: str) -> Any: - """Return a single SourceInfo entity by its UUID.""" + """Return a single SourceInfo entity by its UUID. + + Args: + source_id: UUID of the SourceInfo to retrieve. + + Returns: + The matching SourceInfo object. + + Raises: + NotFoundError: If no SourceInfo with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_source_info") result = self._client.query(_SVC, self.SourceInfo).get(source_id) logger.info("Exiting ConsentConfigurationService.get_source_info") @@ -271,7 +513,18 @@ def get_source_info(self, source_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_SOURCE_INFO) def create_source_info(self, body: dict[str, Any]) -> Any: - """Create a new SourceInfo entity and return it.""" + """Create a new SourceInfo entity and return it. + + Args: + body: Dictionary of field names and values for the new SourceInfo. + + Returns: + The newly created SourceInfo object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_source_info") entity = self.SourceInfo() for k, v in body.items(): @@ -282,7 +535,20 @@ def create_source_info(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_SOURCE_INFO) def update_source_info(self, source_id: str, body: dict[str, Any]) -> Any: - """Fetch a SourceInfo by ID, apply field updates, and PATCH it.""" + """Fetch a SourceInfo by ID, apply field updates, and PATCH it to the service. + + Args: + source_id: UUID of the SourceInfo to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated SourceInfo object. + + Raises: + NotFoundError: If no SourceInfo with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_source_info") entity = self._client.query(_SVC, self.SourceInfo).get(source_id) for k, v in body.items(): @@ -293,7 +559,15 @@ def update_source_info(self, source_id: str, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_SOURCE_INFO) def delete_source_info(self, source_id: str) -> None: - """Delete a SourceInfo entity by its UUID.""" + """Delete a SourceInfo entity by its UUID. + + Args: + source_id: UUID of the SourceInfo to delete. + + Raises: + NotFoundError: If no SourceInfo with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_source_info") entity = self._client.query(_SVC, self.SourceInfo).get(source_id) self._client.delete_entity(entity) @@ -303,7 +577,18 @@ def delete_source_info(self, source_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_CONTROLLERS) def list_controllers(self, **query: Any) -> list[Any]: - """Return all controller records, optionally filtered/paged.""" + """Return all controller records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of Controller objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_controllers") result = _apply_query(self._client.query(_SVC, self.Controller), query).all() logger.info("Exiting ConsentConfigurationService.list_controllers") @@ -311,7 +596,18 @@ def list_controllers(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_CONTROLLER) def get_controller(self, controller_id: str) -> Any: - """Return a single Controller entity by its UUID.""" + """Return a single Controller entity by its UUID. + + Args: + controller_id: UUID of the Controller to retrieve. + + Returns: + The matching Controller object. + + Raises: + NotFoundError: If no Controller with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_controller") result = self._client.query(_SVC, self.Controller).get(controller_id) logger.info("Exiting ConsentConfigurationService.get_controller") @@ -319,7 +615,18 @@ def get_controller(self, controller_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CONTROLLER) def create_controller(self, body: dict[str, Any]) -> Any: - """Create a new Controller entity and return it.""" + """Create a new Controller entity and return it. + + Args: + body: Dictionary of field names and values for the new Controller. + + Returns: + The newly created Controller object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_controller") entity = self.Controller() for k, v in body.items(): @@ -330,7 +637,20 @@ def create_controller(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_CONTROLLER) def update_controller(self, controller_id: str, body: dict[str, Any]) -> Any: - """Fetch a Controller by ID, apply field updates, and PATCH it.""" + """Fetch a Controller by ID, apply field updates, and PATCH it to the service. + + Args: + controller_id: UUID of the Controller to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated Controller object. + + Raises: + NotFoundError: If no Controller with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_controller") entity = self._client.query(_SVC, self.Controller).get(controller_id) for k, v in body.items(): @@ -341,7 +661,15 @@ def update_controller(self, controller_id: str, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_CONTROLLER) def delete_controller(self, controller_id: str) -> None: - """Delete a Controller entity by its UUID.""" + """Delete a Controller entity by its UUID. + + Args: + controller_id: UUID of the Controller to delete. + + Raises: + NotFoundError: If no Controller with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_controller") entity = self._client.query(_SVC, self.Controller).get(controller_id) self._client.delete_entity(entity) @@ -351,7 +679,18 @@ def delete_controller(self, controller_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_DATA_SUBJECT_TYPES) def list_data_subject_types(self, **query: Any) -> list[Any]: - """Return all data subject type records, optionally filtered/paged.""" + """Return all data subject type records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of DataSubjectType objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_data_subject_types") result = _apply_query( self._client.query(_SVC, self.DataSubjectType), query @@ -361,7 +700,18 @@ def list_data_subject_types(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_DATA_SUBJECT_TYPE) def get_data_subject_type(self, data_subject_type_id: str) -> Any: - """Return a single DataSubjectType entity by its UUID.""" + """Return a single DataSubjectType entity by its UUID. + + Args: + data_subject_type_id: UUID of the DataSubjectType to retrieve. + + Returns: + The matching DataSubjectType object. + + Raises: + NotFoundError: If no DataSubjectType with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_data_subject_type") result = self._client.query(_SVC, self.DataSubjectType).get( data_subject_type_id @@ -371,7 +721,18 @@ def get_data_subject_type(self, data_subject_type_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_DATA_SUBJECT_TYPE) def create_data_subject_type(self, body: dict[str, Any]) -> Any: - """Create a new DataSubjectType entity and return it.""" + """Create a new DataSubjectType entity and return it. + + Args: + body: Dictionary of field names and values for the new DataSubjectType. + + Returns: + The newly created DataSubjectType object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_data_subject_type") entity = self.DataSubjectType() for k, v in body.items(): @@ -384,7 +745,20 @@ def create_data_subject_type(self, body: dict[str, Any]) -> Any: def update_data_subject_type( self, data_subject_type_id: str, body: dict[str, Any] ) -> Any: - """Fetch a DataSubjectType by ID, apply field updates, and PATCH it.""" + """Fetch a DataSubjectType by ID, apply field updates, and PATCH it to the service. + + Args: + data_subject_type_id: UUID of the DataSubjectType to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated DataSubjectType object. + + Raises: + NotFoundError: If no DataSubjectType with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_data_subject_type") entity = self._client.query(_SVC, self.DataSubjectType).get( data_subject_type_id @@ -397,7 +771,15 @@ def update_data_subject_type( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_DATA_SUBJECT_TYPE) def delete_data_subject_type(self, data_subject_type_id: str) -> None: - """Delete a DataSubjectType entity by its UUID.""" + """Delete a DataSubjectType entity by its UUID. + + Args: + data_subject_type_id: UUID of the DataSubjectType to delete. + + Raises: + NotFoundError: If no DataSubjectType with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_data_subject_type") entity = self._client.query(_SVC, self.DataSubjectType).get( data_subject_type_id @@ -409,7 +791,18 @@ def delete_data_subject_type(self, data_subject_type_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_APPLICATIONS) def list_applications(self, **query: Any) -> list[Any]: - """Return all application records, optionally filtered/paged.""" + """Return all application records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of Application objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_applications") result = _apply_query(self._client.query(_SVC, self.Application), query).all() logger.info("Exiting ConsentConfigurationService.list_applications") @@ -417,7 +810,18 @@ def list_applications(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_APPLICATION) def get_application(self, application_id: str) -> Any: - """Return a single Application entity by its UUID.""" + """Return a single Application entity by its UUID. + + Args: + application_id: UUID of the Application to retrieve. + + Returns: + The matching Application object. + + Raises: + NotFoundError: If no Application with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_application") result = self._client.query(_SVC, self.Application).get(application_id) logger.info("Exiting ConsentConfigurationService.get_application") @@ -425,7 +829,18 @@ def get_application(self, application_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_APPLICATION) def create_application(self, body: dict[str, Any]) -> Any: - """Create a new Application entity and return it.""" + """Create a new Application entity and return it. + + Args: + body: Dictionary of field names and values for the new Application. + + Returns: + The newly created Application object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_application") entity = self.Application() for k, v in body.items(): @@ -436,7 +851,20 @@ def create_application(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_APPLICATION) def update_application(self, application_id: str, body: dict[str, Any]) -> Any: - """Fetch an Application by ID, apply field updates, and PATCH it.""" + """Fetch an Application by ID, apply field updates, and PATCH it to the service. + + Args: + application_id: UUID of the Application to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated Application object. + + Raises: + NotFoundError: If no Application with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_application") entity = self._client.query(_SVC, self.Application).get(application_id) for k, v in body.items(): @@ -447,7 +875,15 @@ def update_application(self, application_id: str, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_APPLICATION) def delete_application(self, application_id: str) -> None: - """Delete an Application entity by its UUID.""" + """Delete an Application entity by its UUID. + + Args: + application_id: UUID of the Application to delete. + + Raises: + NotFoundError: If no Application with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_application") entity = self._client.query(_SVC, self.Application).get(application_id) self._client.delete_entity(entity) @@ -457,7 +893,18 @@ def delete_application(self, application_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_MASTER_DATA_SOURCES) def list_master_data_sources(self, **query: Any) -> list[Any]: - """Return all master data source records, optionally filtered/paged.""" + """Return all master data source records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of MasterDataSource objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_master_data_sources") result = _apply_query( self._client.query(_SVC, self.MasterDataSource), query @@ -467,7 +914,18 @@ def list_master_data_sources(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_MASTER_DATA_SOURCE) def get_master_data_source(self, master_data_source_id: str) -> Any: - """Return a single MasterDataSource entity by its UUID.""" + """Return a single MasterDataSource entity by its UUID. + + Args: + master_data_source_id: UUID of the MasterDataSource to retrieve. + + Returns: + The matching MasterDataSource object. + + Raises: + NotFoundError: If no MasterDataSource with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_master_data_source") result = self._client.query(_SVC, self.MasterDataSource).get( master_data_source_id @@ -477,7 +935,18 @@ def get_master_data_source(self, master_data_source_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_MASTER_DATA_SOURCE) def create_master_data_source(self, body: dict[str, Any]) -> Any: - """Create a new MasterDataSource entity and return it.""" + """Create a new MasterDataSource entity and return it. + + Args: + body: Dictionary of field names and values for the new MasterDataSource. + + Returns: + The newly created MasterDataSource object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_master_data_source") entity = self.MasterDataSource() for k, v in body.items(): @@ -490,7 +959,20 @@ def create_master_data_source(self, body: dict[str, Any]) -> Any: def update_master_data_source( self, master_data_source_id: str, body: dict[str, Any] ) -> Any: - """Fetch a MasterDataSource by ID, apply field updates, and PATCH it.""" + """Fetch a MasterDataSource by ID, apply field updates, and PATCH it to the service. + + Args: + master_data_source_id: UUID of the MasterDataSource to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated MasterDataSource object. + + Raises: + NotFoundError: If no MasterDataSource with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_master_data_source") entity = self._client.query(_SVC, self.MasterDataSource).get( master_data_source_id @@ -503,7 +985,15 @@ def update_master_data_source( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_MASTER_DATA_SOURCE) def delete_master_data_source(self, master_data_source_id: str) -> None: - """Delete a MasterDataSource entity by its UUID.""" + """Delete a MasterDataSource entity by its UUID. + + Args: + master_data_source_id: UUID of the MasterDataSource to delete. + + Raises: + NotFoundError: If no MasterDataSource with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_master_data_source") entity = self._client.query(_SVC, self.MasterDataSource).get( master_data_source_id @@ -515,7 +1005,18 @@ def delete_master_data_source(self, master_data_source_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_OUTBOUND_CHANNEL_TYPES) def list_outbound_channel_types(self, **query: Any) -> list[Any]: - """Return all outbound channel type records, optionally filtered/paged.""" + """Return all outbound channel type records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of OutboundChannelType objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.list_outbound_channel_types") result = _apply_query( self._client.query(_SVC, self.OutboundChannelType), query @@ -525,7 +1026,18 @@ def list_outbound_channel_types(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_OUTBOUND_CHANNEL_TYPE) def get_outbound_channel_type(self, outbound_channel_type_id: str) -> Any: - """Return a single OutboundChannelType entity by its UUID.""" + """Return a single OutboundChannelType entity by its UUID. + + Args: + outbound_channel_type_id: UUID of the OutboundChannelType to retrieve. + + Returns: + The matching OutboundChannelType object. + + Raises: + NotFoundError: If no OutboundChannelType with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.get_outbound_channel_type") result = self._client.query(_SVC, self.OutboundChannelType).get( outbound_channel_type_id @@ -537,7 +1049,18 @@ def get_outbound_channel_type(self, outbound_channel_type_id: str) -> Any: Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_OUTBOUND_CHANNEL_TYPE ) def create_outbound_channel_type(self, body: dict[str, Any]) -> Any: - """Create a new OutboundChannelType entity and return it.""" + """Create a new OutboundChannelType entity and return it. + + Args: + body: Dictionary of field names and values for the new OutboundChannelType. + + Returns: + The newly created OutboundChannelType object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.create_outbound_channel_type") entity = self.OutboundChannelType() for k, v in body.items(): @@ -552,7 +1075,20 @@ def create_outbound_channel_type(self, body: dict[str, Any]) -> Any: def update_outbound_channel_type( self, outbound_channel_type_id: str, body: dict[str, Any] ) -> Any: - """Fetch an OutboundChannelType by ID, apply field updates, and PATCH it.""" + """Fetch an OutboundChannelType by ID, apply field updates, and PATCH it to the service. + + Args: + outbound_channel_type_id: UUID of the OutboundChannelType to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated OutboundChannelType object. + + Raises: + NotFoundError: If no OutboundChannelType with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.update_outbound_channel_type") entity = self._client.query(_SVC, self.OutboundChannelType).get( outbound_channel_type_id @@ -567,7 +1103,15 @@ def update_outbound_channel_type( Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_OUTBOUND_CHANNEL_TYPE ) def delete_outbound_channel_type(self, outbound_channel_type_id: str) -> None: - """Delete an OutboundChannelType entity by its UUID.""" + """Delete an OutboundChannelType entity by its UUID. + + Args: + outbound_channel_type_id: UUID of the OutboundChannelType to delete. + + Raises: + NotFoundError: If no OutboundChannelType with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentConfigurationService.delete_outbound_channel_type") entity = self._client.query(_SVC, self.OutboundChannelType).get( outbound_channel_type_id diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py index 53ce65d4..472624bf 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py @@ -38,7 +38,18 @@ def __init__( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_PURPOSES) def list_purposes(self, **query: Any) -> list[Any]: - """Return all consent purposes, optionally filtered/paged via OData query kwargs.""" + """Return all consent purpose records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of ConsentPurpose objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.list_purposes") result = _apply_query( self._client.query(_SVC, self.ConsentPurpose), query @@ -48,7 +59,18 @@ def list_purposes(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_PURPOSE) def get_purpose(self, purpose_id: str) -> Any: - """Return a single ConsentPurpose entity by its UUID.""" + """Return a single ConsentPurpose entity by its UUID. + + Args: + purpose_id: UUID of the ConsentPurpose to retrieve. + + Returns: + The matching ConsentPurpose object. + + Raises: + NotFoundError: If no ConsentPurpose with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.get_purpose") result = self._client.query(_SVC, self.ConsentPurpose).get(purpose_id) logger.info("Exiting ConsentPurposeService.get_purpose") @@ -56,7 +78,18 @@ def get_purpose(self, purpose_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_PURPOSE) def create_purpose(self, body: dict[str, Any]) -> Any: - """Create a new ConsentPurpose entity and return it.""" + """Create a new ConsentPurpose entity and return it. + + Args: + body: Dictionary of field names and values for the new ConsentPurpose. + + Returns: + The newly created ConsentPurpose object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.create_purpose") entity = self.ConsentPurpose() for k, v in body.items(): @@ -67,7 +100,20 @@ def create_purpose(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_PURPOSE) def update_purpose(self, purpose_id: str, body: dict[str, Any]) -> Any: - """Fetch a ConsentPurpose by ID, apply field updates, and PATCH it.""" + """Fetch a ConsentPurpose by ID, apply field updates, and PATCH it to the service. + + Args: + purpose_id: UUID of the ConsentPurpose to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated ConsentPurpose object. + + Raises: + NotFoundError: If no ConsentPurpose with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.update_purpose") entity = self._client.query(_SVC, self.ConsentPurpose).get(purpose_id) for k, v in body.items(): @@ -78,7 +124,15 @@ def update_purpose(self, purpose_id: str, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_PURPOSE) def delete_purpose(self, purpose_id: str) -> None: - """Delete a ConsentPurpose by its UUID.""" + """Delete a ConsentPurpose entity by its UUID. + + Args: + purpose_id: UUID of the ConsentPurpose to delete. + + Raises: + NotFoundError: If no ConsentPurpose with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.delete_purpose") entity = self._client.query(_SVC, self.ConsentPurpose).get(purpose_id) self._client.delete_entity(entity) @@ -88,7 +142,18 @@ def delete_purpose(self, purpose_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_PURPOSE_ACTIVE) def set_purpose_active(self, purpose_id: str) -> Any: - """Activate a consent purpose and return the refreshed entity.""" + """Activate a consent purpose and return the refreshed entity. + + Args: + purpose_id: UUID of the ConsentPurpose to activate. + + Returns: + The refreshed ConsentPurpose object with its status set to active. + + Raises: + NotFoundError: If no ConsentPurpose with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.set_purpose_active") self._client.call_action( _SVC, "consentPurposeSetConsentPurposeToActive", {"purposeId": purpose_id} @@ -99,7 +164,18 @@ def set_purpose_active(self, purpose_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_PURPOSE_INACTIVE) def set_purpose_inactive(self, purpose_id: str) -> Any: - """Deactivate a consent purpose and return the refreshed entity.""" + """Deactivate a consent purpose and return the refreshed entity. + + Args: + purpose_id: UUID of the ConsentPurpose to deactivate. + + Returns: + The refreshed ConsentPurpose object with its status set to inactive. + + Raises: + NotFoundError: If no ConsentPurpose with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.set_purpose_inactive") self._client.call_action( _SVC, "consentPurposeSetConsentPurposeToInactive", {"purposeId": purpose_id} @@ -112,7 +188,18 @@ def set_purpose_inactive(self, purpose_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_PURPOSE_TEXTS) def list_purpose_texts(self, **query: Any) -> list[Any]: - """Return all purpose text records, optionally filtered/paged.""" + """Return all consent purpose text records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of ConsentPurposeText objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.list_purpose_texts") result = _apply_query( self._client.query(_SVC, self.ConsentPurposeText), query @@ -124,7 +211,20 @@ def list_purpose_texts(self, **query: Any) -> list[Any]: def get_purpose_text( self, purpose_id: str, type_code: str, language_code: str ) -> Any: - """Return a single ConsentPurposeText by its composite key.""" + """Return a single ConsentPurposeText entity by its composite key. + + Args: + purpose_id: UUID of the parent ConsentPurpose. + type_code: Type code identifying the text category. + language_code: BCP-47 language code of the text entry. + + Returns: + The matching ConsentPurposeText object. + + Raises: + NotFoundError: If no ConsentPurposeText for the given composite key exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.get_purpose_text") result = self._client.query(_SVC, self.ConsentPurposeText).get( purposeId=purpose_id, typeCode=type_code, languageCode=language_code @@ -134,7 +234,19 @@ def get_purpose_text( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_PURPOSE_TEXT) def create_purpose_text(self, body: dict[str, Any]) -> Any: - """Create a new ConsentPurposeText entity and return it.""" + """Create a new ConsentPurposeText entity and return it. + + Args: + body: Dictionary of field names and values for the new ConsentPurposeText. + Must include ``purposeId``, ``typeCode``, and ``languageCode`` as the composite key. + + Returns: + The newly created ConsentPurposeText object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.create_purpose_text") entity = self.ConsentPurposeText() for k, v in body.items(): @@ -147,7 +259,22 @@ def create_purpose_text(self, body: dict[str, Any]) -> Any: def update_purpose_text( self, purpose_id: str, type_code: str, language_code: str, body: dict[str, Any] ) -> Any: - """Fetch a ConsentPurposeText by composite key, apply updates, and PATCH it.""" + """Fetch a ConsentPurposeText by composite key, apply field updates, and PATCH it to the service. + + Args: + purpose_id: UUID of the parent ConsentPurpose. + type_code: Type code identifying the text category. + language_code: BCP-47 language code of the text entry. + body: Dictionary of field names and values to apply. + + Returns: + The updated ConsentPurposeText object. + + Raises: + NotFoundError: If no ConsentPurposeText for the given composite key exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.update_purpose_text") entity = self._client.query(_SVC, self.ConsentPurposeText).get( purposeId=purpose_id, typeCode=type_code, languageCode=language_code @@ -162,7 +289,17 @@ def update_purpose_text( def delete_purpose_text( self, purpose_id: str, type_code: str, language_code: str ) -> None: - """Delete a ConsentPurposeText by its composite key.""" + """Delete a ConsentPurposeText entity by its composite key. + + Args: + purpose_id: UUID of the parent ConsentPurpose. + type_code: Type code identifying the text category. + language_code: BCP-47 language code of the text entry to delete. + + Raises: + NotFoundError: If no ConsentPurposeText for the given composite key exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentPurposeService.delete_purpose_text") entity = self._client.query(_SVC, self.ConsentPurposeText).get( purposeId=purpose_id, typeCode=type_code, languageCode=language_code diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py index e1edcbc5..e1172a72 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py @@ -35,7 +35,18 @@ def __init__( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_RULES) def list_rules(self, **query: Any) -> list[Any]: - """Return all retention rules, optionally filtered/paged via OData query kwargs.""" + """Return all consent retention rule records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of ConsentRetentionRule objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentRetentionService.list_rules") result = _apply_query( self._client.query(_SVC, self.ConsentRetentionRule), query @@ -45,7 +56,18 @@ def list_rules(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_RULE) def get_rule(self, rule_id: str) -> Any: - """Return a single ConsentRetentionRule entity by its UUID.""" + """Return a single ConsentRetentionRule entity by its UUID. + + Args: + rule_id: UUID of the ConsentRetentionRule to retrieve. + + Returns: + The matching ConsentRetentionRule object. + + Raises: + NotFoundError: If no ConsentRetentionRule with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentRetentionService.get_rule") result = self._client.query(_SVC, self.ConsentRetentionRule).get(rule_id) logger.info("Exiting ConsentRetentionService.get_rule") @@ -53,7 +75,18 @@ def get_rule(self, rule_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_RULE) def create_rule(self, body: dict[str, Any]) -> Any: - """Create a new ConsentRetentionRule entity and return it.""" + """Create a new ConsentRetentionRule entity and return it. + + Args: + body: Dictionary of field names and values for the new ConsentRetentionRule. + + Returns: + The newly created ConsentRetentionRule object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentRetentionService.create_rule") entity = self.ConsentRetentionRule() for k, v in body.items(): @@ -64,7 +97,20 @@ def create_rule(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_RULE) def update_rule(self, rule_id: str, body: dict[str, Any]) -> Any: - """Fetch a ConsentRetentionRule by ID, apply field updates, and PATCH it.""" + """Fetch a ConsentRetentionRule by ID, apply field updates, and PATCH it to the service. + + Args: + rule_id: UUID of the ConsentRetentionRule to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated ConsentRetentionRule object. + + Raises: + NotFoundError: If no ConsentRetentionRule with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentRetentionService.update_rule") entity = self._client.query(_SVC, self.ConsentRetentionRule).get(rule_id) for k, v in body.items(): @@ -75,7 +121,15 @@ def update_rule(self, rule_id: str, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_RULE) def delete_rule(self, rule_id: str) -> None: - """Delete a ConsentRetentionRule by its UUID.""" + """Delete a ConsentRetentionRule entity by its UUID. + + Args: + rule_id: UUID of the ConsentRetentionRule to delete. + + Raises: + NotFoundError: If no ConsentRetentionRule with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentRetentionService.delete_rule") entity = self._client.query(_SVC, self.ConsentRetentionRule).get(rule_id) self._client.delete_entity(entity) @@ -85,7 +139,18 @@ def delete_rule(self, rule_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_RULE_ACTIVE) def set_rule_active(self, rule_id: str) -> Any: - """Activate a retention rule and return the refreshed entity.""" + """Activate a consent retention rule and return the refreshed entity. + + Args: + rule_id: UUID of the ConsentRetentionRule to activate. + + Returns: + The refreshed ConsentRetentionRule object with its status set to active. + + Raises: + NotFoundError: If no ConsentRetentionRule with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentRetentionService.set_rule_active") self._client.call_action( _SVC, "consentRetentionRuleSetConsentRetentionToActive", {"ruleId": rule_id} @@ -96,7 +161,18 @@ def set_rule_active(self, rule_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_RULE_INACTIVE) def set_rule_inactive(self, rule_id: str) -> Any: - """Deactivate a retention rule and return the refreshed entity.""" + """Deactivate a consent retention rule and return the refreshed entity. + + Args: + rule_id: UUID of the ConsentRetentionRule to deactivate. + + Returns: + The refreshed ConsentRetentionRule object with its status set to inactive. + + Raises: + NotFoundError: If no ConsentRetentionRule with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentRetentionService.set_rule_inactive") self._client.call_action( _SVC, diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py index ab72e68d..a583e549 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py @@ -40,7 +40,18 @@ def __init__( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_CONSENTS) def list_consents(self, **query: Any) -> list[Any]: - """Return all consents, optionally filtered/paged via OData query kwargs.""" + """Return all consent records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of Consent objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentService.list_consents") result = _apply_query(self._client.query(_SVC, self.Consent), query).all() logger.info("Exiting ConsentService.list_consents") @@ -48,7 +59,18 @@ def list_consents(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_CONSENT) def get_consent(self, consent_id: str) -> Any: - """Return a single Consent entity by its UUID.""" + """Return a single Consent entity by its UUID. + + Args: + consent_id: UUID of the Consent to retrieve. + + Returns: + The matching Consent object. + + Raises: + NotFoundError: If no Consent with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentService.get_consent") result = self._client.query(_SVC, self.Consent).get(consent_id) logger.info("Exiting ConsentService.get_consent") @@ -56,7 +78,15 @@ def get_consent(self, consent_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_CONSENT) def delete_consent(self, consent_id: str) -> None: - """Delete a Consent entity by its UUID.""" + """Delete a Consent entity by its UUID. + + Args: + consent_id: UUID of the Consent to delete. + + Raises: + NotFoundError: If no Consent with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentService.delete_consent") entity = self._client.query(_SVC, self.Consent).get(consent_id) self._client.delete_entity(entity) @@ -68,7 +98,19 @@ def delete_consent(self, consent_id: str) -> None: Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CONSENT_FROM_TEMPLATE ) def create_consent_from_template(self, request: CreateConsentRequest) -> list[Any]: - """Invoke createConsentFromTemplate and return the resulting Consent entities.""" + """Invoke the createConsentFromTemplate OData action and return the resulting Consent entities. + + Args: + request: Populated ``CreateConsentRequest`` with the template name and data subject details. + + Returns: + list of Consent objects created by the action. Returns an empty list if the + service returns no entities. + + Raises: + ValidationError: If the request fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentService.create_consent_from_template") result = self._client.call_action( _SVC, "createConsentFromTemplate", request.to_dict() @@ -89,7 +131,18 @@ def create_consent_from_template(self, request: CreateConsentRequest) -> list[An def withdraw_consent( self, request: WithdrawConsentRequest ) -> dict[str, Any] | None: - """Invoke withdrawConsent and return the raw action response.""" + """Invoke the withdrawConsent OData action and return the raw action response. + + Args: + request: Populated ``WithdrawConsentRequest`` identifying the consent to withdraw. + + Returns: + Raw response dict from the OData action, or ``None`` if the service returns no body. + + Raises: + NotFoundError: If the referenced Consent does not exist. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentService.withdraw_consent") result = self._client.call_action(_SVC, "withdrawConsent", request.to_dict()) logger.info("Exiting ConsentService.withdraw_consent") @@ -99,7 +152,18 @@ def withdraw_consent( def terminate_consent( self, request: WithdrawConsentRequest ) -> dict[str, Any] | None: - """Invoke terminateConsent and return the raw action response.""" + """Invoke the terminateConsent OData action and return the raw action response. + + Args: + request: Populated ``WithdrawConsentRequest`` identifying the consent to terminate. + + Returns: + Raw response dict from the OData action, or ``None`` if the service returns no body. + + Raises: + NotFoundError: If the referenced Consent does not exist. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentService.terminate_consent") result = self._client.call_action(_SVC, "terminateConsent", request.to_dict()) logger.info("Exiting ConsentService.terminate_consent") @@ -109,7 +173,18 @@ def terminate_consent( def check_consent_exists( self, data_subject_id: str, template_id: str ) -> CheckConsentExistsResult: - """Check whether a consent record exists for the given data subject and template.""" + """Check whether an active consent record exists for the given data subject and template. + + Args: + data_subject_id: ID of the data subject to check. + template_id: UUID of the ConsentTemplate to check against. + + Returns: + ``CheckConsentExistsResult`` indicating whether a matching consent was found. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentService.check_consent_exists") result = self._client.call_action( _SVC, diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py index 27671d13..62bf6736 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py @@ -39,7 +39,18 @@ def __init__( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_TEMPLATES) def list_templates(self, **query: Any) -> list[Any]: - """Return all consent templates, optionally filtered/paged via OData query kwargs.""" + """Return all consent template records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of ConsentTemplate objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.list_templates") result = _apply_query( self._client.query(_SVC, self.ConsentTemplate), query @@ -49,7 +60,18 @@ def list_templates(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_TEMPLATE) def get_template(self, template_id: str) -> Any: - """Return a single ConsentTemplate entity by its UUID.""" + """Return a single ConsentTemplate entity by its UUID. + + Args: + template_id: UUID of the ConsentTemplate to retrieve. + + Returns: + The matching ConsentTemplate object. + + Raises: + NotFoundError: If no ConsentTemplate with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.get_template") result = self._client.query(_SVC, self.ConsentTemplate).get(template_id) logger.info("Exiting ConsentTemplateService.get_template") @@ -57,7 +79,18 @@ def get_template(self, template_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_TEMPLATE) def create_template(self, body: dict[str, Any]) -> Any: - """Create a new ConsentTemplate entity and return it.""" + """Create a new ConsentTemplate entity and return it. + + Args: + body: Dictionary of field names and values for the new ConsentTemplate. + + Returns: + The newly created ConsentTemplate object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.create_template") entity = self.ConsentTemplate() for k, v in body.items(): @@ -68,7 +101,20 @@ def create_template(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_TEMPLATE) def update_template(self, template_id: str, body: dict[str, Any]) -> Any: - """Fetch a ConsentTemplate by ID, apply field updates, and PATCH it.""" + """Fetch a ConsentTemplate by ID, apply field updates, and PATCH it to the service. + + Args: + template_id: UUID of the ConsentTemplate to update. + body: Dictionary of field names and values to apply. + + Returns: + The updated ConsentTemplate object. + + Raises: + NotFoundError: If no ConsentTemplate with the given ID exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.update_template") entity = self._client.query(_SVC, self.ConsentTemplate).get(template_id) for k, v in body.items(): @@ -79,7 +125,15 @@ def update_template(self, template_id: str, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_TEMPLATE) def delete_template(self, template_id: str) -> None: - """Delete a ConsentTemplate by its UUID.""" + """Delete a ConsentTemplate entity by its UUID. + + Args: + template_id: UUID of the ConsentTemplate to delete. + + Raises: + NotFoundError: If no ConsentTemplate with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.delete_template") entity = self._client.query(_SVC, self.ConsentTemplate).get(template_id) self._client.delete_entity(entity) @@ -89,7 +143,18 @@ def delete_template(self, template_id: str) -> None: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_TEMPLATE_ACTIVE) def set_template_active(self, template_id: str) -> Any: - """Activate a consent template and return the refreshed entity.""" + """Activate a consent template and return the refreshed entity. + + Args: + template_id: UUID of the ConsentTemplate to activate. + + Returns: + The refreshed ConsentTemplate object with its status set to active. + + Raises: + NotFoundError: If no ConsentTemplate with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.set_template_active") self._client.call_action( _SVC, @@ -102,7 +167,18 @@ def set_template_active(self, template_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_SET_TEMPLATE_INACTIVE) def set_template_inactive(self, template_id: str) -> Any: - """Deactivate a consent template and return the refreshed entity.""" + """Deactivate a consent template and return the refreshed entity. + + Args: + template_id: UUID of the ConsentTemplate to deactivate. + + Returns: + The refreshed ConsentTemplate object with its status set to inactive. + + Raises: + NotFoundError: If no ConsentTemplate with the given ID exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.set_template_inactive") self._client.call_action( _SVC, @@ -117,7 +193,18 @@ def set_template_inactive(self, template_id: str) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_TEMPLATE_TEXTS) def list_template_texts(self, **query: Any) -> list[Any]: - """Return all template text records, optionally filtered/paged.""" + """Return all consent template text records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of ConsentTemplateText objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.list_template_texts") result = _apply_query( self._client.query(_SVC, self.ConsentTemplateText), query @@ -129,7 +216,20 @@ def list_template_texts(self, **query: Any) -> list[Any]: def get_template_text( self, template_id: str, type_code: str, language_code: str ) -> Any: - """Return a single ConsentTemplateText by its composite key.""" + """Return a single ConsentTemplateText entity by its composite key. + + Args: + template_id: UUID of the parent ConsentTemplate. + type_code: Type code identifying the text category. + language_code: BCP-47 language code of the text entry (e.g. ``"en"`` or ``"de"``). + + Returns: + The matching ConsentTemplateText object. + + Raises: + NotFoundError: If no ConsentTemplateText for the given composite key exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.get_template_text") result = self._client.query(_SVC, self.ConsentTemplateText).get( templateId=template_id, typeCode=type_code, languageCode=language_code @@ -139,7 +239,19 @@ def get_template_text( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_TEMPLATE_TEXT) def create_template_text(self, body: dict[str, Any]) -> Any: - """Create a new ConsentTemplateText entity and return it.""" + """Create a new ConsentTemplateText entity and return it. + + Args: + body: Dictionary of field names and values for the new ConsentTemplateText. + Must include ``templateId``, ``typeCode``, and ``languageCode`` (BCP-47) as the composite key. + + Returns: + The newly created ConsentTemplateText object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.create_template_text") entity = self.ConsentTemplateText() for k, v in body.items(): @@ -152,7 +264,22 @@ def create_template_text(self, body: dict[str, Any]) -> Any: def update_template_text( self, template_id: str, type_code: str, language_code: str, body: dict[str, Any] ) -> Any: - """Fetch a ConsentTemplateText by composite key, apply updates, and PATCH it.""" + """Fetch a ConsentTemplateText by composite key, apply field updates, and PATCH it to the service. + + Args: + template_id: UUID of the parent ConsentTemplate. + type_code: Type code identifying the text category. + language_code: BCP-47 language code of the text entry (e.g. ``"en"`` or ``"de"``). + body: Dictionary of field names and values to apply. + + Returns: + The updated ConsentTemplateText object. + + Raises: + NotFoundError: If no ConsentTemplateText for the given composite key exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.update_template_text") entity = self._client.query(_SVC, self.ConsentTemplateText).get( templateId=template_id, typeCode=type_code, languageCode=language_code @@ -167,7 +294,17 @@ def update_template_text( def delete_template_text( self, template_id: str, type_code: str, language_code: str ) -> None: - """Delete a ConsentTemplateText by its composite key.""" + """Delete a ConsentTemplateText entity by its composite key. + + Args: + template_id: UUID of the parent ConsentTemplate. + type_code: Type code identifying the text category. + language_code: BCP-47 language code of the text entry to delete (e.g. ``"en"`` or ``"de"``). + + Raises: + NotFoundError: If no ConsentTemplateText for the given composite key exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.delete_template_text") entity = self._client.query(_SVC, self.ConsentTemplateText).get( templateId=template_id, typeCode=type_code, languageCode=language_code @@ -179,7 +316,18 @@ def delete_template_text( @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_THIRD_PARTY_PERS_DATA) def list_third_party_pers_data(self, **query: Any) -> list[Any]: - """Return all template third-party personal data records.""" + """Return all template third-party personal data records, optionally filtered and paged via OData query kwargs. + + Args: + **query: OData query options forwarded to the service (e.g. ``filter``, + ``top``, ``skip``, ``orderby``). + + Returns: + list of TemplateThirdPartyPersData objects matching the query. + + Raises: + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.list_third_party_pers_data") result = _apply_query( self._client.query(_SVC, self.TemplateThirdPartyPersData), query @@ -189,7 +337,19 @@ def list_third_party_pers_data(self, **query: Any) -> list[Any]: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_THIRD_PARTY_PERS_DATA) def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> Any: - """Return a single TemplateThirdPartyPersData by its composite key.""" + """Return a single TemplateThirdPartyPersData entity by its composite key. + + Args: + template_id: UUID of the parent ConsentTemplate. + third_party_id: UUID of the associated ThirdParty. + + Returns: + The matching TemplateThirdPartyPersData object. + + Raises: + NotFoundError: If no TemplateThirdPartyPersData for the given composite key exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.get_third_party_pers_data") result = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( templateId=template_id, thirdPartyId=third_party_id @@ -201,7 +361,19 @@ def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> An Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_THIRD_PARTY_PERS_DATA ) def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: - """Create a new TemplateThirdPartyPersData entity and return it.""" + """Create a new TemplateThirdPartyPersData entity and return it. + + Args: + body: Dictionary of field names and values for the new TemplateThirdPartyPersData. + Must include ``templateId`` and ``thirdPartyId`` as the composite key. + + Returns: + The newly created TemplateThirdPartyPersData object with server-assigned fields populated. + + Raises: + ValidationError: If the request body fails server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.create_third_party_pers_data") entity = self.TemplateThirdPartyPersData() for k, v in body.items(): @@ -216,7 +388,21 @@ def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: def update_third_party_pers_data( self, template_id: str, third_party_id: str, body: dict[str, Any] ) -> Any: - """Fetch a TemplateThirdPartyPersData by composite key, apply updates, and PATCH it.""" + """Fetch a TemplateThirdPartyPersData by composite key, apply field updates, and PATCH it to the service. + + Args: + template_id: UUID of the parent ConsentTemplate. + third_party_id: UUID of the associated ThirdParty. + body: Dictionary of field names and values to apply. + + Returns: + The updated TemplateThirdPartyPersData object. + + Raises: + NotFoundError: If no TemplateThirdPartyPersData for the given composite key exists. + ValidationError: If the updated fields fail server-side validation. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.update_third_party_pers_data") entity = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( templateId=template_id, thirdPartyId=third_party_id @@ -233,7 +419,16 @@ def update_third_party_pers_data( def delete_third_party_pers_data( self, template_id: str, third_party_id: str ) -> None: - """Delete a TemplateThirdPartyPersData by its composite key.""" + """Delete a TemplateThirdPartyPersData entity by its composite key. + + Args: + template_id: UUID of the parent ConsentTemplate. + third_party_id: UUID of the associated ThirdParty to remove. + + Raises: + NotFoundError: If no TemplateThirdPartyPersData for the given composite key exists. + ODataError: If the OData service returns an unexpected error response. + """ logger.info("Invoked ConsentTemplateService.delete_third_party_pers_data") entity = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( templateId=template_id, thirdPartyId=third_party_id From 11f07563fbe59d77177160eff283f9cec5f16603 Mon Sep 17 00:00:00 2001 From: N Date: Fri, 26 Jun 2026 15:24:11 +0530 Subject: [PATCH 12/27] docs(dpi_ng/consent): align docstrings with DPI service router URL and expand operation details --- .../core/dpi_ng/consent/__init__.py | 8 ++++--- .../core/dpi_ng/consent/client.py | 23 +++++++++++++++---- .../core/dpi_ng/consent/config.py | 10 +++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py index 28ddf2e2..10384bc2 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py @@ -5,7 +5,7 @@ from sap_cloud_sdk.core.dpi_ng.consent import create_client, BearerTokenAuth with create_client( - base_url="https://consent.cfapps.eu10.hana.ondemand.com", + base_url="https://api.service..ngdpi.dpp.cloud.sap", auth=BearerTokenAuth(""), ) as client: consents = client.consents.list_consents(filter="lifecycleStatusCode eq '1'") @@ -53,7 +53,7 @@ class ConsentClient: Access each OData service through its typed attribute: - - ``client.consents`` - consent creation, withdrawal, and reads (consentServices) + - ``client.consents`` - consent record creation, deletion, withdrawal, termination, existence check, and reads (consentServices) - ``client.purposes`` - purpose CRUD and lifecycle (consentPurposeExternalServices) - ``client.templates`` - template CRUD and lifecycle (consentTemplateExternalServices) - ``client.retention`` - retention rule CRUD and lifecycle (consentRetentionExternalServices) @@ -120,7 +120,9 @@ def create_client( Args: config: Pre-built ``ConsentSDKConfig``. When provided, all other kwargs are ignored. - base_url: Host-only root URL of the consent service (no path). + base_url: URL of the DPI external service router + (e.g. ``https://api.service..ngdpi.dpp.cloud.sap``). + Found in the credentials of the ``data-privacy-integration`` service instance. Required when *config* is not provided. auth: Authentication strategy (``BearerTokenAuth``, ``ClientCredentialsAuth``, ``ClientCertificateAuth``, etc.). diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py index 49eba35a..bd8440d3 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -47,10 +47,15 @@ def _register_factories() -> None: class _ODataClient: - """OData v4 client - one ODataService per SAP service endpoint, entity classes bound per service.""" + """OData v4 client for the DPI Consent module — one ODataService per consent service endpoint, with entity classes bound per service.""" def __init__(self, config: ConsentSDKConfig) -> None: - """Initialise the HTTP session and apply the configured auth strategy.""" + """Configure the client from *config*. + + - Creates a shared requests.Session with JSON OData headers. + - Applies the auth strategy from config.auth. + - Initializes an empty ODataService registry; individual instances are created lazily on first use via _get_service. + """ logger.info("Invoked ODataClient.__init__") self._config = config self._session = requests.Session() @@ -76,7 +81,17 @@ def __init__(self, config: ConsentSDKConfig) -> None: # ------------------------------------------------------------------ def _get_service(self, service_name: str) -> ODataService: - """Return (and lazily create) the ODataService for the given service endpoint.""" + """Return the ODataService for *service_name*, creating and caching it on first access. + + - URL is assembled as {config.base_url}{config.service_path}/{service_name}/. + - Subsequent calls with the same name return the cached instance. + + Args: + service_name: The consent service endpoint name (e.g. ``"consentServices"``). + + Returns: + The ODataService bound to the resolved service URL and shared session. + """ logger.info("Invoked ODataClient._get_service") if service_name not in self._services: url = f"{self._config.base_url}{self._config.service_path}/{service_name}/" @@ -169,7 +184,7 @@ def call_action( service: OData service identifier (e.g. ``"consentServices"``). path: Action name relative to the service root URL (e.g. ``"createConsentFromTemplate"``). - body: JSON-serialisable request payload. Defaults to ``{}`` when omitted. + body: JSON-serializable request payload. Defaults to ``{}`` when omitted. params: Optional URL query parameters to append to the request. Returns: diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py index 926b3c98..1d6e52be 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py @@ -16,13 +16,17 @@ class ConsentSDKConfig: """Configuration for the Consent SDK client. Args: - base_url: Root URL of the consent service (e.g. https://consent.cfapps.eu10.hana.ondemand.com). + base_url: URL of the DPI external service router + (e.g. ``https://api.service..ngdpi.dpp.cloud.sap``). + This URL can be found in the credentials of the ``data-privacy-integration`` + service instance. auth: Authentication strategy - one of BearerTokenAuth, ClientCredentialsAuth, - ClientCertificateAuth, or a custom AuthProvider subclass. + or ClientCertificateAuth. timeout: HTTP request timeout in seconds (default 30). verify_ssl: Verify TLS certificates - set False only in local dev. Overridden by ``ClientCertificateAuth`` when a custom ``ca_file`` is provided. - service_path: OData service base path prefix - do not override unless + service_path: Base path that the DPI external service router uses to identify + and forward requests to the consent service. Do not override unless deploying to a non-standard environment. """ From 16aea2e542635584ef454e922c229162ecd495f7 Mon Sep 17 00:00:00 2001 From: N Date: Tue, 30 Jun 2026 13:12:04 +0530 Subject: [PATCH 13/27] test(dpi_ng/consent): use shared base URL env var in integration test fixture --- .env_integration_tests.example | 2 +- docs/INTEGRATION_TESTS.md | 4 ++-- tests/core/integration/dpi_ng/consent/conftest.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env_integration_tests.example b/.env_integration_tests.example index 18e614b2..93b9c185 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -59,7 +59,7 @@ CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-c # DPI NG - CONSENT # Required for all consent integration tests -CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL=https://your-consent-service-host +CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL=https://your-dpi-ng-service-host # Pick ONE of the three auth methods below: diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index a2266bef..8ffbf07d 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -146,8 +146,8 @@ CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-c For DPI NG Consent integration tests, configure the following variables in `.env_integration_tests`: ```bash -# DPI NG Consent Configuration -CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL=https://your-consent-service-host +# DPI NG Consent Configuration — use the shared base URL (applies to all DPI NG sub-services) +CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL=https://your-dpi-ng-service-host ``` Authentication is configurable via one of three methods: diff --git a/tests/core/integration/dpi_ng/consent/conftest.py b/tests/core/integration/dpi_ng/consent/conftest.py index 2637dd07..33baeed0 100644 --- a/tests/core/integration/dpi_ng/consent/conftest.py +++ b/tests/core/integration/dpi_ng/consent/conftest.py @@ -63,11 +63,11 @@ def pytest_collection_modifyitems( def live_client() -> Iterator[ConsentClient]: if _ENV_FILE.exists(): load_dotenv(_ENV_FILE, override=True) - base_url = os.getenv("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL", "") + base_url = os.getenv("CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL", "") auth = _resolve_auth() if not base_url or auth is None: pytest.skip( - "No integration credentials in .env — set CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL plus one auth flow" + "No integration credentials in .env — set CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL plus one auth flow" ) with create_client(base_url=base_url, auth=auth) as client: yield client From 213d2b07e8ca1fcd5565a45c9bb3596721324cc8 Mon Sep 17 00:00:00 2001 From: N Date: Thu, 2 Jul 2026 14:36:39 +0530 Subject: [PATCH 14/27] fix(dpi_ng/consent): add x-tenant-id header support for mTLS client certificate auth --- src/sap_cloud_sdk/core/dpi_ng/consent/client.py | 2 ++ src/sap_cloud_sdk/core/dpi_ng/consent/config.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py index bd8440d3..9d1e25f5 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -65,6 +65,8 @@ def __init__(self, config: ConsentSDKConfig) -> None: "Content-Type": "application/json", } ) + if config.tenant_id: + self._session.headers.update({"x-tenant-id": config.tenant_id}) self._session.verify = config.verify_ssl config.auth.apply(self._session) self._services: dict[str, ODataService] = {} diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py index 1d6e52be..3de611ee 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py @@ -28,6 +28,10 @@ class ConsentSDKConfig: service_path: Base path that the DPI external service router uses to identify and forward requests to the consent service. Do not override unless deploying to a non-standard environment. + tenant_id: Optional tenant identifier sent as the ``x-tenant-id`` HTTP header + on every request. Required when using ``ClientCertificateAuth`` because + mTLS does not carry a tenant claim, so the service router needs it + to route requests to the correct tenant. """ base_url: str @@ -35,6 +39,7 @@ class ConsentSDKConfig: timeout: float = 30.0 verify_ssl: bool = True service_path: str = "/sap/cp/kernel/dpi/consent/odata/v4" + tenant_id: str | None = None def __post_init__(self) -> None: """Validate *base_url* format and *auth* type after dataclass construction. From 94d1e730b7324a449d4c0ea044537c2c3b80a902 Mon Sep 17 00:00:00 2001 From: N Date: Thu, 2 Jul 2026 15:12:22 +0530 Subject: [PATCH 15/27] docs(dpi_ng/consent): document tenant_id and restructure auth section; add unit tests --- .../core/dpi_ng/consent/user-guide.md | 84 +++++++++++++++++-- .../unit/dpi_ng/consent/unit/test_config.py | 12 +++ .../dpi_ng/consent/unit/test_odata_client.py | 43 ++++++++++ 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md index e814b261..16c427e0 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -70,9 +70,35 @@ with create_client(config=config) as client: ## Authentication -Pass a `ClientCredentialsAuth` instance to `ConsentSDKConfig`. The provider -performs the OAuth2 `client_credentials` flow against the given token URL and -refreshes the token transparently 60 seconds before it expires. +The SDK supports three authentication strategies. Pass one as the `auth` +argument to `ConsentSDKConfig`. + +### BearerTokenAuth + +Use when you already have a valid bearer token: + +```python +from sap_cloud_sdk.core.dpi_ng.consent import ConsentSDKConfig, BearerTokenAuth + +config = ConsentSDKConfig( + base_url="https://api.service..ngdpi.dpp.cloud.sap", + auth=BearerTokenAuth(token=""), +) +``` + +| Parameter | Required | Description | +|---|---|---| +| `token` | Yes | Bearer token sent as the `Authorization: Bearer` header on every request. | + +The token is sent as-is — no automatic refresh is performed. Use +`ClientCredentialsAuth` for long-running processes where the token may expire. +Passing an empty string raises `ValueError: token must not be empty`. + +### ClientCredentialsAuth + +Use when you have OAuth2 client credentials. The provider performs the +`client_credentials` grant against the given token URL and refreshes the token +transparently 60 seconds before it expires: ```python from sap_cloud_sdk.core.dpi_ng.consent import ( @@ -81,18 +107,60 @@ from sap_cloud_sdk.core.dpi_ng.consent import ( ) config = ConsentSDKConfig( - base_url="https://", + base_url="https://api.service..ngdpi.dpp.cloud.sap", auth=ClientCredentialsAuth( - token_url="https:///oauth/token", + token_url="https:///oauth/token", client_id="", client_secret="", ), ) ``` -All three fields - `token_url`, `client_id`, and `client_secret` - are required. -Passing an empty string for any of them raises a `ValueError` during -construction. +| Parameter | Required | Description | +|---|---|---| +| `token_url` | Yes | OAuth2 token endpoint URL. | +| `client_id` | Yes | OAuth2 client identifier. | +| `client_secret` | Yes | OAuth2 client secret. | + +Passing an empty string for any field raises +`ValueError: token_url, client_id, and client_secret are all required`. + +### ClientCertificateAuth and tenant_id + +Use when your environment requires mutual TLS (mTLS): + +```python +from sap_cloud_sdk.core.dpi_ng.consent import ( + ConsentSDKConfig, + ClientCertificateAuth, +) + +config = ConsentSDKConfig( + base_url="https://api.service..ngdpi.dpp.cloud.sap", + auth=ClientCertificateAuth( + cert_file="/path/to/client.crt", + key_file="/path/to/client.key", + ca_file="/path/to/ca.crt", + ), + tenant_id="", +) +``` + +| Parameter | Required | Description | +|---|---|---| +| `cert_file` | Yes | Path to the PEM-encoded client certificate file. | +| `key_file` | Yes | Path to the PEM-encoded private key file. | +| `ca_file` | No | Path to a custom CA bundle. Omit to use the system trust store. | +| `tenant_id` | Yes (with mTLS) | Tenant identifier sent as the `x-tenant-id` header on every request. | + +Passing an empty string for `cert_file` or `key_file` raises +`ValueError: cert_file and key_file are required`. + +`tenant_id` is **required** with `ClientCertificateAuth`. The mTLS handshake +does not carry a tenant claim, so the DPI service router needs it to route +requests to the correct tenant. `tenant_id` is optional for `BearerTokenAuth` +and `ClientCredentialsAuth` because the bearer token already encodes the tenant +claim. ## Client structure diff --git a/tests/core/unit/dpi_ng/consent/unit/test_config.py b/tests/core/unit/dpi_ng/consent/unit/test_config.py index 4ce312c1..cfdcefa6 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_config.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_config.py @@ -52,6 +52,10 @@ def test_service_path_default(self): cfg = ConsentSDKConfig(base_url="https://example.com", auth=valid_auth()) assert cfg.service_path == "/sap/cp/kernel/dpi/consent/odata/v4" + def test_tenant_id_default_is_none(self): + cfg = ConsentSDKConfig(base_url="https://example.com", auth=valid_auth()) + assert cfg.tenant_id is None + class TestCustomValues: def test_custom_timeout_stored(self): @@ -74,6 +78,14 @@ def test_custom_service_path_stored(self): ) assert cfg.service_path == "/custom/path" + def test_tenant_id_stored(self): + cfg = ConsentSDKConfig( + base_url="https://example.com", + auth=valid_auth(), + tenant_id="tenant-abc-123", + ) + assert cfg.tenant_id == "tenant-abc-123" + class TestInvalidBaseUrl: def test_empty_string_raises(self): diff --git a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py index a9b6e790..705cb763 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py @@ -36,6 +36,13 @@ def _make_config(): return ConsentSDKConfig(base_url="https://consent.example.com", auth=auth) +def _make_config_with_tenant(tenant_id: str): + auth = MagicMock(spec=AuthProvider) + return ConsentSDKConfig( + base_url="https://consent.example.com", auth=auth, tenant_id=tenant_id + ) + + class TestRaiseForStatus: def test_200_does_not_raise(self): resp = _mock_response(200, json_body={}, text="ok", content=b"ok") @@ -300,6 +307,42 @@ def test_delete_entity_delegates_to_entity_odata_service( entity.__odata_service__.delete.assert_called_once_with(entity) +@patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") +class TestTenantIdHeader: + def test_x_tenant_id_header_set_when_tenant_id_provided( + self, mock_session_cls + ): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + + config = _make_config_with_tenant("my-tenant-id") + _ODataClient(config) + + header_updates = [ + c.args[0] if c.args else c.kwargs.get("arg", {}) + for c in mock_session.headers.update.call_args_list + ] + assert any( + isinstance(h, dict) and h.get("x-tenant-id") == "my-tenant-id" + for h in header_updates + ) + + def test_x_tenant_id_header_not_set_when_tenant_id_is_none( + self, mock_session_cls + ): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + + config = _make_config() + _ODataClient(config) + + header_updates = [ + c.args[0] if c.args else {} + for c in mock_session.headers.update.call_args_list + ] + assert all("x-tenant-id" not in h for h in header_updates) + + @patch("sap_cloud_sdk.core.dpi_ng.consent.client.ODataService") @patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") class TestContextManager: From 3be79199bd1a0283ee1485805cb29f918c771de1 Mon Sep 17 00:00:00 2001 From: N Date: Thu, 2 Jul 2026 15:18:50 +0530 Subject: [PATCH 16/27] chore(dpi_ng/consent): apply ruff formatting --- tests/core/unit/dpi_ng/consent/unit/test_odata_client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py index 705cb763..35a7f5a9 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py @@ -309,9 +309,7 @@ def test_delete_entity_delegates_to_entity_odata_service( @patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") class TestTenantIdHeader: - def test_x_tenant_id_header_set_when_tenant_id_provided( - self, mock_session_cls - ): + def test_x_tenant_id_header_set_when_tenant_id_provided(self, mock_session_cls): mock_session = MagicMock() mock_session_cls.return_value = mock_session @@ -327,9 +325,7 @@ def test_x_tenant_id_header_set_when_tenant_id_provided( for h in header_updates ) - def test_x_tenant_id_header_not_set_when_tenant_id_is_none( - self, mock_session_cls - ): + def test_x_tenant_id_header_not_set_when_tenant_id_is_none(self, mock_session_cls): mock_session = MagicMock() mock_session_cls.return_value = mock_session From b051a06da2c833d0ab49cd303370df4d7f54cddf Mon Sep 17 00:00:00 2001 From: N Date: Thu, 2 Jul 2026 15:48:14 +0530 Subject: [PATCH 17/27] fix(dpi_ng/consent): validate tenant_id is only accepted for ClientCertificateAuth --- .../core/dpi_ng/consent/config.py | 33 ++++++++++++++----- .../unit/dpi_ng/consent/unit/test_config.py | 21 ++++++++++-- .../dpi_ng/consent/unit/test_odata_client.py | 4 +-- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py index 3de611ee..477a4574 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py @@ -4,7 +4,7 @@ import re from dataclasses import dataclass -from .auth import AuthProvider +from .auth import AuthProvider, ClientCertificateAuth logger = logging.getLogger(__name__) @@ -28,10 +28,12 @@ class ConsentSDKConfig: service_path: Base path that the DPI external service router uses to identify and forward requests to the consent service. Do not override unless deploying to a non-standard environment. - tenant_id: Optional tenant identifier sent as the ``x-tenant-id`` HTTP header - on every request. Required when using ``ClientCertificateAuth`` because - mTLS does not carry a tenant claim, so the service router needs it - to route requests to the correct tenant. + tenant_id: Tenant identifier sent as the ``x-tenant-id`` HTTP header. + **Required** for ``ClientCertificateAuth`` — mTLS does not carry a + tenant claim, so the service router needs it to route requests to the + correct tenant. Must not be provided for ``BearerTokenAuth`` or + ``ClientCredentialsAuth``, which already embed the tenant identity in + the token. """ base_url: str @@ -42,11 +44,12 @@ class ConsentSDKConfig: tenant_id: str | None = None def __post_init__(self) -> None: - """Validate *base_url* format and *auth* type after dataclass construction. + """Validate config after dataclass construction. Raises: - ValueError: If *base_url* is not a valid HTTP(S) URL, or if *auth* is - not an ``AuthProvider`` instance. + ValueError: If *base_url* is not a valid HTTP(S) URL, *auth* is not an + ``AuthProvider`` instance, ``ClientCertificateAuth`` is used without + *tenant_id*, or *tenant_id* is provided with a non-cert auth type. """ logger.info("Invoked ConsentSDKConfig.__post_init__") if not _URL_PATTERN.match(self.base_url): @@ -59,6 +62,20 @@ def __post_init__(self) -> None: "auth is not an AuthProvider instance — type=%s", type(self.auth) ) raise ValueError("auth must be an AuthProvider instance") + is_cert_auth = isinstance(self.auth, ClientCertificateAuth) + if is_cert_auth and not self.tenant_id: + logger.error("tenant_id is required for ClientCertificateAuth") + raise ValueError( + "tenant_id is required when using ClientCertificateAuth" + ) + if not is_cert_auth and self.tenant_id is not None: + logger.error( + "tenant_id is not applicable for %s", type(self.auth).__name__ + ) + raise ValueError( + f"tenant_id must not be set for {type(self.auth).__name__}; " + "it is only valid for ClientCertificateAuth" + ) self.base_url = self.base_url.rstrip("/") logger.debug( "Config validated — base_url=%s verify_ssl=%s", diff --git a/tests/core/unit/dpi_ng/consent/unit/test_config.py b/tests/core/unit/dpi_ng/consent/unit/test_config.py index cfdcefa6..0ab58074 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_config.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_config.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import MagicMock -from sap_cloud_sdk.core.dpi_ng.consent.auth import AuthProvider +from sap_cloud_sdk.core.dpi_ng.consent.auth import AuthProvider, ClientCertificateAuth from sap_cloud_sdk.core.dpi_ng.consent.config import ConsentSDKConfig @@ -81,7 +81,7 @@ def test_custom_service_path_stored(self): def test_tenant_id_stored(self): cfg = ConsentSDKConfig( base_url="https://example.com", - auth=valid_auth(), + auth=ClientCertificateAuth(cert_file="cert.pem", key_file="key.pem"), tenant_id="tenant-abc-123", ) assert cfg.tenant_id == "tenant-abc-123" @@ -128,3 +128,20 @@ def test_dict_auth_raises(self): def test_plain_object_auth_raises(self): with pytest.raises(ValueError, match="auth must be an AuthProvider"): ConsentSDKConfig(base_url="https://example.com", auth=object()) # ty: ignore[invalid-argument-type] + + +class TestTenantId: + def test_cert_auth_without_tenant_id_raises(self): + with pytest.raises(ValueError, match="tenant_id is required"): + ConsentSDKConfig( + base_url="https://example.com", + auth=ClientCertificateAuth(cert_file="cert.pem", key_file="key.pem"), + ) + + def test_non_cert_auth_with_tenant_id_raises(self): + with pytest.raises(ValueError, match="tenant_id must not be set"): + ConsentSDKConfig( + base_url="https://example.com", + auth=valid_auth(), + tenant_id="tenant-123", + ) diff --git a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py index 35a7f5a9..68fc7796 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py @@ -6,7 +6,7 @@ import pytest -from sap_cloud_sdk.core.dpi_ng.consent.auth import AuthProvider +from sap_cloud_sdk.core.dpi_ng.consent.auth import AuthProvider, ClientCertificateAuth from sap_cloud_sdk.core.dpi_ng.consent.client import _ODataClient from sap_cloud_sdk.core.dpi_ng.consent.config import ConsentSDKConfig from sap_cloud_sdk.core.dpi_ng.consent.exceptions import ( @@ -37,7 +37,7 @@ def _make_config(): def _make_config_with_tenant(tenant_id: str): - auth = MagicMock(spec=AuthProvider) + auth = ClientCertificateAuth(cert_file="cert.pem", key_file="key.pem") return ConsentSDKConfig( base_url="https://consent.example.com", auth=auth, tenant_id=tenant_id ) From 9fe6df98686853f7cdb0bd3cb6549fb1c9587cd8 Mon Sep 17 00:00:00 2001 From: N Date: Thu, 2 Jul 2026 16:05:16 +0530 Subject: [PATCH 18/27] docs(dpi_ng/consent): add ConsentSDKConfig parameter table and fix tenant_id docs --- .../core/dpi_ng/consent/user-guide.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md index 16c427e0..55323c65 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -68,6 +68,17 @@ with create_client(config=config) as client: print(c.consent_id, c.lifecycle_status_code) ``` +**`ConsentSDKConfig` parameters:** + +| Parameter | Required | Default | Description | +|---|---|---|---| +| `base_url` | Yes | — | URL of the DPI external service router (e.g. `https://api.service..ngdpi.dpp.cloud.sap`). Found in the credentials of the `data-privacy-integration` service instance. | +| `auth` | Yes | — | Authentication strategy — one of `BearerTokenAuth`, `ClientCredentialsAuth`, or `ClientCertificateAuth`. | +| `timeout` | No | `30.0` | HTTP request timeout in seconds. | +| `verify_ssl` | No | `True` | Verify TLS certificates. Set `False` only in local dev. Overridden by `ClientCertificateAuth` when a custom `ca_file` is provided. | +| `service_path` | No | `/sap/cp/kernel/dpi/consent/odata/v4` | Base path the DPI external service router uses to forward requests. Do not override unless deploying to a non-standard environment. | +| `tenant_id` | Required for `ClientCertificateAuth`; **must not be set** for others | `None` | Tenant identifier sent as the `x-tenant-id` header. Required for mTLS because the handshake does not carry a tenant claim. Raises `ValueError` if provided with `BearerTokenAuth` or `ClientCredentialsAuth`. | + ## Authentication The SDK supports three authentication strategies. Pass one as the `auth` @@ -151,17 +162,10 @@ config = ConsentSDKConfig( | `cert_file` | Yes | Path to the PEM-encoded client certificate file. | | `key_file` | Yes | Path to the PEM-encoded private key file. | | `ca_file` | No | Path to a custom CA bundle. Omit to use the system trust store. | -| `tenant_id` | Yes (with mTLS) | Tenant identifier sent as the `x-tenant-id` header on every request. | Passing an empty string for `cert_file` or `key_file` raises `ValueError: cert_file and key_file are required`. -`tenant_id` is **required** with `ClientCertificateAuth`. The mTLS handshake -does not carry a tenant claim, so the DPI service router needs it to route -requests to the correct tenant. `tenant_id` is optional for `BearerTokenAuth` -and `ClientCredentialsAuth` because the bearer token already encodes the tenant -claim. - ## Client structure `ConsentClient` exposes five service attributes: From ba8d3539a9de943f234e736515512f440deaab65 Mon Sep 17 00:00:00 2001 From: N Date: Thu, 2 Jul 2026 16:08:11 +0530 Subject: [PATCH 19/27] docs(dpi_ng/consent): fix CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL env var name in integration tests doc --- docs/INTEGRATION_TESTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index 8ffbf07d..872fdadf 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -174,7 +174,7 @@ CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE=/path/to/client.key CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE=/path/to/ca.crt # optional ``` -Tests are skipped automatically when `CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BASE_URL` or all auth variables are missing. +Tests are skipped automatically when `CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL` or all auth variables are missing. ### ObjectStore Integration Tests From 48d477086e9d807c143c9b6d9e267dd9dc0d601b Mon Sep 17 00:00:00 2001 From: N Date: Thu, 2 Jul 2026 16:12:30 +0530 Subject: [PATCH 20/27] chore(dpi_ng/consent): fix consent user-guide --- src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md index 55323c65..83fbbfe4 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -72,8 +72,8 @@ with create_client(config=config) as client: | Parameter | Required | Default | Description | |---|---|---|---| -| `base_url` | Yes | — | URL of the DPI external service router (e.g. `https://api.service..ngdpi.dpp.cloud.sap`). Found in the credentials of the `data-privacy-integration` service instance. | -| `auth` | Yes | — | Authentication strategy — one of `BearerTokenAuth`, `ClientCredentialsAuth`, or `ClientCertificateAuth`. | +| `base_url` | Yes | - | URL of the DPI external service router (e.g. `https://api.service..ngdpi.dpp.cloud.sap`). Found in the credentials of the `data-privacy-integration` service instance. | +| `auth` | Yes | - | Authentication strategy - one of `BearerTokenAuth`, `ClientCredentialsAuth`, or `ClientCertificateAuth`. | | `timeout` | No | `30.0` | HTTP request timeout in seconds. | | `verify_ssl` | No | `True` | Verify TLS certificates. Set `False` only in local dev. Overridden by `ClientCertificateAuth` when a custom `ca_file` is provided. | | `service_path` | No | `/sap/cp/kernel/dpi/consent/odata/v4` | Base path the DPI external service router uses to forward requests. Do not override unless deploying to a non-standard environment. | @@ -101,7 +101,7 @@ config = ConsentSDKConfig( |---|---|---| | `token` | Yes | Bearer token sent as the `Authorization: Bearer` header on every request. | -The token is sent as-is — no automatic refresh is performed. Use +The token is sent as-is - no automatic refresh is performed. Use `ClientCredentialsAuth` for long-running processes where the token may expire. Passing an empty string raises `ValueError: token must not be empty`. From 0fb939e237b16eba8d1040c3511850857f681870 Mon Sep 17 00:00:00 2001 From: N Date: Mon, 6 Jul 2026 13:32:15 +0530 Subject: [PATCH 21/27] fix(dpi_ng/consent): replace URL substring check with exact equality to resolve CodeQL alert --- src/sap_cloud_sdk/core/dpi_ng/consent/client.py | 2 +- tests/core/unit/dpi_ng/consent/unit/test_config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py index 9d1e25f5..6274dc1f 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -1,4 +1,4 @@ -"""OData v4 client backed by python-odata (https://pypi.org/project/python-odata/).""" +"""OData v4 client backed by python-odata client.""" from __future__ import annotations diff --git a/tests/core/unit/dpi_ng/consent/unit/test_config.py b/tests/core/unit/dpi_ng/consent/unit/test_config.py index 0ab58074..6f798ac9 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_config.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_config.py @@ -23,7 +23,7 @@ def test_url_with_path_accepted(self): base_url="https://consent.cfapps.eu10.hana.ondemand.com", auth=valid_auth(), ) - assert "hana.ondemand.com" in cfg.base_url + assert cfg.base_url == "https://consent.cfapps.eu10.hana.ondemand.com" def test_trailing_slash_stripped(self): cfg = ConsentSDKConfig(base_url="https://example.com/", auth=valid_auth()) From f7fd96abda915245c31edfe2a5e917319da5646f Mon Sep 17 00:00:00 2001 From: N Date: Mon, 6 Jul 2026 13:36:52 +0530 Subject: [PATCH 22/27] fix(dpi_ng/consent): simplify error message formatting in config validation --- src/sap_cloud_sdk/core/dpi_ng/consent/config.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py index 477a4574..cdf80549 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/config.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py @@ -65,13 +65,9 @@ def __post_init__(self) -> None: is_cert_auth = isinstance(self.auth, ClientCertificateAuth) if is_cert_auth and not self.tenant_id: logger.error("tenant_id is required for ClientCertificateAuth") - raise ValueError( - "tenant_id is required when using ClientCertificateAuth" - ) + raise ValueError("tenant_id is required when using ClientCertificateAuth") if not is_cert_auth and self.tenant_id is not None: - logger.error( - "tenant_id is not applicable for %s", type(self.auth).__name__ - ) + logger.error("tenant_id is not applicable for %s", type(self.auth).__name__) raise ValueError( f"tenant_id must not be set for {type(self.auth).__name__}; " "it is only valid for ClientCertificateAuth" From 02b799b71e9ffd886db5f07abc1fbd957e1e193b Mon Sep 17 00:00:00 2001 From: N Date: Fri, 10 Jul 2026 01:36:13 +0530 Subject: [PATCH 23/27] fix(dpi_ng/consent): correct entity schemas, primary keys, OData query, and mutation headers --- .../core/dpi_ng/consent/client.py | 31 ++- .../core/dpi_ng/consent/dtos/consent.py | 4 +- .../core/dpi_ng/consent/entities/consent.py | 14 +- .../consent/entities/consent_configuration.py | 13 +- .../consent/entities/consent_purpose.py | 10 +- .../consent/entities/consent_retention.py | 1 + .../consent/entities/consent_template.py | 17 +- .../core/dpi_ng/consent/services/_query.py | 4 +- .../services/consent_configuration_service.py | 78 +++--- .../services/consent_purpose_service.py | 54 ++-- .../services/consent_retention_service.py | 3 +- .../services/consent_template_service.py | 81 +++--- .../core/dpi_ng/consent/user-guide.md | 250 +++++++++--------- 13 files changed, 267 insertions(+), 293 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py index 6274dc1f..57f428a0 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/client.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -156,9 +156,32 @@ def save(self, entity: Any) -> None: entity: A python-odata entity instance to create or update. """ logger.info("Invoked ODataClient.save") - entity.__odata_service__.save(entity) + if entity.__odata__.persisted: + self._session.headers["If-Match"] = "*" + try: + entity.__odata_service__.save(entity) + finally: + self._session.headers.pop("If-Match", None) logger.info("Exiting ODataClient.save") + @staticmethod + def _apply_body(entity: Any, body: dict[str, Any]) -> None: + """Set fields from *body* on *entity* and force-mark each as dirty. + + The odata library only marks a field dirty when the value changes, so + fields already matching their current value are silently dropped from + the PATCH body. Forcing dirty ensures the server always receives every + field the caller explicitly provided. + + Unknown keys (not mapped as odata property descriptors on the entity + class) are silently ignored and never sent in the request. + """ + for k, v in body.items(): + prop = getattr(type(entity), k, None) + if prop is not None: + setattr(entity, k, v) + entity.__odata__.set_property_dirty(prop) + def delete_entity(self, entity: Any) -> None: """Send a DELETE request for the given entity. @@ -166,7 +189,11 @@ def delete_entity(self, entity: Any) -> None: entity: A python-odata entity instance to delete. """ logger.info("Invoked ODataClient.delete_entity") - entity.__odata_service__.delete(entity) + self._session.headers["If-Match"] = "*" + try: + entity.__odata_service__.delete(entity) + finally: + self._session.headers.pop("If-Match", None) logger.info("Exiting ODataClient.delete_entity") # ------------------------------------------------------------------ diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py index ba0cf36f..8b97183c 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py @@ -64,7 +64,7 @@ class CreateConsentRequest(_CamelSerializable): template_name: str language_code: str data_subject_type_name: str - jurisdiction_code: str + jurisdiction_code: str | None = None data_subject_description: str | None = None outbound_channel_type_name: str | None = None outbound_channel: str | None = None @@ -88,5 +88,5 @@ class WithdrawConsentRequest(_CamelSerializable): """ consent_id: str - withdrawn_by: str + withdrawn_by: str | None = None withdrawn_at: str | None = None diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py index 47911a89..a2bbc8eb 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent.py @@ -46,13 +46,13 @@ class Consent(Service.Entity): third_party_function_code = StringProperty("thirdPartyFunctionCode") consent_status_code = StringProperty("consentStatusCode") lifecycle_status_code = StringProperty("lifecycleStatusCode") - purpose_description_text_id = StringProperty("purposeDescriptionTextId") - purpose_explanatory_text_id = StringProperty("purposeExplanatoryTextId") - template_description_text_id = StringProperty("templateDescriptionTextId") - template_explanatory_text_id = StringProperty("templateExplanatoryTextId") - template_question_text_id = StringProperty("templateQuestionTextId") - template_consequence_text_id = StringProperty("templateConsequenceTextId") - template_data_privacy_statement_text_id = StringProperty( + purpose_description_text_id = UUIDProperty("purposeDescriptionTextId") + purpose_explanatory_text_id = UUIDProperty("purposeExplanatoryTextId") + template_description_text_id = UUIDProperty("templateDescriptionTextId") + template_explanatory_text_id = UUIDProperty("templateExplanatoryTextId") + template_question_text_id = UUIDProperty("templateQuestionTextId") + template_consequence_text_id = UUIDProperty("templateConsequenceTextId") + template_data_privacy_statement_text_id = UUIDProperty( "templateDataPrivacyStatementTextId" ) purpose_sensitive_data_flag = BooleanProperty("purposeSensitiveDataFlag") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py index 87de4196..ae9cb102 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py @@ -30,7 +30,6 @@ class Jurisdiction(Service.Entity): tenant = StringProperty("tenant") jurisdiction_id = UUIDProperty("jurisdictionId", primary_key=True) jurisdiction_code = StringProperty("jurisdictionCode") - description = StringProperty("description") created_at = DatetimeProperty("createdAt") created_by = StringProperty("createdBy") changed_at = DatetimeProperty("changedAt") @@ -40,8 +39,10 @@ class JurisdictionText(Service.Entity): """OData entity representing a localised description for a jurisdiction.""" __odata_collection__ = "jurisdictionTexts" - jurisdiction_id = UUIDProperty("jurisdictionId", primary_key=True) - language_code = StringProperty("languageCode", primary_key=True) + jurisdiction_text_id = UUIDProperty("jurisdictionTextId", primary_key=True) + jurisdiction_code = StringProperty("jurisdictionCode") + jurisdiction_id = UUIDProperty("jurisdictionId") + language_code = StringProperty("languageCode") description = StringProperty("description") class Language(Service.Entity): @@ -59,7 +60,9 @@ class LanguageDescription(Service.Entity): """OData entity representing an additional description for a language.""" __odata_collection__ = "languageDescriptions" - language_code = StringProperty("languageCode", primary_key=True) + language_desc_id = UUIDProperty("languageDescId", primary_key=True) + language_code = StringProperty("languageCode") + description_language_code = StringProperty("descriptionLanguageCode") description = StringProperty("description") class SourceInfo(Service.Entity): @@ -144,7 +147,9 @@ class OutboundChannelType(Service.Entity): outbound_channel_type_name = StringProperty("outboundChannelTypeName") description = StringProperty("description") created_at = DatetimeProperty("createdAt") + created_by = StringProperty("createdBy") changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") return ( ThirdParty, diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py index 9576d11f..1df89792 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py @@ -23,9 +23,6 @@ class ConsentPurpose(Service.Entity): purpose_id = UUIDProperty("purposeId", primary_key=True) purpose_name = StringProperty("purposeName") lifecycle_status_code = StringProperty("lifecycleStatusCode") - lifecycle_status_domain_description = StringProperty( - "lifecycleStatusDomainDescription" - ) sensitive_data_flag = BooleanProperty("sensitiveDataFlag") created_at = DatetimeProperty("createdAt") created_by = StringProperty("createdBy") @@ -37,9 +34,10 @@ class ConsentPurposeText(Service.Entity): __odata_collection__ = "consentPurposeTexts" tenant = StringProperty("tenant") - purpose_id = UUIDProperty("purposeId", primary_key=True) - type_code = StringProperty("typeCode", primary_key=True) - language_code = StringProperty("languageCode", primary_key=True) + purpose_text_id = UUIDProperty("purposeTextId", primary_key=True) + purpose_id = UUIDProperty("purposeId") + language_code = StringProperty("languageCode") + type_code = StringProperty("typeCode") text = StringProperty("text") changed_at = DatetimeProperty("changedAt") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py index 67df962d..21a84981 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py @@ -27,6 +27,7 @@ class ConsentRetentionRule(Service.Entity): controller_id = UUIDProperty("controllerId") jurisdiction_code = StringProperty("jurisdictionCode") consent_model_code = StringProperty("consentModelCode") + retention_period = IntegerProperty("retentionPeriod") retention_years = IntegerProperty("retentionYears") retention_months = IntegerProperty("retentionMonths") retention_days = IntegerProperty("retentionDays") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py index 82c7cc6c..37c62f9a 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py @@ -7,6 +7,7 @@ from odata.property import ( BooleanProperty, DatetimeProperty, + IntegerProperty, StringProperty, UUIDProperty, ) @@ -28,8 +29,8 @@ class ConsentTemplate(Service.Entity): jurisdiction_code = StringProperty("jurisdictionCode") consent_model_code = StringProperty("consentModelCode") application_template_id = StringProperty("applicationTemplateId") - validity_period = StringProperty("validityPeriod") - expiring_period = StringProperty("expiringPeriod") + validity_period = IntegerProperty("validityPeriod") + expiring_period = IntegerProperty("expiringPeriod") lifecycle_status_code = StringProperty("lifecycleStatusCode") purpose_name = StringProperty("purposeName") controller_name = StringProperty("controllerName") @@ -44,9 +45,10 @@ class ConsentTemplateText(Service.Entity): __odata_collection__ = "consentTemplateTexts" tenant = StringProperty("tenant") - template_id = UUIDProperty("templateId", primary_key=True) - type_code = StringProperty("typeCode", primary_key=True) - language_code = StringProperty("languageCode", primary_key=True) + template_text_id = UUIDProperty("templatTextId", primary_key=True) + template_id = UUIDProperty("templateId") + language_code = StringProperty("languageCode") + type_code = StringProperty("typeCode") text = StringProperty("text") changed_at = DatetimeProperty("changedAt") @@ -55,11 +57,12 @@ class TemplateThirdPartyPersData(Service.Entity): __odata_collection__ = "templateThirdPartyPersDatas" tenant = StringProperty("tenant") + third_party_assignment_id = UUIDProperty("thirdPartyAssignmentId", primary_key=True) template_id = UUIDProperty("templateId", primary_key=True) - third_party_id = UUIDProperty("thirdPartyId", primary_key=True) + third_party_id = UUIDProperty("thirdPartyId") + third_party_name = StringProperty("thirdPartyName") third_party_function_code = StringProperty("thirdPartyFunctionCode") sensitive_data_flag = BooleanProperty("sensitiveDataFlag") - created_at = DatetimeProperty("createdAt") changed_at = DatetimeProperty("changedAt") return ( diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py index 9fdb780a..41791b28 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py @@ -19,11 +19,11 @@ def _apply_query(q: Any, params: dict[str, Any]) -> Any: The Query instance with all supported options applied. """ if "filter" in params: - q = q.raw({"$filter": params["filter"]}) + q = q.filter(params["filter"]) if "top" in params: q = q.limit(int(params["top"])) if "skip" in params: q = q.offset(int(params["skip"])) if "orderby" in params: - q = q.raw({"$orderby": params["orderby"]}) + q = q.order_by(params["orderby"]) return q diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py index f843fb16..470ee3ba 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py @@ -123,8 +123,7 @@ def update_third_party(self, third_party_id: str, body: dict[str, Any]) -> Any: """ logger.info("Invoked ConsentConfigurationService.update_third_party") entity = self._client.query(_SVC, self.ThirdParty).get(third_party_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_third_party") return entity @@ -225,8 +224,7 @@ def update_jurisdiction(self, jurisdiction_id: str, body: dict[str, Any]) -> Any """ logger.info("Invoked ConsentConfigurationService.update_jurisdiction") entity = self._client.query(_SVC, self.Jurisdiction).get(jurisdiction_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_jurisdiction") return entity @@ -295,51 +293,42 @@ def create_jurisdiction_text(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_JURISDICTION_TEXT) def update_jurisdiction_text( - self, jurisdiction_id: str, language_code: str, body: dict[str, Any] + self, jurisdiction_text_id: str, body: dict[str, Any] ) -> Any: - """Fetch a JurisdictionText by composite key, apply field updates, and PATCH it to the service. + """Fetch a JurisdictionText by its ID, apply field updates, and PATCH it to the service. Args: - jurisdiction_id: UUID of the parent Jurisdiction. - language_code: BCP-47 language code identifying the text entry. + jurisdiction_text_id: UUID of the JurisdictionText to update. body: Dictionary of field names and values to apply. Returns: The updated JurisdictionText object. Raises: - NotFoundError: If no JurisdictionText for the given composite key exists. + NotFoundError: If no JurisdictionText with the given ID exists. ValidationError: If the updated fields fail server-side validation. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.update_jurisdiction_text") - entity = self._client.query(_SVC, self.JurisdictionText).get( - jurisdictionId=jurisdiction_id, languageCode=language_code - ) - for k, v in body.items(): - setattr(entity, k, v) + entity = self._client.query(_SVC, self.JurisdictionText).get(jurisdiction_text_id) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_jurisdiction_text") return entity @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_JURISDICTION_TEXT) - def delete_jurisdiction_text( - self, jurisdiction_id: str, language_code: str - ) -> None: - """Delete a JurisdictionText entity by its composite key. + def delete_jurisdiction_text(self, jurisdiction_text_id: str) -> None: + """Delete a JurisdictionText entity by its ID. Args: - jurisdiction_id: UUID of the parent Jurisdiction. - language_code: BCP-47 language code identifying the text entry to delete. + jurisdiction_text_id: UUID of the JurisdictionText to delete. Raises: - NotFoundError: If no JurisdictionText for the given composite key exists. + NotFoundError: If no JurisdictionText with the given ID exists. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.delete_jurisdiction_text") - entity = self._client.query(_SVC, self.JurisdictionText).get( - jurisdictionId=jurisdiction_id, languageCode=language_code - ) + entity = self._client.query(_SVC, self.JurisdictionText).get(jurisdiction_text_id) self._client.delete_entity(entity) logger.info("Exiting ConsentConfigurationService.delete_jurisdiction_text") @@ -431,43 +420,42 @@ def create_language_description(self, body: dict[str, Any]) -> Any: @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_LANGUAGE_DESCRIPTION) def update_language_description( - self, language_code: str, body: dict[str, Any] + self, language_desc_id: str, body: dict[str, Any] ) -> Any: - """Fetch a LanguageDescription by language code, apply field updates, and PATCH it to the service. + """Fetch a LanguageDescription by its ID, apply field updates, and PATCH it to the service. Args: - language_code: BCP-47 language code of the LanguageDescription to update. + language_desc_id: UUID of the LanguageDescription to update. body: Dictionary of field names and values to apply. Returns: The updated LanguageDescription object. Raises: - NotFoundError: If no LanguageDescription with the given code exists. + NotFoundError: If no LanguageDescription with the given ID exists. ValidationError: If the updated fields fail server-side validation. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.update_language_description") - entity = self._client.query(_SVC, self.LanguageDescription).get(language_code) - for k, v in body.items(): - setattr(entity, k, v) + entity = self._client.query(_SVC, self.LanguageDescription).get(language_desc_id) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_language_description") return entity @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_LANGUAGE_DESCRIPTION) - def delete_language_description(self, language_code: str) -> None: - """Delete a LanguageDescription entity by its language code. + def delete_language_description(self, language_desc_id: str) -> None: + """Delete a LanguageDescription entity by its ID. Args: - language_code: BCP-47 language code of the LanguageDescription to delete. + language_desc_id: UUID of the LanguageDescription to delete. Raises: - NotFoundError: If no LanguageDescription with the given code exists. + NotFoundError: If no LanguageDescription with the given ID exists. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.delete_language_description") - entity = self._client.query(_SVC, self.LanguageDescription).get(language_code) + entity = self._client.query(_SVC, self.LanguageDescription).get(language_desc_id) self._client.delete_entity(entity) logger.info("Exiting ConsentConfigurationService.delete_language_description") @@ -551,8 +539,7 @@ def update_source_info(self, source_id: str, body: dict[str, Any]) -> Any: """ logger.info("Invoked ConsentConfigurationService.update_source_info") entity = self._client.query(_SVC, self.SourceInfo).get(source_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_source_info") return entity @@ -653,8 +640,7 @@ def update_controller(self, controller_id: str, body: dict[str, Any]) -> Any: """ logger.info("Invoked ConsentConfigurationService.update_controller") entity = self._client.query(_SVC, self.Controller).get(controller_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_controller") return entity @@ -763,8 +749,7 @@ def update_data_subject_type( entity = self._client.query(_SVC, self.DataSubjectType).get( data_subject_type_id ) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_data_subject_type") return entity @@ -867,8 +852,7 @@ def update_application(self, application_id: str, body: dict[str, Any]) -> Any: """ logger.info("Invoked ConsentConfigurationService.update_application") entity = self._client.query(_SVC, self.Application).get(application_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_application") return entity @@ -977,8 +961,7 @@ def update_master_data_source( entity = self._client.query(_SVC, self.MasterDataSource).get( master_data_source_id ) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_master_data_source") return entity @@ -1093,8 +1076,7 @@ def update_outbound_channel_type( entity = self._client.query(_SVC, self.OutboundChannelType).get( outbound_channel_type_id ) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_outbound_channel_type") return entity diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py index 472624bf..eef1a70b 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py @@ -116,8 +116,7 @@ def update_purpose(self, purpose_id: str, body: dict[str, Any]) -> Any: """ logger.info("Invoked ConsentPurposeService.update_purpose") entity = self._client.query(_SVC, self.ConsentPurpose).get(purpose_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentPurposeService.update_purpose") return entity @@ -208,27 +207,21 @@ def list_purpose_texts(self, **query: Any) -> list[Any]: return result @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_PURPOSE_TEXT) - def get_purpose_text( - self, purpose_id: str, type_code: str, language_code: str - ) -> Any: - """Return a single ConsentPurposeText entity by its composite key. + def get_purpose_text(self, purpose_text_id: str) -> Any: + """Return a single ConsentPurposeText entity by its ID. Args: - purpose_id: UUID of the parent ConsentPurpose. - type_code: Type code identifying the text category. - language_code: BCP-47 language code of the text entry. + purpose_text_id: UUID of the ConsentPurposeText to retrieve. Returns: The matching ConsentPurposeText object. Raises: - NotFoundError: If no ConsentPurposeText for the given composite key exists. + NotFoundError: If no ConsentPurposeText with the given ID exists. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentPurposeService.get_purpose_text") - result = self._client.query(_SVC, self.ConsentPurposeText).get( - purposeId=purpose_id, typeCode=type_code, languageCode=language_code - ) + result = self._client.query(_SVC, self.ConsentPurposeText).get(purpose_text_id) logger.info("Exiting ConsentPurposeService.get_purpose_text") return result @@ -256,53 +249,40 @@ def create_purpose_text(self, body: dict[str, Any]) -> Any: return entity @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_PURPOSE_TEXT) - def update_purpose_text( - self, purpose_id: str, type_code: str, language_code: str, body: dict[str, Any] - ) -> Any: - """Fetch a ConsentPurposeText by composite key, apply field updates, and PATCH it to the service. + def update_purpose_text(self, purpose_text_id: str, body: dict[str, Any]) -> Any: + """Fetch a ConsentPurposeText by its ID, apply field updates, and PATCH it to the service. Args: - purpose_id: UUID of the parent ConsentPurpose. - type_code: Type code identifying the text category. - language_code: BCP-47 language code of the text entry. + purpose_text_id: UUID of the ConsentPurposeText to update. body: Dictionary of field names and values to apply. Returns: The updated ConsentPurposeText object. Raises: - NotFoundError: If no ConsentPurposeText for the given composite key exists. + NotFoundError: If no ConsentPurposeText with the given ID exists. ValidationError: If the updated fields fail server-side validation. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentPurposeService.update_purpose_text") - entity = self._client.query(_SVC, self.ConsentPurposeText).get( - purposeId=purpose_id, typeCode=type_code, languageCode=language_code - ) - for k, v in body.items(): - setattr(entity, k, v) + entity = self._client.query(_SVC, self.ConsentPurposeText).get(purpose_text_id) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentPurposeService.update_purpose_text") return entity @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_PURPOSE_TEXT) - def delete_purpose_text( - self, purpose_id: str, type_code: str, language_code: str - ) -> None: - """Delete a ConsentPurposeText entity by its composite key. + def delete_purpose_text(self, purpose_text_id: str) -> None: + """Delete a ConsentPurposeText entity by its ID. Args: - purpose_id: UUID of the parent ConsentPurpose. - type_code: Type code identifying the text category. - language_code: BCP-47 language code of the text entry to delete. + purpose_text_id: UUID of the ConsentPurposeText to delete. Raises: - NotFoundError: If no ConsentPurposeText for the given composite key exists. + NotFoundError: If no ConsentPurposeText with the given ID exists. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentPurposeService.delete_purpose_text") - entity = self._client.query(_SVC, self.ConsentPurposeText).get( - purposeId=purpose_id, typeCode=type_code, languageCode=language_code - ) + entity = self._client.query(_SVC, self.ConsentPurposeText).get(purpose_text_id) self._client.delete_entity(entity) logger.info("Exiting ConsentPurposeService.delete_purpose_text") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py index e1172a72..ebbddde7 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py @@ -113,8 +113,7 @@ def update_rule(self, rule_id: str, body: dict[str, Any]) -> Any: """ logger.info("Invoked ConsentRetentionService.update_rule") entity = self._client.query(_SVC, self.ConsentRetentionRule).get(rule_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentRetentionService.update_rule") return entity diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py index 62bf6736..ce3b0581 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py @@ -117,8 +117,7 @@ def update_template(self, template_id: str, body: dict[str, Any]) -> Any: """ logger.info("Invoked ConsentTemplateService.update_template") entity = self._client.query(_SVC, self.ConsentTemplate).get(template_id) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentTemplateService.update_template") return entity @@ -213,27 +212,21 @@ def list_template_texts(self, **query: Any) -> list[Any]: return result @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_TEMPLATE_TEXT) - def get_template_text( - self, template_id: str, type_code: str, language_code: str - ) -> Any: - """Return a single ConsentTemplateText entity by its composite key. + def get_template_text(self, template_text_id: str) -> Any: + """Return a single ConsentTemplateText entity by its ID. Args: - template_id: UUID of the parent ConsentTemplate. - type_code: Type code identifying the text category. - language_code: BCP-47 language code of the text entry (e.g. ``"en"`` or ``"de"``). + template_text_id: UUID of the ConsentTemplateText to retrieve. Returns: The matching ConsentTemplateText object. Raises: - NotFoundError: If no ConsentTemplateText for the given composite key exists. + NotFoundError: If no ConsentTemplateText with the given ID exists. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentTemplateService.get_template_text") - result = self._client.query(_SVC, self.ConsentTemplateText).get( - templateId=template_id, typeCode=type_code, languageCode=language_code - ) + result = self._client.query(_SVC, self.ConsentTemplateText).get(template_text_id) logger.info("Exiting ConsentTemplateService.get_template_text") return result @@ -261,54 +254,41 @@ def create_template_text(self, body: dict[str, Any]) -> Any: return entity @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_TEMPLATE_TEXT) - def update_template_text( - self, template_id: str, type_code: str, language_code: str, body: dict[str, Any] - ) -> Any: - """Fetch a ConsentTemplateText by composite key, apply field updates, and PATCH it to the service. + def update_template_text(self, template_text_id: str, body: dict[str, Any]) -> Any: + """Fetch a ConsentTemplateText by its ID, apply field updates, and PATCH it to the service. Args: - template_id: UUID of the parent ConsentTemplate. - type_code: Type code identifying the text category. - language_code: BCP-47 language code of the text entry (e.g. ``"en"`` or ``"de"``). + template_text_id: UUID of the ConsentTemplateText to update. body: Dictionary of field names and values to apply. Returns: The updated ConsentTemplateText object. Raises: - NotFoundError: If no ConsentTemplateText for the given composite key exists. + NotFoundError: If no ConsentTemplateText with the given ID exists. ValidationError: If the updated fields fail server-side validation. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentTemplateService.update_template_text") - entity = self._client.query(_SVC, self.ConsentTemplateText).get( - templateId=template_id, typeCode=type_code, languageCode=language_code - ) - for k, v in body.items(): - setattr(entity, k, v) + entity = self._client.query(_SVC, self.ConsentTemplateText).get(template_text_id) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentTemplateService.update_template_text") return entity @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_TEMPLATE_TEXT) - def delete_template_text( - self, template_id: str, type_code: str, language_code: str - ) -> None: - """Delete a ConsentTemplateText entity by its composite key. + def delete_template_text(self, template_text_id: str) -> None: + """Delete a ConsentTemplateText entity by its ID. Args: - template_id: UUID of the parent ConsentTemplate. - type_code: Type code identifying the text category. - language_code: BCP-47 language code of the text entry to delete (e.g. ``"en"`` or ``"de"``). + template_text_id: UUID of the ConsentTemplateText to delete. Raises: - NotFoundError: If no ConsentTemplateText for the given composite key exists. + NotFoundError: If no ConsentTemplateText with the given ID exists. ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentTemplateService.delete_template_text") - entity = self._client.query(_SVC, self.ConsentTemplateText).get( - templateId=template_id, typeCode=type_code, languageCode=language_code - ) + entity = self._client.query(_SVC, self.ConsentTemplateText).get(template_text_id) self._client.delete_entity(entity) logger.info("Exiting ConsentTemplateService.delete_template_text") @@ -336,12 +316,12 @@ def list_third_party_pers_data(self, **query: Any) -> list[Any]: return result @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_THIRD_PARTY_PERS_DATA) - def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> Any: + def get_third_party_pers_data(self, third_party_assignment_id: str, template_id: str) -> Any: """Return a single TemplateThirdPartyPersData entity by its composite key. Args: - template_id: UUID of the parent ConsentTemplate. - third_party_id: UUID of the associated ThirdParty. + third_party_assignment_id: UUID of the ThirdPartyAssignment (primary key). + template_id: UUID of the parent ConsentTemplate (primary key). Returns: The matching TemplateThirdPartyPersData object. @@ -352,7 +332,7 @@ def get_third_party_pers_data(self, template_id: str, third_party_id: str) -> An """ logger.info("Invoked ConsentTemplateService.get_third_party_pers_data") result = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( - templateId=template_id, thirdPartyId=third_party_id + thirdPartyAssignmentId=third_party_assignment_id, templateId=template_id ) logger.info("Exiting ConsentTemplateService.get_third_party_pers_data") return result @@ -386,13 +366,13 @@ def create_third_party_pers_data(self, body: dict[str, Any]) -> Any: Module.DPI_NG, Operation.DPI_NG_CONSENT_UPDATE_THIRD_PARTY_PERS_DATA ) def update_third_party_pers_data( - self, template_id: str, third_party_id: str, body: dict[str, Any] + self, third_party_assignment_id: str, template_id: str, body: dict[str, Any] ) -> Any: """Fetch a TemplateThirdPartyPersData by composite key, apply field updates, and PATCH it to the service. Args: - template_id: UUID of the parent ConsentTemplate. - third_party_id: UUID of the associated ThirdParty. + third_party_assignment_id: UUID of the ThirdPartyAssignment (primary key). + template_id: UUID of the parent ConsentTemplate (primary key). body: Dictionary of field names and values to apply. Returns: @@ -405,10 +385,9 @@ def update_third_party_pers_data( """ logger.info("Invoked ConsentTemplateService.update_third_party_pers_data") entity = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( - templateId=template_id, thirdPartyId=third_party_id + thirdPartyAssignmentId=third_party_assignment_id, templateId=template_id ) - for k, v in body.items(): - setattr(entity, k, v) + self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentTemplateService.update_third_party_pers_data") return entity @@ -417,13 +396,13 @@ def update_third_party_pers_data( Module.DPI_NG, Operation.DPI_NG_CONSENT_DELETE_THIRD_PARTY_PERS_DATA ) def delete_third_party_pers_data( - self, template_id: str, third_party_id: str + self, third_party_assignment_id: str, template_id: str ) -> None: """Delete a TemplateThirdPartyPersData entity by its composite key. Args: - template_id: UUID of the parent ConsentTemplate. - third_party_id: UUID of the associated ThirdParty to remove. + third_party_assignment_id: UUID of the ThirdPartyAssignment (primary key). + template_id: UUID of the parent ConsentTemplate (primary key). Raises: NotFoundError: If no TemplateThirdPartyPersData for the given composite key exists. @@ -431,7 +410,7 @@ def delete_third_party_pers_data( """ logger.info("Invoked ConsentTemplateService.delete_third_party_pers_data") entity = self._client.query(_SVC, self.TemplateThirdPartyPersData).get( - templateId=template_id, thirdPartyId=third_party_id + thirdPartyAssignmentId=third_party_assignment_id, templateId=template_id ) self._client.delete_entity(entity) logger.info("Exiting ConsentTemplateService.delete_third_party_pers_data") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md index 83fbbfe4..70cc0b36 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -205,14 +205,14 @@ for c in consents: #### Get a consent by ID ```python -consent = client.consents.get_consent("3fa85f64-5717-4562-b3fc-2c963f66afa6") +consent = client.consents.get_consent("") print(consent.consent_id, consent.data_subject_id) ``` #### Delete a consent ```python -client.consents.delete_consent("3fa85f64-5717-4562-b3fc-2c963f66afa6") +client.consents.delete_consent("") ``` #### Create a consent from a template @@ -222,10 +222,9 @@ from sap_cloud_sdk.core.dpi_ng.consent import CreateConsentRequest request = CreateConsentRequest( data_subject_id="user@example.com", - template_name="", - language_code="EN", - data_subject_type_name="", - jurisdiction_code="", + template_name="GDPR_Marketing_2024", + language_code="en", + data_subject_type_name="Employee", ) consents = client.consents.create_consent_from_template(request) for c in consents: @@ -233,22 +232,22 @@ for c in consents: ``` **Required fields:** -- `data_subject_id` - identifier of the data subject. -- `template_name` - name of the consent template to use. -- `language_code` - language code for the consent text (e.g. `"EN"`). -- `data_subject_type_name` - type classification for the data subject. -- `jurisdiction_code` - jurisdiction under which the consent is recorded. +- `data_subject_id` - The unique identifier for the data subject. This sets the `DataSubjectId` for resulting consent records. +- `template_name` - The name of the consent form that should be used to create the consent record. +- `language_code` - The language in which the consent was granted. +- `data_subject_type_name` - The name of a data subject type. **Optional fields:** -- `data_subject_description` - human-readable description of the data subject. -- `outbound_channel_type_name` - outbound channel type name. -- `outbound_channel` - outbound channel identifier. -- `valid_from` - ISO 8601 date from which the consent is valid. -- `application_template_id` - application-specific template reference. -- `controller_name` - name of the data controller. -- `granted_by` - actor who granted the consent. -- `granted_at` - ISO 8601 timestamp of consent grant. -- `submission_site` - site or URL where the consent was submitted. +- `data_subject_description` - The full name of the data subject. +- `outbound_channel_type_name` - The name of an outbound channel type. If you include an outbound channel type, you must also include an outbound channel. +- `outbound_channel` - Outbound channel identifier. +- `valid_from` - The date when the consent record starts being valid. If no value is provided, the current timestamp is used. +- `jurisdiction_code` - The legal space in which the consent is valid. Overrides the `JurisdictionCode` from the consent form. +- `application_template_id` - Freely used by the integrating application (e.g. a context string identifying the business process). Overrides the `applicationTemplateId` from the consent form. +- `controller_name` - The name of a data controller. Overrides the `ControllerName` from the consent form. +- `granted_by` - The natural person who granted the consent — either the data subject or another person acting on their behalf (e.g. a customer service representative or legal guardian). +- `granted_at` - When the consent was granted. +- `submission_site` - Where the consent was granted. This could be a physical place (e.g. a hospital name) or a website. #### Withdraw a consent @@ -257,7 +256,7 @@ from sap_cloud_sdk.core.dpi_ng.consent import WithdrawConsentRequest client.consents.withdraw_consent( WithdrawConsentRequest( - consent_id="3fa85f64-5717-4562-b3fc-2c963f66afa6", + consent_id="", withdrawn_by="user@example.com", ) ) @@ -268,13 +267,13 @@ client.consents.withdraw_consent( ```python client.consents.terminate_consent( WithdrawConsentRequest( - consent_id="3fa85f64-5717-4562-b3fc-2c963f66afa6", + consent_id="", withdrawn_by="contract-end-process", ) ) ``` -`WithdrawConsentRequest.withdrawn_at` is optional - omit it to let the service +`WithdrawConsentRequest.withdrawn_by` and `withdrawn_at` are both optional — omit them to let the service record the current timestamp. #### Check whether a consent exists @@ -317,7 +316,7 @@ print(purpose.purpose_name, purpose.sensitive_data_flag) ```python purpose = client.purposes.create_purpose({ - "purpose_name": "Marketing Emails", + "purpose_name": "Marketing_Emails", "sensitive_data_flag": False, }) print(purpose.purpose_id) @@ -328,7 +327,7 @@ print(purpose.purpose_id) ```python purpose = client.purposes.update_purpose( "", - {"purpose_name": "Marketing Emails - Updated"}, + {"purpose_name": "Marketing_Emails_Updated", "sensitive_data_flag": True}, ) ``` @@ -357,16 +356,9 @@ texts = client.purposes.list_purpose_texts( #### Get a purpose text -Purpose texts have a composite key of `purpose_id`, `type_code`, and -`language_code`. - ```python -text = client.purposes.get_purpose_text( - purpose_id="", - type_code="SHORT", - language_code="EN", -) -print(text.text_value) +text = client.purposes.get_purpose_text("") +print(text.text) ``` #### Create a purpose text @@ -374,9 +366,9 @@ print(text.text_value) ```python text = client.purposes.create_purpose_text({ "purpose_id": "", - "type_code": "SHORT", - "language_code": "EN", - "text_value": "We use your email to send marketing updates.", + "type_code": "01", + "language_code": "en", + "text": "We use your email to send marketing updates.", }) ``` @@ -384,21 +376,15 @@ text = client.purposes.create_purpose_text({ ```python text = client.purposes.update_purpose_text( - purpose_id="", - type_code="SHORT", - language_code="EN", - body={"text_value": "Updated marketing email description."}, + "", + body={"purpose_id": "", "type_code": "01", "language_code": "de", "text": "Updated marketing email description."}, ) ``` #### Delete a purpose text ```python -client.purposes.delete_purpose_text( - purpose_id="", - type_code="SHORT", - language_code="EN", -) +client.purposes.delete_purpose_text("") ``` --- @@ -424,8 +410,14 @@ print(template.template_name) ```python template = client.templates.create_template({ - "template_name": "GDPR-Marketing-2024", + "template_name": "GDPR_Marketing_2024", "jurisdiction_code": "EU", + "consent_model_code": "1", + "validity_period": 365, + "expiring_period": 30, + "purpose_name": "Marketing_Emails", + "controller_name": "AB_Corp", + "application_name": "HR_Portal", }) print(template.template_id) ``` @@ -435,7 +427,16 @@ print(template.template_id) ```python template = client.templates.update_template( "", - {"template_name": "GDPR-Marketing-2025"}, + { + "template_name": "GDPR_Marketing_2025", + "jurisdiction_code": "DE", + "consent_model_code": "2", + "validity_period": 180, + "expiring_period": 15, + "purpose_name": "Marketing_Emails_Updated", + "controller_name": "AB_Corp_Updated", + "application_name": "HR_Portal_v2", + }, ) ``` @@ -462,15 +463,9 @@ texts = client.templates.list_template_texts( #### Get a template text -Template texts have a composite key of `template_id`, `type_code`, and -`language_code`. - ```python -text = client.templates.get_template_text( - template_id="", - type_code="LONG", - language_code="EN", -) +text = client.templates.get_template_text("") +print(text.text) ``` #### Create a template text @@ -478,9 +473,9 @@ text = client.templates.get_template_text( ```python text = client.templates.create_template_text({ "template_id": "", - "type_code": "LONG", - "language_code": "EN", - "text_value": "Full consent statement in English...", + "language_code": "en", + "type_code": "51", + "text": "Full consent statement in English...", }) ``` @@ -488,21 +483,15 @@ text = client.templates.create_template_text({ ```python text = client.templates.update_template_text( - template_id="", - type_code="LONG", - language_code="EN", - body={"text_value": "Revised consent statement."}, + "", + body={"template_id": "", "language_code": "de", "type_code": "52", "text": "Revised consent statement."}, ) ``` #### Delete a template text ```python -client.templates.delete_template_text( - template_id="", - type_code="LONG", - language_code="EN", -) +client.templates.delete_template_text("") ``` #### List third-party personal data assignments @@ -515,12 +504,10 @@ records = client.templates.list_third_party_pers_data( #### Get a third-party personal data record -The composite key is `template_id` and `third_party_id`. - ```python record = client.templates.get_third_party_pers_data( + third_party_assignment_id="", template_id="", - third_party_id="", ) ``` @@ -530,6 +517,8 @@ record = client.templates.get_third_party_pers_data( record = client.templates.create_third_party_pers_data({ "template_id": "", "third_party_id": "", + "third_party_function_code": "01", + "sensitive_data_flag": False, }) ``` @@ -537,9 +526,9 @@ record = client.templates.create_third_party_pers_data({ ```python record = client.templates.update_third_party_pers_data( + third_party_assignment_id="", template_id="", - third_party_id="", - body={"description": "Updated description"}, + body={"third_party_id": "", "third_party_function_code": "02", "sensitive_data_flag": True}, ) ``` @@ -547,8 +536,8 @@ record = client.templates.update_third_party_pers_data( ```python client.templates.delete_third_party_pers_data( + third_party_assignment_id="", template_id="", - third_party_id="", ) ``` @@ -568,15 +557,19 @@ rules = client.retention.list_rules( ```python rule = client.retention.get_rule("") -print(rule.retention_period) +print(rule.rule_name, rule.retention_period) ``` #### Create a retention rule ```python rule = client.retention.create_rule({ - "rule_name": "7-year-financial", - "retention_period": 84, + "rule_name": "7_year_financial", + "purpose_name": "Marketing_Emails", + "retention_period": 7, + "jurisdiction_code": "EU", + "controller_name": "AB_Corp", + "consent_model_code": "1", }) print(rule.rule_id) ``` @@ -586,7 +579,14 @@ print(rule.rule_id) ```python rule = client.retention.update_rule( "", - {"retention_period": 96}, + { + "rule_name": "7_year_financial", + "purpose_name": "Marketing_Emails_Updated", + "retention_period": 8, + "jurisdiction_code": "DE", + "controller_name": "AB_Corp_Updated", + "consent_model_code": "2", + }, ) ``` @@ -621,15 +621,15 @@ tp = client.configuration.get_third_party("") # Create tp = client.configuration.create_third_party({ - "third_party_name": "Analytics Corp", - "description": "Third-party analytics provider", + "third_party_name": "Analytics_Corp", + "formatted_description": "Third-party analytics provider", }) print(tp.third_party_id) # Update tp = client.configuration.update_third_party( "", - {"description": "Updated analytics provider description"}, + {"third_party_name": "Analytics_Corp", "formatted_description": "Updated analytics provider description"}, ) # Delete @@ -648,13 +648,12 @@ jurisdiction = client.configuration.get_jurisdiction("") # Create jurisdiction = client.configuration.create_jurisdiction({ "jurisdiction_code": "EU", - "jurisdiction_name": "European Union", }) # Update jurisdiction = client.configuration.update_jurisdiction( "", - {"jurisdiction_name": "EU - GDPR"}, + {"jurisdiction_code": "DE"}, ) # Delete @@ -663,31 +662,25 @@ client.configuration.delete_jurisdiction("") #### Jurisdiction texts -Jurisdiction texts have a composite key of `jurisdiction_id` and `language_code`. - ```python # List texts = client.configuration.list_jurisdiction_texts() # Create text = client.configuration.create_jurisdiction_text({ - "jurisdiction_id": "", - "language_code": "EN", - "text_value": "European Union General Data Protection Regulation", + "jurisdiction_code": "", + "language_code": "en", + "description": "European Union General Data Protection Regulation", }) # Update text = client.configuration.update_jurisdiction_text( - jurisdiction_id="", - language_code="EN", - body={"text_value": "EU GDPR - Revised"}, + "", + body={"language_code": "en", "description": "EU GDPR - Revised"}, ) # Delete -client.configuration.delete_jurisdiction_text( - jurisdiction_id="", - language_code="EN", -) +client.configuration.delete_jurisdiction_text("") ``` #### Languages @@ -697,7 +690,7 @@ client.configuration.delete_jurisdiction_text( languages = client.configuration.list_languages() # Get -language = client.configuration.get_language("EN") +language = client.configuration.get_language("en") print(language.language_code) ``` @@ -711,18 +704,19 @@ descriptions = client.configuration.list_language_descriptions() # Create desc = client.configuration.create_language_description({ - "language_code": "EN", - "description": "English", + "language_code": "ar", + "description_language_code": "en", + "description": "Arabic", }) # Update desc = client.configuration.update_language_description( - language_code="EN", - body={"description": "English (Updated)"}, + "", + body={"language_code": "ar", "description_language_code": "en", "description": "Arabic (Updated)"}, ) # Delete -client.configuration.delete_language_description("EN") +client.configuration.delete_language_description("") ``` #### Source infos @@ -736,14 +730,14 @@ source = client.configuration.get_source_info("") # Create source = client.configuration.create_source_info({ - "source_name": "CRM System", + "source_name": "CRM_System", "description": "Customer relationship management platform", }) # Update source = client.configuration.update_source_info( "", - {"description": "CRM - Updated"}, + {"source_name": "CRM_System", "description": "CRM - Updated"}, ) # Delete @@ -761,7 +755,8 @@ controller = client.configuration.get_controller("") # Create controller = client.configuration.create_controller({ - "controller_name": "AB Corp", + "controller_name": "AB_Corp", + "source_name": "CRM_System", "description": "Main data controller", }) print(controller.controller_id) @@ -769,7 +764,7 @@ print(controller.controller_id) # Update controller = client.configuration.update_controller( "", - {"description": "AB Corp - Primary data controller"}, + {"controller_name": "AB_Corp", "source_name": "CRM_System", "description": "AB Corp - Primary data controller"}, ) # Delete @@ -783,21 +778,22 @@ client.configuration.delete_controller("") types = client.configuration.list_data_subject_types() # Get -dst = client.configuration.get_data_subject_type("") +dst = client.configuration.get_data_subject_type("") # Create dst = client.configuration.create_data_subject_type({ "data_subject_type_name": "Employee", + "master_data_source_name": "SAP_HR", }) # Update dst = client.configuration.update_data_subject_type( - "", - {"data_subject_type_name": "Internal Employee"}, + "", + {"data_subject_type_name": "Internal_Employee", "master_data_source_name": "SAP_SuccessFactors"}, ) # Delete -client.configuration.delete_data_subject_type("") +client.configuration.delete_data_subject_type("") ``` #### Applications @@ -807,21 +803,23 @@ client.configuration.delete_data_subject_type("") apps = client.configuration.list_applications() # Get -app = client.configuration.get_application("") +app = client.configuration.get_application("") # Create app = client.configuration.create_application({ - "application_name": "HR Portal", + "application_name": "HR_Portal", + "source_name": "CRM_System", + "description": "HR self-service portal", }) # Update app = client.configuration.update_application( - "", - {"application_name": "HR Portal v2"}, + "", + {"application_name": "HR_Portal_v2", "source_name": "CRM_System", "description": "HR portal version 2"}, ) # Delete -client.configuration.delete_application("") +client.configuration.delete_application("") ``` #### Master data sources @@ -831,21 +829,22 @@ client.configuration.delete_application("") sources = client.configuration.list_master_data_sources() # Get -source = client.configuration.get_master_data_source("") +source = client.configuration.get_master_data_source("") # Create source = client.configuration.create_master_data_source({ - "master_data_source_name": "SAP S/4HANA", + "master_data_source_name": "SAP_S4HANA", + "description": "SAP S/4HANA master data source", }) # Update source = client.configuration.update_master_data_source( - "", - {"master_data_source_name": "SAP S/4HANA Cloud"}, + "", + {"master_data_source_name": "SAP_S4HANA_Cloud", "description": "SAP S/4HANA Cloud master data source"}, ) # Delete -client.configuration.delete_master_data_source("") +client.configuration.delete_master_data_source("") ``` #### Outbound channel types @@ -855,21 +854,22 @@ client.configuration.delete_master_data_source("") channel_types = client.configuration.list_outbound_channel_types() # Get -ct = client.configuration.get_outbound_channel_type("") +channel_type = client.configuration.get_outbound_channel_type("") # Create -ct = client.configuration.create_outbound_channel_type({ +channel_type = client.configuration.create_outbound_channel_type({ "outbound_channel_type_name": "Email", + "description": "Email notification channel", }) # Update -ct = client.configuration.update_outbound_channel_type( - "", - {"outbound_channel_type_name": "Email Newsletter"}, +channel_type = client.configuration.update_outbound_channel_type( + "", + {"outbound_channel_type_name": "Email_Newsletter", "description": "Email newsletter channel"}, ) # Delete -client.configuration.delete_outbound_channel_type("") +client.configuration.delete_outbound_channel_type("") ``` ## Error Handling From 343c24b68228f91b0445eefb20f6e818ef7987e6 Mon Sep 17 00:00:00 2001 From: N Date: Fri, 10 Jul 2026 01:48:21 +0530 Subject: [PATCH 24/27] chore: bump version to 0.34.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2c6c025f..12234d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.33.2" +version = "0.34.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From b9880e34a9d1f825c04f2ab1f7b15c14720e8b08 Mon Sep 17 00:00:00 2001 From: N Date: Fri, 10 Jul 2026 02:26:17 +0530 Subject: [PATCH 25/27] fix(dpi_ng/consent): align test assertions, method names, and achieve 100% coverage --- .../dpi_ng/consent/unit/entities/conftest.py | 10 ++-- .../unit/entities/test_template_entities.py | 4 +- .../dpi_ng/consent/unit/services/conftest.py | 2 + .../services/test_configuration_service.py | 28 ++++------ .../unit/services/test_consent_service.py | 4 +- .../unit/services/test_purpose_service.py | 20 +++---- .../unit/services/test_retention_service.py | 2 +- .../unit/services/test_template_service.py | 55 +++++++++---------- .../dpi_ng/consent/unit/test_odata_client.py | 44 ++++++++++++++- 9 files changed, 103 insertions(+), 66 deletions(-) diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py b/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py index 97208dd2..1507438a 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py @@ -31,7 +31,7 @@ }, "JurisdictionText": { "collection": "jurisdictionTexts", - "pk": ["jurisdiction_id", "language_code"], + "pk": ["jurisdiction_text_id"], "bool": [], "int": [], }, @@ -43,7 +43,7 @@ }, "LanguageDescription": { "collection": "languageDescriptions", - "pk": ["language_code"], + "pk": ["language_desc_id"], "bool": [], "int": [], }, @@ -94,7 +94,7 @@ }, "ConsentPurposeText": { "collection": "consentPurposeTexts", - "pk": ["purpose_id", "type_code", "language_code"], + "pk": ["purpose_text_id"], "bool": [], "int": [], }, @@ -118,13 +118,13 @@ }, "ConsentTemplateText": { "collection": "consentTemplateTexts", - "pk": ["template_id", "type_code", "language_code"], + "pk": ["template_text_id"], "bool": [], "int": [], }, "TemplateThirdPartyPersData": { "collection": "templateThirdPartyPersDatas", - "pk": ["template_id", "third_party_id"], + "pk": ["third_party_assignment_id", "template_id"], "bool": ["sensitive_data_flag"], "int": [], }, diff --git a/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py b/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py index c1946f81..78606381 100644 --- a/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py @@ -44,6 +44,6 @@ def test_template_id_is_pk(self, template_entities): cls = entity_by_name(template_entities, "TemplateThirdPartyPersData") assert cls.template_id.primary_key is True - def test_third_party_id_is_pk(self, template_entities): + def test_third_party_assignment_id_is_pk(self, template_entities): cls = entity_by_name(template_entities, "TemplateThirdPartyPersData") - assert cls.third_party_id.primary_key is True + assert cls.third_party_assignment_id.primary_key is True diff --git a/tests/core/unit/dpi_ng/consent/unit/services/conftest.py b/tests/core/unit/dpi_ng/consent/unit/services/conftest.py index 54372fc5..00faf16a 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/conftest.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/conftest.py @@ -17,6 +17,8 @@ def _make_mock_client(entities_module): q.all.return_value = [] q.get.return_value = MagicMock() q.raw.return_value = q + q.filter.return_value = q + q.order_by.return_value = q q.limit.return_value = q q.offset.return_value = q c.query.return_value = q diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py index c106c5b1..c2e8a591 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py @@ -134,8 +134,8 @@ def test_delete_calls_delete_entity(svc, row): def test_list_with_filter_kwarg(svc): svc.list_third_parties(filter="third_party_name eq 'ACME'") - svc._client.query.return_value.raw.assert_called_with( - {"$filter": "third_party_name eq 'ACME'"} + svc._client.query.return_value.filter.assert_called_with( + "third_party_name eq 'ACME'" ) @@ -151,8 +151,8 @@ def test_list_with_skip_kwarg(svc): def test_list_with_orderby_kwarg(svc): svc.list_third_parties(orderby="third_party_name asc") - svc._client.query.return_value.raw.assert_called_with( - {"$orderby": "third_party_name asc"} + svc._client.query.return_value.order_by.assert_called_with( + "third_party_name asc" ) @@ -170,24 +170,20 @@ def test_create(self, svc): svc.create_jurisdiction_text({"description": "x"}) svc._client.save.assert_called_once() - def test_update_composite_key(self, svc): - svc.update_jurisdiction_text("jur-1", "EN", {"description": "y"}) - svc._client.query.return_value.get.assert_called_with( - jurisdictionId="jur-1", languageCode="EN" - ) + def test_update_fetches_by_id(self, svc): + svc.update_jurisdiction_text("jur-text-1", {"description": "y"}) + svc._client.query.return_value.get.assert_called_with("jur-text-1") def test_update_calls_save(self, svc): - svc.update_jurisdiction_text("jur-1", "EN", {"description": "y"}) + svc.update_jurisdiction_text("jur-text-1", {"description": "y"}) svc._client.save.assert_called_once() - def test_delete_composite_key(self, svc): - svc.delete_jurisdiction_text("jur-1", "EN") - svc._client.query.return_value.get.assert_called_with( - jurisdictionId="jur-1", languageCode="EN" - ) + def test_delete_fetches_by_id(self, svc): + svc.delete_jurisdiction_text("jur-text-1") + svc._client.query.return_value.get.assert_called_with("jur-text-1") def test_delete_calls_delete_entity(self, svc): - svc.delete_jurisdiction_text("jur-1", "EN") + svc.delete_jurisdiction_text("jur-text-1") svc._client.delete_entity.assert_called_once() diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py index 5a1553d0..effe9e1d 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py @@ -136,7 +136,7 @@ class TestQueryKwargs: def test_query_filter_kwarg(self, svc, mock_consent_client): q = mock_consent_client.query.return_value svc.list_consents(filter="x eq 'y'") - q.raw.assert_called_with({"$filter": "x eq 'y'"}) + q.filter.assert_called_with("x eq 'y'") def test_query_top_skip(self, svc, mock_consent_client): q = mock_consent_client.query.return_value @@ -147,7 +147,7 @@ def test_query_top_skip(self, svc, mock_consent_client): def test_query_orderby(self, svc, mock_consent_client): q = mock_consent_client.query.return_value svc.list_consents(orderby="changedAt desc") - q.raw.assert_called_with({"$orderby": "changedAt desc"}) + q.order_by.assert_called_with("changedAt desc") class TestDictToEntity: diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py index 55304c1d..30f766eb 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py @@ -35,7 +35,7 @@ def test_list_purposes(self, svc, client, q): def test_query_filter(self, svc, client, q): svc.list_purposes(filter="purpose_name eq 'test'") - q.raw.assert_called_with({"$filter": "purpose_name eq 'test'"}) + q.filter.assert_called_with("purpose_name eq 'test'") def test_query_top_skip(self, svc, client, q): svc.list_purposes(top=5, skip=2) @@ -110,10 +110,10 @@ def test_list_purpose_texts(self, svc, client, q): class TestGetPurposeText: - def test_get_purpose_text_composite_key(self, svc, client, q): - result = svc.get_purpose_text("pid", "tc", "EN") + def test_get_purpose_text_by_id(self, svc, client, q): + result = svc.get_purpose_text("ptid") client.query.assert_called_with(_SVC, svc.ConsentPurposeText) - q.get.assert_called_with(purposeId="pid", typeCode="tc", languageCode="EN") + q.get.assert_called_with("ptid") assert result == q.get.return_value @@ -130,15 +130,15 @@ def test_create_purpose_text(self, svc, client): class TestUpdatePurposeText: - def test_update_purpose_text_composite_key(self, svc, client, q): + def test_update_purpose_text_fetches_by_id(self, svc, client, q): body = {"text": "updated"} - svc.update_purpose_text("pid", "tc", "EN", body) - q.get.assert_called_with(purposeId="pid", typeCode="tc", languageCode="EN") + svc.update_purpose_text("ptid", body) + q.get.assert_called_with("ptid") client.save.assert_called_once() class TestDeletePurposeText: - def test_delete_purpose_text_composite_key(self, svc, client, q): - svc.delete_purpose_text("pid", "tc", "EN") - q.get.assert_called_with(purposeId="pid", typeCode="tc", languageCode="EN") + def test_delete_purpose_text_fetches_by_id(self, svc, client, q): + svc.delete_purpose_text("ptid") + q.get.assert_called_with("ptid") client.delete_entity.assert_called_once_with(q.get.return_value) diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py index d4d29b89..722cad1d 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py @@ -35,7 +35,7 @@ def test_list_rules(self, svc, client, q): def test_query_filter(self, svc, client, q): svc.list_rules(filter="lifecycle_status_code eq '1'") - q.raw.assert_called_with({"$filter": "lifecycle_status_code eq '1'"}) + q.filter.assert_called_with("lifecycle_status_code eq '1'") def test_query_top(self, svc, client, q): svc.list_rules(top=3) diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py index c5182143..dd8fba73 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py @@ -52,9 +52,8 @@ def test_update_template(self, svc, mock_template_client): mock_template_client.save.assert_called_once() def test_update_template_applies_fields(self, svc, mock_template_client): - entity = mock_template_client.query.return_value.get.return_value svc.update_template("tid", {"template_name": "new-name"}) - assert entity.template_name == "new-name" + mock_template_client._apply_body.assert_called_once() def test_delete_template(self, svc, mock_template_client): q = mock_template_client.query.return_value @@ -110,11 +109,11 @@ def test_list_template_texts(self, svc, mock_template_client): mock_template_client.query.return_value.all.assert_called_once() assert result == [] - def test_get_template_text_composite_key(self, svc, mock_template_client): + def test_get_template_text_by_id(self, svc, mock_template_client): q = mock_template_client.query.return_value - result = svc.get_template_text("tid", "tc", "EN") + result = svc.get_template_text("ttid") mock_template_client.query.assert_called_with(_SVC, svc.ConsentTemplateText) - q.get.assert_called_with(templateId="tid", typeCode="tc", languageCode="EN") + q.get.assert_called_with("ttid") assert result is q.get.return_value def test_create_template_text(self, svc, mock_template_client): @@ -126,26 +125,25 @@ def test_create_template_text_sets_fields(self, svc, mock_template_client): call_arg = mock_template_client.save.call_args[0][0] assert call_arg.text == "hello" - def test_update_template_text_composite_key(self, svc, mock_template_client): + def test_update_template_text_fetches_by_id(self, svc, mock_template_client): q = mock_template_client.query.return_value - svc.update_template_text("tid", "tc", "EN", {"text": "updated"}) - q.get.assert_called_with(templateId="tid", typeCode="tc", languageCode="EN") + svc.update_template_text("ttid", {"text": "updated"}) + q.get.assert_called_with("ttid") mock_template_client.save.assert_called_once() def test_update_template_text_applies_fields(self, svc, mock_template_client): - entity = mock_template_client.query.return_value.get.return_value - svc.update_template_text("tid", "tc", "EN", {"text": "new text"}) - assert entity.text == "new text" + svc.update_template_text("ttid", {"text": "new text"}) + mock_template_client._apply_body.assert_called_once() - def test_delete_template_text_composite_key(self, svc, mock_template_client): + def test_delete_template_text_fetches_by_id(self, svc, mock_template_client): q = mock_template_client.query.return_value - svc.delete_template_text("tid", "tc", "EN") - q.get.assert_called_with(templateId="tid", typeCode="tc", languageCode="EN") + svc.delete_template_text("ttid") + q.get.assert_called_with("ttid") mock_template_client.delete_entity.assert_called_once_with(q.get.return_value) # --------------------------------------------------------------------------- -# TemplateThirdPartyPersData (composite key: template_id + third_party_id) +# TemplateThirdPartyPersData (composite key: third_party_assignment_id + template_id) # --------------------------------------------------------------------------- @@ -158,13 +156,13 @@ def test_list_third_party_pers_data(self, svc, mock_template_client): mock_template_client.query.return_value.all.assert_called_once() assert result == [] - def test_get_third_party_pers_data_composite_key(self, svc, mock_template_client): + def test_get_third_party_pers_data_by_assignment_and_template(self, svc, mock_template_client): q = mock_template_client.query.return_value - result = svc.get_third_party_pers_data("tid", "tpid") + result = svc.get_third_party_pers_data("tpaid", "tid") mock_template_client.query.assert_called_with( _SVC, svc.TemplateThirdPartyPersData ) - q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") + q.get.assert_called_with(thirdPartyAssignmentId="tpaid", templateId="tid") assert result is q.get.return_value def test_create_third_party_pers_data(self, svc, mock_template_client): @@ -176,31 +174,30 @@ def test_create_third_party_pers_data_sets_fields(self, svc, mock_template_clien call_arg = mock_template_client.save.call_args[0][0] assert call_arg.third_party_function_code == "PROCESSOR" - def test_update_third_party_pers_data_composite_key( + def test_update_third_party_pers_data_by_assignment_and_template( self, svc, mock_template_client ): q = mock_template_client.query.return_value svc.update_third_party_pers_data( - "tid", "tpid", {"third_party_function_code": "CONTROLLER"} + "tpaid", "tid", {"third_party_function_code": "CONTROLLER"} ) - q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") + q.get.assert_called_with(thirdPartyAssignmentId="tpaid", templateId="tid") mock_template_client.save.assert_called_once() def test_update_third_party_pers_data_applies_fields( self, svc, mock_template_client ): - entity = mock_template_client.query.return_value.get.return_value svc.update_third_party_pers_data( - "tid", "tpid", {"third_party_function_code": "CONTROLLER"} + "tpaid", "tid", {"third_party_function_code": "CONTROLLER"} ) - assert entity.third_party_function_code == "CONTROLLER" + mock_template_client._apply_body.assert_called_once() - def test_delete_third_party_pers_data_composite_key( + def test_delete_third_party_pers_data_by_assignment_and_template( self, svc, mock_template_client ): q = mock_template_client.query.return_value - svc.delete_third_party_pers_data("tid", "tpid") - q.get.assert_called_with(templateId="tid", thirdPartyId="tpid") + svc.delete_third_party_pers_data("tpaid", "tid") + q.get.assert_called_with(thirdPartyAssignmentId="tpaid", templateId="tid") mock_template_client.delete_entity.assert_called_once_with(q.get.return_value) @@ -213,7 +210,7 @@ class TestQueryParams: def test_query_filter(self, svc, mock_template_client): q = mock_template_client.query.return_value svc.list_templates(filter="lifecycle_status_code eq '1'") - q.raw.assert_called_with({"$filter": "lifecycle_status_code eq '1'"}) + q.filter.assert_called_with("lifecycle_status_code eq '1'") def test_query_top_skip(self, svc, mock_template_client): q = mock_template_client.query.return_value @@ -224,7 +221,7 @@ def test_query_top_skip(self, svc, mock_template_client): def test_query_orderby(self, svc, mock_template_client): q = mock_template_client.query.return_value svc.list_templates(orderby="template_name asc") - q.raw.assert_called_with({"$orderby": "template_name asc"}) + q.order_by.assert_called_with("template_name asc") def test_query_no_params_skips_raw(self, svc, mock_template_client): q = mock_template_client.query.return_value diff --git a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py index 68fc7796..12d53d46 100644 --- a/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py +++ b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py @@ -288,16 +288,34 @@ def test_save_delegates_to_entity_odata_service( mock_session_cls.return_value = MagicMock() mock_odata_svc_cls.return_value = MagicMock() entity = MagicMock() + entity.__odata__ = MagicMock() + entity.__odata__.persisted = False entity.__odata_service__ = MagicMock() config = _make_config() client = _ODataClient(config) client.save(entity) entity.__odata_service__.save.assert_called_once_with(entity) + def test_save_sets_if_match_header_when_entity_is_persisted( + self, mock_session_cls, mock_odata_svc_cls + ): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_odata_svc_cls.return_value = MagicMock() + entity = MagicMock() + entity.__odata__ = MagicMock() + entity.__odata__.persisted = True + entity.__odata_service__ = MagicMock() + config = _make_config() + client = _ODataClient(config) + client.save(entity) + mock_session.headers.__setitem__.assert_any_call("If-Match", "*") + def test_delete_entity_delegates_to_entity_odata_service( self, mock_session_cls, mock_odata_svc_cls ): - mock_session_cls.return_value = MagicMock() + mock_session = MagicMock() + mock_session_cls.return_value = mock_session mock_odata_svc_cls.return_value = MagicMock() entity = MagicMock() entity.__odata_service__ = MagicMock() @@ -305,6 +323,30 @@ def test_delete_entity_delegates_to_entity_odata_service( client = _ODataClient(config) client.delete_entity(entity) entity.__odata_service__.delete.assert_called_once_with(entity) + mock_session.headers.__setitem__.assert_any_call("If-Match", "*") + + def test_apply_body_sets_known_field_and_marks_dirty( + self, mock_session_cls, mock_odata_svc_cls + ): + mock_session_cls.return_value = MagicMock() + mock_odata_svc_cls.return_value = MagicMock() + prop = MagicMock() + entity = MagicMock() + entity.__odata__ = MagicMock() + type(entity).my_field = prop + _ODataClient._apply_body(entity, {"my_field": "new-value"}) + assert entity.my_field == "new-value" + entity.__odata__.set_property_dirty.assert_called_once_with(prop) + + def test_apply_body_ignores_unknown_fields( + self, mock_session_cls, mock_odata_svc_cls + ): + mock_session_cls.return_value = MagicMock() + mock_odata_svc_cls.return_value = MagicMock() + entity = MagicMock(spec=[]) + entity.__odata__ = MagicMock() + _ODataClient._apply_body(entity, {"nonexistent_field": "x"}) + entity.__odata__.set_property_dirty.assert_not_called() @patch("sap_cloud_sdk.core.dpi_ng.consent.client.requests.Session") From 4dded81776f833e7ded34f85936adefdd73a549b Mon Sep 17 00:00:00 2001 From: N Date: Fri, 10 Jul 2026 02:32:46 +0530 Subject: [PATCH 26/27] chore(dpi_ng/consent): apply linting and formatting to test files --- .../dpi_ng/consent/entities/consent_template.py | 4 +++- .../services/consent_configuration_service.py | 16 ++++++++++++---- .../consent/services/consent_template_service.py | 16 ++++++++++++---- .../unit/services/test_configuration_service.py | 4 +--- .../unit/services/test_template_service.py | 4 +++- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py index 37c62f9a..5a142561 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py @@ -57,7 +57,9 @@ class TemplateThirdPartyPersData(Service.Entity): __odata_collection__ = "templateThirdPartyPersDatas" tenant = StringProperty("tenant") - third_party_assignment_id = UUIDProperty("thirdPartyAssignmentId", primary_key=True) + third_party_assignment_id = UUIDProperty( + "thirdPartyAssignmentId", primary_key=True + ) template_id = UUIDProperty("templateId", primary_key=True) third_party_id = UUIDProperty("thirdPartyId") third_party_name = StringProperty("thirdPartyName") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py index 470ee3ba..a039a923 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py @@ -310,7 +310,9 @@ def update_jurisdiction_text( ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.update_jurisdiction_text") - entity = self._client.query(_SVC, self.JurisdictionText).get(jurisdiction_text_id) + entity = self._client.query(_SVC, self.JurisdictionText).get( + jurisdiction_text_id + ) self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_jurisdiction_text") @@ -328,7 +330,9 @@ def delete_jurisdiction_text(self, jurisdiction_text_id: str) -> None: ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.delete_jurisdiction_text") - entity = self._client.query(_SVC, self.JurisdictionText).get(jurisdiction_text_id) + entity = self._client.query(_SVC, self.JurisdictionText).get( + jurisdiction_text_id + ) self._client.delete_entity(entity) logger.info("Exiting ConsentConfigurationService.delete_jurisdiction_text") @@ -437,7 +441,9 @@ def update_language_description( ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.update_language_description") - entity = self._client.query(_SVC, self.LanguageDescription).get(language_desc_id) + entity = self._client.query(_SVC, self.LanguageDescription).get( + language_desc_id + ) self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentConfigurationService.update_language_description") @@ -455,7 +461,9 @@ def delete_language_description(self, language_desc_id: str) -> None: ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentConfigurationService.delete_language_description") - entity = self._client.query(_SVC, self.LanguageDescription).get(language_desc_id) + entity = self._client.query(_SVC, self.LanguageDescription).get( + language_desc_id + ) self._client.delete_entity(entity) logger.info("Exiting ConsentConfigurationService.delete_language_description") diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py index ce3b0581..2de356e6 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py @@ -226,7 +226,9 @@ def get_template_text(self, template_text_id: str) -> Any: ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentTemplateService.get_template_text") - result = self._client.query(_SVC, self.ConsentTemplateText).get(template_text_id) + result = self._client.query(_SVC, self.ConsentTemplateText).get( + template_text_id + ) logger.info("Exiting ConsentTemplateService.get_template_text") return result @@ -270,7 +272,9 @@ def update_template_text(self, template_text_id: str, body: dict[str, Any]) -> A ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentTemplateService.update_template_text") - entity = self._client.query(_SVC, self.ConsentTemplateText).get(template_text_id) + entity = self._client.query(_SVC, self.ConsentTemplateText).get( + template_text_id + ) self._client._apply_body(entity, body) self._client.save(entity) logger.info("Exiting ConsentTemplateService.update_template_text") @@ -288,7 +292,9 @@ def delete_template_text(self, template_text_id: str) -> None: ODataError: If the OData service returns an unexpected error response. """ logger.info("Invoked ConsentTemplateService.delete_template_text") - entity = self._client.query(_SVC, self.ConsentTemplateText).get(template_text_id) + entity = self._client.query(_SVC, self.ConsentTemplateText).get( + template_text_id + ) self._client.delete_entity(entity) logger.info("Exiting ConsentTemplateService.delete_template_text") @@ -316,7 +322,9 @@ def list_third_party_pers_data(self, **query: Any) -> list[Any]: return result @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_GET_THIRD_PARTY_PERS_DATA) - def get_third_party_pers_data(self, third_party_assignment_id: str, template_id: str) -> Any: + def get_third_party_pers_data( + self, third_party_assignment_id: str, template_id: str + ) -> Any: """Return a single TemplateThirdPartyPersData entity by its composite key. Args: diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py index c2e8a591..5b470d52 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py @@ -151,9 +151,7 @@ def test_list_with_skip_kwarg(svc): def test_list_with_orderby_kwarg(svc): svc.list_third_parties(orderby="third_party_name asc") - svc._client.query.return_value.order_by.assert_called_with( - "third_party_name asc" - ) + svc._client.query.return_value.order_by.assert_called_with("third_party_name asc") # --------------------------------------------------------------------------- diff --git a/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py index dd8fba73..1fb6d1e5 100644 --- a/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py @@ -156,7 +156,9 @@ def test_list_third_party_pers_data(self, svc, mock_template_client): mock_template_client.query.return_value.all.assert_called_once() assert result == [] - def test_get_third_party_pers_data_by_assignment_and_template(self, svc, mock_template_client): + def test_get_third_party_pers_data_by_assignment_and_template( + self, svc, mock_template_client + ): q = mock_template_client.query.return_value result = svc.get_third_party_pers_data("tpaid", "tid") mock_template_client.query.assert_called_with( From 6b20348dbf8e18a71a90442183c4f07523b2dd0f Mon Sep 17 00:00:00 2001 From: N Date: Fri, 10 Jul 2026 13:37:21 +0530 Subject: [PATCH 27/27] docs(dpi_ng/consent): add mTLS quick-start guide and fix env example defaults --- .env_integration_tests.example | 12 ++--- docs/INTEGRATION_TESTS.md | 2 +- .../core/dpi_ng/consent/user-guide.md | 46 ++++++++++++++++++- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/.env_integration_tests.example b/.env_integration_tests.example index 93b9c185..835a7d23 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -66,13 +66,13 @@ CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL=https://your-dpi-ng-service-host # Option A: Static Bearer Token # CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN=your-bearer-token-here # Option B: OAuth 2.0 Client Credentials -CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL=https://your-auth-host/oauth/token -CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID=your-client-id -CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET=your-client-secret +# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL=https://your-auth-host/oauth/token +# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID=your-client-id +# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET=your-client-secret # Option C: Mutual TLS (mTLS) -# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE=/path/to/client.crt -# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE=/path/to/client.key -# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE=/path/to/ca.crt # optional +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE=/path/to/client.crt +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE=/path/to/client.key +CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE=/path/to/ca.crt # optional # OBJECT STORE CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_HOST=your-objectstore-host-here diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index 872fdadf..2a55317b 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -146,7 +146,7 @@ CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-c For DPI NG Consent integration tests, configure the following variables in `.env_integration_tests`: ```bash -# DPI NG Consent Configuration — use the shared base URL (applies to all DPI NG sub-services) +# DPI NG Consent Configuration - use the shared base URL (applies to all DPI NG sub-services) CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL=https://your-dpi-ng-service-host ``` diff --git a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md index 70cc0b36..eab03ae2 100644 --- a/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -27,7 +27,9 @@ Import what you need explicitly: from sap_cloud_sdk.core.dpi_ng.consent import ( create_client, ConsentSDKConfig, + ClientCertificateAuth, ClientCredentialsAuth, + BearerTokenAuth, CreateConsentRequest, WithdrawConsentRequest, CheckConsentExistsResult, @@ -42,6 +44,8 @@ from sap_cloud_sdk.core.dpi_ng.consent import * ## Quick Start +### With client credentials (OAuth2) + Pass a `ConsentSDKConfig` with `ClientCredentialsAuth` when OAuth2 client credentials are available. The auth provider fetches and refreshes bearer tokens automatically. @@ -68,6 +72,35 @@ with create_client(config=config) as client: print(c.consent_id, c.lifecycle_status_code) ``` +### With a client certificate (mTLS) - recommended for production + +`ClientCertificateAuth` is the preferred authentication method for production +deployments. It uses mutual TLS (mTLS) instead of bearer tokens, so there is no +shared secret to rotate and no token expiry to manage. The `tenant_id` header is +required because the mTLS handshake does not carry a tenant claim. + +```python +from sap_cloud_sdk.core.dpi_ng.consent import ( + create_client, + ConsentSDKConfig, + ClientCertificateAuth, +) + +config = ConsentSDKConfig( + base_url="https://api.service..ngdpi.dpp.cloud.sap", + auth=ClientCertificateAuth( + cert_file="/path/to/client.crt", + key_file="/path/to/client.key", + ), + tenant_id="", +) + +with create_client(config=config) as client: + consents = client.consents.list_consents(filter="lifecycleStatusCode eq '1'") + for c in consents: + print(c.consent_id, c.lifecycle_status_code) +``` + **`ConsentSDKConfig` parameters:** | Parameter | Required | Default | Description | @@ -84,6 +117,15 @@ with create_client(config=config) as client: The SDK supports three authentication strategies. Pass one as the `auth` argument to `ConsentSDKConfig`. +| Strategy | Best for | Token refresh | `tenant_id` required | +|---|---|---|---| +| `ClientCertificateAuth` | Production, mTLS environments | n/a - no tokens | Yes | +| `ClientCredentialsAuth` | Services with OAuth2 credentials | Automatic (60 s before expiry) | No | +| `BearerTokenAuth` | Short-lived scripts, testing | Manual | No | + +`ClientCertificateAuth` is recommended for production: no shared secret, no token +expiry, and rotation is handled at the infrastructure level. + ### BearerTokenAuth Use when you already have a valid bearer token: @@ -245,7 +287,7 @@ for c in consents: - `jurisdiction_code` - The legal space in which the consent is valid. Overrides the `JurisdictionCode` from the consent form. - `application_template_id` - Freely used by the integrating application (e.g. a context string identifying the business process). Overrides the `applicationTemplateId` from the consent form. - `controller_name` - The name of a data controller. Overrides the `ControllerName` from the consent form. -- `granted_by` - The natural person who granted the consent — either the data subject or another person acting on their behalf (e.g. a customer service representative or legal guardian). +- `granted_by` - The natural person who granted the consent - either the data subject or another person acting on their behalf (e.g. a customer service representative or legal guardian). - `granted_at` - When the consent was granted. - `submission_site` - Where the consent was granted. This could be a physical place (e.g. a hospital name) or a website. @@ -273,7 +315,7 @@ client.consents.terminate_consent( ) ``` -`WithdrawConsentRequest.withdrawn_by` and `withdrawn_at` are both optional — omit them to let the service +`WithdrawConsentRequest.withdrawn_by` and `withdrawn_at` are both optional - omit them to let the service record the current timestamp. #### Check whether a consent exists