From 68161985a5eed8c97a9f7db2e9c5552bc13280b4 Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Fri, 15 May 2026 15:16:26 +0530 Subject: [PATCH 01/14] added output management --- .../outputmanagement/__init__.py | 73 +++ src/sap_cloud_sdk/outputmanagement/client.py | 79 +++ .../outputmanagement/client_provider.py | 92 +++ .../outputmanagement/clients/__init__.py | 11 + .../outputmanagement/clients/email_client.py | 191 ++++++ .../clients/output_requests_client.py | 133 +++++ .../clients/output_requests_client_impl.py | 551 ++++++++++++++++++ .../outputmanagement/config/__init__.py | 5 + .../config/destination_credential_config.py | 104 ++++ .../outputmanagement/constants.py | 50 ++ .../outputmanagement/exceptions.py | 69 +++ .../outputmanagement/models/__init__.py | 22 + .../models/attachment_config.py | 49 ++ .../models/direct_share_configuration.py | 29 + .../models/email_configuration.py | 112 ++++ .../models/form_configuration.py | 25 + .../models/output_management_info.py | 105 ++++ .../outputmanagement/models/output_request.py | 235 ++++++++ .../models/output_request_data.py | 84 +++ .../models/output_response.py | 120 ++++ .../outputmanagement/utils/__init__.py | 5 + .../utils/request_validator.py | 315 ++++++++++ 22 files changed, 2459 insertions(+) create mode 100644 src/sap_cloud_sdk/outputmanagement/__init__.py create mode 100644 src/sap_cloud_sdk/outputmanagement/client.py create mode 100644 src/sap_cloud_sdk/outputmanagement/client_provider.py create mode 100644 src/sap_cloud_sdk/outputmanagement/clients/__init__.py create mode 100644 src/sap_cloud_sdk/outputmanagement/clients/email_client.py create mode 100644 src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py create mode 100644 src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py create mode 100644 src/sap_cloud_sdk/outputmanagement/config/__init__.py create mode 100644 src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py create mode 100644 src/sap_cloud_sdk/outputmanagement/constants.py create mode 100644 src/sap_cloud_sdk/outputmanagement/exceptions.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/__init__.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/attachment_config.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/direct_share_configuration.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/email_configuration.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/form_configuration.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/output_management_info.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/output_request.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/output_request_data.py create mode 100644 src/sap_cloud_sdk/outputmanagement/models/output_response.py create mode 100644 src/sap_cloud_sdk/outputmanagement/utils/__init__.py create mode 100644 src/sap_cloud_sdk/outputmanagement/utils/request_validator.py diff --git a/src/sap_cloud_sdk/outputmanagement/__init__.py b/src/sap_cloud_sdk/outputmanagement/__init__.py new file mode 100644 index 00000000..812b9ff8 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/__init__.py @@ -0,0 +1,73 @@ +"""SAP Ariba Output Management Service SDK for Python.""" + +from .client import ( + OutputManagementServiceClient, + OutputManagementServiceDefaultClient, +) +from .client_provider import ( + OutputManagementServiceClientProvider, + OutputManagementServiceClientProviderBuilder, +) +from .models.output_request import OutputRequest, OutputRequestBuilder +from .models.output_response import ( + OutputResponse, + OutputRequestStatusResponse, + DocumentResponse, +) +from .models.email_configuration import EmailConfiguration +from .models.attachment_config import AttachmentConfig +from .models.output_management_info import OutputManagementInfo +from .models.output_request_data import OutputRequestData +from .models.direct_share_configuration import DirectShareConfiguration +from .models.form_configuration import FormConfiguration +from .clients.email_client import EmailClient +from .config.destination_credential_config import DestinationCredentialConfig +from .constants import FileFormat, Channel, Status +from .exceptions import ( + OutputManagementException, + AuthenticationException, + ValidationException, + NetworkException, + DestinationNotFoundException, + DestinationAccessException, +) + +__version__ = "1.0.0" + +__all__ = [ + # Client classes + "OutputManagementServiceClient", + "OutputManagementServiceDefaultClient", + "OutputManagementServiceClientProvider", + "OutputManagementServiceClientProviderBuilder", + "EmailClient", + # Models + "OutputRequest", + "OutputRequestBuilder", + "OutputResponse", + "OutputRequestStatusResponse", + "DocumentResponse", + "EmailConfiguration", + "AttachmentConfig", + "OutputManagementInfo", + "OutputRequestData", + "DirectShareConfiguration", + "FormConfiguration", + # Configuration + "DestinationCredentialConfig", + # Constants/Enums + "FileFormat", + "Channel", + "Status", + # Exceptions + "OutputManagementException", + "AuthenticationException", + "ValidationException", + "NetworkException", + "DestinationNotFoundException", + "DestinationAccessException", +] + + + + diff --git a/src/sap_cloud_sdk/outputmanagement/client.py b/src/sap_cloud_sdk/outputmanagement/client.py new file mode 100644 index 00000000..be03d0c1 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/client.py @@ -0,0 +1,79 @@ +"""Main client classes.""" + +import logging +import requests +from abc import ABC, abstractmethod + +from .clients.output_requests_client import OutputRequestsClient +from .clients.output_requests_client_impl import OutputRequestsClientImpl + +logger = logging.getLogger(__name__) + + +class OutputManagementServiceClient(ABC): + """Abstract base class for Output Management Service client.""" + + @abstractmethod + def get_output_requests_client(self) -> OutputRequestsClient: + """Get output requests client. + + Returns: + Output requests client + """ + pass + + + @abstractmethod + def close(self) -> None: + """Close the client and release resources.""" + pass + + +class OutputManagementServiceDefaultClient(OutputManagementServiceClient): + """Default implementation of Output Management Service client.""" + + def __init__( + self, + base_url: str, + destination: any = None, + ): + """Initialize client. + + Args: + base_url: Base URL of the service + destination: Optional Cloud SDK destination object for making requests + """ + self._base_url = base_url.rstrip("/") + self._destination = destination + + # Create a simple requests session + self._session = requests.Session() + + # Initialize output requests client + self._output_requests_client = OutputRequestsClientImpl( + self._session, + self._base_url, + self._destination, + ) + + logger.info(f"Initialized Output Management Service client for {base_url}") + + def get_output_requests_client(self) -> OutputRequestsClient: + """Get output requests client.""" + return self._output_requests_client + + + def close(self) -> None: + """Close the client and release resources.""" + self._session.close() + logger.info("Output Management Service client closed") + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + diff --git a/src/sap_cloud_sdk/outputmanagement/client_provider.py b/src/sap_cloud_sdk/outputmanagement/client_provider.py new file mode 100644 index 00000000..ddccaab5 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/client_provider.py @@ -0,0 +1,92 @@ +"""Client provider and builder.""" + +import logging + +from .client import ( + OutputManagementServiceClient, + OutputManagementServiceDefaultClient, +) +from .config.destination_credential_config import DestinationCredentialConfig +from .exceptions import ValidationException + +logger = logging.getLogger(__name__) + + +class OutputManagementServiceClientProvider: + """Provider for Output Management Service client.""" + + def __init__(self, client: OutputManagementServiceClient): + """Initialize provider. + + Args: + client: Output Management Service client + """ + self._client = client + + def get_client(self) -> OutputManagementServiceClient: + """Get the client instance. + + Returns: + Output Management Service client + """ + return self._client + + +class OutputManagementServiceClientProviderBuilder: + """Builder for Output Management Service client provider.""" + + def __init__(self): + """Initialize builder.""" + self._destination_credential_config: DestinationCredentialConfig = None + + def with_destination_credentials( + self, config: DestinationCredentialConfig + ) -> "OutputManagementServiceClientProviderBuilder": + """Configure with destination credentials. + + Args: + config: Destination credential configuration + + Returns: + Builder instance + """ + self._destination_credential_config = config + return self + + def build(self) -> OutputManagementServiceClientProvider: + """Build the client provider. + + Returns: + Client provider + + Raises: + ValidationException: If configuration is invalid + """ + if not self._destination_credential_config: + raise ValidationException( + "Destination credentials must be configured", + error_code="MISSING_CONFIGURATION", + ) + + # For destination credentials, use SAP Cloud SDK + logger.info("Using destination credential configuration") + + # Get the destination object - it handles authentication automatically + http_destination = self._destination_credential_config.get_destination() + + # Get the base URL from destinatiozxn + base_url = self._destination_credential_config.get_base_url() + logger.info(f"Retrieved destination base URL: {base_url}") + + # Build client with destination object + # The destination object handles auth automatically + client = OutputManagementServiceDefaultClient( + base_url=base_url, + destination=http_destination, + ) + + logger.info("Built Output Management Service client provider") + + return OutputManagementServiceClientProvider(client) + + diff --git a/src/sap_cloud_sdk/outputmanagement/clients/__init__.py b/src/sap_cloud_sdk/outputmanagement/clients/__init__.py new file mode 100644 index 00000000..b254eafc --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/__init__.py @@ -0,0 +1,11 @@ +"""Client implementations.""" + +from .output_requests_client import OutputRequestsClient +from .output_requests_client_impl import OutputRequestsClientImpl +from .email_client import EmailClient + +__all__ = [ + "OutputRequestsClient", + "OutputRequestsClientImpl", + "EmailClient", +] \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py new file mode 100644 index 00000000..75806c25 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -0,0 +1,191 @@ +"""Email client for simplified email sending via SAP Ariba Output Service.""" + +from typing import Optional, List, Dict, Any +from ..models.output_request import OutputRequest +from ..models.output_request_data import OutputRequestData +from ..models.output_management_info import OutputManagementInfo +from ..models.email_configuration import EmailConfiguration +from ..models.output_response import OutputResponse, ErrorResponse +from ..config.destination_credential_config import DestinationCredentialConfig +from ..constants import Channel +from ..utils.request_validator import RequestValidator + + +class EmailClient: + """ + Simplified client for sending emails through SAP Ariba Output Service. + + This client handles all the complexity internally - users only need to provide + minimal information: template key, recipients, business document, and destination. + """ + + def send_email( + self, + notification_template_key: str, + to: List[str], + business_document: Dict[str, Any], + destination_name: str, + cc: Optional[List[str]] = None, + template_language: str = "en", + access_strategy: str = "PROVIDER_ONLY" + ) -> OutputResponse: + """ + Send an email using the SAP Ariba Output Service. + + This method builds the complete OutputRequest structure internally. + All CloudEvents metadata and document types are auto-generated. + + Args: + notification_template_key: ANS template identifier (e.g., "PO_APPROVAL_NOTIFICATION") + to: List of recipient email addresses + business_document: The business document as a dictionary + destination_name: Name of the destination for authentication and endpoint + cc: Optional list of CC email addresses + template_language: ISO language code for email template (default: "en") + access_strategy: Destination access strategy - "PROVIDER_ONLY" or "SUBSCRIBER_ONLY" (default: "PROVIDER_ONLY") + + Returns: + OutputResponse: Response from the output service + + Raises: + ValueError: If required parameters are invalid + Exception: If the email sending fails + + Example: + ```python + from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + + client = EmailClient() + + # Just provide essentials - that's it! + response = client.send_email( + notification_template_key="PO_APPROVAL_NOTIFICATION", + to=["finance@company.com"], + business_document={ + "PurchaseOrder": { + "orderId": "PO-12345", + "vendor": "ACME Corp", + "total": 1500.00 + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + if response.error: + print(f"Failed: {response.error}") + else: + print(f"Success: {response.output_request_id}") + ``` + """ + # Validate input parameters using RequestValidator + validation_error = RequestValidator.validate_email_parameters( + notification_template_key=notification_template_key, + to=to, + business_document=business_document, + template_language=template_language, + cc=cc + ) + if validation_error: + return OutputResponse( + output_request_id=None, + error=ErrorResponse( + message=validation_error, + code="INVALID_REQUEST" + ) + ) + + # Validate destination_name + if not destination_name or not destination_name.strip(): + return OutputResponse( + output_request_id=None, + error=ErrorResponse( + message="destination_name cannot be null or empty", + code="INVALID_REQUEST" + ) + ) + + try: + # Import here to avoid circular import at module initialization + from ..client_provider import OutputManagementServiceClientProviderBuilder + + # Extract document type and ID from business document + # Assuming the first key in business_document is the document type + doc_type_key = next(iter(business_document.keys())) + doc_content = business_document[doc_type_key] + + # Try to extract document ID from common field names + doc_id = None + for id_field in ['id', 'orderId', 'invoiceNumber', 'documentId', 'number']: + if id_field in doc_content: + doc_id = str(doc_content[id_field]) + break + + # If no ID found, use template key as fallback + if not doc_id: + doc_id = f"{notification_template_key}-{id(business_document)}" + + # Generate business document type from the key + business_document_type = f"com.sap.{doc_type_key.lower()}" + + # Build email configuration + email_config = EmailConfiguration( + email_notification_template_key=notification_template_key, + email_template_language=template_language, + to=to, + cc=cc + ) + + # Build output management info + output_mgmt = OutputManagementInfo( + business_document_type=business_document_type, + business_document_id=doc_id, + is_priority=False, + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + + # Build request data (OutputManagement + BusinessDocument) + data = OutputRequestData( + output_management=output_mgmt, + business_document=business_document + ) + + # Build output request (CloudEvents structure) + # Source format must be /region/application/tenant per CloudEvents spec + output_request = OutputRequest( + source=f"/region/sap/{doc_type_key}", + type=f"{business_document_type}.notification.created.v1", + data=data + ) + + # Validate the output request using RequestValidator + validation_error = RequestValidator.validate(output_request) + if validation_error: + return OutputResponse( + output_request_id=None, + error=ErrorResponse( + message=validation_error, + code="INVALID_REQUEST" + ) + ) + + # Create destination config with access strategy + destination_config = DestinationCredentialConfig( + destination_name=destination_name, + access_strategy=access_strategy + ) + + # Build the client provider using the existing builder + provider_builder = OutputManagementServiceClientProviderBuilder() + provider_builder.with_destination_credentials(destination_config) + + # Build the provider and get the client + provider = provider_builder.build() + oms_client = provider.get_client() + + # Get the output requests client and send the request + output_requests_client = oms_client.get_output_requests_client() + return output_requests_client.send_output_request(output_request) + + except Exception as e: + raise Exception(f"Failed to send email via destination '{destination_name}': {str(e)}") from e \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py new file mode 100644 index 00000000..3d2eb085 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py @@ -0,0 +1,133 @@ +"""Output requests client interface.""" + +import logging +from abc import ABC, abstractmethod + +from ..models.output_request import OutputRequest +from ..models.output_response import ( + OutputResponse, + JobStatusResponse, + DocumentResponse, +) + +logger = logging.getLogger(__name__) + + +class OutputRequestsClient(ABC): + """ + Interface for managing output requests in the Output Management service. + + This interface defines operations for: + - Submitting output requests for document generation and delivery + - Checking the status of submitted requests + - Retrieving generated documents + + Usage Example: + from sap_cloud_sdk.outputmanagement import OutputManagementServiceClient + + client = OutputManagementServiceClient.from_destination("DEST") + requests_client = client.get_output_requests_client() + + # Submit request + request = OutputRequest( + source="https://...", + type="com.sap.procurement.po.created", + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123" + ) + + response = requests_client.send_output_request(request) + if response.has_errors(): + print(f"Errors: {response.errors}") + else: + request_id = response.output_request_id + print(f"Request ID: {request_id}") + + # Check status + status = requests_client.get_output_request_status(request_id) + if not status.errors: + print(f"Status: {status.created_at}") + + # Get document + document_response = requests_client.get_document("DIRECT_SHARE", request_id) + if not document_response.errors: + document = document_response.document_content + print(f"Document size: {len(document)}") + """ + + @abstractmethod + def send_output_request(self, output_request: OutputRequest) -> OutputResponse: + """ + Submits an output request to the Output Management service. + + This method sends a complete output request to trigger document generation and delivery. + The request is processed asynchronously, and this method returns immediately with an + OutputResponse containing the request ID or error information. + + Response Handling: + - HTTP 202 (Accepted) - Request successfully submitted, returns OutputResponse with request ID + - HTTP 4xx - Client error, returns OutputResponse with error details + - HTTP 5xx - Server error, returns OutputResponse with error details + - Validation Error - Returns OutputResponse with validation error message + + Note: This method does not raise exceptions. Check the response's has_errors() method + or errors list to determine if the operation was successful. + + Args: + output_request: The output request to submit + + Returns: + OutputResponse containing the request ID if successful, or error details if failed + """ + pass + + @abstractmethod + def get_output_request_status(self, request_id: str) -> JobStatusResponse: + """ + Retrieves the status of a previously submitted output request. + + Use this method to check the processing status of an output request after submission. + The response contains detailed information about the request processing state. + + Common Status Values: + - PENDING - Request is queued for processing + - PROCESSING - Document generation in progress + - COMPLETED - Document successfully generated and delivered + - FAILED - Processing failed (check error details) + + Note: This method does not raise exceptions. Check the response's errors field + to determine if the operation failed. + + Args: + request_id: The ID of the request to check + + Returns: + JobStatusResponse containing request details if successful, or error details if failed + """ + pass + + @abstractmethod + def get_document(self, channel: str, output_request_id: str) -> DocumentResponse: + """ + Retrieves a generated document from the Output Management service. + + This method downloads the binary content of a document that was generated + as part of an output request. The document must be available (request status = COMPLETED) + before it can be retrieved. + + Supported Channels: + - DIRECT_SHARE - Documents stored for direct download + - EMAIL - Attachments from email deliveries (if accessible) + - PRINT - Print-ready documents + + Note: This method does not raise exceptions. Check the response's errors field + to determine if the operation failed. + + Args: + channel: The delivery channel (e.g., "DIRECT_SHARE") + output_request_id: The output request ID + + Returns: + DocumentResponse containing the document content if successful, or error details if failed + """ + pass \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py new file mode 100644 index 00000000..61b88bf2 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py @@ -0,0 +1,551 @@ +"""Implementation of output requests client.""" + +import logging +from typing import Dict, Optional +import os +import json +import uuid +import requests + +from .output_requests_client import OutputRequestsClient +from ..models.output_request import OutputRequest +from ..models.output_response import ( + OutputResponse, + JobStatusResponse, + OutputRequestStatusResponse, + DocumentResponse, +) +from ..constants import Constants +from ..utils.request_validator import RequestValidator + +logger = logging.getLogger(__name__) + + +class OutputRequestsClientImpl(OutputRequestsClient): + """ + Implementation of OutputRequestsClient for managing output requests. + + This implementation provides HTTP-based communication with the Output Management service + for submitting, tracking, and retrieving output requests and generated documents. + """ + + def __init__( + self, + http_session: requests.Session, + base_url: str, + destination: any = None, + ): + """ + Constructs a new OutputRequestsClientImpl. + + Args: + http_session: The requests Session for making HTTP requests + base_url: The base URL of the Output Management service + destination: Optional Cloud SDK destination object for making authenticated requests + """ + self._http_session = http_session + self._base_url = base_url.rstrip("/") + self._destination = destination + + # Get sender-provider-subaccount-id from environment variable + self._sender_provider_subaccount_id = os.getenv("SENDER_PROVIDER_SUBACCOUNT_ID") + if self._sender_provider_subaccount_id: + logger.info(f"Loaded SENDER_PROVIDER_SUBACCOUNT_ID: {self._sender_provider_subaccount_id}") + else: + logger.debug("SENDER_PROVIDER_SUBACCOUNT_ID environment variable not set") + + def send_output_request(self, output_request: OutputRequest) -> OutputResponse: + """Submits an output request to the Output Management service.""" + logger.info("Sending output request") + + if output_request is None: + logger.error("OutputRequest cannot be None") + return self._create_output_error_response( + "INVALID_REQUEST", + "OutputRequest cannot be None" + ) + + # Validate the output request + validation_error = RequestValidator.validate(output_request) + if validation_error: + logger.error(f"Validation failed: {validation_error}") + return self._create_output_error_response( + "INVALID_REQUEST", + validation_error + ) + + endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}outputRequest" + logger.debug(f"Endpoint: {endpoint}") + + headers = self._get_headers() + headers[Constants.HEADER_CONTENT_TYPE] = Constants.CONTENT_TYPE_JSON + headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_JSON + + # Add sender-provider-subaccount-id header if available + if self._sender_provider_subaccount_id: + headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id + logger.debug(f"Added sender-provider-subaccount-id header") + + try: + request_body = output_request.model_dump(by_alias=True, exclude_none=True) + logger.info(f"Request body: {request_body}") + + response = self._execute_request('POST', endpoint, json=request_body, headers=headers) + status_code = response.status_code + + logger.debug(f"Response status: {status_code}") + + if status_code == 202: + response_data = response.json() + request_id = response_data.get("requestId") + logger.info(f"Request submitted successfully with ID: {request_id}") + return OutputResponse(output_request_id=request_id, errors=None) + + # Handle error responses + response_body = response.text + if self._is_retryable(status_code): + logger.error(f"Retryable error with status: {status_code}, body: {response_body}") + else: + logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") + + error_type = self._map_status_code_to_error(status_code) + if error_type: + return self._create_output_error_response(error_type, status_code) + else: + logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") + return self._create_output_error_response(status_code, response_body) + + except Exception as e: + logger.error(f"Exception occurred: {e}", exc_info=True) + return self._create_output_error_response( + "OUTPUT_REQUEST_FAILED", + f"Failed to send output request: {str(e)}" + ) + + def get_output_request_status(self, request_id: str) -> JobStatusResponse: + """Retrieves the status of a previously submitted output request.""" + logger.info(f"Getting status for request: {request_id}") + + validation_error = RequestValidator.validate_job_status_request(request_id) + if validation_error: + logger.error(f"Validation failed for output request status: {validation_error}") + return self._create_status_error_response( + "INVALID_REQUEST", + validation_error + ) + + endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}outputRequest/{request_id}" + logger.debug(f"Endpoint: {endpoint}") + + headers = self._get_headers() + headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_JSON + + # Add sender-provider-subaccount-id header if available + if self._sender_provider_subaccount_id: + headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id + logger.debug(f"Added sender-provider-subaccount-id header for get status") + + try: + response = self._execute_request('GET', endpoint, headers=headers) + status_code = response.status_code + + logger.debug(f"Response status: {status_code}") + + if status_code == 200: + response_data = response.json() + status_response = OutputRequestStatusResponse(**response_data) + logger.info("Status retrieved successfully") + return JobStatusResponse(status=status_response, errors=None) + + # Handle error responses + response_body = response.text + if self._is_retryable(status_code): + logger.error(f"Retryable error with status: {status_code}, body: {response_body}") + else: + logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") + + error_type = self._map_status_code_to_error(status_code) + if error_type: + return self._create_status_error_response(error_type, status_code) + else: + logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") + return self._create_status_error_response(status_code, response_body) + + except Exception as e: + logger.error(f"Exception occurred: {e}", exc_info=True) + return self._create_status_error_response( + f"Failed to get output request status: {str(e)}" + ) + + def get_document(self, channel: str, output_request_id: str) -> DocumentResponse: + """Retrieves a generated document from the Output Management service.""" + logger.info(f"Getting document for request: {output_request_id}, channel: {channel}") + + validation_error = RequestValidator.validate_get_document_request(channel, output_request_id) + if validation_error: + logger.error(f"Validation failed for get document: {validation_error}") + return self._create_document_error_response( + "INVALID_REQUEST", + validation_error + ) + + endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}document/{channel}" + logger.debug(f"Endpoint: {endpoint}") + + headers = self._get_headers() + headers[Constants.HEADER_CONTENT_TYPE] = Constants.CONTENT_TYPE_JSON + headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_PDF + + # Add sender-provider-subaccount-id header if available + if self._sender_provider_subaccount_id: + headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id + logger.debug(f"Added sender-provider-subaccount-id header for get document") + + try: + response = self._execute_request('POST', endpoint, data=output_request_id, headers=headers) + status_code = response.status_code + + logger.debug(f"Response status: {status_code}") + + if status_code == 200: + document = response.content + logger.info(f"Document retrieved successfully, size: {len(document)} bytes") + return DocumentResponse(document_content=document, errors=None) + + # Handle error responses + response_body = response.text + if self._is_retryable(status_code): + logger.error(f"Retryable error with status: {status_code}, body: {response_body}") + else: + logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") + + error_type = self._map_status_code_to_error(status_code) + if error_type: + return self._create_document_error_response(error_type, status_code) + else: + logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") + return self._create_document_error_response(status_code, response_body) + + except Exception as e: + logger.error(f"Exception occurred: {e}", exc_info=True) + return self._create_document_error_response( + f"Failed to get document: {str(e)}" + ) + + def _fetch_oauth_token_from_destination(self) -> Optional[str]: + """Fetch OAuth token using destination's OAuth configuration with mTLS. + + Uses SAP Cloud SDK to retrieve certificates from the Destination Service. + + Returns: + OAuth access token or None if fetch fails + """ + if not self._destination or not hasattr(self._destination, 'properties'): + return None + + props = self._destination.properties + if not isinstance(props, dict): + return None + + # Log destination properties for debugging + logger.debug(f"Destination properties keys: {list(props.keys())}") + + # Extract OAuth configuration from destination properties + token_url = props.get('tokenServiceURL') + client_id = props.get('client_id') or props.get('clientId') or props.get('tokenService.body.client_id') + grant_type = props.get('tokenService.body.grant_type', 'client_credentials') + app_tid = props.get('tokenService.body.app_tid') + + # Certificate name to lookup in Destination Service + # The certificate must be uploaded to Destination Service first using: + # certificate_client.create_certificate(Certificate(name="my-cert.p12", content=base64_content, type="PKCS12")) + cert_name = props.get('tokenService.KeyStoreLocation') + cert_password = props.get('tokenService.KeyStorePassword') + + if not token_url or not client_id: + logger.error(f"Missing OAuth config: tokenServiceURL={token_url}, clientId={client_id}") + return None + + if not cert_name: + logger.error("✗ No certificate name in destination properties (tokenService.certificate)") + logger.error("✗ Please upload your keystore to Destination Service and reference it") + logger.error("✗ Example: certificate_client.create_certificate(Certificate(name='my-cert.p12', content=base64_content, type='PKCS12'))") + return None + + # Track temp files for cleanup + temp_files_created = False + cert_file = None + key_file = None + + try: + # Build OAuth token request + token_data = { + 'grant_type': grant_type, + 'client_id': client_id + } + if app_tid: + token_data['app_tid'] = app_tid + + logger.info(f"Fetching OAuth token from {token_url} using mTLS") + logger.info(f"✓ Using certificate from Destination Service: {cert_name}") + + # Get certificate from Cloud SDK Destination Service + try: + from sap_cloud_sdk.destination import create_certificate_client, AccessStrategy + import tempfile + import base64 + from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption + + certificate_client = create_certificate_client() + cert = certificate_client.get_subaccount_certificate(cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY) + + # Check if certificate was found + if cert is None: + logger.error(f"✗ Certificate '{cert_name}' not found in Destination Service") + logger.error("✗ Please ensure the certificate is uploaded to Destination Service") + logger.error("✗ Example: certificate_client.create_certificate(Certificate(name='my-cert.p12', content=base64_content, type='PKCS12'))") + return None + + logger.info(f"✓ Retrieved certificate '{cert.name}' (type: {cert.type})") + + # Decode base64 content + cert_binary = base64.b64decode(cert.content) + logger.debug(f"✓ Decoded certificate content ({len(cert_binary)} bytes)") + + # Parse certificate - try PKCS12 format first (most common for mTLS) + password = cert_password.encode('utf-8') if cert_password else None + + try: + private_key, certificate, additional_certs = pkcs12.load_key_and_certificates( + cert_binary, + password + ) + + if not (certificate and private_key): + logger.error("✗ No certificate or key found in PKCS12") + return None + + logger.info("✓ Successfully parsed certificate and extracted keys") + + # Write certificate to temp file (include chain) + cert_fd, cert_file = tempfile.mkstemp(suffix='.pem') + with os.fdopen(cert_fd, 'wb') as f: + f.write(certificate.public_bytes(Encoding.PEM)) + if additional_certs: + for c in additional_certs: + f.write(c.public_bytes(Encoding.PEM)) + + # Write private key to temp file + key_fd, key_file = tempfile.mkstemp(suffix='.key') + with os.fdopen(key_fd, 'wb') as f: + f.write(private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption() + )) + + temp_files_created = True + + except Exception as e: + logger.error(f"✗ Failed to parse certificate: {e}") + logger.error("✗ Certificate must be in PKCS12 format (.p12/.pfx) containing both certificate and private key") + return None + + except ImportError as e: + logger.error("✗ sap-cloud-sdk or cryptography library not installed") + logger.error("✗ Install with: pip install sap-cloud-sdk cryptography") + logger.error(f"✗ ImportError details: {e}") + return None + except Exception as e: + logger.error(f"✗ Failed to retrieve/process certificate '{cert_name}': {e}", exc_info=True) + return None + + # Make token request with mTLS + if not(cert_file and key_file): + logger.error("✗ No client certificates available") + return None + + request_kwargs = { + 'data': token_data, + 'headers': {'Content-Type': 'application/x-www-form-urlencoded'}, + 'timeout': 30, + 'verify': True, + 'cert': (cert_file, key_file) + } + + logger.info("✓ Configuring mTLS with certificate files") + logger.debug(f" Cert file: {cert_file}") + logger.debug(f" Key file: {key_file}") + + response = requests.post(token_url, **request_kwargs) + + # Clean up temp files + if temp_files_created: + try: + os.unlink(cert_file) + if key_file != cert_file: + os.unlink(key_file) + logger.debug("✓ Cleaned up temporary certificate files") + except Exception as e: + logger.warning(f"⚠ Failed to cleanup temp files: {e}") + + # Handle response + if response.status_code == 200: + token_response = response.json() + access_token = token_response.get('access_token') + if access_token: + logger.info(f"✓ Successfully fetched OAuth token (length: {len(access_token)})") + return access_token + else: + logger.error(f"✗ No access_token in response: {list(token_response.keys())}") + else: + # Parse OAuth error response + try: + error_response = response.json() + error_type = error_response.get('error', 'unknown') + error_desc = error_response.get('error_description', 'No description') + logger.error(f"✗ Token fetch failed with status {response.status_code}") + logger.error(f"✗ OAuth error: {error_type} - {error_desc}") + except: + logger.error(f"✗ Token fetch failed with status {response.status_code}: {response.text}") + + logger.error("✗ mTLS authentication failed - check certificates and credentials") + + except Exception as e: + logger.error(f"✗ Exception fetching OAuth token: {e}", exc_info=True) + # Clean up temp files even on exception + if temp_files_created: + try: + if cert_file: + os.unlink(cert_file) + if key_file and key_file != cert_file: + os.unlink(key_file) + except: + pass + + return None + + def _execute_request(self, method: str, endpoint: str, **kwargs): + """Execute HTTP request using destination if available, otherwise use session. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: Full endpoint URL + **kwargs: Additional arguments to pass to the request + + Returns: + Response object + """ + # Always use regular session - authentication is handled in _get_headers + return self._http_session.request(method, endpoint, **kwargs) + + def _get_headers(self) -> Dict[str, str]: + """Get request headers with authentication.""" + headers = {} + + # Add trace parent header for distributed tracing + headers[Constants.HEADER_TRACE_PARENT] = self._generate_trace_id() + + # If using destination, get auth token from it + if self._destination: + logger.debug(f"Using destination for authentication. Destination type: {type(self._destination)}") + + # Try to fetch OAuth token using destination's OAuth configuration + token = self._fetch_oauth_token_from_destination() + if token: + headers[Constants.AUTHORIZATION] = f"{Constants.BEARER} {token}" + logger.info("✓ Authorization header added to request") + else: + logger.error("✗ Failed to fetch OAuth token from destination") + logger.error("✗ NO Authorization header - request will fail") + else: + logger.error("✗ No destination available for authentication") + + return headers + + @staticmethod + def _generate_trace_id() -> str: + """ + Generate traceparent header in W3C Trace Context format. + + Format: version-trace-id-parent-id-trace-flags + - version: 2 hex digits (00) + - trace-id: 32 hex digits (16 bytes) + - parent-id: 16 hex digits (8 bytes) + - trace-flags: 2 hex digits (01 = sampled) + + Returns: + Traceparent header value in format: 00-{trace_id}-{parent_id}-01 + """ + trace_id = uuid.uuid4().hex # 32 hex chars + parent_id = uuid.uuid4().hex[:16] # 16 hex chars + return f"00-{trace_id}-{parent_id}-01" + + @staticmethod + def _is_retryable(status_code: int) -> bool: + """ + Checks if the HTTP status code represents a retryable error. + + Args: + status_code: The HTTP status code + + Returns: + True if the status code is 5xx (server error) or 429 (Too Many Requests) + """ + return status_code >= 500 or status_code == 429 + + @staticmethod + def _map_status_code_to_error(status_code: int) -> Optional[str]: + """ + Maps HTTP error status codes to appropriate error types. + + Note: This method returns None for unhandled status codes. + + Args: + status_code: The HTTP error status code + + Returns: + The corresponding error type, or None if not mapped + """ + error_mapping = { + # Client errors (4xx) + 400: "INVALID_REQUEST", + 401: "AUTHENTICATION_FAILED", + 403: "FORBIDDEN", + 404: "RESOURCE_NOT_FOUND", + 409: "CONFLICT", + 429: "INVALID_REQUEST", # Too Many Requests + + # Server errors (5xx) + 500: "INTERNAL_SERVER_ERROR", + 502: "INTERNAL_SERVER_ERROR", # Bad Gateway + 503: "SERVICE_UNAVAILABLE", + 504: "GATEWAY_TIMEOUT", + } + return error_mapping.get(status_code) + + @staticmethod + def _create_output_error_response(error_type, message) -> OutputResponse: + """Create an OutputResponse with error information.""" + return OutputResponse( + output_request_id=None, + errors=[{"type": error_type, "message": str(message)}] + ) + + @staticmethod + def _create_status_error_response(error_type_or_message, status_code=None) -> JobStatusResponse: + """Create a JobStatusResponse with error information.""" + if status_code: + error_msg = {"type": error_type_or_message, "status_code": status_code} + else: + error_msg = {"message": error_type_or_message} + return JobStatusResponse(status=None, errors=[error_msg]) + + @staticmethod + def _create_document_error_response(error_type_or_message, status_code=None) -> DocumentResponse: + """Create a DocumentResponse with error information.""" + if status_code: + error_msg = {"type": error_type_or_message, "status_code": status_code} + else: + error_msg = {"message": error_type_or_message} + return DocumentResponse(document_content=None, errors=[error_msg]) \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/config/__init__.py b/src/sap_cloud_sdk/outputmanagement/config/__init__.py new file mode 100644 index 00000000..a0f06e6e --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration classes for the SDK.""" + +from .destination_credential_config import DestinationCredentialConfig + +__all__ = ["DestinationCredentialConfig"] diff --git a/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py new file mode 100644 index 00000000..611bd85d --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py @@ -0,0 +1,104 @@ +"""Destination credential configuration for Output Management Service.""" +from typing import Optional +from pydantic import BaseModel, Field, field_validator +import logging +logger = logging.getLogger(__name__) +class DestinationCredentialConfig(BaseModel): + """Configuration for accessing Output Management Service via SAP BTP Destination. + This class provides a simple configuration wrapper for destination-based access. + Uses relative imports since this module is part of sap_cloud_sdk. + Attributes: + destination_name: Name of the destination in SAP BTP Destination Service + access_strategy: Optional access strategy - "PROVIDER_ONLY" or "SUBSCRIBER_ONLY" + Example: + ```python + from sap_cloud_sdk.outputmanagement import OutputManagementServiceClientProvider + from sap_cloud_sdk.outputmanagement.config import DestinationCredentialConfig + # Create config + config = DestinationCredentialConfig( + destination_name="OUTPUT_MANAGEMENT_DEST", + access_strategy="PROVIDER_ONLY" + ) + # Create client using destination + client = OutputManagementServiceClientProvider.create_from_destination(config) + ``` + """ + destination_name: str = Field( + ..., + description="Name of the destination in SAP BTP Destination Service" + ) + access_strategy: Optional[str] = Field( + default=None, + description="Access strategy: 'PROVIDER_ONLY' or 'SUBSCRIBER_ONLY' (optional)" + ) + @field_validator("destination_name") + @classmethod + def validate_destination_name(cls, v: str) -> str: + """Validate destination name is not empty.""" + if not v or not v.strip(): + raise ValueError("Destination name cannot be empty") + return v.strip() + @field_validator("access_strategy") + @classmethod + def validate_access_strategy(cls, v: Optional[str]) -> Optional[str]: + """Validate access strategy if provided.""" + if v is not None: + v = v.strip().upper() + if v not in ["PROVIDER_ONLY", "SUBSCRIBER_ONLY"]: + raise ValueError( + f"Invalid access_strategy: {v}. " + "Must be 'PROVIDER_ONLY' or 'SUBSCRIBER_ONLY'" + ) + return v + class Config: + """Pydantic configuration.""" + frozen = True + str_strip_whitespace = True + def get_destination(self): + """Retrieve the destination from SAP BTP Destination Service. + Uses relative import to access sap_cloud_sdk.destination module. + Returns: + Destination object with URL, authentication, and properties + Raises: + Exception: If destination retrieval fails + """ + from ...destination import create_client, AccessStrategy + logger.info(f"Retrieving destination '{self.destination_name}'") + client = create_client() + if self.access_strategy: + if self.access_strategy == "PROVIDER_ONLY": + strategy = AccessStrategy.PROVIDER_ONLY + else: + strategy = AccessStrategy.SUBSCRIBER_ONLY + destination = client.get_subaccount_destination( + name=self.destination_name, + access_strategy=strategy + ) + if destination is None: + raise ValueError( + f"Destination '{self.destination_name}' not found " + f"with access strategy '{self.access_strategy}'" + ) + logger.info(f"Retrieved destination with {self.access_strategy} strategy") + else: + destination = client.get_instance_destination(name=self.destination_name) + if destination is None: + destination = client.get_subaccount_destination(name=self.destination_name) + if destination is None: + raise ValueError(f"Destination '{self.destination_name}' not found") + logger.info(f"Retrieved destination '{self.destination_name}'") + return destination + def get_base_url(self) -> str: + """Get the base URL from the destination.""" + destination = self.get_destination() + if hasattr(destination, 'url'): + url = destination.url + elif hasattr(destination, 'get_url'): + url = destination.get_url() + elif hasattr(destination, 'get_uri'): + url = destination.get_uri() + else: + raise ValueError(f"Cannot extract URL from destination '{self.destination_name}'") + if not url: + raise ValueError(f"Destination '{self.destination_name}' does not have a URL") + return url.rstrip('/') diff --git a/src/sap_cloud_sdk/outputmanagement/constants.py b/src/sap_cloud_sdk/outputmanagement/constants.py new file mode 100644 index 00000000..f53cc889 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/constants.py @@ -0,0 +1,50 @@ +"""Constants for the Output Management SDK.""" + +from enum import Enum + + +class Constants: + """SDK constants.""" + + # API Endpoints + API_OUTPUT_CONTROL = "/api/output-control-api/v1/" + + # Headers + CONTENT_TYPE = "Content-Type" + APPLICATION_JSON = "application/json" + AUTHORIZATION = "Authorization" + BEARER = "Bearer" + HEADER_CONTENT_TYPE = "Content-Type" + HEADER_ACCEPT = "Accept" + HEADER_SENDER_PROVIDER_SUBACCOUNT_ID = "sender-provider-subaccount-id" + HEADER_TRACE_PARENT = "traceparent" + CONTENT_TYPE_JSON = "application/json" + CONTENT_TYPE_PDF = "application/pdf" + + +class Status(Enum): + """Output request status.""" + + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + + +class FileFormat(Enum): + """Supported file formats.""" + + PDF = "PDF" + DOCX = "DOCX" + HTML = "HTML" + XML = "XML" + + +class Channel(Enum): + """Output channels.""" + + EMAIL = "EMAIL" + INTERNAL_EMAIL = "INTERNAL_EMAIL" + DIRECT_SHARE = "DIRECT_SHARE" + FORM = "FORM" diff --git a/src/sap_cloud_sdk/outputmanagement/exceptions.py b/src/sap_cloud_sdk/outputmanagement/exceptions.py new file mode 100644 index 00000000..f7f874d3 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/exceptions.py @@ -0,0 +1,69 @@ +"""Exception classes for the Output Management SDK.""" + +from typing import Optional, Dict, Any + + +class OutputManagementException(Exception): + """Base exception for Output Management SDK.""" + + def __init__( + self, + message: str, + error_code: Optional[str] = None, + status_code: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + ): + """Initialize exception. + + Args: + message: Error message + error_code: Error code + status_code: HTTP status code + details: Additional error details + """ + super().__init__(message) + self.message = message + self.error_code = error_code + self.status_code = status_code + self.details = details or {} + + def __str__(self) -> str: + """Return string representation.""" + parts = [self.message] + if self.error_code: + parts.append(f"Error Code: {self.error_code}") + if self.status_code: + parts.append(f"Status Code: {self.status_code}") + return " | ".join(parts) + + + +class AuthenticationException(OutputManagementException): + """Exception for authentication failures.""" + + pass + + +class ValidationException(OutputManagementException): + """Exception for validation failures.""" + + pass + + +class NetworkException(OutputManagementException): + """Exception for network-related errors.""" + + pass + + +class DestinationNotFoundException(OutputManagementException): + """Exception for destination not found errors.""" + + pass + + +class DestinationAccessException(OutputManagementException): + """Exception for destination access errors.""" + + pass + diff --git a/src/sap_cloud_sdk/outputmanagement/models/__init__.py b/src/sap_cloud_sdk/outputmanagement/models/__init__.py new file mode 100644 index 00000000..756a0688 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/__init__.py @@ -0,0 +1,22 @@ +"""Model classes for the SDK.""" + +from .output_request import OutputRequest, OutputRequestBuilder +from .output_request_data import OutputRequestData +from .output_management_info import OutputManagementInfo +from .output_response import OutputResponse +from .email_configuration import EmailConfiguration +from .attachment_config import AttachmentConfig +from .direct_share_configuration import DirectShareConfiguration +from .form_configuration import FormConfiguration + +__all__ = [ + "OutputRequest", + "OutputRequestBuilder", + "OutputRequestData", + "OutputManagementInfo", + "OutputResponse", + "EmailConfiguration", + "AttachmentConfig", + "DirectShareConfiguration", + "FormConfiguration", +] \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py b/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py new file mode 100644 index 00000000..750b941d --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py @@ -0,0 +1,49 @@ +"""Attachment configuration model for email documents.""" + +from typing import Optional +from pydantic import BaseModel, Field + +from .form_configuration import FormConfiguration + + +class AttachmentConfig(BaseModel): + """ + Attachment configuration for email documents. + + This is a helper class used to parse attachment configuration from INTERNAL_EMAIL requests. + It contains form configuration details that will be used to populate FormConfiguration + for document generation. + + If provided in EmailConfiguration, a PDF document will be generated using these form details + and attached to the email. If not provided, no document will be generated. + + Attributes: + form_configuration: Form configuration for PDF generation + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.attachment_config import AttachmentConfig + from sap_cloud_sdk.outputmanagement.models.form_configuration import FormConfiguration + from sap_cloud_sdk.outputmanagement.constants import FileFormat + + form_config = FormConfiguration( + form_name="PurchaseOrderForm", + form_template_name="PurchaseOrderFormTemplate", + form_language="en", + file_format=FileFormat.PDF + ) + + attachment = AttachmentConfig(form_configuration=form_config) + ``` + """ + + form_configuration: FormConfiguration = Field( + ..., + alias="formConfiguration", + description="Form configuration for PDF generation" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/direct_share_configuration.py b/src/sap_cloud_sdk/outputmanagement/models/direct_share_configuration.py new file mode 100644 index 00000000..b893c2b9 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/direct_share_configuration.py @@ -0,0 +1,29 @@ +"""Direct share configuration model.""" + +from typing import Optional, List +from pydantic import BaseModel, Field + + +class DirectShareConfiguration(BaseModel): + """Direct share channel configuration. + + Attributes: + user_ids: List of user IDs to share with + group_ids: Optional list of group IDs to share with + message: Optional message to include + expiration_days: Optional number of days until expiration + """ + + user_ids: List[str] = Field( + ..., min_length=1, description="User IDs to share with" + ) + group_ids: Optional[List[str]] = Field(None, description="Group IDs to share with") + message: Optional[str] = Field(None, description="Message to include") + expiration_days: Optional[int] = Field( + None, ge=1, description="Days until expiration" + ) + + class Config: + """Pydantic configuration.""" + + str_strip_whitespace = True diff --git a/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py b/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py new file mode 100644 index 00000000..92e3331a --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py @@ -0,0 +1,112 @@ +"""Email configuration model for INTERNAL_EMAIL channel.""" + +from typing import Optional, List +from pydantic import BaseModel, Field, field_validator + +from .attachment_config import AttachmentConfig + + +class EmailConfiguration(BaseModel): + """ + Email configuration for INTERNAL_EMAIL channel. + + This class contains all the configuration needed to send emails via the INTERNAL_EMAIL channel, + which skips Business Policy Framework evaluation and uses the configuration provided in the request. + + Usage Modes: + - Mode 1 (Simple Notification): No attachment - fast email notification only + - Mode 2 (With Document): With attachment - email with PDF attachment + + Attributes: + email_notification_template_key: ANS template identifier for email body and subject (required) + email_template_language: ISO language code for the email template (required) + to: List of recipient email addresses (required, minimum 1) + cc: List of CC recipient email addresses (optional) + attachment: Optional attachment configuration for PDF generation (optional) + + Example - Simple Notification: + ```python + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + + config = EmailConfiguration( + email_notification_template_key="PO_APPROVAL_NOTIFICATION", + email_template_language="en", + to=["finance@company.com", "warehouse@company.com"], + cc=["manager@company.com"] + ) + ``` + + Example - With Document Attachment: + ```python + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + from sap_cloud_sdk.outputmanagement.models.attachment_config import AttachmentConfig + from sap_cloud_sdk.outputmanagement.models.form_configuration import FormConfiguration + from sap_cloud_sdk.outputmanagement.constants import FileFormat + + form_config = FormConfiguration( + form_name="PurchaseOrderForm", + form_template_name="PurchaseOrderFormTemplate", + form_language="en", + file_format=FileFormat.PDF + ) + + attachment = AttachmentConfig(form_configuration=form_config) + + config = EmailConfiguration( + email_notification_template_key="PO_APPROVED_WITH_DOC", + email_template_language="en", + to=["audit@company.com"], + attachment=attachment + ) + ``` + """ + + email_notification_template_key: str = Field( + ..., + alias="emailNotificationTemplateKey", + min_length=1, + description="ANS template identifier for email body and subject" + ) + + email_template_language: str = Field( + ..., + alias="emailTemplateLanguage", + min_length=1, + description="ISO language code for the email template (e.g., 'en', 'de', 'fr')" + ) + + to: List[str] = Field( + ..., + min_length=1, + description="List of recipient email addresses" + ) + + cc: Optional[List[str]] = Field( + None, + description="List of CC recipient email addresses" + ) + + bcc: Optional[List[str]] = Field( + None, + description="List of BCC recipient email addresses" + ) + + attachment: Optional[AttachmentConfig] = Field( + None, + description="Optional attachment configuration for PDF generation" + ) + + @field_validator("to", "cc", "bcc") + @classmethod + def validate_email_list(cls, v: Optional[List[str]]) -> Optional[List[str]]: + """Validate email addresses.""" + if v is not None: + for email in v: + if not email or "@" not in email: + raise ValueError(f"Invalid email address: {email}") + return v + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/form_configuration.py b/src/sap_cloud_sdk/outputmanagement/models/form_configuration.py new file mode 100644 index 00000000..d4771ea4 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/form_configuration.py @@ -0,0 +1,25 @@ +"""Form configuration model.""" + +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field + + +class FormConfiguration(BaseModel): + """Form channel configuration. + + Attributes: + form_id: Form identifier + form_data: Optional form data + callback_url: Optional callback URL for form submission + """ + + form_id: str = Field(..., min_length=1, description="Form identifier") + form_data: Optional[Dict[str, Any]] = Field(None, description="Form data") + callback_url: Optional[str] = Field( + None, description="Callback URL for form submission" + ) + + class Config: + """Pydantic configuration.""" + + str_strip_whitespace = True diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_management_info.py b/src/sap_cloud_sdk/outputmanagement/models/output_management_info.py new file mode 100644 index 00000000..5046fa65 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_management_info.py @@ -0,0 +1,105 @@ +"""Output Management information model.""" + +from typing import Optional, List, Any +from pydantic import BaseModel, Field + +from ..constants import Channel +from .email_configuration import EmailConfiguration +from .direct_share_configuration import DirectShareConfiguration + + +class OutputManagementInfo(BaseModel): + """ + Contains information required by Output Management to decide on how to orchestrate the output. + + This class encapsulates the configuration and metadata needed for output processing, + including business document identification, delivery channels, and channel-specific configurations. + + Attributes: + business_document_type: Type of the business document (required) + business_document_id: ID of the business document (required) + is_priority: Indicates if this is a priority request (optional, default: False) + user_id: User ID who triggered the output request (optional) + channels: List of channels for output delivery (required) + direct_share_configuration: Configuration for direct share channel (optional) + email_configuration: Configuration for internal email channel (optional) + cig_data_center: CIG Data Center information (optional) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.output_management_info import OutputManagementInfo + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + from sap_cloud_sdk.outputmanagement.constants import Channel + + email_config = EmailConfiguration( + email_notification_template_key="PO_NOTIFICATION", + email_template_language="en", + to=["recipient@example.com"] + ) + + output_mgmt = OutputManagementInfo( + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123", + is_priority=False, + user_id="user@sap.com", + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + ``` + """ + + business_document_type: str = Field( + ..., + alias="businessDocumentType", + min_length=1, + description="Type of the business document (e.g., 'com.sap.procurement.PurchaseOrder')" + ) + + business_document_id: str = Field( + ..., + alias="businessDocumentId", + min_length=1, + description="ID of the business document (e.g., 'PO00551100')" + ) + + is_priority: bool = Field( + False, + alias="isPriority", + description="Indicates if this is a priority request" + ) + + user_id: Optional[str] = Field( + None, + alias="userId", + description="User ID who triggered the output request (e.g., 'user@sap.com')" + ) + + channels: List[Channel] = Field( + ..., + min_length=1, + description="List of channels for output delivery" + ) + + direct_share_configuration: Optional[DirectShareConfiguration] = Field( + None, + alias="directShareConfiguration", + description="Configuration for direct share channel" + ) + + email_configuration: Optional[EmailConfiguration] = Field( + None, + alias="emailConfiguration", + description="Configuration for internal email channel" + ) + + cig_data_center: Optional[str] = Field( + None, + alias="cigDataCenter", + description="CIG Data Center information" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True + use_enum_values = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_request.py b/src/sap_cloud_sdk/outputmanagement/models/output_request.py new file mode 100644 index 00000000..31059c46 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_request.py @@ -0,0 +1,235 @@ +"""Output request model following CloudEvents 1.0 specification.""" + +from typing import Optional +from datetime import datetime +from pydantic import BaseModel, Field +import uuid + +from .output_request_data import OutputRequestData + + +class OutputRequest(BaseModel): + """ + Represents an Output Management request following the CloudEvents 1.0 specification. + + This is the main request object that encapsulates all information required to trigger + document generation and delivery through the Output Management service. It follows the + CloudEvents specification for event-driven architectures. + + Attributes: + spec_version: CloudEvents specification version (default: "1.0") + id: Unique ID for this event (auto-generated UUID if not provided) + source: Identifies where this event originated from (required) + time: Timestamp when the output request was triggered (auto-generated if not provided) + type: Describes the type of event (required) + data_content_type: Content type of event's data (default: "application/json") + data: Contains OutputManagement and BusinessDocument (required) + xsapsisgwdestapp: SAP system gateway destination application identifier (optional) + xsapsisgwdestappid: SAP system gateway destination application ID (optional) + xsapsisgwbackendid: SAP system gateway backend ID (optional) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.output_request import OutputRequest + from sap_cloud_sdk.outputmanagement.models.output_request_data import OutputRequestData + from sap_cloud_sdk.outputmanagement.models.output_management_info import OutputManagementInfo + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + from sap_cloud_sdk.outputmanagement.constants import Channel + + # Create email configuration + email_config = EmailConfiguration( + email_notification_template_key="PO_NOTIFICATION", + email_template_language="en", + to=["recipient@example.com"] + ) + + # Create output management info + output_mgmt = OutputManagementInfo( + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123", + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + + # Create business document + business_doc = { + "PurchaseOrder": { + "orderId": "PO-123", + "vendor": "ABC Corp" + } + } + + # Create request data + data = OutputRequestData( + output_management=output_mgmt, + business_document=business_doc + ) + + # Create output request + request = OutputRequest( + source="/eu12/sap.procurement/tenant-123", + type="com.sap.procurement.purchaseorder.created", + data=data + ) + ``` + """ + + spec_version: str = Field( + default="1.0", + alias="specversion", + description="CloudEvents specification version (should be '1.0')" + ) + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID for this event (UUID). Producers must ensure source + id is unique." + ) + + source: str = Field( + ..., + min_length=1, + description="Identifies where this event originated from (e.g., '/eu12/sap.nexus.px/8d4bb3fa')" + ) + + time: str = Field( + default_factory=lambda: datetime.utcnow().isoformat() + "Z", + description="Timestamp when the output request was triggered (ISO 8601 format)" + ) + + type: str = Field( + ..., + min_length=1, + description="Type of event (e.g., 'sap.nexus.px.purchaseorder.PurchaseOrder.Created.v1')" + ) + + data_content_type: str = Field( + default="application/json", + alias="datacontenttype", + description="Content type of the event's data (must be 'application/json')" + ) + + data: OutputRequestData = Field( + ..., + description="Contains OutputManagement and BusinessDocument nodes" + ) + + xsapsisgwdestapp: Optional[str] = Field( + None, + description="SAP system gateway destination application identifier" + ) + + xsapsisgwdestappid: Optional[str] = Field( + None, + description="SAP system gateway destination application ID" + ) + + xsapsisgwbackendid: Optional[str] = Field( + None, + description="SAP system gateway backend ID" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True + + +class OutputRequestBuilder: + """ + Builder for constructing OutputRequest objects. + + This builder provides a fluent API for creating OutputRequest instances with proper validation. + """ + + def __init__(self): + """Initialize builder with default values.""" + self._spec_version: str = "1.0" + self._id: Optional[str] = None + self._source: Optional[str] = None + self._time: Optional[str] = None + self._type: Optional[str] = None + self._data_content_type: str = "application/json" + self._data: Optional[OutputRequestData] = None + self._xsapsisgwdestapp: Optional[str] = None + self._xsapsisgwdestappid: Optional[str] = None + self._xsapsisgwbackendid: Optional[str] = None + + def spec_version(self, spec_version: str) -> "OutputRequestBuilder": + """Set CloudEvents specification version.""" + self._spec_version = spec_version + return self + + def id(self, id: str) -> "OutputRequestBuilder": + """Set event ID.""" + self._id = id + return self + + def source(self, source: str) -> "OutputRequestBuilder": + """Set event source.""" + self._source = source + return self + + def time(self, time: str) -> "OutputRequestBuilder": + """Set event timestamp.""" + self._time = time + return self + + def type(self, type: str) -> "OutputRequestBuilder": + """Set event type.""" + self._type = type + return self + + def data_content_type(self, data_content_type: str) -> "OutputRequestBuilder": + """Set data content type.""" + self._data_content_type = data_content_type + return self + + def data(self, data: OutputRequestData) -> "OutputRequestBuilder": + """Set request data.""" + self._data = data + return self + + def xsapsisgwdestapp(self, xsapsisgwdestapp: str) -> "OutputRequestBuilder": + """Set SAP system gateway destination app.""" + self._xsapsisgwdestapp = xsapsisgwdestapp + return self + + def xsapsisgwdestappid(self, xsapsisgwdestappid: str) -> "OutputRequestBuilder": + """Set SAP system gateway destination app ID.""" + self._xsapsisgwdestappid = xsapsisgwdestappid + return self + + def xsapsisgwbackendid(self, xsapsisgwbackendid: str) -> "OutputRequestBuilder": + """Set SAP system gateway backend ID.""" + self._xsapsisgwbackendid = xsapsisgwbackendid + return self + + def build(self) -> OutputRequest: + """ + Build OutputRequest instance. + + Returns: + OutputRequest instance + + Raises: + ValueError: If required fields are missing + """ + if not self._source: + raise ValueError("source is required") + if not self._type: + raise ValueError("type is required") + if not self._data: + raise ValueError("data is required") + + return OutputRequest( + spec_version=self._spec_version, + id=self._id or str(uuid.uuid4()), + source=self._source, + time=self._time or datetime.utcnow().isoformat() + "Z", + type=self._type, + data_content_type=self._data_content_type, + data=self._data, + xsapsisgwdestapp=self._xsapsisgwdestapp, + xsapsisgwdestappid=self._xsapsisgwdestappid, + xsapsisgwbackendid=self._xsapsisgwbackendid, + ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_request_data.py b/src/sap_cloud_sdk/outputmanagement/models/output_request_data.py new file mode 100644 index 00000000..24571ff7 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_request_data.py @@ -0,0 +1,84 @@ +"""Output Request Data model.""" + +from typing import Any, Dict +from pydantic import BaseModel, Field + +from .output_management_info import OutputManagementInfo + + +class OutputRequestData(BaseModel): + """ + Container for the data payload of an Output Management request. + + This class serves as the envelope for the actual request data, containing two essential components: + - OutputManagement: Metadata and configuration for output orchestration + - BusinessDocument: The actual business document data to be processed + + The business document is stored as a dictionary to provide maximum flexibility + in handling different document structures and types. + + JSON Structure: + ```json + { + "OutputManagement": { + "businessDocumentType": "com.sap.procurement.PurchaseOrder", + "businessDocumentId": "PO-123", + ... + }, + "BusinessDocument": { + "PurchaseOrder": { + "orderId": "PO-123", + "vendor": "ABC Corp", + ... + } + } + } + ``` + + Attributes: + output_management: Information required by Output Management for orchestration (required) + business_document: The business document as a dictionary/JSON object (required) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.output_request_data import OutputRequestData + from sap_cloud_sdk.outputmanagement.models.output_management_info import OutputManagementInfo + from sap_cloud_sdk.outputmanagement.constants import Channel + + output_mgmt = OutputManagementInfo( + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123", + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + + business_doc = { + "PurchaseOrder": { + "orderId": "PO-123", + "vendor": "ABC Corp", + "total": 1500.00 + } + } + + data = OutputRequestData( + output_management=output_mgmt, + business_document=business_doc + ) + ``` + """ + + output_management: OutputManagementInfo = Field( + ..., + alias="OutputManagement", + description="Information required by Output Management to orchestrate the output" + ) + + business_document: Dict[str, Any] = Field( + ..., + alias="BusinessDocument", + description="The business document as a JSON object" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_response.py b/src/sap_cloud_sdk/outputmanagement/models/output_response.py new file mode 100644 index 00000000..5393355b --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_response.py @@ -0,0 +1,120 @@ +"""Output response model.""" + +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field + + + +class ErrorResponse(BaseModel): + """Error response model.""" + + message: str = Field(..., description="Error message") + code: Optional[str] = Field(None, description="Error code") + details: Optional[Dict[str, Any]] = Field(None, description="Additional error details") + + class Config: + """Pydantic configuration.""" + + str_strip_whitespace = True + +class OutputResponse(BaseModel): + """Output response wrapper. + + Response object for Output Management service operations. + Contains the request identifier or error information. + """ + + output_request_id: Optional[str] = Field( + None, + alias="outputRequestId", + description="The unique identifier for the output request" + ) + error: Optional[ErrorResponse] = Field(None, description="Error encountered during processing") + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + +class OutputRequestChannelResponse(BaseModel): + """Output request channel response.""" + + channel: str = Field(..., description="Channel name") + status: str = Field(..., description="Channel status") + error_message: Optional[str] = Field( + None, + alias="errorMessage", + description="Error message if any" + ) + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + +class OutputRequestStatusResponse(BaseModel): + """Output request status response.""" + + request_id: str = Field(..., alias="requestId", description="Request identifier") + business_document_id: Optional[str] = Field( + None, + alias="businessDocumentId", + description="Business document identifier" + ) + business_document_type: Optional[str] = Field( + None, + alias="businessDocumentType", + description="Business document type" + ) + created_at: str = Field(..., alias="createdAt", description="Creation timestamp") + channels: Optional[List[OutputRequestChannelResponse]] = Field( + None, + description="Channel responses" + ) + error_message: Optional[str] = Field( + None, + alias="errorMessage", + description="Error message" + ) + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + + +class DocumentResponse(BaseModel): + """Document response wrapper. + + Contains either the document content or an error response. + """ + + document_content: Optional[bytes] = Field( + None, + alias="documentContent", + description="Binary document content" + ) + error: Optional[ErrorResponse] = Field(None, description="Error response") + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + +class JobStatusResponse(BaseModel): + """Job status response wrapper. + + Contains either the output request status response or an error response. + """ + + output_request_status_response: Optional[OutputRequestStatusResponse] = Field( + None, + alias="outputRequestStatusResponse", + description="Output request status response" + ) + error: Optional[ErrorResponse] = Field(None, description="Error response") + + class Config: + """Pydantic configuration.""" + + populate_by_name = True diff --git a/src/sap_cloud_sdk/outputmanagement/utils/__init__.py b/src/sap_cloud_sdk/outputmanagement/utils/__init__.py new file mode 100644 index 00000000..f20e25eb --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/utils/__init__.py @@ -0,0 +1,5 @@ +"""Utility functions.""" + +from .request_validator import RequestValidator + +__all__ = ["RequestValidator"] diff --git a/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py b/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py new file mode 100644 index 00000000..8d180f89 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py @@ -0,0 +1,315 @@ +"""Request validation utilities. + +This module provides comprehensive validation for Output Management requests including: +- CloudEvents specification compliance +- Business document validation +- Output management configuration validation +- Channel-specific validation + +Author: SAP SE +Version: 1.0.0 +Since: 1.0.0 +""" + +from typing import Optional, List +from ..models.output_request import OutputRequest +from ..models.email_configuration import EmailConfiguration +from ..models.direct_share_configuration import DirectShareConfiguration +from ..constants import Channel + + +class RequestValidator: + """ + Validator utility class for Output Management requests. + + This class provides comprehensive validation for OutputRequest objects including: + - CloudEvents specification compliance + - Business document validation + - Output management configuration validation + - Channel-specific validation (delegates to specific validators) + """ + + def __init__(self): + """Private constructor to prevent instantiation.""" + raise TypeError("This is a utility class and cannot be instantiated") + + @staticmethod + def validate(output_request: OutputRequest) -> Optional[str]: + """ + Validates an OutputRequest according to CloudEvents specification and business requirements. + + This method performs comprehensive validation including: + - CloudEvents specification compliance (source, id, type format) + - Required business document information + - Output management metadata validation + - Channel configuration validation (delegates to channel-specific validators) + + Args: + output_request: The request to validate + + Returns: + Optional error message if validation fails, None if valid + """ + # CloudEvents spec: validate source + source = output_request.source + if not source or not source.strip(): + return "'source' cannot be null or empty" + + # CloudEvents spec: source format should be /region/application/tenant (3 parts separated by /) + source_parts = [part for part in source.split('/') if part] + if len(source_parts) != 3: + return "'source' does not conform to cloud event spec. Expected format: /region/application/tenant" + + # CloudEvents spec: validate id + if not output_request.id or not output_request.id.strip(): + return "'id' cannot be null or empty" + + # CloudEvents spec: validate type + event_type = output_request.type + if not event_type or not event_type.strip(): + return "'type' cannot be null or empty" + + # CloudEvents spec: type format should have at least 4 parts separated by dots + # Example: sap.nexus.px.purchaseorder.PurchaseOrder.Created.v1 + type_parts = event_type.split('.') + if len(type_parts) < 4: + return "'type' does not conform to cloud event spec. Expected format: domain.application.module.event" + + # Validate data payload + data = output_request.data + if data is None: + return "Request data cannot be null" + + # Validate business document + business_document = data.business_document + if business_document is None or not business_document: + return "Business document cannot be null" + + # Validate output management info + output_management = data.output_management + if output_management is None: + return "Output management related parameters not specified" + + # Validate business document ID + business_document_id = output_management.business_document_id + if not business_document_id or not business_document_id.strip(): + return "Business document id cannot be null or empty" + + # Validate business document type. It is required unless DIRECT_SHARE channel is used + business_document_type = output_management.business_document_type + channels = output_management.channels + + if (not business_document_type or not business_document_type.strip()) and \ + channels and len(channels) > 0 and Channel.DIRECT_SHARE not in channels: + return "Business document type cannot be null or empty" + + # Validate channels if present + if channels is not None and len(channels) > 0: + channel_error = RequestValidator._validate_channels(channels) + if channel_error is not None: + return channel_error + + # Validate direct share configuration if DIRECT_SHARE channel is present + has_direct_share = Channel.DIRECT_SHARE in channels + + if has_direct_share: + direct_share_config = output_management.direct_share_configuration + direct_share_error = DirectShareConfigValidator.validate(direct_share_config) + if direct_share_error is not None: + return direct_share_error + + # Validate internal email configuration if EMAIL or INTERNAL_EMAIL channel is present + has_email = Channel.EMAIL in channels or Channel.INTERNAL_EMAIL in channels + + if has_email: + email_config = output_management.email_configuration + email_error = InternalEmailConfigValidator.validate(email_config) + if email_error is not None: + return email_error + + return None + + @staticmethod + def _validate_channels(channels: List[Channel]) -> Optional[str]: + """ + Validates channel configuration. + + Args: + channels: The list of channels to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if channels is None or len(channels) == 0: + return "At least one channel must be specified" + + for channel in channels: + if channel is None: + return "Channel cannot be null" + + return None + + @staticmethod + def validate_job_status_request(output_request_id: str) -> Optional[str]: + """ + Validates job status request parameters. + + Args: + output_request_id: The output request ID to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if not output_request_id or not output_request_id.strip(): + return "OutputRequestId id cannot be null or empty" + return None + + @staticmethod + def validate_get_document_request(channel: str, output_request_id: str) -> Optional[str]: + """ + Validates get document request parameters. + + Args: + channel: The channel name + output_request_id: The output request ID + + Returns: + Optional error message if validation fails, None if valid + """ + if not channel or not channel.strip(): + return "Channel cannot be null or empty" + + if not output_request_id or not output_request_id.strip(): + return "Output Request ID cannot be null or empty" + + return None + + @staticmethod + def validate_email_parameters( + notification_template_key: str, + to: List[str], + business_document: dict, + template_language: str, + cc: Optional[List[str]] = None + ) -> Optional[str]: + """ + Validates email-specific parameters for EmailClient. + + Args: + notification_template_key: ANS template identifier + to: List of recipient email addresses + business_document: The business document dictionary + template_language: ISO language code for email template + cc: Optional list of CC email addresses + + Returns: + Optional error message if validation fails, None if valid + """ + # Validate notification_template_key + if not notification_template_key or not notification_template_key.strip(): + return "notification_template_key cannot be null or empty" + + # Validate recipients list + if not to or len(to) == 0: + return "At least one recipient is required in email configuration" + + # Validate email addresses in recipients list + for recipient in to: + if not recipient or not recipient.strip(): + return "Email recipient cannot be null or empty" + + # Validate CC recipients if present + if cc: + for recipient in cc: + if not recipient or not recipient.strip(): + return "Email CC recipient cannot be null or empty" + + # Validate business_document + if not business_document or len(business_document) == 0: + return "Business document cannot be null" + + # Validate template_language + if not template_language or not template_language.strip(): + return "email_template_language cannot be null or empty" + + return None + + +class DirectShareConfigValidator: + """ + Validator for Direct Share configuration. + + This class provides validation for DirectShareConfiguration objects. + """ + + def __init__(self): + """Private constructor to prevent instantiation.""" + raise TypeError("This is a utility class and cannot be instantiated") + + @staticmethod + def validate(config: Optional[DirectShareConfiguration]) -> Optional[str]: + """ + Validates a DirectShareConfiguration. + + Args: + config: The configuration to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if config is None: + return "Direct share configuration cannot be null when DIRECT_SHARE channel is specified" + + # Add additional direct share specific validations here as needed + # For now, the basic structure validation is done by Pydantic models + + return None + + +class InternalEmailConfigValidator: + """ + Validator for Internal Email configuration. + + This class provides validation for EmailConfiguration objects. + """ + + def __init__(self): + """Private constructor to prevent instantiation.""" + raise TypeError("This is a utility class and cannot be instantiated") + + @staticmethod + def validate(config: Optional[EmailConfiguration]) -> Optional[str]: + """ + Validates an EmailConfiguration. + + Args: + config: The configuration to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if config is None: + return "Email configuration cannot be null when EMAIL channel is specified" + + # Validate recipients + if not config.to or len(config.to) == 0: + return "At least one recipient is required in email configuration" + + # Validate email addresses in recipients list + for recipient in config.to: + if not recipient or not recipient.strip(): + return "Email recipient cannot be null or empty" + + # Validate CC recipients if present + if config.cc: + for recipient in config.cc: + if not recipient or not recipient.strip(): + return "Email CC recipient cannot be null or empty" + + # Validate BCC recipients if present + if config.bcc: + for recipient in config.bcc: + if not recipient or not recipient.strip(): + return "Email BCC recipient cannot be null or empty" + + return None \ No newline at end of file From 477e98b930c150f0a7f3cb773e35bbf4ebf3e3c6 Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Sun, 17 May 2026 12:37:24 +0530 Subject: [PATCH 02/14] updated workaround --- .../outputmanagement/config/destination_credential_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py index 611bd85d..c24b65be 100644 --- a/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py +++ b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py @@ -64,7 +64,7 @@ def get_destination(self): """ from ...destination import create_client, AccessStrategy logger.info(f"Retrieving destination '{self.destination_name}'") - client = create_client() + client = create_client(instance="ariba-sourcing-event-instance") if self.access_strategy: if self.access_strategy == "PROVIDER_ONLY": strategy = AccessStrategy.PROVIDER_ONLY From 0e0bdfdb9bb15e684990b7644438187bbcfc270b Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Sun, 17 May 2026 17:54:42 +0530 Subject: [PATCH 03/14] updated workaround --- .../outputmanagement/clients/output_requests_client_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py index 61b88bf2..b215a91d 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py @@ -296,7 +296,7 @@ def _fetch_oauth_token_from_destination(self) -> Optional[str]: import base64 from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption - certificate_client = create_certificate_client() + certificate_client = create_certificate_client(instance="ariba-sourcing-event-instance") cert = certificate_client.get_subaccount_certificate(cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY) # Check if certificate was found From 21ec7d5d47d864605b18f95e8ebee819ab33270b Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Mon, 18 May 2026 12:04:19 +0530 Subject: [PATCH 04/14] updated workaround --- .../outputmanagement/clients/output_requests_client_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py index b215a91d..1c596eb8 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py @@ -48,7 +48,7 @@ def __init__( self._destination = destination # Get sender-provider-subaccount-id from environment variable - self._sender_provider_subaccount_id = os.getenv("SENDER_PROVIDER_SUBACCOUNT_ID") + self._sender_provider_subaccount_id = os.getenv("APPFND_CONHOS_SUBACCOUNTID") if self._sender_provider_subaccount_id: logger.info(f"Loaded SENDER_PROVIDER_SUBACCOUNT_ID: {self._sender_provider_subaccount_id}") else: From 78ce649e914a32277de0595c1f475194cb306127 Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Wed, 20 May 2026 21:01:55 +0530 Subject: [PATCH 05/14] updated workaround --- .../outputmanagement/clients/email_client.py | 129 +++++++++++------- 1 file changed, 82 insertions(+), 47 deletions(-) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py index 75806c25..6bf1c655 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -19,6 +19,82 @@ class EmailClient: minimal information: template key, recipients, business document, and destination. """ + def create_output_request( + self, + notification_template_key: str, + to: List[str], + business_document: Dict[str, Any], + cc: Optional[List[str]] = None, + template_language: str = "en" + ) -> OutputRequest: + """ + Create an OutputRequest object from the provided parameters. + + This method handles all the complexity of building the CloudEvents structure, + extracting document metadata, and configuring email settings. + + Args: + notification_template_key: ANS template identifier + to: List of recipient email addresses + business_document: The business document as a dictionary + cc: Optional list of CC email addresses + template_language: ISO language code for email template + + Returns: + OutputRequest: Fully constructed output request ready to send + """ + # Extract document type and ID from business document + # Assuming the first key in business_document is the document type + doc_type_key = next(iter(business_document.keys())) + doc_content = business_document[doc_type_key] + + # Try to extract document ID from common field names + doc_id = None + for id_field in ['id', 'orderId', 'invoiceNumber', 'documentId', 'number']: + if id_field in doc_content: + doc_id = str(doc_content[id_field]) + break + + # If no ID found, use template key as fallback + if not doc_id: + doc_id = f"{notification_template_key}-{id(business_document)}" + + # Generate business document type from the key + business_document_type = f"com.sap.{doc_type_key.lower()}" + + # Build email configuration + email_config = EmailConfiguration( + email_notification_template_key=notification_template_key, + email_template_language=template_language, + to=to, + cc=cc + ) + + # Build output management info + output_mgmt = OutputManagementInfo( + business_document_type=business_document_type, + business_document_id=doc_id, + is_priority=False, + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + + # Build request data (OutputManagement + BusinessDocument) + data = OutputRequestData( + output_management=output_mgmt, + business_document=business_document + ) + + # Build output request (CloudEvents structure) + # Source format must be /region/application/tenant per CloudEvents spec + output_request = OutputRequest( + source=f"/region/sap/{doc_type_key}", + type=f"{business_document_type}.notification.created.v1", + data=data + ) + + return output_request + def send_email( self, notification_template_key: str, @@ -108,54 +184,13 @@ def send_email( # Import here to avoid circular import at module initialization from ..client_provider import OutputManagementServiceClientProviderBuilder - # Extract document type and ID from business document - # Assuming the first key in business_document is the document type - doc_type_key = next(iter(business_document.keys())) - doc_content = business_document[doc_type_key] - - # Try to extract document ID from common field names - doc_id = None - for id_field in ['id', 'orderId', 'invoiceNumber', 'documentId', 'number']: - if id_field in doc_content: - doc_id = str(doc_content[id_field]) - break - - # If no ID found, use template key as fallback - if not doc_id: - doc_id = f"{notification_template_key}-{id(business_document)}" - - # Generate business document type from the key - business_document_type = f"com.sap.{doc_type_key.lower()}" - - # Build email configuration - email_config = EmailConfiguration( - email_notification_template_key=notification_template_key, - email_template_language=template_language, + # Create the output request using the extracted method + output_request = self.create_output_request( + notification_template_key=notification_template_key, to=to, - cc=cc - ) - - # Build output management info - output_mgmt = OutputManagementInfo( - business_document_type=business_document_type, - business_document_id=doc_id, - is_priority=False, - channels=[Channel.INTERNAL_EMAIL], - email_configuration=email_config - ) - - # Build request data (OutputManagement + BusinessDocument) - data = OutputRequestData( - output_management=output_mgmt, - business_document=business_document - ) - - # Build output request (CloudEvents structure) - # Source format must be /region/application/tenant per CloudEvents spec - output_request = OutputRequest( - source=f"/region/sap/{doc_type_key}", - type=f"{business_document_type}.notification.created.v1", - data=data + business_document=business_document, + cc=cc, + template_language=template_language ) # Validate the output request using RequestValidator From ef838863ae3c1e070fa19f2ee2ec5a07f41acff0 Mon Sep 17 00:00:00 2001 From: I555296 Date: Fri, 22 May 2026 10:45:54 +0530 Subject: [PATCH 06/14] adding a logger to test access to this repo --- src/sap_cloud_sdk/outputmanagement/clients/email_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py index 6bf1c655..39cbaaed 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -10,6 +10,7 @@ from ..constants import Channel from ..utils.request_validator import RequestValidator +logger = logging.getLogger(__name__) class EmailClient: """ @@ -223,4 +224,5 @@ def send_email( return output_requests_client.send_output_request(output_request) except Exception as e: + logger.error(f"failed: {e}") raise Exception(f"Failed to send email via destination '{destination_name}': {str(e)}") from e \ No newline at end of file From ad3c8d14b7061359f8a912e0d6576062ed3ee8d1 Mon Sep 17 00:00:00 2001 From: I555296 Date: Fri, 22 May 2026 14:51:15 +0530 Subject: [PATCH 07/14] adding a logger to test access to this repo --- src/sap_cloud_sdk/outputmanagement/clients/email_client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py index 39cbaaed..6bf1c655 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -10,7 +10,6 @@ from ..constants import Channel from ..utils.request_validator import RequestValidator -logger = logging.getLogger(__name__) class EmailClient: """ @@ -224,5 +223,4 @@ def send_email( return output_requests_client.send_output_request(output_request) except Exception as e: - logger.error(f"failed: {e}") raise Exception(f"Failed to send email via destination '{destination_name}': {str(e)}") from e \ No newline at end of file From b817b564d96b9483ca7beebec05f659317aaeb6c Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Thu, 11 Jun 2026 18:04:36 +0530 Subject: [PATCH 08/14] removed harcoded values --- .../outputmanagement/__init__.py | 4 - src/sap_cloud_sdk/outputmanagement/client.py | 4 + .../outputmanagement/client_provider.py | 4 +- .../outputmanagement/clients/email_client.py | 18 ++- .../clients/output_requests_client.py | 62 ------- .../clients/output_requests_client_impl.py | 151 +++--------------- .../config/destination_credential_config.py | 20 ++- .../models/output_response.py | 87 +--------- .../utils/request_validator.py | 35 ---- 9 files changed, 61 insertions(+), 324 deletions(-) diff --git a/src/sap_cloud_sdk/outputmanagement/__init__.py b/src/sap_cloud_sdk/outputmanagement/__init__.py index 812b9ff8..bc90bb3b 100644 --- a/src/sap_cloud_sdk/outputmanagement/__init__.py +++ b/src/sap_cloud_sdk/outputmanagement/__init__.py @@ -11,8 +11,6 @@ from .models.output_request import OutputRequest, OutputRequestBuilder from .models.output_response import ( OutputResponse, - OutputRequestStatusResponse, - DocumentResponse, ) from .models.email_configuration import EmailConfiguration from .models.attachment_config import AttachmentConfig @@ -45,8 +43,6 @@ "OutputRequest", "OutputRequestBuilder", "OutputResponse", - "OutputRequestStatusResponse", - "DocumentResponse", "EmailConfiguration", "AttachmentConfig", "OutputManagementInfo", diff --git a/src/sap_cloud_sdk/outputmanagement/client.py b/src/sap_cloud_sdk/outputmanagement/client.py index be03d0c1..d7ac4bd3 100644 --- a/src/sap_cloud_sdk/outputmanagement/client.py +++ b/src/sap_cloud_sdk/outputmanagement/client.py @@ -36,15 +36,18 @@ def __init__( self, base_url: str, destination: any = None, + destination_instance: str = None, ): """Initialize client. Args: base_url: Base URL of the service destination: Optional Cloud SDK destination object for making requests + destination_instance: Optional Destination Service instance name """ self._base_url = base_url.rstrip("/") self._destination = destination + self._destination_instance = destination_instance # Create a simple requests session self._session = requests.Session() @@ -54,6 +57,7 @@ def __init__( self._session, self._base_url, self._destination, + self._destination_instance, ) logger.info(f"Initialized Output Management Service client for {base_url}") diff --git a/src/sap_cloud_sdk/outputmanagement/client_provider.py b/src/sap_cloud_sdk/outputmanagement/client_provider.py index ddccaab5..639a48ca 100644 --- a/src/sap_cloud_sdk/outputmanagement/client_provider.py +++ b/src/sap_cloud_sdk/outputmanagement/client_provider.py @@ -78,11 +78,13 @@ def build(self) -> OutputManagementServiceClientProvider: base_url = self._destination_credential_config.get_base_url() logger.info(f"Retrieved destination base URL: {base_url}") - # Build client with destination object + # Build client with destination object and instance name # The destination object handles auth automatically + # Use the same instance for both destination fetch and certificate retrieval client = OutputManagementServiceDefaultClient( base_url=base_url, destination=http_destination, + destination_instance=self._destination_credential_config.instance, ) logger.info("Built Output Management Service client provider") diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py index 6bf1c655..012bd213 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -103,7 +103,8 @@ def send_email( destination_name: str, cc: Optional[List[str]] = None, template_language: str = "en", - access_strategy: str = "PROVIDER_ONLY" + access_strategy: str = "PROVIDER_ONLY", + instance: Optional[str] = None ) -> OutputResponse: """ Send an email using the SAP Ariba Output Service. @@ -119,6 +120,7 @@ def send_email( cc: Optional list of CC email addresses template_language: ISO language code for email template (default: "en") access_strategy: Destination access strategy - "PROVIDER_ONLY" or "SUBSCRIBER_ONLY" (default: "PROVIDER_ONLY") + instance: Destination service instance name (defaults to "default" if not provided) Returns: OutputResponse: Response from the output service @@ -183,6 +185,12 @@ def send_email( try: # Import here to avoid circular import at module initialization from ..client_provider import OutputManagementServiceClientProviderBuilder + import logging + logger = logging.getLogger(__name__) + + # Resolve instance name for logging + inst = instance or "default" + logger.info(f"Sending email via destination '{destination_name}' using instance '{inst}'") # Create the output request using the extracted method output_request = self.create_output_request( @@ -192,10 +200,12 @@ def send_email( cc=cc, template_language=template_language ) + logger.debug(f"Created output request for template '{notification_template_key}'") # Validate the output request using RequestValidator validation_error = RequestValidator.validate(output_request) if validation_error: + logger.error(f"Output request validation failed: {validation_error}") return OutputResponse( output_request_id=None, error=ErrorResponse( @@ -204,10 +214,12 @@ def send_email( ) ) - # Create destination config with access strategy + # Create destination config with access strategy and instance + logger.debug(f"Creating destination config with access_strategy='{access_strategy}', instance='{inst}'") destination_config = DestinationCredentialConfig( destination_name=destination_name, - access_strategy=access_strategy + access_strategy=access_strategy, + instance=instance ) # Build the client provider using the existing builder diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py index 3d2eb085..b0d0a8cd 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py @@ -6,8 +6,6 @@ from ..models.output_request import OutputRequest from ..models.output_response import ( OutputResponse, - JobStatusResponse, - DocumentResponse, ) logger = logging.getLogger(__name__) @@ -42,17 +40,6 @@ class OutputRequestsClient(ABC): else: request_id = response.output_request_id print(f"Request ID: {request_id}") - - # Check status - status = requests_client.get_output_request_status(request_id) - if not status.errors: - print(f"Status: {status.created_at}") - - # Get document - document_response = requests_client.get_document("DIRECT_SHARE", request_id) - if not document_response.errors: - document = document_response.document_content - print(f"Document size: {len(document)}") """ @abstractmethod @@ -81,53 +68,4 @@ def send_output_request(self, output_request: OutputRequest) -> OutputResponse: """ pass - @abstractmethod - def get_output_request_status(self, request_id: str) -> JobStatusResponse: - """ - Retrieves the status of a previously submitted output request. - - Use this method to check the processing status of an output request after submission. - The response contains detailed information about the request processing state. - - Common Status Values: - - PENDING - Request is queued for processing - - PROCESSING - Document generation in progress - - COMPLETED - Document successfully generated and delivered - - FAILED - Processing failed (check error details) - - Note: This method does not raise exceptions. Check the response's errors field - to determine if the operation failed. - - Args: - request_id: The ID of the request to check - - Returns: - JobStatusResponse containing request details if successful, or error details if failed - """ - pass - @abstractmethod - def get_document(self, channel: str, output_request_id: str) -> DocumentResponse: - """ - Retrieves a generated document from the Output Management service. - - This method downloads the binary content of a document that was generated - as part of an output request. The document must be available (request status = COMPLETED) - before it can be retrieved. - - Supported Channels: - - DIRECT_SHARE - Documents stored for direct download - - EMAIL - Attachments from email deliveries (if accessible) - - PRINT - Print-ready documents - - Note: This method does not raise exceptions. Check the response's errors field - to determine if the operation failed. - - Args: - channel: The delivery channel (e.g., "DIRECT_SHARE") - output_request_id: The output request ID - - Returns: - DocumentResponse containing the document content if successful, or error details if failed - """ - pass \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py index 1c596eb8..e1c3e736 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py @@ -11,9 +11,6 @@ from ..models.output_request import OutputRequest from ..models.output_response import ( OutputResponse, - JobStatusResponse, - OutputRequestStatusResponse, - DocumentResponse, ) from ..constants import Constants from ..utils.request_validator import RequestValidator @@ -34,6 +31,7 @@ def __init__( http_session: requests.Session, base_url: str, destination: any = None, + destination_instance: str = None, ): """ Constructs a new OutputRequestsClientImpl. @@ -42,10 +40,12 @@ def __init__( http_session: The requests Session for making HTTP requests base_url: The base URL of the Output Management service destination: Optional Cloud SDK destination object for making authenticated requests + destination_instance: Optional Destination Service instance name (defaults to "default") """ self._http_session = http_session self._base_url = base_url.rstrip("/") self._destination = destination + self._destination_instance = destination_instance # Get sender-provider-subaccount-id from environment variable self._sender_provider_subaccount_id = os.getenv("APPFND_CONHOS_SUBACCOUNTID") @@ -99,7 +99,7 @@ def send_output_request(self, output_request: OutputRequest) -> OutputResponse: response_data = response.json() request_id = response_data.get("requestId") logger.info(f"Request submitted successfully with ID: {request_id}") - return OutputResponse(output_request_id=request_id, errors=None) + return OutputResponse(output_request_id=request_id, error=None) # Handle error responses response_body = response.text @@ -122,116 +122,6 @@ def send_output_request(self, output_request: OutputRequest) -> OutputResponse: f"Failed to send output request: {str(e)}" ) - def get_output_request_status(self, request_id: str) -> JobStatusResponse: - """Retrieves the status of a previously submitted output request.""" - logger.info(f"Getting status for request: {request_id}") - - validation_error = RequestValidator.validate_job_status_request(request_id) - if validation_error: - logger.error(f"Validation failed for output request status: {validation_error}") - return self._create_status_error_response( - "INVALID_REQUEST", - validation_error - ) - - endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}outputRequest/{request_id}" - logger.debug(f"Endpoint: {endpoint}") - - headers = self._get_headers() - headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_JSON - - # Add sender-provider-subaccount-id header if available - if self._sender_provider_subaccount_id: - headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id - logger.debug(f"Added sender-provider-subaccount-id header for get status") - - try: - response = self._execute_request('GET', endpoint, headers=headers) - status_code = response.status_code - - logger.debug(f"Response status: {status_code}") - - if status_code == 200: - response_data = response.json() - status_response = OutputRequestStatusResponse(**response_data) - logger.info("Status retrieved successfully") - return JobStatusResponse(status=status_response, errors=None) - - # Handle error responses - response_body = response.text - if self._is_retryable(status_code): - logger.error(f"Retryable error with status: {status_code}, body: {response_body}") - else: - logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") - - error_type = self._map_status_code_to_error(status_code) - if error_type: - return self._create_status_error_response(error_type, status_code) - else: - logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") - return self._create_status_error_response(status_code, response_body) - - except Exception as e: - logger.error(f"Exception occurred: {e}", exc_info=True) - return self._create_status_error_response( - f"Failed to get output request status: {str(e)}" - ) - - def get_document(self, channel: str, output_request_id: str) -> DocumentResponse: - """Retrieves a generated document from the Output Management service.""" - logger.info(f"Getting document for request: {output_request_id}, channel: {channel}") - - validation_error = RequestValidator.validate_get_document_request(channel, output_request_id) - if validation_error: - logger.error(f"Validation failed for get document: {validation_error}") - return self._create_document_error_response( - "INVALID_REQUEST", - validation_error - ) - - endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}document/{channel}" - logger.debug(f"Endpoint: {endpoint}") - - headers = self._get_headers() - headers[Constants.HEADER_CONTENT_TYPE] = Constants.CONTENT_TYPE_JSON - headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_PDF - - # Add sender-provider-subaccount-id header if available - if self._sender_provider_subaccount_id: - headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id - logger.debug(f"Added sender-provider-subaccount-id header for get document") - - try: - response = self._execute_request('POST', endpoint, data=output_request_id, headers=headers) - status_code = response.status_code - - logger.debug(f"Response status: {status_code}") - - if status_code == 200: - document = response.content - logger.info(f"Document retrieved successfully, size: {len(document)} bytes") - return DocumentResponse(document_content=document, errors=None) - - # Handle error responses - response_body = response.text - if self._is_retryable(status_code): - logger.error(f"Retryable error with status: {status_code}, body: {response_body}") - else: - logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") - - error_type = self._map_status_code_to_error(status_code) - if error_type: - return self._create_document_error_response(error_type, status_code) - else: - logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") - return self._create_document_error_response(status_code, response_body) - - except Exception as e: - logger.error(f"Exception occurred: {e}", exc_info=True) - return self._create_document_error_response( - f"Failed to get document: {str(e)}" - ) - def _fetch_oauth_token_from_destination(self) -> Optional[str]: """Fetch OAuth token using destination's OAuth configuration with mTLS. @@ -296,7 +186,19 @@ def _fetch_oauth_token_from_destination(self) -> Optional[str]: import base64 from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption - certificate_client = create_certificate_client(instance="ariba-sourcing-event-instance") + # Resolve instance name: use provided value or default to "default" (following DMS pattern) + inst = self._destination_instance or "default" + logger.info(f"✓ Creating certificate client for instance '{inst}'") + + try: + certificate_client = create_certificate_client(instance=inst) + logger.info(f"✓ Certificate client created successfully for instance '{inst}'") + except Exception as e: + logger.error(f"✗ Failed to create certificate client for instance '{inst}': {e}") + logger.error("✗ Ensure the Destination Service is properly bound and configured") + return None + + logger.info(f"✓ Retrieving certificate '{cert_name}' from Destination Service") cert = certificate_client.get_subaccount_certificate(cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY) # Check if certificate was found @@ -527,25 +429,10 @@ def _map_status_code_to_error(status_code: int) -> Optional[str]: @staticmethod def _create_output_error_response(error_type, message) -> OutputResponse: """Create an OutputResponse with error information.""" + from ..models.output_response import ErrorResponse return OutputResponse( output_request_id=None, - errors=[{"type": error_type, "message": str(message)}] + error=ErrorResponse(message=str(message), code=error_type) ) - @staticmethod - def _create_status_error_response(error_type_or_message, status_code=None) -> JobStatusResponse: - """Create a JobStatusResponse with error information.""" - if status_code: - error_msg = {"type": error_type_or_message, "status_code": status_code} - else: - error_msg = {"message": error_type_or_message} - return JobStatusResponse(status=None, errors=[error_msg]) - @staticmethod - def _create_document_error_response(error_type_or_message, status_code=None) -> DocumentResponse: - """Create a DocumentResponse with error information.""" - if status_code: - error_msg = {"type": error_type_or_message, "status_code": status_code} - else: - error_msg = {"message": error_type_or_message} - return DocumentResponse(document_content=None, errors=[error_msg]) \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py index c24b65be..fc32ae90 100644 --- a/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py +++ b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py @@ -31,6 +31,10 @@ class DestinationCredentialConfig(BaseModel): default=None, description="Access strategy: 'PROVIDER_ONLY' or 'SUBSCRIBER_ONLY' (optional)" ) + instance: Optional[str] = Field( + default=None, + description="Destination service instance name (defaults to 'default' if not provided)" + ) @field_validator("destination_name") @classmethod def validate_destination_name(cls, v: str) -> str: @@ -60,11 +64,23 @@ def get_destination(self): Returns: Destination object with URL, authentication, and properties Raises: + ValueError: If destination is not found Exception: If destination retrieval fails """ from ...destination import create_client, AccessStrategy - logger.info(f"Retrieving destination '{self.destination_name}'") - client = create_client(instance="ariba-sourcing-event-instance") + + # Resolve instance name: use provided value or default to "default" + inst = self.instance or "default" + logger.info(f"Retrieving destination '{self.destination_name}' from instance '{inst}'") + + try: + client = create_client(instance=inst) + except Exception as e: + logger.error(f"Failed to create destination client for instance '{inst}': {e}") + raise ValueError( + f"Failed to create destination client for instance '{inst}'. " + f"Ensure the Destination Service is properly bound and configured." + ) from e if self.access_strategy: if self.access_strategy == "PROVIDER_ONLY": strategy = AccessStrategy.PROVIDER_ONLY diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_response.py b/src/sap_cloud_sdk/outputmanagement/models/output_response.py index 5393355b..11ce3b22 100644 --- a/src/sap_cloud_sdk/outputmanagement/models/output_response.py +++ b/src/sap_cloud_sdk/outputmanagement/models/output_response.py @@ -1,10 +1,9 @@ """Output response model.""" -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any from pydantic import BaseModel, Field - class ErrorResponse(BaseModel): """Error response model.""" @@ -17,6 +16,7 @@ class Config: str_strip_whitespace = True + class OutputResponse(BaseModel): """Output response wrapper. @@ -35,86 +35,3 @@ class Config: """Pydantic configuration.""" populate_by_name = True - -class OutputRequestChannelResponse(BaseModel): - """Output request channel response.""" - - channel: str = Field(..., description="Channel name") - status: str = Field(..., description="Channel status") - error_message: Optional[str] = Field( - None, - alias="errorMessage", - description="Error message if any" - ) - - class Config: - """Pydantic configuration.""" - - populate_by_name = True - -class OutputRequestStatusResponse(BaseModel): - """Output request status response.""" - - request_id: str = Field(..., alias="requestId", description="Request identifier") - business_document_id: Optional[str] = Field( - None, - alias="businessDocumentId", - description="Business document identifier" - ) - business_document_type: Optional[str] = Field( - None, - alias="businessDocumentType", - description="Business document type" - ) - created_at: str = Field(..., alias="createdAt", description="Creation timestamp") - channels: Optional[List[OutputRequestChannelResponse]] = Field( - None, - description="Channel responses" - ) - error_message: Optional[str] = Field( - None, - alias="errorMessage", - description="Error message" - ) - - class Config: - """Pydantic configuration.""" - - populate_by_name = True - - -class DocumentResponse(BaseModel): - """Document response wrapper. - - Contains either the document content or an error response. - """ - - document_content: Optional[bytes] = Field( - None, - alias="documentContent", - description="Binary document content" - ) - error: Optional[ErrorResponse] = Field(None, description="Error response") - - class Config: - """Pydantic configuration.""" - - populate_by_name = True - -class JobStatusResponse(BaseModel): - """Job status response wrapper. - - Contains either the output request status response or an error response. - """ - - output_request_status_response: Optional[OutputRequestStatusResponse] = Field( - None, - alias="outputRequestStatusResponse", - description="Output request status response" - ) - error: Optional[ErrorResponse] = Field(None, description="Error response") - - class Config: - """Pydantic configuration.""" - - populate_by_name = True diff --git a/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py b/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py index 8d180f89..5553cb43 100644 --- a/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py +++ b/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py @@ -149,41 +149,6 @@ def _validate_channels(channels: List[Channel]) -> Optional[str]: return None - @staticmethod - def validate_job_status_request(output_request_id: str) -> Optional[str]: - """ - Validates job status request parameters. - - Args: - output_request_id: The output request ID to validate - - Returns: - Optional error message if validation fails, None if valid - """ - if not output_request_id or not output_request_id.strip(): - return "OutputRequestId id cannot be null or empty" - return None - - @staticmethod - def validate_get_document_request(channel: str, output_request_id: str) -> Optional[str]: - """ - Validates get document request parameters. - - Args: - channel: The channel name - output_request_id: The output request ID - - Returns: - Optional error message if validation fails, None if valid - """ - if not channel or not channel.strip(): - return "Channel cannot be null or empty" - - if not output_request_id or not output_request_id.strip(): - return "Output Request ID cannot be null or empty" - - return None - @staticmethod def validate_email_parameters( notification_template_key: str, From 8e1da26acbd022fd38df5998818b33f86b6d314e Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Fri, 12 Jun 2026 09:48:29 +0530 Subject: [PATCH 09/14] added pre generated attachment section --- .../outputmanagement/models/__init__.py | 4 +- .../models/attachment_config.py | 52 +++++++++++---- .../models/email_configuration.py | 2 +- .../models/pre_generated_attachment.py | 66 +++++++++++++++++++ 4 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 src/sap_cloud_sdk/outputmanagement/models/pre_generated_attachment.py diff --git a/src/sap_cloud_sdk/outputmanagement/models/__init__.py b/src/sap_cloud_sdk/outputmanagement/models/__init__.py index 756a0688..6c7a3791 100644 --- a/src/sap_cloud_sdk/outputmanagement/models/__init__.py +++ b/src/sap_cloud_sdk/outputmanagement/models/__init__.py @@ -6,6 +6,7 @@ from .output_response import OutputResponse from .email_configuration import EmailConfiguration from .attachment_config import AttachmentConfig +from .pre_generated_attachment import PreGeneratedAttachment from .direct_share_configuration import DirectShareConfiguration from .form_configuration import FormConfiguration @@ -17,6 +18,7 @@ "OutputResponse", "EmailConfiguration", "AttachmentConfig", + "PreGeneratedAttachment", "DirectShareConfiguration", "FormConfiguration", -] \ No newline at end of file +] diff --git a/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py b/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py index 750b941d..cf382576 100644 --- a/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py +++ b/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py @@ -1,26 +1,25 @@ """Attachment configuration model for email documents.""" -from typing import Optional +from typing import Optional, List from pydantic import BaseModel, Field from .form_configuration import FormConfiguration +from .pre_generated_attachment import PreGeneratedAttachment class AttachmentConfig(BaseModel): """ Attachment configuration for email documents. - This is a helper class used to parse attachment configuration from INTERNAL_EMAIL requests. - It contains form configuration details that will be used to populate FormConfiguration - for document generation. - - If provided in EmailConfiguration, a PDF document will be generated using these form details - and attached to the email. If not provided, no document will be generated. + This class supports two types of attachments: + 1. Generated attachments: PDF documents generated from form templates + 2. Pre-generated attachments: Existing documents from external systems (e.g., DMS) Attributes: - form_configuration: Form configuration for PDF generation + form_configuration: Form configuration for PDF generation (optional) + pre_generated_attachments: List of pre-generated attachments from external sources (optional) - Example: + Example - Generated Attachment: ```python from sap_cloud_sdk.outputmanagement.models.attachment_config import AttachmentConfig from sap_cloud_sdk.outputmanagement.models.form_configuration import FormConfiguration @@ -35,15 +34,44 @@ class AttachmentConfig(BaseModel): attachment = AttachmentConfig(form_configuration=form_config) ``` + + Example - Pre-generated Attachment from DMS: + ```python + from sap_cloud_sdk.outputmanagement.models.attachment_config import AttachmentConfig + from sap_cloud_sdk.outputmanagement.models.pre_generated_attachment import PreGeneratedAttachment + + pre_gen_attachment = PreGeneratedAttachment( + url="https://dms.example.com/browser/root?objectId=12345&cmisselector=content", + source="DMS" + ) + + attachment = AttachmentConfig( + pre_generated_attachments=[pre_gen_attachment] + ) + ``` + + Example - Both Types: + ```python + attachment = AttachmentConfig( + form_configuration=form_config, + pre_generated_attachments=[pre_gen_attachment] + ) + ``` """ - form_configuration: FormConfiguration = Field( - ..., + form_configuration: Optional[FormConfiguration] = Field( + None, alias="formConfiguration", description="Form configuration for PDF generation" ) + + pre_generated_attachments: Optional[List[PreGeneratedAttachment]] = Field( + None, + alias="preGeneratedAttachments", + description="List of pre-generated attachments from external sources like DMS" + ) class Config: """Pydantic configuration.""" populate_by_name = True - str_strip_whitespace = True \ No newline at end of file + str_strip_whitespace = True diff --git a/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py b/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py index 92e3331a..a727f438 100644 --- a/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py +++ b/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py @@ -93,7 +93,7 @@ class EmailConfiguration(BaseModel): attachment: Optional[AttachmentConfig] = Field( None, - description="Optional attachment configuration for PDF generation" + description="Optional attachment configuration for PDF generation and pre-generated attachments" ) @field_validator("to", "cc", "bcc") diff --git a/src/sap_cloud_sdk/outputmanagement/models/pre_generated_attachment.py b/src/sap_cloud_sdk/outputmanagement/models/pre_generated_attachment.py new file mode 100644 index 00000000..a894afdf --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/pre_generated_attachment.py @@ -0,0 +1,66 @@ +"""Pre-generated attachment model for email attachments from external sources like DMS.""" + +from typing import Literal +from pydantic import BaseModel, Field, field_validator + + +class PreGeneratedAttachment(BaseModel): + """ + Pre-generated attachment configuration for email attachments from external sources. + + This class represents an attachment that already exists in an external system (like DMS) + and should be attached to the email by reference via URL. + + Attributes: + url: The URL to access the pre-generated attachment (required) + source: The source system of the attachment, currently only "DMS" is supported (required) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.pre_generated_attachment import PreGeneratedAttachment + + attachment = PreGeneratedAttachment( + url="https://dms.example.com/browser/root?objectId=12345&cmisselector=content", + source="DMS" + ) + ``` + """ + + url: str = Field( + ..., + min_length=1, + description="The URL to access the pre-generated attachment" + ) + + source: Literal["DMS"] = Field( + ..., + description="The source system of the attachment (currently only 'DMS' is supported)" + ) + + @field_validator("url") + @classmethod + def validate_url(cls, v: str) -> str: + """Validate that URL is not empty and is a valid URL format.""" + if not v or not v.strip(): + raise ValueError("URL cannot be empty") + + v = v.strip() + + # Basic URL validation - must start with http:// or https:// + if not (v.startswith("http://") or v.startswith("https://")): + raise ValueError("URL must start with http:// or https://") + + return v + + @field_validator("source") + @classmethod + def validate_source(cls, v: str) -> str: + """Validate that source is 'DMS'.""" + if v != "DMS": + raise ValueError("Currently only 'DMS' is supported as attachment source") + return v + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True \ No newline at end of file From 6bfcf9d115cc65447dfe397665a3946841e7804b Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Fri, 12 Jun 2026 10:02:49 +0530 Subject: [PATCH 10/14] added pre generated attachment section --- .../outputmanagement/clients/email_client.py | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py index 012bd213..15703ea2 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -25,7 +25,8 @@ def create_output_request( to: List[str], business_document: Dict[str, Any], cc: Optional[List[str]] = None, - template_language: str = "en" + template_language: str = "en", + attachment_urls: Optional[List[str]] = None ) -> OutputRequest: """ Create an OutputRequest object from the provided parameters. @@ -39,6 +40,7 @@ def create_output_request( business_document: The business document as a dictionary cc: Optional list of CC email addresses template_language: ISO language code for email template + attachment_urls: Optional list of DMS URLs for pre-generated attachments Returns: OutputRequest: Fully constructed output request ready to send @@ -62,12 +64,29 @@ def create_output_request( # Generate business document type from the key business_document_type = f"com.sap.{doc_type_key.lower()}" + # Build attachment config if URLs are provided + attachment_config = None + if attachment_urls: + from ..models.attachment_config import AttachmentConfig + from ..models.pre_generated_attachment import PreGeneratedAttachment + + # Convert URLs to PreGeneratedAttachment objects + pre_gen_attachments = [ + PreGeneratedAttachment(url=url, source="DMS") + for url in attachment_urls + ] + + attachment_config = AttachmentConfig( + pre_generated_attachments=pre_gen_attachments + ) + # Build email configuration email_config = EmailConfiguration( email_notification_template_key=notification_template_key, email_template_language=template_language, to=to, - cc=cc + cc=cc, + attachment=attachment_config ) # Build output management info @@ -104,7 +123,8 @@ def send_email( cc: Optional[List[str]] = None, template_language: str = "en", access_strategy: str = "PROVIDER_ONLY", - instance: Optional[str] = None + instance: Optional[str] = None, + attachment_urls: Optional[List[str]] = None ) -> OutputResponse: """ Send an email using the SAP Ariba Output Service. @@ -121,6 +141,7 @@ def send_email( template_language: ISO language code for email template (default: "en") access_strategy: Destination access strategy - "PROVIDER_ONLY" or "SUBSCRIBER_ONLY" (default: "PROVIDER_ONLY") instance: Destination service instance name (defaults to "default" if not provided) + attachment_urls: Optional list of DMS URLs for pre-generated attachments (default: None) Returns: OutputResponse: Response from the output service @@ -129,13 +150,12 @@ def send_email( ValueError: If required parameters are invalid Exception: If the email sending fails - Example: + Example - Simple Email: ```python from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient client = EmailClient() - # Just provide essentials - that's it! response = client.send_email( notification_template_key="PO_APPROVAL_NOTIFICATION", to=["finance@company.com"], @@ -154,6 +174,20 @@ def send_email( else: print(f"Success: {response.output_request_id}") ``` + + Example - Email with DMS Attachments: + ```python + response = client.send_email( + notification_template_key="PO_APPROVAL_NOTIFICATION", + to=["finance@company.com"], + business_document={"PurchaseOrder": {"orderId": "PO-12345"}}, + destination_name="ARIBA_OUTPUT_SERVICE", + attachment_urls=[ + "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", + "https://dms.example.com/browser/root?objectId=67890&cmisselector=content" + ] + ) + ``` """ # Validate input parameters using RequestValidator validation_error = RequestValidator.validate_email_parameters( @@ -198,9 +232,14 @@ def send_email( to=to, business_document=business_document, cc=cc, - template_language=template_language + template_language=template_language, + attachment_urls=attachment_urls ) - logger.debug(f"Created output request for template '{notification_template_key}'") + + if attachment_urls: + logger.debug(f"Created output request for template '{notification_template_key}' with {len(attachment_urls)} DMS attachment(s)") + else: + logger.debug(f"Created output request for template '{notification_template_key}'") # Validate the output request using RequestValidator validation_error = RequestValidator.validate(output_request) From 68079abad242362b3d19ac4888390cca9be45252 Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Sun, 14 Jun 2026 17:50:26 +0530 Subject: [PATCH 11/14] added tests --- .../outputmanagement/user-guide.md | 684 ++++++++++++++++++ tests/outputmanagement/__init__.py | 2 + tests/outputmanagement/unit/__init__.py | 2 + tests/outputmanagement/unit/conftest.py | 67 ++ tests/outputmanagement/unit/test_basic.py | 84 +++ .../unit/test_comprehensive.py | 370 ++++++++++ tests/outputmanagement/unit/test_constants.py | 59 ++ .../outputmanagement/unit/test_exceptions.py | 81 +++ .../outputmanagement/unit/test_integration.py | 224 ++++++ tests/outputmanagement/unit/test_models.py | 242 +++++++ 10 files changed, 1815 insertions(+) create mode 100644 src/sap_cloud_sdk/outputmanagement/user-guide.md create mode 100644 tests/outputmanagement/__init__.py create mode 100644 tests/outputmanagement/unit/__init__.py create mode 100644 tests/outputmanagement/unit/conftest.py create mode 100644 tests/outputmanagement/unit/test_basic.py create mode 100644 tests/outputmanagement/unit/test_comprehensive.py create mode 100644 tests/outputmanagement/unit/test_constants.py create mode 100644 tests/outputmanagement/unit/test_exceptions.py create mode 100644 tests/outputmanagement/unit/test_integration.py create mode 100644 tests/outputmanagement/unit/test_models.py diff --git a/src/sap_cloud_sdk/outputmanagement/user-guide.md b/src/sap_cloud_sdk/outputmanagement/user-guide.md new file mode 100644 index 00000000..d3d562c6 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/user-guide.md @@ -0,0 +1,684 @@ +# SAP Cloud SDK for Python - Output Management Email Service User Guide + +## Overview + +The Output Management Email Service provides a simplified way to send emails through SAP Ariba Output Service. This guide focuses on the email sending functionality, which allows you to send notification emails with optional attachments using ANS (Ariba Notification Service) templates. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Quick Start](#quick-start) +3. [Basic Email Sending](#basic-email-sending) +4. [Email with DMS Attachments](#email-with-dms-attachments) +5. [Advanced Configuration](#advanced-configuration) +6. [Error Handling](#error-handling) +7. [Best Practices](#best-practices) +8. [API Reference](#api-reference) + +## Prerequisites + +### Required Setup + +1. **SAP BTP Destination**: Configure a destination in SAP BTP Destination Service pointing to your Output Management service +2. **ANS Template**: Create notification templates in Ariba Notification Service (ANS) +3. **Python Environment**: Python 3.11 or higher +4. **SAP Cloud SDK**: Install the SAP Cloud SDK for Python + +```bash +pip install sap-cloud-sdk +``` + +### Destination Configuration + +Your destination should be configured with: +- **Name**: e.g., `ARIBA_OUTPUT_SERVICE` +- **Type**: HTTP +- **URL**: Your Output Management service endpoint +- **Authentication**: OAuth2 with mTLS (client certificate) +- **Properties**: Include OAuth token service URL and certificate configuration + +## Quick Start + +Here's the simplest way to send an email: + +```python +from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + +# Initialize the email client +client = EmailClient() + +# Send a simple notification email +response = client.send_email( + notification_template_key="PO_APPROVAL_NOTIFICATION", + to=["finance@company.com"], + business_document={ + "PurchaseOrder": { + "orderId": "PO-12345", + "vendor": "ACME Corp", + "total": 1500.00 + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" +) + +# Check the result +if response.error: + print(f"Failed to send email: {response.error.message}") +else: + print(f"Email sent successfully! Request ID: {response.output_request_id}") +``` + +## Basic Email Sending + +### Simple Notification Email + +Send a notification email using an ANS template: + +```python +from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + +client = EmailClient() + +response = client.send_email( + notification_template_key="ORDER_CONFIRMATION", + to=["customer@example.com"], + business_document={ + "Order": { + "orderId": "ORD-789", + "customerName": "John Doe", + "orderDate": "2024-01-15", + "totalAmount": 2500.00 + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" +) +``` + +### Email with Multiple Recipients + +Send to multiple recipients with CC: + +```python +response = client.send_email( + notification_template_key="INVOICE_NOTIFICATION", + to=["customer@example.com", "billing@example.com"], + cc=["manager@example.com", "audit@example.com"], + business_document={ + "Invoice": { + "invoiceNumber": "INV-2024-001", + "amount": 5000.00, + "dueDate": "2024-02-15" + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" +) +``` + +### Email with Custom Language + +Specify the template language: + +```python +response = client.send_email( + notification_template_key="WELCOME_EMAIL", + to=["user@example.com"], + business_document={ + "User": { + "userId": "U12345", + "name": "Jane Smith" + } + }, + destination_name="ARIBA_OUTPUT_SERVICE", + template_language="de" # German template +) +``` + +## Email with DMS Attachments + +### Single DMS Attachment + +Attach a pre-generated document from DMS (Document Management Service): + +```python +response = client.send_email( + notification_template_key="CONTRACT_NOTIFICATION", + to=["legal@company.com"], + business_document={ + "Contract": { + "contractId": "CNT-2024-100", + "partyName": "Partner Corp" + } + }, + destination_name="ARIBA_OUTPUT_SERVICE", + attachment_urls=[ + "https://dms.example.com/browser/root?objectId=12345&cmisselector=content" + ] +) +``` + +### Multiple DMS Attachments + +Attach multiple documents from DMS: + +```python +response = client.send_email( + notification_template_key="REPORT_PACKAGE", + to=["management@company.com"], + business_document={ + "Report": { + "reportId": "RPT-Q1-2024", + "quarter": "Q1", + "year": 2024 + } + }, + destination_name="ARIBA_OUTPUT_SERVICE", + attachment_urls=[ + "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", + "https://dms.example.com/browser/root?objectId=67890&cmisselector=content", + "https://dms.example.com/browser/root?objectId=11111&cmisselector=content" + ] +) +``` + +## Advanced Configuration + +### Using Different Access Strategies + +Control how the destination is accessed: + +```python +# Provider-only access (default) +response = client.send_email( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + destination_name="ARIBA_OUTPUT_SERVICE", + access_strategy="PROVIDER_ONLY" +) + +# Subscriber-only access +response = client.send_email( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + destination_name="ARIBA_OUTPUT_SERVICE", + access_strategy="SUBSCRIBER_ONLY" +) +``` + +### Using Custom Destination Instance + +Specify a custom destination service instance: + +```python +response = client.send_email( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + destination_name="ARIBA_OUTPUT_SERVICE", + instance="my-custom-instance" +) +``` + +### Creating Output Request Separately + +For more control, create the output request object separately: + +```python +from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + +client = EmailClient() + +# Create the output request +output_request = client.create_output_request( + notification_template_key="CUSTOM_NOTIFICATION", + to=["recipient@example.com"], + business_document={ + "CustomDocument": { + "id": "DOC-456", + "type": "Important" + } + }, + cc=["supervisor@example.com"], + template_language="en", + attachment_urls=["https://dms.example.com/browser/root?objectId=999&cmisselector=content"] +) + +# Inspect or modify the request if needed +print(f"Request source: {output_request.source}") +print(f"Request type: {output_request.type}") + +# Send using the client provider +from sap_cloud_sdk.outputmanagement.client_provider import OutputManagementServiceClientProviderBuilder +from sap_cloud_sdk.outputmanagement.config.destination_credential_config import DestinationCredentialConfig + +config = DestinationCredentialConfig( + destination_name="ARIBA_OUTPUT_SERVICE", + access_strategy="PROVIDER_ONLY" +) + +provider = OutputManagementServiceClientProviderBuilder() \ + .with_destination_credentials(config) \ + .build() + +oms_client = provider.get_client() +output_client = oms_client.get_output_requests_client() +response = output_client.send_output_request(output_request) +``` + +## Error Handling + +### Basic Error Handling + +Always check for errors in the response: + +```python +response = client.send_email( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + destination_name="ARIBA_OUTPUT_SERVICE" +) + +if response.error: + print(f"Error Code: {response.error.code}") + print(f"Error Message: {response.error.message}") + if response.error.details: + print(f"Error Details: {response.error.details}") +else: + print(f"Success! Request ID: {response.output_request_id}") +``` + +### Handling Validation Errors + +Validation errors occur before the request is sent: + +```python +try: + response = client.send_email( + notification_template_key="", # Invalid: empty template key + to=[], # Invalid: no recipients + business_document={}, # Invalid: empty document + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + if response.error: + if response.error.code == "INVALID_REQUEST": + print(f"Validation failed: {response.error.message}") + else: + print(f"Request failed: {response.error.message}") + +except Exception as e: + print(f"Unexpected error: {str(e)}") +``` + +### Handling Network Errors + +Handle network and authentication errors: + +```python +try: + response = client.send_email( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + if response.error: + error_code = response.error.code + + if error_code == "AUTHENTICATION_FAILED": + print("Authentication failed. Check your destination configuration.") + elif error_code == "DESTINATION_NOT_FOUND": + print("Destination not found. Verify the destination name.") + elif error_code == "NETWORK_ERROR": + print("Network error. Check connectivity to the service.") + else: + print(f"Error: {response.error.message}") + +except Exception as e: + print(f"Fatal error: {str(e)}") +``` + +## Best Practices + +### 1. Reuse the Email Client + +Create the client once and reuse it: + +```python +# Good: Create once +client = EmailClient() + +for order in orders: + response = client.send_email( + notification_template_key="ORDER_CONFIRMATION", + to=[order.customer_email], + business_document={"Order": order.to_dict()}, + destination_name="ARIBA_OUTPUT_SERVICE" + ) +``` + +### 2. Validate Input Before Sending + +Validate your data before calling the API: + +```python +def send_order_confirmation(order): + # Validate input + if not order.customer_email: + raise ValueError("Customer email is required") + + if not order.order_id: + raise ValueError("Order ID is required") + + # Send email + client = EmailClient() + response = client.send_email( + notification_template_key="ORDER_CONFIRMATION", + to=[order.customer_email], + business_document={ + "Order": { + "orderId": order.order_id, + "total": order.total + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + return response +``` + +### 3. Use Meaningful Business Document IDs + +Ensure your business documents have identifiable IDs: + +```python +# Good: Clear, unique ID +business_document = { + "Invoice": { + "invoiceNumber": "INV-2024-001", # Clear identifier + "customerId": "CUST-12345", + "amount": 1000.00 + } +} + +# Avoid: Generic or missing IDs +business_document = { + "Invoice": { + "id": "123", # Too generic + "amount": 1000.00 + } +} +``` + +### 4. Handle Errors Gracefully + +Always handle errors and provide meaningful feedback: + +```python +def send_notification_with_retry(template_key, recipients, document, max_retries=3): + client = EmailClient() + + for attempt in range(max_retries): + try: + response = client.send_email( + notification_template_key=template_key, + to=recipients, + business_document=document, + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + if response.error: + if response.error.code in ["NETWORK_ERROR", "SERVICE_UNAVAILABLE"]: + if attempt < max_retries - 1: + print(f"Retrying... (attempt {attempt + 1}/{max_retries})") + continue + + print(f"Failed to send email: {response.error.message}") + return None + + return response.output_request_id + + except Exception as e: + if attempt < max_retries - 1: + print(f"Error occurred, retrying... (attempt {attempt + 1}/{max_retries})") + continue + raise + + return None +``` + +### 5. Log Request IDs + +Always log the request ID for tracking: + +```python +import logging + +logger = logging.getLogger(__name__) + +response = client.send_email( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + destination_name="ARIBA_OUTPUT_SERVICE" +) + +if response.error: + logger.error(f"Email send failed: {response.error.message}") +else: + logger.info(f"Email sent successfully. Request ID: {response.output_request_id}") + # Store request ID for tracking + save_request_id(response.output_request_id) +``` + +## API Reference + +### EmailClient + +#### `send_email()` + +Sends an email using the SAP Ariba Output Service. + +**Parameters:** + +- `notification_template_key` (str, required): ANS template identifier +- `to` (List[str], required): List of recipient email addresses +- `business_document` (Dict[str, Any], required): Business document as a dictionary +- `destination_name` (str, required): Name of the destination for authentication +- `cc` (List[str], optional): List of CC email addresses +- `template_language` (str, optional): ISO language code (default: "en") +- `access_strategy` (str, optional): "PROVIDER_ONLY" or "SUBSCRIBER_ONLY" (default: "PROVIDER_ONLY") +- `instance` (str, optional): Destination service instance name (default: "default") +- `attachment_urls` (List[str], optional): List of DMS URLs for attachments + +**Returns:** + +`OutputResponse` object with: +- `output_request_id` (str): Request ID if successful +- `error` (ErrorResponse): Error details if failed + - `message` (str): Error message + - `code` (str): Error code + - `details` (Dict): Additional error details + +**Example:** + +```python +response = client.send_email( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + destination_name="ARIBA_OUTPUT_SERVICE" +) +``` + +#### `create_output_request()` + +Creates an OutputRequest object without sending it. + +**Parameters:** + +- `notification_template_key` (str, required): ANS template identifier +- `to` (List[str], required): List of recipient email addresses +- `business_document` (Dict[str, Any], required): Business document as a dictionary +- `cc` (List[str], optional): List of CC email addresses +- `template_language` (str, optional): ISO language code (default: "en") +- `attachment_urls` (List[str], optional): List of DMS URLs for attachments + +**Returns:** + +`OutputRequest` object ready to be sent + +**Example:** + +```python +output_request = client.create_output_request( + notification_template_key="NOTIFICATION", + to=["user@example.com"], + business_document={"Document": {"id": "123"}}, + cc=["manager@example.com"] +) +``` + +## Common Use Cases + +### Use Case 1: Order Confirmation + +```python +def send_order_confirmation(order): + client = EmailClient() + + response = client.send_email( + notification_template_key="ORDER_CONFIRMATION", + to=[order.customer_email], + cc=[order.sales_rep_email], + business_document={ + "Order": { + "orderId": order.id, + "orderDate": order.date.isoformat(), + "customerName": order.customer_name, + "totalAmount": float(order.total), + "items": [ + { + "productName": item.product_name, + "quantity": item.quantity, + "price": float(item.price) + } + for item in order.items + ] + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + return response +``` + +### Use Case 2: Invoice with PDF Attachment + +```python +def send_invoice_with_pdf(invoice, pdf_dms_url): + client = EmailClient() + + response = client.send_email( + notification_template_key="INVOICE_WITH_PDF", + to=[invoice.customer_email], + cc=["accounting@company.com"], + business_document={ + "Invoice": { + "invoiceNumber": invoice.number, + "invoiceDate": invoice.date.isoformat(), + "dueDate": invoice.due_date.isoformat(), + "amount": float(invoice.amount), + "currency": invoice.currency + } + }, + destination_name="ARIBA_OUTPUT_SERVICE", + attachment_urls=[pdf_dms_url] + ) + + return response +``` + +### Use Case 3: Bulk Notification + +```python +def send_bulk_notification(recipients, notification_data): + client = EmailClient() + + response = client.send_email( + notification_template_key="BULK_NOTIFICATION", + to=recipients, + business_document={ + "Notification": { + "notificationId": notification_data["id"], + "title": notification_data["title"], + "message": notification_data["message"], + "timestamp": notification_data["timestamp"] + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + return response +``` + +## Troubleshooting + +### Issue: "Destination not found" + +**Solution:** Verify the destination name and ensure it exists in your BTP subaccount: + +```python +# Check destination name matches exactly +destination_name = "ARIBA_OUTPUT_SERVICE" # Case-sensitive +``` + +### Issue: "Authentication failed" + +**Solution:** Verify your destination has proper OAuth2 configuration with mTLS: +- Check token service URL +- Verify client certificate is uploaded to Destination Service +- Ensure certificate password is correct + +### Issue: "Template not found" + +**Solution:** Verify the ANS template key exists: + +```python +# Use exact template key from ANS +notification_template_key = "PO_APPROVAL_NOTIFICATION" # Must match ANS +``` + +### Issue: "Invalid email address" + +**Solution:** Ensure email addresses are properly formatted: + +```python +# Good +to = ["user@example.com", "admin@example.com"] + +# Bad +to = ["invalid-email", "user@"] +``` + +## Additional Resources + +- [SAP Ariba Output Management Documentation](https://help.sap.com/docs/ariba) +- [SAP BTP Destination Service](https://help.sap.com/docs/connectivity) +- [SAP Cloud SDK for Python](https://github.com/SAP/cloud-sdk-python) + +## Support + +For issues or questions: +1. Check the troubleshooting section above +2. Review the API reference +3. Consult SAP support channels +4. Report bugs via GitHub issues + +--- + +**Version:** 1.0.0 +**Last Updated:** 2024-01-15 \ No newline at end of file diff --git a/tests/outputmanagement/__init__.py b/tests/outputmanagement/__init__.py new file mode 100644 index 00000000..54c53dcf --- /dev/null +++ b/tests/outputmanagement/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 \ No newline at end of file diff --git a/tests/outputmanagement/unit/__init__.py b/tests/outputmanagement/unit/__init__.py new file mode 100644 index 00000000..54c53dcf --- /dev/null +++ b/tests/outputmanagement/unit/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 \ No newline at end of file diff --git a/tests/outputmanagement/unit/conftest.py b/tests/outputmanagement/unit/conftest.py new file mode 100644 index 00000000..10b3947e --- /dev/null +++ b/tests/outputmanagement/unit/conftest.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Pytest configuration for output management unit tests.""" + +import pytest + + +@pytest.fixture +def sample_email_config(): + """Provide a sample email configuration for testing.""" + from sap_cloud_sdk.outputmanagement.models import EmailConfiguration + + return EmailConfiguration( + to=["recipient@example.com"], + subject="Test Email", + body="This is a test email body" + ) + + +@pytest.fixture +def sample_attachment(): + """Provide a sample attachment for testing.""" + from sap_cloud_sdk.outputmanagement.models import AttachmentConfig + + return AttachmentConfig( + filename="test-document.pdf", + content_type="application/pdf", + content=b"Sample PDF content" + ) + + +@pytest.fixture +def sample_output_response(): + """Provide a sample output response for testing.""" + from sap_cloud_sdk.outputmanagement.models import OutputResponse + + return OutputResponse( + request_id="test-req-123", + status="SUCCESS", + message="Test output generated successfully" + ) + + +@pytest.fixture +def sample_pre_generated_attachment(): + """Provide a sample pre-generated attachment for testing.""" + from sap_cloud_sdk.outputmanagement.models import PreGeneratedAttachment + + return PreGeneratedAttachment( + object_key="attachments/test-file.pdf", + filename="test-file.pdf", + content_type="application/pdf", + size=1024 + ) + + +@pytest.fixture +def sample_destination_config(): + """Provide a sample destination configuration for testing.""" + from sap_cloud_sdk.outputmanagement.config.destination_credential_config import ( + DestinationCredentialConfig, + ) + + return DestinationCredentialConfig( + destination_name="test-output-management" + ) \ No newline at end of file diff --git a/tests/outputmanagement/unit/test_basic.py b/tests/outputmanagement/unit/test_basic.py new file mode 100644 index 00000000..7d9f87b8 --- /dev/null +++ b/tests/outputmanagement/unit/test_basic.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Basic unit tests for output management module.""" + +import pytest + + +class TestOutputManagementModule: + """Test basic output management module functionality.""" + + def test_module_imports(self): + """Test that output management module can be imported.""" + from sap_cloud_sdk import outputmanagement + assert outputmanagement is not None + + def test_client_import(self): + """Test that client can be imported.""" + from sap_cloud_sdk.outputmanagement.client import OutputManagementClient + assert OutputManagementClient is not None + + def test_client_provider_import(self): + """Test that client provider can be imported.""" + from sap_cloud_sdk.outputmanagement.client_provider import OutputManagementClientProvider + assert OutputManagementClientProvider is not None + + def test_constants_import(self): + """Test that constants can be imported.""" + from sap_cloud_sdk.outputmanagement.constants import ( + DEFAULT_DESTINATION_NAME, + OUTPUT_MANAGEMENT_SERVICE_PATH, + EMAIL_SERVICE_PATH, + ) + assert DEFAULT_DESTINATION_NAME is not None + assert OUTPUT_MANAGEMENT_SERVICE_PATH is not None + assert EMAIL_SERVICE_PATH is not None + + def test_exceptions_import(self): + """Test that exceptions can be imported.""" + from sap_cloud_sdk.outputmanagement.exceptions import ( + OutputManagementError, + OutputManagementValidationError, + OutputManagementClientError, + ) + assert OutputManagementError is not None + assert OutputManagementValidationError is not None + assert OutputManagementClientError is not None + + def test_models_import(self): + """Test that models can be imported.""" + from sap_cloud_sdk.outputmanagement.models import ( + OutputResponse, + EmailConfiguration, + AttachmentConfig, + PreGeneratedAttachment, + ) + assert OutputResponse is not None + assert EmailConfiguration is not None + assert AttachmentConfig is not None + assert PreGeneratedAttachment is not None + + def test_clients_import(self): + """Test that clients can be imported.""" + from sap_cloud_sdk.outputmanagement.clients.output_requests_client import ( + OutputRequestsClient, + ) + from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + + assert OutputRequestsClient is not None + assert EmailClient is not None + + def test_config_import(self): + """Test that config can be imported.""" + from sap_cloud_sdk.outputmanagement.config.destination_credential_config import ( + DestinationCredentialConfig, + ) + assert DestinationCredentialConfig is not None + + def test_utils_import(self): + """Test that utils can be imported.""" + from sap_cloud_sdk.outputmanagement.utils.request_validator import ( + RequestValidator, + ) + assert RequestValidator is not None \ No newline at end of file diff --git a/tests/outputmanagement/unit/test_comprehensive.py b/tests/outputmanagement/unit/test_comprehensive.py new file mode 100644 index 00000000..254c24f3 --- /dev/null +++ b/tests/outputmanagement/unit/test_comprehensive.py @@ -0,0 +1,370 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Comprehensive tests for output management module.""" + +import pytest +from dataclasses import asdict, fields + +from sap_cloud_sdk.outputmanagement.models import ( + OutputResponse, + EmailConfiguration, + AttachmentConfig, + PreGeneratedAttachment, +) +from sap_cloud_sdk.outputmanagement.exceptions import ( + OutputManagementError, + OutputManagementValidationError, + OutputManagementClientError, +) + + +class TestDataclassFeatures: + """Test dataclass-specific features of models.""" + + def test_output_response_fields(self): + """Test OutputResponse has expected fields.""" + response = OutputResponse(request_id="test", status="SUCCESS") + field_names = {f.name for f in fields(response)} + + assert "request_id" in field_names + assert "status" in field_names + + def test_email_configuration_fields(self): + """Test EmailConfiguration has expected fields.""" + config = EmailConfiguration( + to=["test@example.com"], + subject="Test", + body="Test" + ) + field_names = {f.name for f in fields(config)} + + assert "to" in field_names + assert "subject" in field_names + assert "body" in field_names + + def test_attachment_config_fields(self): + """Test AttachmentConfig has expected fields.""" + attachment = AttachmentConfig( + filename="test.pdf", + content_type="application/pdf" + ) + field_names = {f.name for f in fields(attachment)} + + assert "filename" in field_names + assert "content_type" in field_names + + def test_pre_generated_attachment_fields(self): + """Test PreGeneratedAttachment has expected fields.""" + attachment = PreGeneratedAttachment( + object_key="path/file.pdf", + filename="file.pdf" + ) + field_names = {f.name for f in fields(attachment)} + + assert "object_key" in field_names + assert "filename" in field_names + + +class TestModelValidation: + """Test model validation and constraints.""" + + def test_email_configuration_requires_recipients(self): + """Test that email configuration requires recipients.""" + # Should be able to create with recipients + config = EmailConfiguration( + to=["user@example.com"], + subject="Test", + body="Test" + ) + assert len(config.to) > 0 + + def test_attachment_requires_filename(self): + """Test that attachment requires filename.""" + attachment = AttachmentConfig( + filename="document.pdf", + content_type="application/pdf" + ) + assert attachment.filename is not None + assert len(attachment.filename) > 0 + + def test_output_response_requires_request_id(self): + """Test that output response requires request ID.""" + response = OutputResponse( + request_id="req-123", + status="SUCCESS" + ) + assert response.request_id is not None + assert len(response.request_id) > 0 + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_email_with_empty_body(self): + """Test email configuration with empty body.""" + config = EmailConfiguration( + to=["user@example.com"], + subject="Test", + body="" + ) + assert config.body == "" + + def test_email_with_long_subject(self): + """Test email configuration with very long subject.""" + long_subject = "A" * 1000 + config = EmailConfiguration( + to=["user@example.com"], + subject=long_subject, + body="Test" + ) + assert len(config.subject) == 1000 + + def test_attachment_with_large_content(self): + """Test attachment with large content.""" + large_content = b"X" * (1024 * 1024) # 1MB + attachment = AttachmentConfig( + filename="large-file.bin", + content_type="application/octet-stream", + content=large_content + ) + assert len(attachment.content) == 1024 * 1024 + + def test_email_with_many_recipients(self): + """Test email with many recipients.""" + many_recipients = [f"user{i}@example.com" for i in range(100)] + config = EmailConfiguration( + to=many_recipients, + subject="Mass Email", + body="Test" + ) + assert len(config.to) == 100 + + def test_email_with_special_characters(self): + """Test email with special characters in subject and body.""" + config = EmailConfiguration( + to=["user@example.com"], + subject="Test: Special chars !@#$%^&*()", + body="Body with unicode: 你好 مرحبا שלום" + ) + assert "!@#$%^&*()" in config.subject + assert "你好" in config.body + + def test_attachment_with_unicode_filename(self): + """Test attachment with unicode filename.""" + attachment = AttachmentConfig( + filename="文档.pdf", + content_type="application/pdf" + ) + assert "文档" in attachment.filename + + def test_output_response_with_none_message(self): + """Test output response with None message.""" + response = OutputResponse( + request_id="req-123", + status="PENDING", + message=None + ) + assert response.message is None + + +class TestExceptionScenarios: + """Test various exception scenarios.""" + + def test_exception_with_nested_message(self): + """Test exception with nested error message.""" + try: + raise ValueError("Inner error") + except ValueError as e: + error = OutputManagementError(f"Outer error: {str(e)}") + assert "Inner error" in str(error) + assert "Outer error" in str(error) + + def test_multiple_exception_types(self): + """Test catching different exception types.""" + errors = [ + OutputManagementError("General error"), + OutputManagementValidationError("Validation error"), + OutputManagementClientError("Client error"), + ] + + for error in errors: + assert isinstance(error, OutputManagementError) + assert isinstance(error, Exception) + + def test_exception_repr(self): + """Test exception representation.""" + error = OutputManagementError("Test error") + repr_str = repr(error) + assert "OutputManagementError" in repr_str or "Test error" in repr_str + + +class TestModelComparisons: + """Test model comparison operations.""" + + def test_output_response_equality_with_different_fields(self): + """Test output response equality with different optional fields.""" + response1 = OutputResponse( + request_id="req-1", + status="SUCCESS", + message="Done" + ) + response2 = OutputResponse( + request_id="req-1", + status="SUCCESS", + message="Done" + ) + response3 = OutputResponse( + request_id="req-1", + status="SUCCESS", + message="Different message" + ) + + assert response1 == response2 + assert response1 != response3 + + def test_attachment_equality(self): + """Test attachment equality.""" + att1 = AttachmentConfig( + filename="file.pdf", + content_type="application/pdf", + content=b"content" + ) + att2 = AttachmentConfig( + filename="file.pdf", + content_type="application/pdf", + content=b"content" + ) + att3 = AttachmentConfig( + filename="other.pdf", + content_type="application/pdf", + content=b"content" + ) + + assert att1 == att2 + assert att1 != att3 + + +class TestModelSerialization: + """Test model serialization capabilities.""" + + def test_output_response_to_dict(self): + """Test converting OutputResponse to dictionary.""" + response = OutputResponse( + request_id="req-123", + status="SUCCESS", + message="Done" + ) + response_dict = asdict(response) + + assert isinstance(response_dict, dict) + assert response_dict["request_id"] == "req-123" + assert response_dict["status"] == "SUCCESS" + assert response_dict["message"] == "Done" + + def test_email_configuration_to_dict(self): + """Test converting EmailConfiguration to dictionary.""" + config = EmailConfiguration( + to=["user@example.com"], + subject="Test", + body="Test body" + ) + config_dict = asdict(config) + + assert isinstance(config_dict, dict) + assert config_dict["to"] == ["user@example.com"] + assert config_dict["subject"] == "Test" + + def test_attachment_to_dict(self): + """Test converting AttachmentConfig to dictionary.""" + attachment = AttachmentConfig( + filename="file.pdf", + content_type="application/pdf", + content=b"content" + ) + att_dict = asdict(attachment) + + assert isinstance(att_dict, dict) + assert att_dict["filename"] == "file.pdf" + assert att_dict["content_type"] == "application/pdf" + + +class TestRealWorldScenarios: + """Test real-world usage scenarios.""" + + def test_invoice_email_scenario(self): + """Test sending an invoice email scenario.""" + invoice_pdf = AttachmentConfig( + filename="invoice_2024_001.pdf", + content_type="application/pdf", + content=b"Invoice PDF content", + size=50000 + ) + + email = EmailConfiguration( + to=["customer@company.com"], + cc=["accounting@company.com"], + subject="Invoice #2024-001 - Payment Due", + body="Dear Customer,\n\nPlease find attached invoice #2024-001.\n\nPayment is due within 30 days.\n\nBest regards,\nAccounting Team", + attachments=[invoice_pdf] + ) + + assert "Invoice" in email.subject + assert len(email.attachments) == 1 + assert email.attachments[0].size == 50000 + + def test_report_generation_scenario(self): + """Test report generation scenario.""" + response = OutputResponse( + request_id="report-2024-q1", + status="PROCESSING", + message="Generating quarterly report" + ) + + assert response.request_id.startswith("report-") + assert response.status == "PROCESSING" + + def test_bulk_email_scenario(self): + """Test bulk email sending scenario.""" + recipients = [f"employee{i}@company.com" for i in range(1, 51)] + + email = EmailConfiguration( + to=recipients, + subject="Company Newsletter - January 2024", + body="Dear Team,\n\nPlease find this month's newsletter...", + ) + + assert len(email.to) == 50 + assert "Newsletter" in email.subject + + def test_multi_attachment_report_scenario(self): + """Test report with multiple attachments scenario.""" + attachments = [ + AttachmentConfig( + filename="summary.pdf", + content_type="application/pdf", + content=b"Summary PDF" + ), + AttachmentConfig( + filename="data.xlsx", + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + content=b"Excel data" + ), + AttachmentConfig( + filename="notes.txt", + content_type="text/plain", + content=b"Additional notes" + ), + ] + + email = EmailConfiguration( + to=["manager@company.com"], + subject="Monthly Report Package", + body="Please find the complete monthly report package attached.", + attachments=attachments + ) + + assert len(email.attachments) == 3 + assert any(att.filename.endswith(".pdf") for att in email.attachments) + assert any(att.filename.endswith(".xlsx") for att in email.attachments) + assert any(att.filename.endswith(".txt") for att in email.attachments) \ No newline at end of file diff --git a/tests/outputmanagement/unit/test_constants.py b/tests/outputmanagement/unit/test_constants.py new file mode 100644 index 00000000..f92cc445 --- /dev/null +++ b/tests/outputmanagement/unit/test_constants.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for output management constants.""" + +import pytest + +from sap_cloud_sdk.outputmanagement.constants import ( + DEFAULT_DESTINATION_NAME, + OUTPUT_MANAGEMENT_SERVICE_PATH, + EMAIL_SERVICE_PATH, +) + + +class TestConstants: + """Test output management constants.""" + + def test_default_destination_name_exists(self): + """Test default destination name constant exists.""" + assert DEFAULT_DESTINATION_NAME is not None + assert isinstance(DEFAULT_DESTINATION_NAME, str) + assert len(DEFAULT_DESTINATION_NAME) > 0 + + def test_output_management_service_path_exists(self): + """Test output management service path constant exists.""" + assert OUTPUT_MANAGEMENT_SERVICE_PATH is not None + assert isinstance(OUTPUT_MANAGEMENT_SERVICE_PATH, str) + assert len(OUTPUT_MANAGEMENT_SERVICE_PATH) > 0 + + def test_email_service_path_exists(self): + """Test email service path constant exists.""" + assert EMAIL_SERVICE_PATH is not None + assert isinstance(EMAIL_SERVICE_PATH, str) + assert len(EMAIL_SERVICE_PATH) > 0 + + def test_service_paths_start_with_slash(self): + """Test that service paths start with a slash.""" + assert OUTPUT_MANAGEMENT_SERVICE_PATH.startswith("/") + assert EMAIL_SERVICE_PATH.startswith("/") + + def test_constants_are_strings(self): + """Test that all constants are strings.""" + assert isinstance(DEFAULT_DESTINATION_NAME, str) + assert isinstance(OUTPUT_MANAGEMENT_SERVICE_PATH, str) + assert isinstance(EMAIL_SERVICE_PATH, str) + + def test_constants_not_empty(self): + """Test that constants are not empty strings.""" + assert DEFAULT_DESTINATION_NAME != "" + assert OUTPUT_MANAGEMENT_SERVICE_PATH != "" + assert EMAIL_SERVICE_PATH != "" + + def test_service_paths_format(self): + """Test service paths follow expected format.""" + # Service paths should be valid URL paths + assert not OUTPUT_MANAGEMENT_SERVICE_PATH.endswith("/") + assert not EMAIL_SERVICE_PATH.endswith("/") + assert " " not in OUTPUT_MANAGEMENT_SERVICE_PATH + assert " " not in EMAIL_SERVICE_PATH \ No newline at end of file diff --git a/tests/outputmanagement/unit/test_exceptions.py b/tests/outputmanagement/unit/test_exceptions.py new file mode 100644 index 00000000..b53c4ab5 --- /dev/null +++ b/tests/outputmanagement/unit/test_exceptions.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for output management exceptions.""" + +import pytest + +from sap_cloud_sdk.outputmanagement.exceptions import ( + OutputManagementError, + OutputManagementValidationError, + OutputManagementClientError, +) + + +class TestOutputManagementExceptions: + """Test output management exception classes.""" + + def test_output_management_error_basic(self): + """Test basic OutputManagementError.""" + error = OutputManagementError("Test error") + assert str(error) == "Test error" + assert isinstance(error, Exception) + + def test_output_management_error_with_message(self): + """Test OutputManagementError with custom message.""" + message = "Something went wrong in output management" + error = OutputManagementError(message) + assert str(error) == message + + def test_output_management_validation_error(self): + """Test OutputManagementValidationError.""" + error = OutputManagementValidationError("Validation failed") + assert str(error) == "Validation failed" + assert isinstance(error, OutputManagementError) + assert isinstance(error, Exception) + + def test_output_management_client_error(self): + """Test OutputManagementClientError.""" + error = OutputManagementClientError("Client error occurred") + assert str(error) == "Client error occurred" + assert isinstance(error, OutputManagementError) + assert isinstance(error, Exception) + + def test_exception_inheritance_chain(self): + """Test exception inheritance chain.""" + assert issubclass(OutputManagementValidationError, OutputManagementError) + assert issubclass(OutputManagementClientError, OutputManagementError) + assert issubclass(OutputManagementError, Exception) + + def test_exceptions_can_be_raised(self): + """Test that exceptions can be raised and caught.""" + with pytest.raises(OutputManagementError): + raise OutputManagementError("Test") + + with pytest.raises(OutputManagementValidationError): + raise OutputManagementValidationError("Test") + + with pytest.raises(OutputManagementClientError): + raise OutputManagementClientError("Test") + + def test_validation_error_caught_as_base_error(self): + """Test that ValidationError can be caught as base OutputManagementError.""" + with pytest.raises(OutputManagementError): + raise OutputManagementValidationError("Validation failed") + + def test_client_error_caught_as_base_error(self): + """Test that ClientError can be caught as base OutputManagementError.""" + with pytest.raises(OutputManagementError): + raise OutputManagementClientError("Client failed") + + def test_exception_with_empty_message(self): + """Test exceptions with empty message.""" + error = OutputManagementError("") + assert str(error) == "" + + def test_exception_with_multiline_message(self): + """Test exceptions with multiline message.""" + message = "Error occurred:\nLine 1\nLine 2" + error = OutputManagementError(message) + assert str(error) == message + assert "\n" in str(error) \ No newline at end of file diff --git a/tests/outputmanagement/unit/test_integration.py b/tests/outputmanagement/unit/test_integration.py new file mode 100644 index 00000000..92acb432 --- /dev/null +++ b/tests/outputmanagement/unit/test_integration.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Integration-style unit tests for output management module.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from sap_cloud_sdk.outputmanagement.client import OutputManagementClient +from sap_cloud_sdk.outputmanagement.client_provider import OutputManagementClientProvider +from sap_cloud_sdk.outputmanagement.config.destination_credential_config import ( + DestinationCredentialConfig, +) +from sap_cloud_sdk.outputmanagement.models import ( + EmailConfiguration, + AttachmentConfig, + OutputResponse, +) +from sap_cloud_sdk.outputmanagement.exceptions import ( + OutputManagementError, + OutputManagementValidationError, +) + + +class TestOutputManagementIntegration: + """Integration-style tests for output management.""" + + def test_end_to_end_email_workflow(self): + """Test complete email sending workflow.""" + # Create email configuration + attachment = AttachmentConfig( + filename="report.pdf", + content_type="application/pdf", + content=b"PDF content" + ) + + email_config = EmailConfiguration( + to=["recipient@example.com"], + cc=["cc@example.com"], + subject="Monthly Report", + body="Please find the monthly report attached.", + attachments=[attachment] + ) + + # Verify configuration is created correctly + assert email_config.to == ["recipient@example.com"] + assert email_config.cc == ["cc@example.com"] + assert email_config.subject == "Monthly Report" + assert len(email_config.attachments) == 1 + assert email_config.attachments[0].filename == "report.pdf" + + def test_end_to_end_output_request_workflow(self): + """Test complete output request workflow.""" + # Create output response + response = OutputResponse( + request_id="req-12345", + status="SUCCESS", + message="Output generated successfully" + ) + + # Verify response + assert response.request_id == "req-12345" + assert response.status == "SUCCESS" + assert response.message == "Output generated successfully" + + def test_configuration_creation_workflow(self): + """Test configuration creation workflow.""" + # Test with destination name + config1 = DestinationCredentialConfig( + destination_name="output-management-dest" + ) + assert config1.destination_name == "output-management-dest" + + # Test with URL and credentials + config2 = DestinationCredentialConfig( + url="https://api.example.com", + username="testuser", + password="testpass" + ) + assert config2.url == "https://api.example.com" + assert config2.username == "testuser" + assert config2.password == "testpass" + + def test_multiple_attachments_workflow(self): + """Test workflow with multiple attachments.""" + attachments = [ + AttachmentConfig( + filename="report1.pdf", + content_type="application/pdf", + content=b"PDF 1" + ), + AttachmentConfig( + filename="data.csv", + content_type="text/csv", + content=b"CSV data" + ), + AttachmentConfig( + filename="summary.txt", + content_type="text/plain", + content=b"Summary text" + ), + ] + + email_config = EmailConfiguration( + to=["recipient@example.com"], + subject="Multiple Attachments", + body="Please find multiple files attached.", + attachments=attachments + ) + + assert len(email_config.attachments) == 3 + assert email_config.attachments[0].content_type == "application/pdf" + assert email_config.attachments[1].content_type == "text/csv" + assert email_config.attachments[2].content_type == "text/plain" + + def test_multiple_recipients_workflow(self): + """Test workflow with multiple recipients.""" + email_config = EmailConfiguration( + to=[ + "user1@example.com", + "user2@example.com", + "user3@example.com" + ], + cc=["manager@example.com"], + bcc=["archive@example.com"], + subject="Team Update", + body="Important team update" + ) + + assert len(email_config.to) == 3 + assert len(email_config.cc) == 1 + assert len(email_config.bcc) == 1 + + def test_error_handling_workflow(self): + """Test error handling in workflow.""" + # Test that exceptions can be raised and caught + with pytest.raises(OutputManagementError): + raise OutputManagementError("General error") + + with pytest.raises(OutputManagementValidationError): + raise OutputManagementValidationError("Validation error") + + def test_dataclass_immutability_workflow(self): + """Test that dataclass instances work as expected.""" + response1 = OutputResponse(request_id="req-1", status="SUCCESS") + response2 = OutputResponse(request_id="req-1", status="SUCCESS") + response3 = OutputResponse(request_id="req-2", status="SUCCESS") + + # Test equality + assert response1 == response2 + assert response1 != response3 + + # Test that we can access fields + assert response1.request_id == "req-1" + assert response1.status == "SUCCESS" + + def test_complex_email_scenario(self): + """Test complex email scenario with all features.""" + # Create multiple attachments + pdf_attachment = AttachmentConfig( + filename="invoice.pdf", + content_type="application/pdf", + content=b"Invoice PDF content", + size=1024 + ) + + csv_attachment = AttachmentConfig( + filename="details.csv", + content_type="text/csv", + content=b"Detail,Value\nItem1,100\nItem2,200", + size=512 + ) + + # Create email with all features + email_config = EmailConfiguration( + to=["customer@example.com", "billing@example.com"], + cc=["manager@example.com"], + bcc=["archive@example.com", "audit@example.com"], + subject="Invoice #12345 - Payment Due", + body="Dear Customer,\n\nPlease find your invoice attached.\n\nBest regards,\nBilling Team", + attachments=[pdf_attachment, csv_attachment] + ) + + # Verify all components + assert len(email_config.to) == 2 + assert len(email_config.cc) == 1 + assert len(email_config.bcc) == 2 + assert len(email_config.attachments) == 2 + assert "Invoice" in email_config.subject + assert "Dear Customer" in email_config.body + + def test_output_response_lifecycle(self): + """Test output response through different states.""" + # Pending state + pending = OutputResponse( + request_id="req-100", + status="PENDING", + message="Request submitted" + ) + assert pending.status == "PENDING" + + # Processing state + processing = OutputResponse( + request_id="req-100", + status="PROCESSING", + message="Generating output" + ) + assert processing.status == "PROCESSING" + + # Success state + success = OutputResponse( + request_id="req-100", + status="SUCCESS", + message="Output generated successfully" + ) + assert success.status == "SUCCESS" + + # Error state + error = OutputResponse( + request_id="req-100", + status="ERROR", + message="Failed to generate output" + ) + assert error.status == "ERROR" \ No newline at end of file diff --git a/tests/outputmanagement/unit/test_models.py b/tests/outputmanagement/unit/test_models.py new file mode 100644 index 00000000..cd592028 --- /dev/null +++ b/tests/outputmanagement/unit/test_models.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for output management models.""" + +import pytest +from dataclasses import is_dataclass + +from sap_cloud_sdk.outputmanagement.models import ( + OutputResponse, + EmailConfiguration, + AttachmentConfig, + PreGeneratedAttachment, +) + + +class TestOutputResponse: + """Test OutputResponse model.""" + + def test_output_response_is_dataclass(self): + """Test that OutputResponse is a dataclass.""" + assert is_dataclass(OutputResponse) + + def test_output_response_creation_basic(self): + """Test creating a basic OutputResponse.""" + response = OutputResponse( + request_id="req-123", + status="SUCCESS" + ) + assert response.request_id == "req-123" + assert response.status == "SUCCESS" + + def test_output_response_with_all_fields(self): + """Test OutputResponse with all fields.""" + response = OutputResponse( + request_id="req-456", + status="PENDING", + message="Processing", + created_at="2024-01-01T00:00:00Z", + updated_at="2024-01-01T00:01:00Z" + ) + assert response.request_id == "req-456" + assert response.status == "PENDING" + assert response.message == "Processing" + assert response.created_at == "2024-01-01T00:00:00Z" + assert response.updated_at == "2024-01-01T00:01:00Z" + + def test_output_response_equality(self): + """Test OutputResponse equality.""" + response1 = OutputResponse(request_id="req-1", status="SUCCESS") + response2 = OutputResponse(request_id="req-1", status="SUCCESS") + response3 = OutputResponse(request_id="req-2", status="SUCCESS") + + assert response1 == response2 + assert response1 != response3 + + +class TestEmailConfiguration: + """Test EmailConfiguration model.""" + + def test_email_configuration_is_dataclass(self): + """Test that EmailConfiguration is a dataclass.""" + assert is_dataclass(EmailConfiguration) + + def test_email_configuration_basic(self): + """Test basic EmailConfiguration creation.""" + config = EmailConfiguration( + to=["recipient@example.com"], + subject="Test Email", + body="This is a test email" + ) + assert config.to == ["recipient@example.com"] + assert config.subject == "Test Email" + assert config.body == "This is a test email" + + def test_email_configuration_with_cc_bcc(self): + """Test EmailConfiguration with CC and BCC.""" + config = EmailConfiguration( + to=["recipient@example.com"], + cc=["cc@example.com"], + bcc=["bcc@example.com"], + subject="Test Email", + body="Test body" + ) + assert config.cc == ["cc@example.com"] + assert config.bcc == ["bcc@example.com"] + + def test_email_configuration_multiple_recipients(self): + """Test EmailConfiguration with multiple recipients.""" + config = EmailConfiguration( + to=["user1@example.com", "user2@example.com", "user3@example.com"], + subject="Multi-recipient Email", + body="Test" + ) + assert len(config.to) == 3 + assert "user1@example.com" in config.to + assert "user2@example.com" in config.to + assert "user3@example.com" in config.to + + def test_email_configuration_with_attachments(self): + """Test EmailConfiguration with attachments.""" + attachment = AttachmentConfig( + filename="document.pdf", + content_type="application/pdf", + content=b"PDF content" + ) + config = EmailConfiguration( + to=["recipient@example.com"], + subject="Email with Attachment", + body="Please find attached", + attachments=[attachment] + ) + assert len(config.attachments) == 1 + assert config.attachments[0].filename == "document.pdf" + + def test_email_configuration_empty_lists_default(self): + """Test EmailConfiguration with default empty lists.""" + config = EmailConfiguration( + to=["recipient@example.com"], + subject="Test", + body="Test" + ) + # Check that optional list fields have appropriate defaults + assert hasattr(config, 'cc') + assert hasattr(config, 'bcc') + assert hasattr(config, 'attachments') + + +class TestAttachmentConfig: + """Test AttachmentConfig model.""" + + def test_attachment_config_is_dataclass(self): + """Test that AttachmentConfig is a dataclass.""" + assert is_dataclass(AttachmentConfig) + + def test_attachment_config_basic(self): + """Test basic AttachmentConfig creation.""" + attachment = AttachmentConfig( + filename="report.pdf", + content_type="application/pdf" + ) + assert attachment.filename == "report.pdf" + assert attachment.content_type == "application/pdf" + + def test_attachment_config_with_content(self): + """Test AttachmentConfig with content.""" + content = b"Sample PDF content" + attachment = AttachmentConfig( + filename="data.pdf", + content_type="application/pdf", + content=content + ) + assert attachment.content == content + assert isinstance(attachment.content, bytes) + + def test_attachment_config_various_types(self): + """Test AttachmentConfig with various content types.""" + pdf = AttachmentConfig(filename="doc.pdf", content_type="application/pdf") + csv = AttachmentConfig(filename="data.csv", content_type="text/csv") + xml = AttachmentConfig(filename="config.xml", content_type="application/xml") + txt = AttachmentConfig(filename="readme.txt", content_type="text/plain") + + assert pdf.content_type == "application/pdf" + assert csv.content_type == "text/csv" + assert xml.content_type == "application/xml" + assert txt.content_type == "text/plain" + + def test_attachment_config_with_size(self): + """Test AttachmentConfig with size information.""" + attachment = AttachmentConfig( + filename="large-file.pdf", + content_type="application/pdf", + content=b"x" * 1024, + size=1024 + ) + assert attachment.size == 1024 + assert len(attachment.content) == 1024 + + +class TestPreGeneratedAttachment: + """Test PreGeneratedAttachment model.""" + + def test_pre_generated_attachment_is_dataclass(self): + """Test that PreGeneratedAttachment is a dataclass.""" + assert is_dataclass(PreGeneratedAttachment) + + def test_pre_generated_attachment_basic(self): + """Test basic PreGeneratedAttachment creation.""" + attachment = PreGeneratedAttachment( + object_key="attachments/report-123.pdf", + filename="report.pdf" + ) + assert attachment.object_key == "attachments/report-123.pdf" + assert attachment.filename == "report.pdf" + + def test_pre_generated_attachment_with_metadata(self): + """Test PreGeneratedAttachment with metadata.""" + attachment = PreGeneratedAttachment( + object_key="docs/invoice-456.pdf", + filename="invoice.pdf", + content_type="application/pdf", + size=102400 + ) + assert attachment.content_type == "application/pdf" + assert attachment.size == 102400 + + def test_pre_generated_attachment_object_key_formats(self): + """Test PreGeneratedAttachment with various object key formats.""" + att1 = PreGeneratedAttachment( + object_key="folder/subfolder/file.pdf", + filename="file.pdf" + ) + att2 = PreGeneratedAttachment( + object_key="simple-file.txt", + filename="simple-file.txt" + ) + att3 = PreGeneratedAttachment( + object_key="deep/nested/path/to/document.docx", + filename="document.docx" + ) + + assert "/" in att1.object_key + assert "/" not in att2.object_key + assert att3.object_key.count("/") == 3 + + def test_pre_generated_attachment_equality(self): + """Test PreGeneratedAttachment equality.""" + att1 = PreGeneratedAttachment( + object_key="path/file.pdf", + filename="file.pdf" + ) + att2 = PreGeneratedAttachment( + object_key="path/file.pdf", + filename="file.pdf" + ) + att3 = PreGeneratedAttachment( + object_key="other/file.pdf", + filename="file.pdf" + ) + + assert att1 == att2 + assert att1 != att3 \ No newline at end of file From 15606cdf72a98add2cdee28023b5c9a46bb54ce8 Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Mon, 15 Jun 2026 11:12:15 +0530 Subject: [PATCH 12/14] added mcp way to communicate --- .../outputmanagement/clients/email_client.py | 124 +++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py index 15703ea2..1517a9ef 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -274,4 +274,126 @@ def send_email( return output_requests_client.send_output_request(output_request) except Exception as e: - raise Exception(f"Failed to send email via destination '{destination_name}': {str(e)}") from e \ No newline at end of file + raise Exception(f"Failed to send email via destination '{destination_name}': {str(e)}") from e + + async def send_email_with_mcp( + self, + tool_name: str, + notification_template_key: str, + to_emails: List[str], + business_document: Dict[str, Any], + cc_email: Optional[str] = None, + attachment_url: Optional[str] = None, + mcp_tool: Any = None, + sender_provider_subaccount_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create an output request and invoke MCP tool with traceparent and sender_provider_subaccount_id. + + This method first generates an output request using the provided parameters, then invokes + the specified MCP tool with the output request as the body, along with traceparent for + distributed tracing and sender_provider_subaccount_id for multi-tenancy support. + + Args: + tool_name: Name of the MCP tool to invoke + notification_template_key: Template key for the notification + to_emails: List of recipient email addresses + business_document: Business document data + cc_email: Optional CC email address + attachment_url: Optional attachment URL + mcp_tool: The MCP tool instance to invoke + sender_provider_subaccount_id: Optional sender provider subaccount ID (defaults to env var) + + Returns: + Result from the MCP tool invocation + + Raises: + Exception: If the MCP tool invocation fails + + Example: + ```python + import asyncio + from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + + async def send_with_mcp(): + client = EmailClient() + + result = await client.send_email_with_output_request_and_mcp_async( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document={ + "PurchaseOrder": { + "orderId": "PO-12345", + "vendor": "ACME Corp", + "total": 1500.00 + } + }, + mcp_tool=my_mcp_tool, + sender_provider_subaccount_id="my-subaccount-id" + ) + return result + + result = asyncio.run(send_with_mcp()) + ``` + """ + import logging + import os + + logger = logging.getLogger(__name__) + + try: + logger.info("Creating output request for MCP tool '%s'", tool_name) + + # Create the output request + output_request = self.create_output_request( + notification_template_key=notification_template_key, + to=to_emails, + business_document=business_document, + cc=[cc_email] if cc_email else None, + template_language="en", + attachment_urls=[attachment_url] if attachment_url else None + ) + + logger.info("Output request created successfully") + + # Convert output request to dict for MCP payload + payload = output_request.model_dump(by_alias=True, exclude_none=True) + + # Get sender_provider_subaccount_id from parameter or environment variable + subaccount_id = sender_provider_subaccount_id or os.getenv("APPFND_CONHOS_SUBACCOUNTID") + + if not subaccount_id: + logger.warning("sender_provider_subaccount_id not provided and APPFND_CONHOS_SUBACCOUNTID env var not set") + + logger.info("Invoking MCP tool '%s' with body, traceparent, and sender_provider_subaccount_id", tool_name) + + # Generate traceparent for distributed tracing + import uuid + trace_id = uuid.uuid4().hex # 32 hex chars + parent_id = uuid.uuid4().hex[:16] # 16 hex chars + traceparent = f"00-{trace_id}-{parent_id}-01" + + # Prepare the invocation payload + invocation_payload = { + "body": payload, + "traceparent": traceparent, + "sender_provider_subaccount_id": subaccount_id + } + + # Log the payload before invoking + logger.info("MCP tool '%s' invocation payload: %s", tool_name, invocation_payload) + + # Validate that mcp_tool is provided + if mcp_tool is None: + raise ValueError("mcp_tool parameter is required") + + # Use ainvoke for async invocation + result = await mcp_tool.ainvoke(invocation_payload) + logger.info("MCP tool '%s' executed successfully", tool_name) + logger.info("Result from MCP tool '%s': %s", tool_name, result) + return result + + except Exception as e: + logger.error("Failed to invoke MCP tool '%s': %s", tool_name, str(e)) + raise Exception(f"MCP tool invocation failed: {str(e)}") from e From 59426a32940ed7687eb7aaa865d58197de8718f9 Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Mon, 15 Jun 2026 11:24:55 +0530 Subject: [PATCH 13/14] added mcp way to communicate --- src/sap_cloud_sdk/outputmanagement/clients/email_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py index 1517a9ef..457bcb1d 100644 --- a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -283,7 +283,7 @@ async def send_email_with_mcp( to_emails: List[str], business_document: Dict[str, Any], cc_email: Optional[str] = None, - attachment_url: Optional[str] = None, + attachment_urls: Optional[List[str]] = None, mcp_tool: Any = None, sender_provider_subaccount_id: Optional[str] = None ) -> Dict[str, Any]: @@ -352,7 +352,7 @@ async def send_with_mcp(): business_document=business_document, cc=[cc_email] if cc_email else None, template_language="en", - attachment_urls=[attachment_url] if attachment_url else None + attachment_urls=attachment_urls ) logger.info("Output request created successfully") From 9b90568df5b95e6bd0938ddec1b62d56638dd25d Mon Sep 17 00:00:00 2001 From: S Venkatakrishnan Date: Mon, 15 Jun 2026 11:40:19 +0530 Subject: [PATCH 14/14] added test --- .../unit/test_email_client_mcp.py | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 tests/outputmanagement/unit/test_email_client_mcp.py diff --git a/tests/outputmanagement/unit/test_email_client_mcp.py b/tests/outputmanagement/unit/test_email_client_mcp.py new file mode 100644 index 00000000..1d3a5e1c --- /dev/null +++ b/tests/outputmanagement/unit/test_email_client_mcp.py @@ -0,0 +1,384 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for EmailClient MCP integration.""" + +import pytest +import asyncio +from unittest.mock import Mock, AsyncMock, patch +from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + + +class TestEmailClientMCP: + """Test EmailClient MCP integration methods.""" + + @pytest.fixture + def email_client(self): + """Create an EmailClient instance for testing.""" + return EmailClient() + + @pytest.fixture + def sample_business_document(self): + """Sample business document for testing.""" + return { + "PurchaseOrder": { + "orderId": "PO-12345", + "vendor": "ACME Corp", + "total": 1500.00, + "items": [ + {"product": "Widget A", "quantity": 10, "price": 100.00}, + {"product": "Widget B", "quantity": 5, "price": 100.00} + ] + } + } + + @pytest.fixture + def mock_mcp_tool(self): + """Create a mock MCP tool.""" + tool = Mock() + tool.ainvoke = AsyncMock(return_value={"status": "success", "requestId": "req-123"}) + return tool + + @pytest.mark.asyncio + async def test_send_email_with_mcp_basic(self, email_client, sample_business_document, mock_mcp_tool): + """Test basic MCP email sending.""" + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_mcp_tool + ) + + assert result is not None + assert result["status"] == "success" + assert result["requestId"] == "req-123" + mock_mcp_tool.ainvoke.assert_called_once() + + @pytest.mark.asyncio + async def test_send_email_with_mcp_with_cc(self, email_client, sample_business_document, mock_mcp_tool): + """Test MCP email sending with CC.""" + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + cc_email="manager@company.com", + mcp_tool=mock_mcp_tool + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify CC was included in the payload + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + assert "body" in call_args + assert call_args["body"]["data"]["outputManagement"]["emailConfiguration"]["cc"] == ["manager@company.com"] + + @pytest.mark.asyncio + async def test_send_email_with_mcp_with_attachments(self, email_client, sample_business_document, mock_mcp_tool): + """Test MCP email sending with attachments.""" + attachment_urls = [ + "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", + "https://dms.example.com/browser/root?objectId=67890&cmisselector=content" + ] + + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + attachment_urls=attachment_urls, + mcp_tool=mock_mcp_tool + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify attachments were included in the payload + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + assert "body" in call_args + email_config = call_args["body"]["data"]["outputManagement"]["emailConfiguration"] + assert "attachment" in email_config + assert len(email_config["attachment"]["preGeneratedAttachments"]) == 2 + + @pytest.mark.asyncio + async def test_send_email_with_mcp_traceparent_generated(self, email_client, sample_business_document, mock_mcp_tool): + """Test that traceparent is generated correctly.""" + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_mcp_tool + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify traceparent format (W3C Trace Context) + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + assert "traceparent" in call_args + traceparent = call_args["traceparent"] + + # Format: 00-{trace_id}-{parent_id}-01 + parts = traceparent.split("-") + assert len(parts) == 4 + assert parts[0] == "00" # version + assert len(parts[1]) == 32 # trace_id (32 hex chars) + assert len(parts[2]) == 16 # parent_id (16 hex chars) + assert parts[3] == "01" # trace-flags + + @pytest.mark.asyncio + async def test_send_email_with_mcp_sender_subaccount_from_param(self, email_client, sample_business_document, mock_mcp_tool): + """Test sender_provider_subaccount_id from parameter.""" + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_mcp_tool, + sender_provider_subaccount_id="test-subaccount-123" + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify sender_provider_subaccount_id was included + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + assert "sender_provider_subaccount_id" in call_args + assert call_args["sender_provider_subaccount_id"] == "test-subaccount-123" + + @pytest.mark.asyncio + @patch.dict('os.environ', {'APPFND_CONHOS_SUBACCOUNTID': 'env-subaccount-456'}) + async def test_send_email_with_mcp_sender_subaccount_from_env(self, email_client, sample_business_document, mock_mcp_tool): + """Test sender_provider_subaccount_id from environment variable.""" + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_mcp_tool + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify sender_provider_subaccount_id from env was used + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + assert "sender_provider_subaccount_id" in call_args + assert call_args["sender_provider_subaccount_id"] == "env-subaccount-456" + + @pytest.mark.asyncio + async def test_send_email_with_mcp_missing_tool_raises_error(self, email_client, sample_business_document): + """Test that missing MCP tool raises ValueError.""" + with pytest.raises(ValueError, match="mcp_tool parameter is required"): + await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=None + ) + + @pytest.mark.asyncio + async def test_send_email_with_mcp_tool_failure(self, email_client, sample_business_document): + """Test handling of MCP tool invocation failure.""" + mock_tool = Mock() + mock_tool.ainvoke = AsyncMock(side_effect=Exception("MCP tool error")) + + with pytest.raises(Exception, match="MCP tool invocation failed"): + await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_tool + ) + + @pytest.mark.asyncio + async def test_send_email_with_mcp_payload_structure(self, email_client, sample_business_document, mock_mcp_tool): + """Test that the MCP payload has correct structure.""" + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com", "accounting@company.com"], + business_document=sample_business_document, + cc_email="manager@company.com", + mcp_tool=mock_mcp_tool, + sender_provider_subaccount_id="test-subaccount" + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify complete payload structure + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + + # Top-level keys + assert "body" in call_args + assert "traceparent" in call_args + assert "sender_provider_subaccount_id" in call_args + + # Body structure (CloudEvents format) + body = call_args["body"] + assert "source" in body + assert "type" in body + assert "data" in body + + # Data structure + data = body["data"] + assert "outputManagement" in data + assert "businessDocument" in data + + # Output management structure + output_mgmt = data["outputManagement"] + assert "businessDocumentType" in output_mgmt + assert "businessDocumentId" in output_mgmt + assert "channels" in output_mgmt + assert "emailConfiguration" in output_mgmt + + # Email configuration + email_config = output_mgmt["emailConfiguration"] + assert email_config["to"] == ["finance@company.com", "accounting@company.com"] + assert email_config["cc"] == ["manager@company.com"] + assert email_config["emailNotificationTemplateKey"] == "PO_APPROVAL_NOTIFICATION" + assert email_config["emailTemplateLanguage"] == "en" + + @pytest.mark.asyncio + async def test_send_email_with_mcp_multiple_recipients(self, email_client, sample_business_document, mock_mcp_tool): + """Test MCP email sending with multiple recipients.""" + recipients = [ + "finance@company.com", + "accounting@company.com", + "manager@company.com" + ] + + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=recipients, + business_document=sample_business_document, + mcp_tool=mock_mcp_tool + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify all recipients were included + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + email_config = call_args["body"]["data"]["outputManagement"]["emailConfiguration"] + assert len(email_config["to"]) == 3 + assert set(email_config["to"]) == set(recipients) + + @pytest.mark.asyncio + async def test_send_email_with_mcp_complex_business_document(self, email_client, mock_mcp_tool): + """Test MCP email with complex nested business document.""" + complex_doc = { + "Invoice": { + "invoiceNumber": "INV-2024-001", + "customer": { + "id": "CUST-123", + "name": "ACME Corporation", + "address": { + "street": "123 Main St", + "city": "New York", + "country": "USA" + } + }, + "lineItems": [ + { + "itemId": "ITEM-001", + "description": "Product A", + "quantity": 10, + "unitPrice": 100.00, + "total": 1000.00 + }, + { + "itemId": "ITEM-002", + "description": "Product B", + "quantity": 5, + "unitPrice": 200.00, + "total": 1000.00 + } + ], + "subtotal": 2000.00, + "tax": 200.00, + "total": 2200.00 + } + } + + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="INVOICE_NOTIFICATION", + to_emails=["billing@customer.com"], + business_document=complex_doc, + mcp_tool=mock_mcp_tool + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify business document was preserved + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + business_doc = call_args["body"]["data"]["businessDocument"] + assert "Invoice" in business_doc + assert business_doc["Invoice"]["invoiceNumber"] == "INV-2024-001" + assert len(business_doc["Invoice"]["lineItems"]) == 2 + + @pytest.mark.asyncio + async def test_send_email_with_mcp_unique_trace_ids(self, email_client, sample_business_document, mock_mcp_tool): + """Test that each invocation generates unique trace IDs.""" + # Call the method twice + await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_mcp_tool + ) + + await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_mcp_tool + ) + + # Verify two calls were made + assert mock_mcp_tool.ainvoke.call_count == 2 + + # Get trace IDs from both calls + call1_args = mock_mcp_tool.ainvoke.call_args_list[0][0][0] + call2_args = mock_mcp_tool.ainvoke.call_args_list[1][0][0] + + traceparent1 = call1_args["traceparent"] + traceparent2 = call2_args["traceparent"] + + # Verify they are different + assert traceparent1 != traceparent2 + + @pytest.mark.asyncio + async def test_send_email_with_mcp_no_optional_params(self, email_client, sample_business_document, mock_mcp_tool): + """Test MCP email with only required parameters.""" + result = await email_client.send_email_with_mcp( + tool_name="send_output_request", + notification_template_key="PO_APPROVAL_NOTIFICATION", + to_emails=["finance@company.com"], + business_document=sample_business_document, + mcp_tool=mock_mcp_tool + ) + + assert result is not None + mock_mcp_tool.ainvoke.assert_called_once() + + # Verify optional fields are not present or None + call_args = mock_mcp_tool.ainvoke.call_args[0][0] + email_config = call_args["body"]["data"]["outputManagement"]["emailConfiguration"] + + # CC should not be present when not provided + assert "cc" not in email_config or email_config.get("cc") is None + + # Attachment should not be present when not provided + assert "attachment" not in email_config or email_config.get("attachment") is None \ No newline at end of file