diff --git a/src/sap_cloud_sdk/adms/__init__.py b/src/sap_cloud_sdk/adms/__init__.py index c420626d..d1df3308 100644 --- a/src/sap_cloud_sdk/adms/__init__.py +++ b/src/sap_cloud_sdk/adms/__init__.py @@ -56,14 +56,20 @@ ) from sap_cloud_sdk.adms._models import ( AllowedDomain, + ApplicationTenant, BaseType, + BusinessObjectNodeChangeLog, BusinessObjectNodeType, + ChangeLog, CreateAllowedDomainInput, + CreateApplicationTenantInput, CreateBusinessObjectNodeTypeInput, CreateDocumentTypeBoTypeMapInput, CreateDocumentInput, CreateDocumentRelationInput, CreateDocumentTypeInput, + CreateFileExtensionPolicyInput, + DeleteBusinessObjectNodeResult, DeleteUserDataJobParameters, Document, DocumentContentVersion, @@ -74,6 +80,7 @@ DraftActivateInput, DraftAdministrativeData, DraftInput, + FileExtensionPolicy, JobInput, JobOutput, JobStatus, @@ -113,8 +120,11 @@ "ScanNotCleanError", # models — core "BaseType", + "ChangeLog", + "BusinessObjectNodeChangeLog", "CreateDocumentInput", "CreateDocumentRelationInput", + "DeleteBusinessObjectNodeResult", "DeleteUserDataJobParameters", "Document", "DocumentContentVersion", @@ -131,17 +141,21 @@ "ZipDownloadJobParameters", # models — config "AllowedDomain", + "ApplicationTenant", "BusinessObjectNodeType", "CreateAllowedDomainInput", + "CreateApplicationTenantInput", "CreateBusinessObjectNodeTypeInput", - "UpdateAllowedDomainInput", - "UpdateBusinessObjectNodeTypeInput", - "UpdateDocumentTypeInput", "CreateDocumentTypeBoTypeMapInput", "CreateDocumentTypeInput", + "CreateFileExtensionPolicyInput", "DocumentType", "DocumentTypeBusinessObjectTypeMap", "DocumentTypeText", + "FileExtensionPolicy", + "UpdateAllowedDomainInput", + "UpdateBusinessObjectNodeTypeInput", + "UpdateDocumentTypeInput", # query options "ConfigQueryOptions", "DocumentQueryOptions", diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index d6bf295e..842672c5 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -10,6 +10,7 @@ build_business_object_node_type_key_path, build_doctype_botype_map_key_path, build_document_type_key_path, + quote_odata_string_key, ) from sap_cloud_sdk.adms._models import ( AllowedDomain, @@ -243,42 +244,78 @@ def create_type_mapping( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCTYPE_BOTYPE_MAP) def get_type_mapping( - self, document_type_bo_type_map_id: str + self, + document_type_id: str, + business_object_node_type_unique_id: str, ) -> DocumentTypeBusinessObjectTypeMap: - """Fetch a single DocumentType ↔ BusinessObjectNodeType mapping by its UUID.""" + """Fetch a single DocumentType ↔ BusinessObjectNodeType mapping by its composite key. + + Args: + document_type_id: The ``DocumentTypeID`` half of the composite key. + business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. + + Note: + The ADM ``DocumentTypeBusinessObjectTypeMap`` entity uses a **composite key** + (``DocumentTypeID`` + ``BusinessObjectNodeTypeUniqueID``). The previous + single-argument overload used a fabricated ``DocumentTypeBOTypeMapID`` that + was never accepted by the service (HTTP 400), so this change is a bug-fix + rather than a breaking API change. + """ resp = self._http.get( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCTYPE_BOTYPE_MAP) - def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: - """Delete a DocumentType ↔ BusinessObjectNodeType mapping.""" + def delete_type_mapping( + self, + document_type_id: str, + business_object_node_type_unique_id: str, + ) -> None: + """Delete a DocumentType ↔ BusinessObjectNodeType mapping by its composite key. + + Args: + document_type_id: The ``DocumentTypeID`` half of the composite key. + business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. + + Note: + Same composite-key fix as :meth:`get_type_mapping` — the previous single-argument + overload used a non-existent ``DocumentTypeBOTypeMapID`` field. + """ self._http.delete( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_MARK_DEFAULT) - def mark_default(self, document_type_bo_type_map_id: str) -> None: - """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default.""" + def mark_default( + self, + document_type_id: str, + business_object_node_type_unique_id: str, + ) -> None: + """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default. + + Args: + document_type_id: The ``DocumentTypeID`` half of the composite key. + business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. + """ self._http.post( - f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/markDefault", + f"{build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) - # ── FileExtensionPolicy ──────────────────────────────────────────────────── + # ── DocumentTypeFileExtensionPolicy ─────────────────────────────────────── @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_ALL_FILE_EXT_POLICIES) def get_all_file_extension_policies( self, options: ConfigQueryOptions | None = None ) -> list[FileExtensionPolicy]: - """Return all file extension allow/block policies.""" + """Return all document-type file extension policies.""" params = options.to_query_params() if options else {} resp = self._http.get( - "FileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH + "DocumentTypeFileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH ) return [ FileExtensionPolicy.from_dict(item) for item in resp.json().get("value", []) @@ -288,30 +325,19 @@ def get_all_file_extension_policies( def create_file_extension_policy( self, payload: CreateFileExtensionPolicyInput ) -> FileExtensionPolicy: - """Create a file extension allow/block policy.""" + """Create a document-type file extension policy.""" resp = self._http.post( - "FileExtensionPolicy", + "DocumentTypeFileExtensionPolicy", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, ) return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_FILE_EXT_POLICY) - def get_file_extension_policy( - self, file_extension_policy_id: str - ) -> FileExtensionPolicy: - """Fetch a single FileExtensionPolicy by its UUID.""" - resp = self._http.get( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", - service_base=_CONFIG_SERVICE_PATH, - ) - return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_FILE_EXT_POLICY) - def delete_file_extension_policy(self, file_extension_policy_id: str) -> None: - """Delete a file extension policy.""" + def delete_file_extension_policy(self, document_type_id: str, file_extension: str) -> None: + """Delete a document-type file extension policy by composite key.""" self._http.delete( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", + f"DocumentTypeFileExtensionPolicy(DocumentTypeID={quote_odata_string_key(document_type_id)},FileExtension={quote_odata_string_key(file_extension)})", service_base=_CONFIG_SERVICE_PATH, ) @@ -319,12 +345,24 @@ def delete_file_extension_policy(self, file_extension_policy_id: str) -> None: @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_ALL_APP_TENANTS) def get_all_application_tenants( - self, options: ConfigQueryOptions | None = None + self, + options: ConfigQueryOptions | None = None, + *, + subaccount_id: str | None = None, ) -> list[ApplicationTenant]: - """Return all application tenant configurations.""" + """Return all application tenant configurations. + + Args: + options: Optional OData query parameters. + subaccount_id: BTP subaccount ID. ADM requires this header + (``x-subaccount-id``) on ApplicationTenant operations. + """ params = options.to_query_params() if options else {} resp = self._http.get( - "ApplicationTenant", params=params, service_base=_CONFIG_SERVICE_PATH + "ApplicationTenant", + params=params, + service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return [ ApplicationTenant.from_dict(item) for item in resp.json().get("value", []) @@ -332,34 +370,61 @@ def get_all_application_tenants( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_CREATE_APP_TENANT) def create_application_tenant( - self, payload: CreateApplicationTenantInput + self, + payload: CreateApplicationTenantInput, + *, + subaccount_id: str | None = None, ) -> ApplicationTenant: - """Create an application tenant configuration.""" + """Create an application tenant configuration. + + Args: + payload: Tenant fields. + subaccount_id: BTP subaccount ID. ADM requires this header + (``x-subaccount-id``) on ApplicationTenant operations. + """ resp = self._http.post( "ApplicationTenant", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_APP_TENANT) - def get_application_tenant(self, application_tenant_id: str) -> ApplicationTenant: + def get_application_tenant( + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, + ) -> ApplicationTenant: """Fetch a single ApplicationTenant by its ID.""" resp = self._http.get( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_APP_TENANT) - def delete_application_tenant(self, application_tenant_id: str) -> None: + def delete_application_tenant( + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, + ) -> None: """Delete an application tenant configuration.""" self._http.delete( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) +def _subaccount_header(subaccount_id: str | None) -> dict[str, str] | None: + """Build the X-SubaccountId header dict, or None if not provided.""" + return {"X-SubaccountId": subaccount_id} if subaccount_id else None + + def _quote_guid(value: str) -> str: """Wrap a UUID value in the OData Edm.Guid format for key segments.""" from sap_cloud_sdk.adms._http import quote_odata_guid_key @@ -581,28 +646,28 @@ async def create_type_mapping( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCTYPE_BOTYPE_MAP) async def get_type_mapping( - self, document_type_bo_type_map_id: str + self, document_type_id: str, business_object_node_type_unique_id: str ) -> DocumentTypeBusinessObjectTypeMap: """Async variant of :meth:`_ConfigurationApi.get_type_mapping` — same semantics.""" resp = await self._http.get( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCTYPE_BOTYPE_MAP) - async def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: + async def delete_type_mapping(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Async variant of :meth:`_ConfigurationApi.delete_type_mapping` — same semantics.""" await self._http.delete( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_MARK_DEFAULT) - async def mark_default(self, document_type_bo_type_map_id: str) -> None: + async def mark_default(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Async variant of :meth:`_ConfigurationApi.mark_default` — same semantics.""" await self._http.post( - f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/markDefault", + f"{build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -614,7 +679,7 @@ async def get_all_file_extension_policies( """Async variant of :meth:`_ConfigurationApi.get_all_file_extension_policies` — same semantics.""" params = options.to_query_params() if options else {} resp = await self._http.get( - "FileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH + "DocumentTypeFileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH ) return [ FileExtensionPolicy.from_dict(item) for item in resp.json().get("value", []) @@ -626,39 +691,34 @@ async def create_file_extension_policy( ) -> FileExtensionPolicy: """Async variant of :meth:`_ConfigurationApi.create_file_extension_policy` — same semantics.""" resp = await self._http.post( - "FileExtensionPolicy", + "DocumentTypeFileExtensionPolicy", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, ) return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_FILE_EXT_POLICY) - async def get_file_extension_policy( - self, file_extension_policy_id: str - ) -> FileExtensionPolicy: - """Async variant of :meth:`_ConfigurationApi.get_file_extension_policy` — same semantics.""" - resp = await self._http.get( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", - service_base=_CONFIG_SERVICE_PATH, - ) - return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_FILE_EXT_POLICY) - async def delete_file_extension_policy(self, file_extension_policy_id: str) -> None: + async def delete_file_extension_policy(self, document_type_id: str, file_extension: str) -> None: """Async variant of :meth:`_ConfigurationApi.delete_file_extension_policy` — same semantics.""" await self._http.delete( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", + f"DocumentTypeFileExtensionPolicy(DocumentTypeID={quote_odata_string_key(document_type_id)},FileExtension={quote_odata_string_key(file_extension)})", service_base=_CONFIG_SERVICE_PATH, ) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_ALL_APP_TENANTS) async def get_all_application_tenants( - self, options: ConfigQueryOptions | None = None + self, + options: ConfigQueryOptions | None = None, + *, + subaccount_id: str | None = None, ) -> list[ApplicationTenant]: """Async variant of :meth:`_ConfigurationApi.get_all_application_tenants` — same semantics.""" params = options.to_query_params() if options else {} resp = await self._http.get( - "ApplicationTenant", params=params, service_base=_CONFIG_SERVICE_PATH + "ApplicationTenant", + params=params, + service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return [ ApplicationTenant.from_dict(item) for item in resp.json().get("value", []) @@ -666,29 +726,42 @@ async def get_all_application_tenants( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_CREATE_APP_TENANT) async def create_application_tenant( - self, payload: CreateApplicationTenantInput + self, + payload: CreateApplicationTenantInput, + *, + subaccount_id: str | None = None, ) -> ApplicationTenant: """Async variant of :meth:`_ConfigurationApi.create_application_tenant` — same semantics.""" resp = await self._http.post( "ApplicationTenant", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_APP_TENANT) async def get_application_tenant( - self, application_tenant_id: str + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, ) -> ApplicationTenant: """Async variant of :meth:`_ConfigurationApi.get_application_tenant` — same semantics.""" resp = await self._http.get( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_APP_TENANT) - async def delete_application_tenant(self, application_tenant_id: str) -> None: + async def delete_application_tenant( + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, + ) -> None: """Async variant of :meth:`_ConfigurationApi.delete_application_tenant` — same semantics.""" await self._http.delete( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", diff --git a/src/sap_cloud_sdk/adms/_document_api.py b/src/sap_cloud_sdk/adms/_document_api.py index ab1bebdd..6e0a1e0c 100644 --- a/src/sap_cloud_sdk/adms/_document_api.py +++ b/src/sap_cloud_sdk/adms/_document_api.py @@ -115,7 +115,7 @@ def get_download_url( document_relation_id: str, *, is_active_entity: bool = True, - doc_content_version_id: str, + doc_content_version_id: str | None = None, ) -> str: """Return a time-limited presigned download URL for a document. @@ -125,6 +125,7 @@ def get_download_url( document_relation_id: UUID of the parent DocumentRelation. is_active_entity: Active vs draft entity flag. doc_content_version_id: Content version to download (e.g. ``"1.0"``). + If ``None``, the latest version is downloaded. Returns: Presigned URL string. @@ -153,10 +154,13 @@ def get_download_url( f"Downloads are only permitted when state is CLEAN." ) - fn_key = ( - f"{rel_key}/DownloadDocument(" - f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" - ) + if doc_content_version_id is not None: + fn_key = ( + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" + f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" + ) + else: + fn_key = f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument()" resp = self._http.get(fn_key, service_base=_SERVICE_PATH) return resp.json().get("value", "") @@ -170,29 +174,21 @@ def update( ) -> Document: """Update document metadata via the bound ``UpdateDocument`` action. - ADM's UpdateDocument action returns only the changed fields. This - method transparently follows up with a GET to return the full Document. - Args: document_relation_id: UUID of the parent DocumentRelation. update_input: Fields to update (only non-None fields are sent). is_active_entity: Active vs draft entity flag. Returns: - Full updated :class:`~sap_cloud_sdk.adms._models.Document`. + Partial :class:`~sap_cloud_sdk.adms._models.Document` as returned + by the ADM ``UpdateDocument`` action (only changed fields populated). """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UpdateDocument" + + "/com.sap.adm.DocumentService.UpdateDocument" ) payload = {"Document": update_input.to_odata_dict()} - self._http.post(path, json=payload, service_base=_SERVICE_PATH) - # UpdateDocument returns only changed fields — fetch the full entity. - full_path = ( - build_relation_key_path(document_relation_id, is_active_entity) - + "/Document" - ) - resp = self._http.get(full_path, service_base=_SERVICE_PATH) + resp = self._http.post(path, json=payload, service_base=_SERVICE_PATH) return Document.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_DOCUMENTS_RESTORE_CONTENT_VERSION) @@ -217,7 +213,7 @@ def restore_content_version( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/RestoreDocumentContentVersion" + + "/com.sap.adm.DocumentService.RestoreDocumentContentVersion" ) payload: dict = { "DocumentContentVersion": { @@ -246,7 +242,7 @@ def delete_content_version( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/DeleteDocumentContentVersion" + + "/com.sap.adm.DocumentService.DeleteDocumentContentVersion" ) self._http.post( path, @@ -316,7 +312,7 @@ async def get_download_url( document_relation_id: str, *, is_active_entity: bool = True, - doc_content_version_id: str, + doc_content_version_id: str | None = None, ) -> str: """Async download URL fetch with scan-state gate.""" rel_key = build_relation_key_path(document_relation_id, is_active_entity) @@ -339,10 +335,13 @@ async def get_download_url( f"Downloads are only permitted when state is CLEAN." ) - fn_key = ( - f"{rel_key}/DownloadDocument(" - f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" - ) + if doc_content_version_id is not None: + fn_key = ( + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" + f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" + ) + else: + fn_key = f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument()" resp = await self._http.get(fn_key, service_base=_SERVICE_PATH) return resp.json().get("value", "") @@ -357,15 +356,10 @@ async def update( """Async variant of :meth:`_DocumentApi.update` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UpdateDocument" + + "/com.sap.adm.DocumentService.UpdateDocument" ) payload = {"Document": update.to_odata_dict()} - await self._http.post(path, json=payload, service_base=_SERVICE_PATH) - full_path = ( - build_relation_key_path(document_relation_id, is_active_entity) - + "/Document" - ) - resp = await self._http.get(full_path, service_base=_SERVICE_PATH) + resp = await self._http.post(path, json=payload, service_base=_SERVICE_PATH) return Document.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_DOCUMENTS_DELETE_CONTENT_VERSION) @@ -379,7 +373,7 @@ async def delete_content_version( """Async variant of :meth:`_DocumentApi.delete_content_version` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/DeleteDocumentContentVersion" + + "/com.sap.adm.DocumentService.DeleteDocumentContentVersion" ) await self._http.post( path, @@ -399,7 +393,7 @@ async def restore_content_version( """Async variant of :meth:`_DocumentApi.restore_content_version` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/RestoreDocumentContentVersion" + + "/com.sap.adm.DocumentService.RestoreDocumentContentVersion" ) payload: dict = { "DocumentContentVersion": { diff --git a/src/sap_cloud_sdk/adms/_http.py b/src/sap_cloud_sdk/adms/_http.py index 76a076ab..f4d8f3ae 100644 --- a/src/sap_cloud_sdk/adms/_http.py +++ b/src/sap_cloud_sdk/adms/_http.py @@ -128,11 +128,12 @@ def build_business_object_node_type_key_path(unique_id: str) -> str: ) -def build_doctype_botype_map_key_path(map_id: str) -> str: - """Return ``DocumentTypeBusinessObjectTypeMap(DocumentTypeBOTypeMapID=)``.""" +def build_doctype_botype_map_key_path(document_type_id: str, business_object_node_type_unique_id: str) -> str: + """Return ``DocumentTypeBusinessObjectTypeMap(DocumentTypeID='x',BusinessObjectNodeTypeUniqueID='y')``.""" return ( f"DocumentTypeBusinessObjectTypeMap(" - f"DocumentTypeBOTypeMapID={quote_odata_guid_key(map_id)})" + f"DocumentTypeID={quote_odata_string_key(document_type_id)}," + f"BusinessObjectNodeTypeUniqueID={quote_odata_string_key(business_object_node_type_unique_id)})" ) @@ -211,8 +212,15 @@ def get( *, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: - return self._request("GET", path, params=params, service_base=service_base) + return self._request( + "GET", + path, + params=params, + service_base=service_base, + extra_headers=extra_headers, + ) def post( self, @@ -221,9 +229,15 @@ def post( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: return self._send_with_csrf( - "POST", path, json=json, params=params, service_base=service_base + "POST", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) def delete( @@ -232,9 +246,14 @@ def delete( *, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: return self._send_with_csrf( - "DELETE", path, params=params, service_base=service_base + "DELETE", + path, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) def patch( @@ -244,9 +263,15 @@ def patch( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: return self._send_with_csrf( - "PATCH", path, json=json, params=params, service_base=service_base + "PATCH", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) def _send_with_csrf( @@ -257,8 +282,12 @@ def _send_with_csrf( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: csrf = self._get_csrf_token(service_base) + merged: dict[str, str] = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) try: return self._request( method, @@ -266,7 +295,7 @@ def _send_with_csrf( json=json, params=params, service_base=service_base, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) except HttpError as exc: if exc.status_code != 403: @@ -277,13 +306,16 @@ def _send_with_csrf( if self._csrf_tokens.get(service_base or "") == csrf: self._csrf_tokens.pop(service_base or "", None) csrf = self._get_csrf_token(service_base) + merged = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) return self._request( method, path, json=json, params=params, service_base=service_base, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) # ------------------------------------------------------------------ @@ -478,9 +510,13 @@ async def get( params: dict[str, Any] | None = None, service_base: str | None = None, headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._request( - "GET", self._prefixed(path, service_base), params=params + "GET", + self._prefixed(path, service_base), + params=params, + extra_headers=extra_headers, ) async def post( @@ -492,9 +528,15 @@ async def post( service_base: str | None = None, content: bytes | None = None, # accepted for LSP compat, ignored headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._send_with_csrf( - "POST", path, json=json, params=params, service_base=service_base + "POST", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) async def delete( @@ -504,9 +546,14 @@ async def delete( params: dict[str, Any] | None = None, service_base: str | None = None, headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._send_with_csrf( - "DELETE", path, params=params, service_base=service_base + "DELETE", + path, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) async def patch( @@ -517,9 +564,15 @@ async def patch( params: dict[str, Any] | None = None, service_base: str | None = None, headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._send_with_csrf( - "PATCH", path, json=json, params=params, service_base=service_base + "PATCH", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) async def _send_with_csrf( @@ -530,15 +583,19 @@ async def _send_with_csrf( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: csrf = await self._get_csrf_token(service_base) + merged: dict[str, str] = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) try: return await self._request( method, self._prefixed(path, service_base), json=json, params=params, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) except HttpError as exc: if exc.status_code != 403: @@ -549,12 +606,15 @@ async def _send_with_csrf( if self._csrf_tokens.get(service_base or "") == csrf: self._csrf_tokens.pop(service_base or "", None) csrf = await self._get_csrf_token(service_base) + merged = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) return await self._request( method, self._prefixed(path, service_base), json=json, params=params, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) # ------------------------------------------------------------------ diff --git a/src/sap_cloud_sdk/adms/_job_api.py b/src/sap_cloud_sdk/adms/_job_api.py index 5c932aba..18941a5a 100644 --- a/src/sap_cloud_sdk/adms/_job_api.py +++ b/src/sap_cloud_sdk/adms/_job_api.py @@ -16,6 +16,11 @@ from sap_cloud_sdk.adms.config import _ADMIN_SERVICE_PATH, _SERVICE_PATH from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +# Fully-qualified OData V4 unbound action / function paths. +# Unbound action/function import names — no namespace prefix (per EDMX ActionImport/FunctionImport). +_START_JOB_DOC_SERVICE = "StartJob" +_START_JOB_ADMIN_SERVICE = "StartJob" + class _JobApi: """Async job operations for the ADMS module. @@ -42,7 +47,9 @@ def start_zip_download(self, params: ZipDownloadJobParameters) -> JobOutput: "JobParameters": params.to_odata_dict(), } } - resp = self._http.post("StartJob", json=payload, service_base=_SERVICE_PATH) + resp = self._http.post( + _START_JOB_DOC_SERVICE, json=payload, service_base=_SERVICE_PATH + ) return JobOutput.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_JOBS_START_DELETE_USER_DATA) @@ -62,7 +69,9 @@ def start_delete_user_data(self, params: DeleteUserDataJobParameters) -> JobOutp } } resp = self._http.post( - "StartJob", json=payload, service_base=_ADMIN_SERVICE_PATH + _START_JOB_ADMIN_SERVICE, + json=payload, + service_base=_ADMIN_SERVICE_PATH, ) return JobOutput.from_dict(resp.json()) @@ -108,7 +117,7 @@ async def start_zip_download(self, params: ZipDownloadJobParameters) -> JobOutpu } } resp = await self._http.post( - "StartJob", json=payload, service_base=_SERVICE_PATH + _START_JOB_DOC_SERVICE, json=payload, service_base=_SERVICE_PATH ) return JobOutput.from_dict(resp.json()) @@ -124,7 +133,9 @@ async def start_delete_user_data( } } resp = await self._http.post( - "StartJob", json=payload, service_base=_ADMIN_SERVICE_PATH + _START_JOB_ADMIN_SERVICE, + json=payload, + service_base=_ADMIN_SERVICE_PATH, ) return JobOutput.from_dict(resp.json()) diff --git a/src/sap_cloud_sdk/adms/_models.py b/src/sap_cloud_sdk/adms/_models.py index 67b09913..40915379 100644 --- a/src/sap_cloud_sdk/adms/_models.py +++ b/src/sap_cloud_sdk/adms/_models.py @@ -838,19 +838,24 @@ class CreateBusinessObjectNodeTypeInput: business_object_node_type: Short identifier code (max 30 chars), e.g. ``"PO"``. business_object_node_type_name: Human-readable label (max 50 chars). application_tenant_id: Tenant this BO type belongs to. + odm_entity_name: Optional ODM (One Domain Model) entity name. """ business_object_node_type: str business_object_node_type_name: str application_tenant_id: str + odm_entity_name: str | None = None def to_odata_dict(self) -> dict: """Serialise to the OData payload shape expected by ADM.""" - return { + d: dict = { "BusinessObjectNodeType": self.business_object_node_type, "BusinessObjectNodeTypeName": self.business_object_node_type_name, "ApplicationTenantID": self.application_tenant_id, } + if self.odm_entity_name is not None: + d["ODMEntityName"] = self.odm_entity_name + return d @dataclass @@ -884,26 +889,23 @@ class DocumentTypeBusinessObjectTypeMap: to a business object. Attributes: - document_type_bo_type_map_id: Primary key UUID. - business_object_node_type_unique_id: FK to :class:`BusinessObjectNodeType`. - document_type_id: FK to :class:`DocumentType`. + document_type_id: FK to :class:`DocumentType` (part of composite key). + business_object_node_type_unique_id: FK to :class:`BusinessObjectNodeType` (part of composite key). is_default: If ``True`` this is the default type for the BO node type. """ - document_type_bo_type_map_id: str - business_object_node_type_unique_id: str document_type_id: str + business_object_node_type_unique_id: str is_default: bool = False @classmethod def from_dict(cls, data: dict) -> DocumentTypeBusinessObjectTypeMap: return cls( - document_type_bo_type_map_id=data.get("DocumentTypeBOTypeMapID", ""), + document_type_id=data.get("DocumentTypeID", ""), business_object_node_type_unique_id=data.get( "BusinessObjectNodeTypeUniqueID", "" ), - document_type_id=data.get("DocumentTypeID", ""), - is_default=data.get("IsDefault", False), + is_default=data.get("DocumentTypeIsDefault", False), ) def to_odata_dict(self) -> dict: @@ -911,7 +913,6 @@ def to_odata_dict(self) -> dict: return { "BusinessObjectNodeTypeUniqueID": self.business_object_node_type_unique_id, "DocumentTypeID": self.document_type_id, - "IsDefault": self.is_default, } @@ -922,19 +923,16 @@ class CreateDocumentTypeBoTypeMapInput: Attributes: business_object_node_type_unique_id: The BO node type UUID to map. document_type_id: The document type code to allow. - is_default: Whether this mapping is the default for the BO node type. """ business_object_node_type_unique_id: str document_type_id: str - is_default: bool = False def to_odata_dict(self) -> dict: """Serialise to the OData payload shape expected by ADM.""" return { "BusinessObjectNodeTypeUniqueID": self.business_object_node_type_unique_id, "DocumentTypeID": self.document_type_id, - "IsDefault": self.is_default, } @@ -1205,49 +1203,35 @@ def from_dict(cls, data: dict) -> DeleteBusinessObjectNodeResult: # --------------------------------------------------------------------------- -# FileExtensionPolicy model +# DocumentTypeFileExtensionPolicy model # --------------------------------------------------------------------------- -class MimeTypePolicy(str, Enum): - """Controls whether a file extension is allowed or blocked.""" - - ALLOW = "A" - BLOCK = "B" - - @dataclass class FileExtensionPolicy: - """Tenant-level file extension allow/block policy. + """Mapping that restricts which file extensions are allowed for a document type. - ADM checks this list before accepting an upload. + ADM entity set: ``DocumentTypeFileExtensionPolicy``. + Composite key: ``DocumentTypeID`` + ``FileExtension``. Attributes: - file_extension_policy_id: Primary key UUID. - file_extension_policy_option: ``ALLOW`` (``"A"``) or ``BLOCK`` (``"B"``). + document_type_id: FK to :class:`DocumentType`. file_extension: File extension string, e.g. ``"pdf"``, ``"exe"``. """ - file_extension_policy_id: str - file_extension_policy_option: MimeTypePolicy + document_type_id: str file_extension: str @classmethod def from_dict(cls, data: dict) -> FileExtensionPolicy: - option_raw = data.get("FileExtensionPolicyOption", MimeTypePolicy.ALLOW.value) - try: - option = MimeTypePolicy(option_raw) - except ValueError: - option = MimeTypePolicy.ALLOW return cls( - file_extension_policy_id=data.get("FileExtensionPolicyID", ""), - file_extension_policy_option=option, + document_type_id=data.get("DocumentTypeID", ""), file_extension=data.get("FileExtension", ""), ) def to_odata_dict(self) -> dict: return { - "FileExtensionPolicyOption": self.file_extension_policy_option.value, + "DocumentTypeID": self.document_type_id, "FileExtension": self.file_extension, } @@ -1257,16 +1241,16 @@ class CreateFileExtensionPolicyInput: """Input for creating a :class:`FileExtensionPolicy` entry. Attributes: - file_extension_policy_option: ``MimeTypePolicy.ALLOW`` or ``MimeTypePolicy.BLOCK``. - file_extension: File extension to allow/block (e.g. ``"pdf"``). + document_type_id: The document type to associate the extension with. + file_extension: File extension to allow (e.g. ``"pdf"``). """ - file_extension_policy_option: MimeTypePolicy + document_type_id: str file_extension: str def to_odata_dict(self) -> dict: return { - "FileExtensionPolicyOption": self.file_extension_policy_option.value, + "DocumentTypeID": self.document_type_id, "FileExtension": self.file_extension, } diff --git a/src/sap_cloud_sdk/adms/_relation_api.py b/src/sap_cloud_sdk/adms/_relation_api.py index b5a5dfe2..2f189a83 100644 --- a/src/sap_cloud_sdk/adms/_relation_api.py +++ b/src/sap_cloud_sdk/adms/_relation_api.py @@ -123,7 +123,7 @@ def generate_upload_urls( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/GenerateDocumentUploadURLs" + + "/com.sap.adm.DocumentService.GenerateDocumentUploadURLs" ) payload = { "DocumentIsMultipart": is_multipart, @@ -147,7 +147,7 @@ def complete_multipart_upload( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/CompleteMultipartUpload" + + "/com.sap.adm.DocumentService.CompleteMultipartUpload" ) self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -161,7 +161,7 @@ def lock( """Lock a document and its relation to prevent concurrent modifications.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/LockDocumentAndRelation" + + "/com.sap.adm.DocumentService.LockDocumentAndRelation" ) self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -175,7 +175,7 @@ def unlock( """Unlock a previously locked document and relation.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UnlockDocumentAndRelation" + + "/com.sap.adm.DocumentService.UnlockDocumentAndRelation" ) self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -386,7 +386,7 @@ async def generate_upload_urls( """Async variant of :meth:`_DocumentRelationApi.generate_upload_urls` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/GenerateDocumentUploadURLs" + + "/com.sap.adm.DocumentService.GenerateDocumentUploadURLs" ) payload = { "DocumentIsMultipart": is_multipart, @@ -405,7 +405,7 @@ async def complete_multipart_upload( """Async variant of :meth:`_DocumentRelationApi.complete_multipart_upload` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/CompleteMultipartUpload" + + "/com.sap.adm.DocumentService.CompleteMultipartUpload" ) await self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -419,7 +419,7 @@ async def lock( """Async variant of :meth:`_DocumentRelationApi.lock` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/LockDocumentAndRelation" + + "/com.sap.adm.DocumentService.LockDocumentAndRelation" ) await self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -433,7 +433,7 @@ async def unlock( """Async variant of :meth:`_DocumentRelationApi.unlock` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UnlockDocumentAndRelation" + + "/com.sap.adm.DocumentService.UnlockDocumentAndRelation" ) await self._http.post(path, json={}, service_base=_SERVICE_PATH) diff --git a/tests/adms/unit/test_client.py b/tests/adms/unit/test_client.py index f789428a..8ded5cb6 100644 --- a/tests/adms/unit/test_client.py +++ b/tests/adms/unit/test_client.py @@ -734,7 +734,7 @@ def test_update_calls_bound_action(self): doc = api.update("11111111-1111-1111-1111-111111111111", upd) call_path = http.post.call_args[0][0] - assert "UpdateDocument" in call_path + assert "com.sap.adm.DocumentService.UpdateDocument" in call_path assert isinstance(doc, Document) def test_update_sends_only_set_fields(self): @@ -759,7 +759,7 @@ def test_restore_content_version(self): ) call_path = http.post.call_args[0][0] - assert "RestoreDocumentContentVersion" in call_path + assert "com.sap.adm.DocumentService.RestoreDocumentContentVersion" in call_path payload = http.post.call_args[1]["json"] assert payload["DocumentContentVersion"]["DocContentVersionID"] == "1.0" assert payload["DocumentContentVersion"]["DocContentVersionComment"] == "Revert" @@ -773,7 +773,7 @@ def test_delete_content_version(self): api.delete_content_version("11111111-1111-1111-1111-111111111111", "2.0") call_path = http.post.call_args[0][0] - assert "DeleteDocumentContentVersion" in call_path + assert "com.sap.adm.DocumentService.DeleteDocumentContentVersion" in call_path assert http.post.call_args[1]["json"]["DocContentVersionID"] == "2.0" @@ -1021,7 +1021,7 @@ def test_generate_upload_urls_calls_action(self): doc = api.generate_upload_urls("11111111-1111-1111-1111-111111111111") call_path = http.post.call_args[0][0] - assert "GenerateDocumentUploadURLs" in call_path + assert "com.sap.adm.DocumentService.GenerateDocumentUploadURLs" in call_path assert doc.document_content_upload_urls == ["https://s3.example.com/upload-url"] def test_complete_multipart_upload(self): @@ -1031,7 +1031,7 @@ def test_complete_multipart_upload(self): api.complete_multipart_upload("11111111-1111-1111-1111-111111111111") call_path = http.post.call_args[0][0] - assert "CompleteMultipartUpload" in call_path + assert "com.sap.adm.DocumentService.CompleteMultipartUpload" in call_path class TestDocumentRelationApiLockDelete: @@ -1039,13 +1039,19 @@ def test_lock(self): http = _rel_http() api = _DocumentRelationApi(http) api.lock("11111111-1111-1111-1111-111111111111") - assert "LockDocumentAndRelation" in http.post.call_args[0][0] + assert ( + "com.sap.adm.DocumentService.LockDocumentAndRelation" + in http.post.call_args[0][0] + ) def test_unlock(self): http = _rel_http() api = _DocumentRelationApi(http) api.unlock("11111111-1111-1111-1111-111111111111") - assert "UnlockDocumentAndRelation" in http.post.call_args[0][0] + assert ( + "com.sap.adm.DocumentService.UnlockDocumentAndRelation" + in http.post.call_args[0][0] + ) def test_delete_calls_http_delete(self): http = _rel_http()