diff --git a/.env_integration_tests.example b/.env_integration_tests.example index e8c7a779..835a7d23 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -57,6 +57,23 @@ 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 +CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL=https://your-dpi-ng-service-host + +# Pick ONE of the three auth methods below: + +# 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 +# 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 + # 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/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/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index 133247f4..2a55317b 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 - 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: + +**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** + +```bash +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 +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 `CLOUD_SDK_CFG_DPI_NG_DEFAULT_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 ``` diff --git a/pyproject.toml b/pyproject.toml index b50b26ad..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" @@ -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] 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..10384bc2 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/__init__.py @@ -0,0 +1,184 @@ +"""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://api.service..ngdpi.dpp.cloud.sap", + 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 sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + +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 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) + - ``client.configuration`` - reference data CRUD (consentConfigurationExternalServices) + """ + + def __init__( + self, + config: ConsentSDKConfig, + *, + _telemetry_source: Module | None = None, + ) -> None: + """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 + self._odata = _ODataClient(config) + 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, _telemetry_source=_telemetry_source + ) + + 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() + + +@record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_CREATE_CLIENT) +def create_client( + config: ConsentSDKConfig | None = None, + *, + base_url: str | None = None, + auth: AuthProvider | None = None, + timeout: float = 30.0, + verify_ssl: bool = True, + _telemetry_source: Module | None = None, +) -> ConsentClient: + """Create a ConsentClient with explicit configuration or individual keyword arguments. + + Args: + config: Pre-built ``ConsentSDKConfig``. When provided, all other kwargs + are ignored. + 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.). + 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 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: + 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, _telemetry_source=_telemetry_source) + 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..61e8831d --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/auth.py @@ -0,0 +1,213 @@ +"""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. + + 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") + raise ValueError("token must not be empty") + self._token = token + logger.info("Exiting BearerTokenAuth.__init__") + + def apply(self, session: requests.Session) -> None: + """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") + + +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 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") + 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. + + 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 + ) + 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 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") + 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. + + Args: + session: The ``requests.Session`` to configure. + """ + logger.info("Invoked ClientCertificateAuth.apply") + session.cert = (self._cert_file, self._key_file) + if 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") + + +# ------------------------------------------------------------------ +# 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}" # ty: ignore[invalid-assignment] + 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..57f428a0 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/client.py @@ -0,0 +1,317 @@ +"""OData v4 client backed by python-odata client.""" + +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 for the DPI Consent module — one ODataService per consent service endpoint, with entity classes bound per service.""" + + def __init__(self, config: ConsentSDKConfig) -> None: + """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() + self._session.headers.update( + { + "Accept": "application/json;odata.metadata=minimal", + "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] = {} + 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 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}/" + 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. + + 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) + 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. + + 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) + logger.info("Exiting ODataClient.query") + return result + + def save(self, entity: Any) -> None: + """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") + 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. + + Args: + entity: A python-odata entity instance to delete. + """ + logger.info("Invoked ODataClient.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") + + # ------------------------------------------------------------------ + # 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. + + Args: + service: OData service identifier (e.g. ``"consentServices"``). + path: Action name relative to the service root URL + (e.g. ``"createConsentFromTemplate"``). + body: JSON-serializable 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}" + 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 ``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 + 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", status_code, message) + match 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, 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..cdf80549 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/config.py @@ -0,0 +1,81 @@ +"""Configuration for the Consent SDK client.""" + +import logging +import re +from dataclasses import dataclass + +from .auth import AuthProvider, ClientCertificateAuth + +logger = logging.getLogger(__name__) + +_URL_PATTERN = re.compile(r"^https?://[^\s/$.?#].[^\s]*$") + + +@dataclass +class ConsentSDKConfig: + """Configuration for the Consent SDK client. + + Args: + 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, + 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: 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: 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 + auth: AuthProvider + 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 config after dataclass construction. + + Raises: + 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): + 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") + 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", + 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..8b97183c --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/dtos/consent.py @@ -0,0 +1,92 @@ +"""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: + """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 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") + ) + + +@dataclass +class CreateConsentRequest(_CamelSerializable): + """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 + language_code: str + data_subject_type_name: str + jurisdiction_code: str | None = None + 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 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 | None = None + 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..a2bbc8eb --- /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 = 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") + 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..ae9cb102 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_configuration.py @@ -0,0 +1,166 @@ +"""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") + 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_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): + """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_desc_id = UUIDProperty("languageDescId", primary_key=True) + language_code = StringProperty("languageCode") + description_language_code = StringProperty("descriptionLanguageCode") + 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") + created_by = StringProperty("createdBy") + changed_at = DatetimeProperty("changedAt") + changed_by = StringProperty("changedBy") + + 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..1df89792 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_purpose.py @@ -0,0 +1,47 @@ +"""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") + 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_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") + + 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..21a84981 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_retention.py @@ -0,0 +1,41 @@ +"""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_period = IntegerProperty("retentionPeriod") + 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..5a142561 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/entities/consent_template.py @@ -0,0 +1,74 @@ +"""python-odata entity classes for consentTemplateExternalServices.""" + +from __future__ import annotations + +from typing import Any + +from odata.property import ( + BooleanProperty, + DatetimeProperty, + IntegerProperty, + 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 = IntegerProperty("validityPeriod") + expiring_period = IntegerProperty("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_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") + + class TemplateThirdPartyPersData(Service.Entity): + """OData entity linking a consent template to a third party's personal data handling.""" + + __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") + third_party_name = StringProperty("thirdPartyName") + third_party_function_code = StringProperty("thirdPartyFunctionCode") + sensitive_data_flag = BooleanProperty("sensitiveDataFlag") + 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..cb6026e4 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/exceptions.py @@ -0,0 +1,58 @@ +"""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 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 {} + + +class ClientCreationError(ConsentSDKError): + """Raised when the SDK client fails to initialize.""" + + +class AuthenticationError(ConsentSDKError): + """Raised on HTTP 401 - credentials are missing, expired, or rejected by the service.""" + + +class AuthorizationError(ConsentSDKError): + """Raised on HTTP 403 - the caller is authenticated but lacks permission for the operation.""" + + +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 (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. + + 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/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..41791b28 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/_query.py @@ -0,0 +1,29 @@ +"""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 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.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.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 new file mode 100644 index 00000000..a039a923 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_configuration_service.py @@ -0,0 +1,1110 @@ +"""Service client for consentConfigurationExternalServices.""" + +from __future__ import annotations + +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 + +logger = logging.getLogger(__name__) + +_SVC = "consentConfigurationExternalServices" + + +class ConsentConfigurationService: + """Client for consentConfigurationExternalServices - CRUD on reference/configuration data.""" + + 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, + 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 ------ + + @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 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") + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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) + logger.info("Exiting ConsentConfigurationService.delete_third_party") + + # ------ 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 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") + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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) + logger.info("Exiting ConsentConfigurationService.delete_jurisdiction") + + # ------ 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 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 + ).all() + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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_text_id: str, body: dict[str, Any] + ) -> Any: + """Fetch a JurisdictionText by its ID, apply field updates, and PATCH it to the service. + + Args: + 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 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( + 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_text_id: str) -> None: + """Delete a JurisdictionText entity by its ID. + + Args: + jurisdiction_text_id: UUID of the JurisdictionText to delete. + + Raises: + 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( + jurisdiction_text_id + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.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, 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") + 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 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") + return result + + # ------ 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, 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 + ).all() + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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_desc_id: str, body: dict[str, Any] + ) -> Any: + """Fetch a LanguageDescription by its ID, apply field updates, and PATCH it to the service. + + Args: + 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 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_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_desc_id: str) -> None: + """Delete a LanguageDescription entity by its ID. + + Args: + language_desc_id: UUID of the LanguageDescription to delete. + + Raises: + 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_desc_id + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_language_description") + + # ------ 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 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") + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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) + logger.info("Exiting ConsentConfigurationService.delete_source_info") + + # ------ 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 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") + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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) + logger.info("Exiting ConsentConfigurationService.delete_controller") + + # ------ 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 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 + ).all() + 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. + + 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 + ) + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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: + """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 + ) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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 + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_data_subject_type") + + # ------ 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 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") + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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) + logger.info("Exiting ConsentConfigurationService.delete_application") + + # ------ 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 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 + ).all() + 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. + + 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 + ) + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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: + """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 + ) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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 + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentConfigurationService.delete_master_data_source") + + # ------ 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 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 + ).all() + 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. + + 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 + ) + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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: + """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 + ) + self._client._apply_body(entity, body) + self._client.save(entity) + 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. + + 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 + ) + 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..eef1a70b --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_purpose_service.py @@ -0,0 +1,288 @@ +"""Service client for consentPurposeExternalServices.""" + +from __future__ import annotations + +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 + +logger = logging.getLogger(__name__) + +_SVC = "consentPurposeExternalServices" + + +class ConsentPurposeService: + """Client for consentPurposeExternalServices - CRUD on purposes and their texts.""" + + 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, + ) = client.get_entity_classes(_SVC) + logger.info("Exiting ConsentPurposeService.__init__") + + # ------ consentPurposes ------ + + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_PURPOSES) + def list_purposes(self, **query: Any) -> list[Any]: + """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 + ).all() + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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 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) + logger.info("Exiting ConsentPurposeService.delete_purpose") + + # ------ 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. + + 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} + ) + result = self.get_purpose(purpose_id) + 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. + + 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} + ) + result = self.get_purpose(purpose_id) + logger.info("Exiting ConsentPurposeService.set_purpose_inactive") + return result + + # ------ consentPurposeTexts ------ + + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_PURPOSE_TEXTS) + def list_purpose_texts(self, **query: Any) -> list[Any]: + """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 + ).all() + 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_text_id: str) -> Any: + """Return a single ConsentPurposeText entity by its ID. + + Args: + purpose_text_id: UUID of the ConsentPurposeText to retrieve. + + Returns: + The matching ConsentPurposeText object. + + Raises: + 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(purpose_text_id) + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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_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_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 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(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_text_id: str) -> None: + """Delete a ConsentPurposeText entity by its ID. + + Args: + purpose_text_id: UUID of the ConsentPurposeText to delete. + + Raises: + 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(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 new file mode 100644 index 00000000..ebbddde7 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_retention_service.py @@ -0,0 +1,183 @@ +"""Service client for consentRetentionExternalServices.""" + +from __future__ import annotations + +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 + +logger = logging.getLogger(__name__) + +_SVC = "consentRetentionExternalServices" + + +class ConsentRetentionService: + """Client for consentRetentionExternalServices - CRUD on data retention rules.""" + + 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 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 + ).all() + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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 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) + logger.info("Exiting ConsentRetentionService.delete_rule") + + # ------ 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 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} + ) + result = self.get_rule(rule_id) + 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 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, + "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..a583e549 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_service.py @@ -0,0 +1,204 @@ +"""Service client for consentServices.""" + +from __future__ import annotations + +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, + 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, + *, + _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 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") + 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. + + 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") + 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. + + 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) + logger.info("Exiting ConsentService.delete_consent") + + # ------ 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 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() + ) + 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 + + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_WITHDRAW_CONSENT) + def withdraw_consent( + self, request: WithdrawConsentRequest + ) -> dict[str, Any] | None: + """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") + return result + + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_TERMINATE_CONSENT) + def terminate_consent( + self, request: WithdrawConsentRequest + ) -> dict[str, Any] | None: + """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") + 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: + """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, + "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..2de356e6 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/services/consent_template_service.py @@ -0,0 +1,424 @@ +"""Service client for consentTemplateExternalServices.""" + +from __future__ import annotations + +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 + +logger = logging.getLogger(__name__) + +_SVC = "consentTemplateExternalServices" + + +class ConsentTemplateService: + """Client for consentTemplateExternalServices - CRUD on templates and related entities.""" + + 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, + self.TemplateThirdPartyPersData, + ) = client.get_entity_classes(_SVC) + logger.info("Exiting ConsentTemplateService.__init__") + + # ------ consentTemplates ------ + + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_TEMPLATES) + def list_templates(self, **query: Any) -> list[Any]: + """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 + ).all() + 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. + + 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") + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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 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) + self._client._apply_body(entity, body) + self._client.save(entity) + 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 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) + logger.info("Exiting ConsentTemplateService.delete_template") + + # ------ 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. + + 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, + "consentTemplateSetConsentTemplateToActive", + {"templateId": template_id}, + ) + result = self.get_template(template_id) + 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. + + 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, + "consentTemplateSetConsentTemplateToInactive", + {"templateId": template_id}, + ) + result = self.get_template(template_id) + logger.info("Exiting ConsentTemplateService.set_template_inactive") + return result + + # ------ consentTemplateTexts ------ + + @record_metrics(Module.DPI_NG, Operation.DPI_NG_CONSENT_LIST_TEMPLATE_TEXTS) + def list_template_texts(self, **query: Any) -> list[Any]: + """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 + ).all() + 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_text_id: str) -> Any: + """Return a single ConsentTemplateText entity by its ID. + + Args: + template_text_id: UUID of the ConsentTemplateText to retrieve. + + Returns: + The matching ConsentTemplateText object. + + Raises: + 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( + template_text_id + ) + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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_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_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 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( + 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_text_id: str) -> None: + """Delete a ConsentTemplateText entity by its ID. + + Args: + template_text_id: UUID of the ConsentTemplateText to delete. + + Raises: + 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( + template_text_id + ) + self._client.delete_entity(entity) + logger.info("Exiting ConsentTemplateService.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, 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 + ).all() + 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, third_party_assignment_id: str, template_id: str + ) -> Any: + """Return a single TemplateThirdPartyPersData entity by its composite key. + + Args: + third_party_assignment_id: UUID of the ThirdPartyAssignment (primary key). + template_id: UUID of the parent ConsentTemplate (primary key). + + 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( + thirdPartyAssignmentId=third_party_assignment_id, templateId=template_id + ) + 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. + + 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(): + setattr(entity, k, v) + self._client.save(entity) + 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, 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: + 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: + 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( + thirdPartyAssignmentId=third_party_assignment_id, templateId=template_id + ) + self._client._apply_body(entity, body) + self._client.save(entity) + 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, third_party_assignment_id: str, template_id: str + ) -> None: + """Delete a TemplateThirdPartyPersData entity by its composite key. + + Args: + 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. + 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( + 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 new file mode 100644 index 00000000..eab03ae2 --- /dev/null +++ b/src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md @@ -0,0 +1,995 @@ +# 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+. + +## Import + +Import what you need explicitly: + +```python +from sap_cloud_sdk.core.dpi_ng.consent import ( + create_client, + ConsentSDKConfig, + ClientCertificateAuth, + ClientCredentialsAuth, + BearerTokenAuth, + CreateConsentRequest, + WithdrawConsentRequest, + CheckConsentExistsResult, +) +``` + +Or use a star import for convenience: + +```python +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. + +```python +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(filter="lifecycleStatusCode eq '1'") + for c in consents: + 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 | +|---|---|---|---| +| `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` +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: + +```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 ( + ConsentSDKConfig, + ClientCredentialsAuth, +) + +config = ConsentSDKConfig( + base_url="https://api.service..ngdpi.dpp.cloud.sap", + auth=ClientCredentialsAuth( + token_url="https:///oauth/token", + client_id="", + client_secret="", + ), +) +``` + +| 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. | + +Passing an empty string for `cert_file` or `key_file` raises +`ValueError: cert_file and key_file are required`. + +## Client structure + +`ConsentClient` exposes five service attributes: + +| Attribute | OData service | Purpose | +|---|---|---| +| `client.consents` | `consentServices` | Consent creation, withdrawal, termination, reads | +| `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 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("") +print(consent.consent_id, consent.data_subject_id) +``` + +#### Delete a consent + +```python +client.consents.delete_consent("") +``` + +#### 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="GDPR_Marketing_2024", + language_code="en", + data_subject_type_name="Employee", +) +consents = client.consents.create_consent_from_template(request) +for c in consents: + print(c.consent_id) +``` + +**Required fields:** +- `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` - 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 + +```python +from sap_cloud_sdk.core.dpi_ng.consent import WithdrawConsentRequest + +client.consents.withdraw_consent( + WithdrawConsentRequest( + consent_id="", + withdrawn_by="user@example.com", + ) +) +``` + +#### Terminate a consent + +```python +client.consents.terminate_consent( + WithdrawConsentRequest( + consent_id="", + withdrawn_by="contract-end-process", + ) +) +``` + +`WithdrawConsentRequest.withdrawn_by` and `withdrawn_at` are both optional - omit them 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`). + +--- + +### Purposes (`client.purposes`) + +#### List purposes + +```python +purposes = client.purposes.list_purposes( + filter="lifecycleStatusCode eq '2'", + top=25, +) +for p in purposes: + print(p.purpose_id, p.purpose_name) +``` + +#### 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", "sensitive_data_flag": True}, +) +``` + +#### Delete a purpose + +```python +client.purposes.delete_purpose("") +``` + +#### Set a purpose active / inactive + +```python +client.purposes.set_purpose_active("") +client.purposes.set_purpose_inactive("") +``` + +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 + +```python +text = client.purposes.get_purpose_text("") +print(text.text) +``` + +#### Create a purpose text + +```python +text = client.purposes.create_purpose_text({ + "purpose_id": "", + "type_code": "01", + "language_code": "en", + "text": "We use your email to send marketing updates.", +}) +``` + +#### Update a purpose text + +```python +text = client.purposes.update_purpose_text( + "", + body={"purpose_id": "", "type_code": "01", "language_code": "de", "text": "Updated marketing email description."}, +) +``` + +#### Delete a purpose text + +```python +client.purposes.delete_purpose_text("") +``` + +--- + +### Templates (`client.templates`) + +#### List templates + +```python +templates = client.templates.list_templates( + filter="lifecycleStatusCode eq '2'" +) +``` + +#### Get a template by ID + +```python +template = client.templates.get_template("") +print(template.template_name) +``` + +#### Create a template + +```python +template = client.templates.create_template({ + "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) +``` + +#### Update a template + +```python +template = client.templates.update_template( + "", + { + "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", + }, +) +``` + +#### 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 + +```python +text = client.templates.get_template_text("") +print(text.text) +``` + +#### Create a template text + +```python +text = client.templates.create_template_text({ + "template_id": "", + "language_code": "en", + "type_code": "51", + "text": "Full consent statement in English...", +}) +``` + +#### Update a template text + +```python +text = client.templates.update_template_text( + "", + body={"template_id": "", "language_code": "de", "type_code": "52", "text": "Revised consent statement."}, +) +``` + +#### Delete a template text + +```python +client.templates.delete_template_text("") +``` + +#### 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 + +```python +record = client.templates.get_third_party_pers_data( + third_party_assignment_id="", + template_id="", +) +``` + +#### Create a third-party personal data record + +```python +record = client.templates.create_third_party_pers_data({ + "template_id": "", + "third_party_id": "", + "third_party_function_code": "01", + "sensitive_data_flag": False, +}) +``` + +#### Update a third-party personal data record + +```python +record = client.templates.update_third_party_pers_data( + third_party_assignment_id="", + template_id="", + body={"third_party_id": "", "third_party_function_code": "02", "sensitive_data_flag": True}, +) +``` + +#### Delete a third-party personal data record + +```python +client.templates.delete_third_party_pers_data( + third_party_assignment_id="", + template_id="", +) +``` + +--- + +### Retention rules (`client.retention`) + +#### List retention rules + +```python +rules = client.retention.list_rules( + filter="lifecycleStatusCode eq '2'" +) +``` + +#### Get a retention rule by ID + +```python +rule = client.retention.get_rule("") +print(rule.rule_name, rule.retention_period) +``` + +#### Create a retention rule + +```python +rule = client.retention.create_rule({ + "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) +``` + +#### Update a retention rule + +```python +rule = client.retention.update_rule( + "", + { + "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", + }, +) +``` + +#### 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("") +``` + +--- + +### Configuration reference data (`client.configuration`) + +The configuration service manages all reference data that other entities reference +by ID or code. + +#### Third parties + +```python +# List +third_parties = client.configuration.list_third_parties() + +# Get +tp = client.configuration.get_third_party("") + +# Create +tp = client.configuration.create_third_party({ + "third_party_name": "Analytics_Corp", + "formatted_description": "Third-party analytics provider", +}) +print(tp.third_party_id) + +# Update +tp = client.configuration.update_third_party( + "", + {"third_party_name": "Analytics_Corp", "formatted_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", +}) + +# Update +jurisdiction = client.configuration.update_jurisdiction( + "", + {"jurisdiction_code": "DE"}, +) + +# Delete +client.configuration.delete_jurisdiction("") +``` + +#### Jurisdiction texts + +```python +# List +texts = client.configuration.list_jurisdiction_texts() + +# Create +text = client.configuration.create_jurisdiction_text({ + "jurisdiction_code": "", + "language_code": "en", + "description": "European Union General Data Protection Regulation", +}) + +# Update +text = client.configuration.update_jurisdiction_text( + "", + body={"language_code": "en", "description": "EU GDPR - Revised"}, +) + +# Delete +client.configuration.delete_jurisdiction_text("") +``` + +#### Languages + +```python +# List +languages = client.configuration.list_languages() + +# Get +language = client.configuration.get_language("en") +print(language.language_code) +``` + +Languages are read-only reference data - create and delete are not exposed. + +#### Language descriptions + +```python +# List +descriptions = client.configuration.list_language_descriptions() + +# Create +desc = client.configuration.create_language_description({ + "language_code": "ar", + "description_language_code": "en", + "description": "Arabic", +}) + +# Update +desc = client.configuration.update_language_description( + "", + body={"language_code": "ar", "description_language_code": "en", "description": "Arabic (Updated)"}, +) + +# Delete +client.configuration.delete_language_description("") +``` + +#### 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( + "", + {"source_name": "CRM_System", "description": "CRM - Updated"}, +) + +# Delete +client.configuration.delete_source_info("") +``` + +#### Controllers + +```python +# List +controllers = client.configuration.list_controllers() + +# Get +controller = client.configuration.get_controller("") + +# Create +controller = client.configuration.create_controller({ + "controller_name": "AB_Corp", + "source_name": "CRM_System", + "description": "Main data controller", +}) +print(controller.controller_id) + +# Update +controller = client.configuration.update_controller( + "", + {"controller_name": "AB_Corp", "source_name": "CRM_System", "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", + "master_data_source_name": "SAP_HR", +}) + +# Update +dst = client.configuration.update_data_subject_type( + "", + {"data_subject_type_name": "Internal_Employee", "master_data_source_name": "SAP_SuccessFactors"}, +) + +# 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", + "source_name": "CRM_System", + "description": "HR self-service portal", +}) + +# Update +app = client.configuration.update_application( + "", + {"application_name": "HR_Portal_v2", "source_name": "CRM_System", "description": "HR portal version 2"}, +) + +# Delete +client.configuration.delete_application("") +``` + +#### 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_S4HANA", + "description": "SAP S/4HANA master data source", +}) + +# Update +source = client.configuration.update_master_data_source( + "", + {"master_data_source_name": "SAP_S4HANA_Cloud", "description": "SAP S/4HANA Cloud master data source"}, +) + +# Delete +client.configuration.delete_master_data_source("") +``` + +#### Outbound channel types + +```python +# List +channel_types = client.configuration.list_outbound_channel_types() + +# Get +channel_type = client.configuration.get_outbound_channel_type("") + +# Create +channel_type = client.configuration.create_outbound_channel_type({ + "outbound_channel_type_name": "Email", + "description": "Email notification channel", +}) + +# Update +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("") +``` + +## 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 | + +Always catch `ConsentSDKError` or its subclasses around calls: + +```python +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: + 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 + +`ConsentClient` supports the context-manager protocol, which ensures the +underlying `requests.Session` is closed when the block exits: + +```python +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 +``` + +Or call `client.close()` explicitly when not using `with`. 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" 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..33baeed0 --- /dev/null +++ b/tests/core/integration/dpi_ng/consent/conftest.py @@ -0,0 +1,93 @@ +"""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("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") + 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("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("CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_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("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_DEFAULT_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..dbf5011e --- /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..7973f675 --- /dev/null +++ b/tests/core/integration/dpi_ng/consent/test_consent_creation_bdd.py @@ -0,0 +1,400 @@ +"""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..d38ec885 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/conftest.py @@ -0,0 +1,12 @@ +"""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..1507438a --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/conftest.py @@ -0,0 +1,186 @@ +"""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_text_id"], + "bool": [], + "int": [], + }, + "Language": { + "collection": "languages", + "pk": ["language_code"], + "bool": [], + "int": [], + }, + "LanguageDescription": { + "collection": "languageDescriptions", + "pk": ["language_desc_id"], + "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_text_id"], + "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_text_id"], + "bool": [], + "int": [], + }, + "TemplateThirdPartyPersData": { + "collection": "templateThirdPartyPersDatas", + "pk": ["third_party_assignment_id", "template_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..6088a6b9 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_configuration_entities.py @@ -0,0 +1,39 @@ +"""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..ba233cb2 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_consent_entities.py @@ -0,0 +1,67 @@ +"""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..8da12750 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_purpose_entities.py @@ -0,0 +1,39 @@ +"""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..bfe02073 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_retention_entities.py @@ -0,0 +1,39 @@ +"""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..78606381 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/entities/test_template_entities.py @@ -0,0 +1,49 @@ +"""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_assignment_id_is_pk(self, template_entities): + cls = entity_by_name(template_entities, "TemplateThirdPartyPersData") + assert cls.third_party_assignment_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..00faf16a --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/conftest.py @@ -0,0 +1,60 @@ +"""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.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 + 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..5b470d52 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_configuration_service.py @@ -0,0 +1,231 @@ +"""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.filter.assert_called_with( + "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.order_by.assert_called_with("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_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-text-1", {"description": "y"}) + svc._client.save.assert_called_once() + + 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-text-1") + 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..effe9e1d --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_consent_service.py @@ -0,0 +1,166 @@ +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.filter.assert_called_with("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.order_by.assert_called_with("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..30f766eb --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_purpose_service.py @@ -0,0 +1,144 @@ +"""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.filter.assert_called_with("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_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("ptid") + 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_fetches_by_id(self, svc, client, q): + body = {"text": "updated"} + svc.update_purpose_text("ptid", body) + q.get.assert_called_with("ptid") + client.save.assert_called_once() + + +class TestDeletePurposeText: + 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 new file mode 100644 index 00000000..722cad1d --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_retention_service.py @@ -0,0 +1,100 @@ +"""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.filter.assert_called_with("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..1fb6d1e5 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/services/test_template_service.py @@ -0,0 +1,233 @@ +"""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): + svc.update_template("tid", {"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 + 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_by_id(self, svc, mock_template_client): + q = mock_template_client.query.return_value + result = svc.get_template_text("ttid") + mock_template_client.query.assert_called_with(_SVC, svc.ConsentTemplateText) + q.get.assert_called_with("ttid") + 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_fetches_by_id(self, svc, mock_template_client): + q = mock_template_client.query.return_value + 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): + svc.update_template_text("ttid", {"text": "new text"}) + mock_template_client._apply_body.assert_called_once() + + def test_delete_template_text_fetches_by_id(self, svc, mock_template_client): + q = mock_template_client.query.return_value + 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: third_party_assignment_id + template_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_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( + _SVC, svc.TemplateThirdPartyPersData + ) + 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): + 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_by_assignment_and_template( + self, svc, mock_template_client + ): + q = mock_template_client.query.return_value + svc.update_third_party_pers_data( + "tpaid", "tid", {"third_party_function_code": "CONTROLLER"} + ) + 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 + ): + svc.update_third_party_pers_data( + "tpaid", "tid", {"third_party_function_code": "CONTROLLER"} + ) + mock_template_client._apply_body.assert_called_once() + + 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("tpaid", "tid") + q.get.assert_called_with(thirdPartyAssignmentId="tpaid", templateId="tid") + 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.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 + 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.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 + 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..28707855 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_auth.py @@ -0,0 +1,160 @@ +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" # ty: ignore[not-subscriptable] + + 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..6f798ac9 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_config.py @@ -0,0 +1,147 @@ +import pytest +from unittest.mock import MagicMock + +from sap_cloud_sdk.core.dpi_ng.consent.auth import AuthProvider, ClientCertificateAuth +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 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()) + 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" + + 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): + 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" + + def test_tenant_id_stored(self): + cfg = ConsentSDKConfig( + base_url="https://example.com", + auth=ClientCertificateAuth(cert_file="cert.pem", key_file="key.pem"), + tenant_id="tenant-abc-123", + ) + assert cfg.tenant_id == "tenant-abc-123" + + +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] + + +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_consent_client.py b/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py new file mode 100644 index 00000000..26832b87 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_consent_client.py @@ -0,0 +1,161 @@ +"""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..9fec3950 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_dtos.py @@ -0,0 +1,120 @@ +"""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..12d53d46 --- /dev/null +++ b/tests/core/unit/dpi_ng/consent/unit/test_odata_client.py @@ -0,0 +1,407 @@ +"""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, 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 ( + 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) + + +def _make_config_with_tenant(tenant_id: str): + auth = ClientCertificateAuth(cert_file="cert.pem", key_file="key.pem") + 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") + _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__ = 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 = MagicMock() + mock_session_cls.return_value = mock_session + 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) + 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") +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: + 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() 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