From 9c2fb841dc5bc30504123aaafaf166fed2122015 Mon Sep 17 00:00:00 2001 From: i743000 Date: Wed, 24 Jun 2026 10:36:34 +0530 Subject: [PATCH 1/5] fix(adms): align all action paths with com.sap.adm.{Service} namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors cloud-sdk-java contrib-java/adms commit e5dd1da's wire-format audit. The Java audit identified 12 fixes against the canonical CDS sources + live ADMS tenant. Cross-verified Python state vs CDS: - 7 fixes were already correct in Python (UpdateDocumentInput fields, RestoreContentVersion envelope, DeleteContentVersion body shape, DownloadDocument GET function, StartJob payload, JobStatus function, LateHostBusinessObjectNodeID casing). - 5 namespace-prefix fixes were genuinely missing (lock, unlock, complete_multipart_upload, generate_upload_urls, mark_default) — Python used the short form which CAP accepts leniently but is not the canonical OData V4 wire format. For cross-SDK consistency with Java, ALL 12 bound/unbound action paths now use the fully-qualified com.sap.adm.{DocumentService,AdminService, ConfigurationService}.X form: DocumentService bound actions on DocumentRelation: UpdateDocument, CompleteMultipartUpload, RestoreDocumentContentVersion, DeleteDocumentContentVersion, LockDocumentAndRelation, UnlockDocumentAndRelation, GenerateDocumentUploadURLs, DownloadDocument (function) DocumentService unbound: StartJob, JobStatus (function) AdminService unbound: StartJob, JobStatus (function) ConfigurationService bound on DocumentTypeBusinessObjectTypeMap: markDefault Test changes: - Tightened 7 substring assertions in test_client.py to include the namespace prefix so the wire contract is captured in tests. Also includes prior work on this branch: - extra_headers support on sync + async HTTP verbs (for x-subaccount-id header on ApplicationTenant operations) - subaccount_id parameter on all 4 ApplicationTenant config methods No API surface changes, no model changes, no behavioural changes — purely wire-protocol alignment with Java commit e5dd1da. --- src/sap_cloud_sdk/adms/__init__.py | 22 ++++- src/sap_cloud_sdk/adms/_configuration_api.py | 86 ++++++++++++++++---- src/sap_cloud_sdk/adms/_document_api.py | 16 ++-- src/sap_cloud_sdk/adms/_http.py | 83 ++++++++++++++++--- src/sap_cloud_sdk/adms/_job_api.py | 36 ++++++-- src/sap_cloud_sdk/adms/_relation_api.py | 16 ++-- tests/adms/unit/test_client.py | 20 +++-- 7 files changed, 221 insertions(+), 58 deletions(-) diff --git a/src/sap_cloud_sdk/adms/__init__.py b/src/sap_cloud_sdk/adms/__init__.py index c420626d..13f69c43 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,10 +80,12 @@ DraftActivateInput, DraftAdministrativeData, DraftInput, + FileExtensionPolicy, JobInput, JobOutput, JobStatus, JobType, + MimeTypePolicy, ScanStatus, UpdateAllowedDomainInput, UpdateBusinessObjectNodeTypeInput, @@ -113,8 +121,11 @@ "ScanNotCleanError", # models — core "BaseType", + "ChangeLog", + "BusinessObjectNodeChangeLog", "CreateDocumentInput", "CreateDocumentRelationInput", + "DeleteBusinessObjectNodeResult", "DeleteUserDataJobParameters", "Document", "DocumentContentVersion", @@ -126,22 +137,27 @@ "JobOutput", "JobStatus", "JobType", + "MimeTypePolicy", "ScanStatus", "UpdateDocumentInput", "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..a537378b 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -264,7 +264,7 @@ def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: def mark_default(self, document_type_bo_type_map_id: str) -> None: """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default.""" 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_bo_type_map_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -319,12 +319,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 +344,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-subaccount-id header dict, or None if not provided.""" + return {"x-subaccount-id": 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 @@ -602,7 +641,7 @@ async def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: async def mark_default(self, document_type_bo_type_map_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_bo_type_map_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -653,12 +692,18 @@ async def delete_file_extension_policy(self, file_extension_policy_id: str) -> N @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 +711,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..0ccf7ba9 100644 --- a/src/sap_cloud_sdk/adms/_document_api.py +++ b/src/sap_cloud_sdk/adms/_document_api.py @@ -154,7 +154,7 @@ def get_download_url( ) fn_key = ( - f"{rel_key}/DownloadDocument(" + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" ) resp = self._http.get(fn_key, service_base=_SERVICE_PATH) @@ -183,7 +183,7 @@ def update( """ 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) @@ -217,7 +217,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 +246,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, @@ -340,7 +340,7 @@ async def get_download_url( ) fn_key = ( - f"{rel_key}/DownloadDocument(" + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" ) resp = await self._http.get(fn_key, service_base=_SERVICE_PATH) @@ -357,7 +357,7 @@ 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) @@ -379,7 +379,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 +399,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..f08109b4 100644 --- a/src/sap_cloud_sdk/adms/_http.py +++ b/src/sap_cloud_sdk/adms/_http.py @@ -211,8 +211,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 +228,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 +245,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 +262,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 +281,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 +294,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 +305,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 +509,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 +527,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 +545,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 +563,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 +582,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 +605,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..181d0db3 100644 --- a/src/sap_cloud_sdk/adms/_job_api.py +++ b/src/sap_cloud_sdk/adms/_job_api.py @@ -16,6 +16,14 @@ 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. +# The Java SDK (cloud-sdk-java contrib-java/adms commit e5dd1da) confirmed +# the canonical wire format includes the namespace prefix. +_START_JOB_DOC_SERVICE = "com.sap.adm.DocumentService.StartJob" +_START_JOB_ADMIN_SERVICE = "com.sap.adm.AdminService.StartJob" +_JOB_STATUS_DOC_SERVICE_NS = "com.sap.adm.DocumentService" +_JOB_STATUS_ADMIN_SERVICE_NS = "com.sap.adm.AdminService" + class _JobApi: """Async job operations for the ADMS module. @@ -42,7 +50,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 +72,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()) @@ -84,7 +96,12 @@ def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - path = build_job_status_key_path(job_id) + ns = ( + _JOB_STATUS_ADMIN_SERVICE_NS + if use_admin_service + else _JOB_STATUS_DOC_SERVICE_NS + ) + path = f"{ns}.{build_job_status_key_path(job_id)}" resp = self._http.get(path, service_base=service) return JobOutput.from_dict(resp.json()) @@ -108,7 +125,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 +141,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()) @@ -146,6 +165,11 @@ async def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - path = build_job_status_key_path(job_id) + ns = ( + _JOB_STATUS_ADMIN_SERVICE_NS + if use_admin_service + else _JOB_STATUS_DOC_SERVICE_NS + ) + path = f"{ns}.{build_job_status_key_path(job_id)}" resp = await self._http.get(path, service_base=service) return JobOutput.from_dict(resp.json()) 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() From d5f354f89ce2456c9781a3c7a90b4f4ef10d0553 Mon Sep 17 00:00:00 2001 From: i743000 Date: Wed, 24 Jun 2026 11:02:17 +0530 Subject: [PATCH 2/5] chore(adms): temporarily add scripts/adms_cli.py for live tenant testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive CLI for testing all ADMS APIs against a real ADM instance. Used during this PR's development to verify each of the 12 wire-format fixes returns the expected response from the live tenant. WILL BE REMOVED BEFORE MERGE — kept in this PR only as a debugging aid so reviewers can reproduce the live-tenant verification if desired. Usage: set -a && source .env.adms && set +a .venv/bin/python scripts/adms_cli.py --- scripts/adms_cli.py | 1534 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1534 insertions(+) create mode 100755 scripts/adms_cli.py diff --git a/scripts/adms_cli.py b/scripts/adms_cli.py new file mode 100755 index 00000000..a2409e64 --- /dev/null +++ b/scripts/adms_cli.py @@ -0,0 +1,1534 @@ +#!/usr/bin/env python +"""Interactive CLI for testing the ADMS SDK. + +Exposes every public API on :class:`AdmsClient`: + + * ``client.documents`` — Document entity (list/get/update/download URL, + restore + soft-delete content versions). + * ``client.relations`` — DocumentRelation entity (list/get/delete, + presigned upload URLs, multipart completion, lock/unlock, + full draft lifecycle: create / validate / activate / discard). + * ``client.config`` — Tenant configuration (AllowedDomain, DocumentType, + BusinessObjectNodeType, DocumentType↔BO type maps — full CRUD). + * ``client.jobs`` — Async jobs (ZIP_DOWNLOAD, DELETE_USER_DATA, status poll). + +Usage: + # Load creds from .env.adms and run interactively + set -a && source .env.adms && set +a + .venv/bin/python scripts/adms_cli.py + + # Or pass a specific command directly (see "Commands" below) + .venv/bin/python scripts/adms_cli.py relations list + .venv/bin/python scripts/adms_cli.py documents update + .venv/bin/python scripts/adms_cli.py config maps + +Commands: + RELATIONS + relations list — list all DocumentRelations + relations get — get single relation + relations delete — delete a relation + relations create-draft — start a draft for a BO node + relations validate-draft — validate a draft before activation + relations activate-draft — activate a draft + relations discard-draft — discard a draft without activation + relations upload-urls — generate presigned upload URLs + relations complete-upload — finalise a multipart upload + relations lock — lock document & relation + relations unlock — release lock + DOCUMENTS + documents list — list all Documents + documents get — Document linked to a relation + documents update — update document metadata + documents download — presigned download URL + documents restore — restore a previous content version + documents delete-version — soft-delete a content version + CONFIG + config domains — list AllowedDomains + config domains-create — register an AllowedDomain + config domains-delete — remove an AllowedDomain + config doctypes — list DocumentTypes + config doctypes-create — create a DocumentType + config doctypes-delete — delete a DocumentType + config botypes — list BusinessObjectNodeTypes + config botypes-create — register a BusinessObjectNodeType + config botypes-delete — delete a BusinessObjectNodeType + config maps — list DocumentType ↔ BO type mappings + config maps-create — create a mapping + config maps-delete — delete a mapping + JOBS + jobs status — poll DocumentService job status + jobs zip — start ZIP_DOWNLOAD job + jobs delete-user-data — start DELETE_USER_DATA job (admin) +""" + +from __future__ import annotations + +import json +import os +import sys +import textwrap +from typing import Optional + +# ── ensure src/ is on the path when run from the repo root ────────────────── +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(_REPO_ROOT, "src")) + +from sap_cloud_sdk.adms.client import AdmsClient, create_client +from sap_cloud_sdk.adms.config import load_from_env_or_mount +from sap_cloud_sdk.adms.exceptions import ( + AdmsError, + DocumentNotFoundError, + HttpError, + ScanNotCleanError, +) +from sap_cloud_sdk.adms._models import ( + BaseType, + CreateAllowedDomainInput, + CreateApplicationTenantInput, + CreateBusinessObjectNodeTypeInput, + CreateDocumentInput, + CreateDocumentRelationInput, + CreateDocumentTypeBoTypeMapInput, + CreateDocumentTypeInput, + CreateFileExtensionPolicyInput, + DeleteUserDataJobParameters, + DraftActivateInput, + DraftInput, + MimeTypePolicy, + UpdateAllowedDomainInput, + UpdateBusinessObjectNodeTypeInput, + UpdateDocumentInput, + UpdateDocumentTypeInput, + ZipDownloadJobParameters, +) + +# ── pretty-printing helpers ────────────────────────────────────────────────── + +# Maps Python snake_case field names → OData PascalCase wire key names +# so CLI output matches Postman / Bruno responses exactly. +_WIRE_KEYS: dict[str, str] = { + # DocumentRelation + "document_relation_id": "DocumentRelationID", + "business_object_node_type_unique_id": "BusinessObjectNodeTypeUniqueID", + "host_business_object_node_id": "HostBusinessObjectNodeID", + "host_business_obj_node_display_id": "HostBusinessObjNodeDisplayID", + "document_id": "DocumentID", + "document_is_active_entity": "DocumentIsActiveEntity", + "is_active_entity": "IsActiveEntity", + "has_active_entity": "HasActiveEntity", + "has_draft_entity": "HasDraftEntity", + "document_relation_is_locked": "DocumentRelationIsLocked", + "document_relation_is_deleted": "DocumentRelationIsDeleted", + "document_relation_is_output_relevant": "DocumentRelationIsOutputRelevant", + "draft_messages": "DraftMessages", + "draft_administrative_data": "DraftAdministrativeData", + # DraftAdministrativeData fields + "draft_uuid": "DraftUUID", + "creation_date_time": "CreationDateTime", + "created_by_user": "CreatedByUser", + "draft_is_created_by_me": "DraftIsCreatedByMe", + "last_change_date_time": "LastChangeDateTime", + "last_changed_by_user": "LastChangedByUser", + "in_process_by_user": "InProcessByUser", + "draft_is_processed_by_me": "DraftIsProcessedByMe", + "doc_relation_created_by_user_name": "DocRelationCreatedByUserName", + "doc_relation_created_at_date_time": "DocRelationCreatedAtDateTime", + "doc_relation_changed_by_user_name": "DocRelationChangedByUserName", + "doc_relation_changed_at_date_time": "DocRelationChangedAtDateTime", + "document": "Document", # expanded DocumentRelation → Document nav property + # Document + "document_name": "DocumentName", + "document_base_type": "DocumentBaseType", + "document_type_id": "DocumentTypeID", + "document_state": "DocumentState", + "document_mime_type": "DocumentMimeType", + "document_description": "DocumentDescription", + "document_size_in_byte": "DocumentSizeInByte", + "document_content_stream_uri": "DocumentContentStreamURI", + "document_external_content_url": "DocumentExternalContentURL", + "document_is_locked": "DocumentIsLocked", + "document_is_soft_deleted": "DocumentIsSoftDeleted", + "has_active_document_entity": "HasActiveDocumentEntity", + "has_draft_document_entity": "HasDraftDocumentEntity", + "draft_uuid": "DraftUUID", + "document_content_upload_urls": "DocumentContentUploadURLs", + "document_is_multi_referenced": "DocumentIsMultiReferenced", + "document_created_by_user_name": "DocumentCreatedByUserName", + "document_created_at_date_time": "DocumentCreatedAtDateTime", + "document_changed_by_user_name": "DocumentChangedByUserName", + "document_changed_at_date_time": "DocumentChangedAtDateTime", + # DocumentContentVersion + "doc_content_version_id": "DocContentVersionID", + "doc_content_version_state": "DocContentVersionState", + "doc_content_version_name": "DocContentVersionName", + "doc_content_version_comment": "DocContentVersionComment", + "doc_content_version_is_latest": "DocContentVersionIsLatest", + "doc_content_version_mime_type": "DocContentVersionMimeType", + "doc_content_version_size_in_byte": "DocContentVersionSizeInByte", + "doc_content_version_stream_uri": "DocContentVersionStreamURI", + "doc_content_version_content_hash": "DocContentVersionContentHash", + "doc_content_version_upload_id": "DocContentVersionUploadID", + "doc_content_version_is_soft_deleted": "DocContentVersionIsSoftDeleted", + # AllowedDomain + "allowed_domain_id": "AllowedDomainID", + "allowed_domain_host_name": "AllowedDomainHostName", + "allowed_domain_protocol": "AllowedDomainProtocol", + "allowed_domain_port": "AllowedDomainPort", + # DocumentType + "document_type_name": "DocumentTypeName", + "document_type_description": "DocumentTypeDescription", + # BusinessObjectNodeType + "business_object_node_type": "BusinessObjectNodeType", + "business_object_node_type_name": "BusinessObjectNodeTypeName", + "odm_entity_name": "ODMEntityName", + "application_tenant_id": "ApplicationTenantID", + # DocumentTypeBusinessObjectTypeMap + "document_type_bo_type_map_id": "DocumentTypeBOTypeMapID", + "is_default": "IsDefault", + # JobOutput + "job_id": "JobID", + "job_status": "JobStatus", + "job_result": "JobResult", + "job_error_details": "JobErrorDetails", + "job_progress_percentage": "JobProgressPercentage", +} + + +def _to_jsonable(obj): + """Recursively convert dataclasses / enums to JSON-serialisable dicts. + + Uses Python snake_case field names — the SDK model as application code sees it. + """ + from enum import Enum + from dataclasses import fields, is_dataclass + + if isinstance(obj, Enum): + return obj.value + elif is_dataclass(obj) and not isinstance(obj, type): + return {f.name: _to_jsonable(getattr(obj, f.name)) for f in fields(obj)} + elif isinstance(obj, list): + return [_to_jsonable(i) for i in obj] + return obj + + +def _print_json(obj) -> None: + """Print a dataclass or dict as indented JSON.""" + print(json.dumps(_to_jsonable(obj), indent=2)) + + +def _print_list(items, label: str) -> None: + print(f"\n{'─' * 62}") + print(f" {label} ({len(items)} items)") + print(f"{'─' * 62}") + for item in items: + _print_json(item) + print() + + +# ── raw response capture ────────────────────────────────────────────────────── +# When raw mode is on, every HTTP response body is printed verbatim (wire JSON) +# before the parsed SDK model is shown — identical to Postman output. + +_raw_mode = False +_last_raw_responses: list[str] = [] + + +def _patch_http_for_raw(client: AdmsClient) -> None: + """Monkey-patch AdmsHttp._request to capture raw response bodies.""" + from sap_cloud_sdk.adms._http import AdmsHttp + + original_request = AdmsHttp._request + + def _capturing_request(self, method, path, **kwargs): + resp = original_request(self, method, path, **kwargs) + try: + body = resp.json() + _last_raw_responses.append(json.dumps(body, indent=2)) + except Exception: + _last_raw_responses.append(resp.text) + return resp + + AdmsHttp._request = _capturing_request # type: ignore[method-assign] + + +def _print_raw_if_enabled(label: str = "Raw API response") -> None: + """Print the most recent captured raw response(s) if raw mode is on.""" + if not _raw_mode or not _last_raw_responses: + return + print(f"\n ── {label} (wire JSON) ──") + for body in _last_raw_responses: + print(body) + print() + + +def _clear_raw_captures() -> None: + _last_raw_responses.clear() + + +def _prompt(prompt: str, default: Optional[str] = None) -> str: + value = input(f" {prompt}: ").strip() + if not value and default: + return default + return value + + +def _prompt_optional(prompt: str) -> Optional[str]: + """Prompt for an optional value — empty input returns None.""" + value = input(f" {prompt} (optional): ").strip() + return value or None + + +def _prompt_int(prompt: str, default: Optional[int] = None) -> Optional[int]: + label = f" (optional, default {default})" if default is not None else " (optional)" + raw = input(f" {prompt}{label}: ").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + _err(f" Invalid integer: {raw!r}. Skipping.") + return None + + +def _prompt_bool(prompt: str, default: bool = False) -> bool: + default_label = "y" if default else "n" + raw = input(f" {prompt} (y/n, default {default_label}): ").strip().lower() + if not raw: + return default + return raw in ("y", "yes", "1", "true") + + +def _confirm(question: str) -> bool: + return input(f"\n {question} (y/n): ").strip().lower() == "y" + + +def _ok(msg: str) -> None: + print(f"\n ✓ {msg}\n") + + +def _err(msg: str) -> None: + print(f"\n ✗ {msg}\n", file=sys.stderr) + + +def _err_exc(label: str, exc: Exception) -> None: + """Print a user-friendly error, including the raw ADM response body when available.""" + _err(f"{label}: {exc}") + if isinstance(exc, HttpError) and exc.response_text: + print(f" ADM response: {exc.response_text}\n", file=sys.stderr) + + +# ── build client ───────────────────────────────────────────────────────────── + + +def _build_client() -> AdmsClient: + try: + config = load_from_env_or_mount("default") + except Exception as exc: + _err(f"Could not load ADMS config: {exc}") + _err("Make sure you ran: set -a && source .env.adms && set +a") + sys.exit(1) + client = create_client(config=config) + print(f" Connected to: {config.service_url}") + return client + + +# ── RELATIONS handlers ─────────────────────────────────────────────────────── + + +def cmd_relations_list(client: AdmsClient) -> None: + print("\nFetching all DocumentRelations …") + items = client.relations.get_all() + _print_list(items, "DocumentRelations") + + +def cmd_relations_get(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(f"\nFetching DocumentRelation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") + try: + rel = client.relations.get(relation_id, is_active_entity=is_active) + _print_json(rel) + except DocumentNotFoundError: + _err(f"Relation {relation_id!r} not found.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_delete(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + if not _confirm(f"Delete relation {relation_id!r} (IsActiveEntity={str(is_active).lower()})?"): + print(" Aborted.") + return + try: + client.relations.delete(relation_id, is_active_entity=is_active) + _ok(f"Deleted {relation_id}") + except DocumentNotFoundError: + _err(f"Relation {relation_id!r} not found.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def _prompt_draft_input() -> Optional[DraftInput]: + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both fields are required.") + return None + return DraftInput( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + ) + + +def cmd_relations_create_draft(client: AdmsClient) -> None: + print("\n── Create draft DocumentRelations for a BO node ────────────") + draft = _prompt_draft_input() + if not draft: + return + try: + items = client.relations.create_draft(draft) + _print_list(items, "Draft DocumentRelations") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_validate_draft(client: AdmsClient) -> None: + print("\n── Validate draft DocumentRelations ────────────────────────") + draft = _prompt_draft_input() + if not draft: + return + try: + items = client.relations.validate_draft(draft) + _print_list(items, "Validated draft DocumentRelations") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_activate_draft(client: AdmsClient) -> None: + print("\n── Activate draft DocumentRelations ────────────────────────") + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both fields are required.") + return + late = _prompt_optional("LateHostBusinessObjectNodeID") + activate = DraftActivateInput( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + late_host_business_object_node_id=late, + ) + try: + items = client.relations.activate_draft(activate) + _print_list(items, "Activated DocumentRelations") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_discard_draft(client: AdmsClient) -> None: + print("\n── Discard draft DocumentRelations ─────────────────────────") + draft = _prompt_draft_input() + if not draft: + return + if not _confirm("Discard the draft? This cannot be undone."): + print(" Aborted.") + return + try: + client.relations.discard_draft(draft) + _ok("Draft discarded.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_upload_urls(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + is_multipart = _prompt_bool("Multipart upload?", default=False) + no_of_parts = _prompt_int("Number of parts", default=1) or 1 + print(f"\nGenerating upload URLs for {relation_id} …") + try: + doc = client.relations.generate_upload_urls( + relation_id, + is_active_entity=is_active, + is_multipart=is_multipart, + no_of_parts=no_of_parts, + ) + _ok(f"Generated {len(doc.document_content_upload_urls)} upload URL(s).") + _print_json(doc) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_complete_upload(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(f"\nCompleting multipart upload for {relation_id} …") + try: + client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) + _ok("Multipart upload completed.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_lock(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + try: + client.relations.lock(relation_id, is_active_entity=is_active) + _ok(f"Locked {relation_id}.") + except AdmsError as exc: + _err_exc("Lock failed", exc) + + +def cmd_relations_unlock(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + try: + client.relations.unlock(relation_id, is_active_entity=is_active) + _ok(f"Unlocked {relation_id}.") + except AdmsError as exc: + _err_exc("Unlock failed", exc) + + +def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: + """Generate upload URLs, PUT file to GCS, then complete the upload.""" + import os + + file_path = _prompt("Local file path (absolute or relative)") + if not file_path or not os.path.isfile(file_path): + _err(f"File not found: {file_path!r}") + return + file_name = os.path.basename(file_path) + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + is_multipart = _prompt_bool("Multipart upload?", default=False) + no_of_parts = _prompt_int("Number of parts", default=1) or 1 + + print(f"\nStep 1 — Generating upload URL(s) for relation {relation_id} …") + try: + doc = client.relations.generate_upload_urls( + relation_id, + is_active_entity=is_active, + is_multipart=is_multipart, + no_of_parts=no_of_parts, + ) + except AdmsError as exc: + _err_exc("Failed to generate upload URLs", exc) + return + + urls = doc.document_content_upload_urls + if not urls: + _err("No upload URLs returned by ADM.") + return + _ok(f"Got {len(urls)} URL(s).") + upload_url = urls[0] + + # The x-goog-meta-filename value was baked into the GCS signature by ADM + # at URL-generation time. We must send the document name ADM registered, + # not the local file name — a mismatch causes SignatureDoesNotMatch. + adm_filename = doc.document_name or file_name + + print(f"\nStep 2 — Uploading {file_name!r} as '{adm_filename}' to GCS …") + try: + import urllib.request as _urllib_request + + with open(file_path, "rb") as f: + file_bytes = f.read() + + req = _urllib_request.Request( + upload_url, + data=file_bytes, + method="PUT", + headers={"x-goog-meta-filename": adm_filename}, + ) + try: + with _urllib_request.urlopen(req, timeout=120) as response: + status = response.status + except _urllib_request.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + _err(f"GCS upload failed: HTTP {exc.code} — {body[:400]}") + return + if status not in (200, 204): + _err(f"GCS upload failed: HTTP {status}") + return + _ok(f"Uploaded {len(file_bytes):,} bytes.") + except Exception as exc: + _err(f"Upload error: {exc}") + return + + if is_multipart: + print(f"\nStep 3 — Completing multipart upload for {relation_id} …") + try: + client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) + _ok("Multipart upload completed.") + except AdmsError as exc: + _err_exc("Complete upload failed", exc) + else: + _ok("Single-part upload complete — no rcu step needed.") + + +def cmd_relations_delete_bo_node(client: AdmsClient) -> None: + print("\n── Delete all DocumentRelations for a BO node ──────────────") + print(" ⚠ This permanently deletes ALL relations for the given BO node.") + draft = _prompt_draft_input() + if not draft: + return + if not _confirm("Delete ALL relations for this BO node? This is irreversible."): + print(" Aborted.") + return + try: + result = client.relations.delete_business_object_node(draft) + _ok(f"Deleted {result.relations_deleted} relation(s).") + _print_json(result) + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_relations_change_logs(client: AdmsClient) -> None: + print("\nFetching ChangeLog …") + items = client.relations.get_change_logs() + _print_list(items, "ChangeLog") + + +def cmd_relations_bo_change_logs(client: AdmsClient) -> None: + print("\nFetching BusinessObjectNodeChangeLog …") + items = client.relations.get_bo_node_change_logs() + _print_list(items, "BusinessObjectNodeChangeLog") + + +# ── DOCUMENTS handlers ─────────────────────────────────────────────────────── + + +def cmd_documents_list(client: AdmsClient) -> None: + print("\nFetching all Documents (via DocumentRelation?$expand=Document) …") + items = client.documents.get_all() + _print_list(items, "Documents") + + +def cmd_documents_get(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(f"\nFetching Document via relation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") + try: + doc = client.documents.get(relation_id, is_active_entity=is_active) + _print_json(doc) + except DocumentNotFoundError: + _err(f"No document found for relation {relation_id!r}.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: + print("\n── Update Document metadata ─────────────────────────────────") + print(" Leave any field blank to leave it unchanged.") + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + update = UpdateDocumentInput( + document_name=_prompt_optional("New document name"), + document_description=_prompt_optional("New description"), + document_type_id=_prompt_optional("New DocumentTypeID"), + doc_content_version_comment=_prompt_optional("Content version comment"), + document_external_content_url=_prompt_optional("New external URL"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + doc = client.documents.update(relation_id, update, is_active_entity=is_active) + _ok("Document updated.") + _print_json(doc) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_documents_download(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + version = _prompt("DocContentVersionID", default="1.0") + print("\nFetching presigned download URL …") + try: + url = client.documents.get_download_url( + document_relation_id=relation_id, + is_active_entity=is_active, + doc_content_version_id=version, + ) + _ok("Presigned URL (valid for a short time — do not cache):") + print(f" {url}\n") + except ScanNotCleanError as exc: + _err_exc("Download blocked — scan not CLEAN", exc) + except DocumentNotFoundError: + _err(f"Relation {relation_id!r} not found.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_documents_restore( + client: AdmsClient, relation_id: str, version_id: str +) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + comment = _prompt_optional("Comment (optional)") + print(f"\nRestoring version {version_id} on {relation_id} …") + try: + doc = client.documents.restore_content_version( + relation_id, version_id, is_active_entity=is_active, comment=comment + ) + _ok(f"Restored version {version_id}.") + _print_json(doc) + except AdmsError as exc: + _err_exc("Restore failed", exc) + + +def cmd_documents_delete_version( + client: AdmsClient, relation_id: str, version_id: str +) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + if not _confirm(f"Soft-delete version {version_id} on relation {relation_id} (IsActiveEntity={str(is_active).lower()})?"): + print(" Aborted.") + return + try: + client.documents.delete_content_version(relation_id, version_id, is_active_entity=is_active) + _ok(f"Deleted version {version_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +# ── CONFIGURATION handlers ─────────────────────────────────────────────────── + + +def cmd_config_domains(client: AdmsClient) -> None: + print("\nFetching AllowedDomains …") + items = client.config.get_all_allowed_domains() + _print_list(items, "AllowedDomains") + + +def cmd_config_domains_create(client: AdmsClient) -> None: + print("\n── Create AllowedDomain ────────────────────────────────────") + host = _prompt("Hostname (e.g. storage.example.com)") + proto = _prompt("Protocol", default="https") + port = _prompt_int("Port (blank = protocol default)") + if not host or not proto: + _err("Hostname and protocol are required.") + return + try: + out = client.config.create_allowed_domain( + CreateAllowedDomainInput(host_name=host, protocol=proto, port=port) + ) + _ok(f"Created AllowedDomain {out.allowed_domain_id}") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_domains_get(client: AdmsClient, allowed_domain_id: str) -> None: + print(f"\nFetching AllowedDomain {allowed_domain_id} …") + try: + out = client.config.get_allowed_domain(allowed_domain_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_domains_update(client: AdmsClient, allowed_domain_id: str) -> None: + print("\n── Update AllowedDomain ────────────────────────────────────") + print(" Leave any field blank to leave it unchanged.") + update = UpdateAllowedDomainInput( + host_name=_prompt_optional("New hostname"), + protocol=_prompt_optional("New protocol (https/http)"), + port=_prompt_int("New port (blank = unchanged)"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + out = client.config.update_allowed_domain(allowed_domain_id, update) + _ok(f"Updated AllowedDomain {out.allowed_domain_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_config_domains_delete(client: AdmsClient, allowed_domain_id: str) -> None: + if not _confirm(f"Delete AllowedDomain {allowed_domain_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_allowed_domain(allowed_domain_id) + _ok(f"Deleted {allowed_domain_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_doctypes(client: AdmsClient) -> None: + print("\nFetching DocumentTypes …") + items = client.config.get_all_document_types() + _print_list(items, "DocumentTypes") + + +def cmd_config_doctypes_create(client: AdmsClient) -> None: + print("\n── Create DocumentType ─────────────────────────────────────") + type_id = _prompt("DocumentTypeID (max 10 chars)") + name = _prompt("DocumentTypeName") + desc = _prompt_optional("Description") + if not type_id or not name: + _err("DocumentTypeID and DocumentTypeName are required.") + return + try: + out = client.config.create_document_type( + CreateDocumentTypeInput( + document_type_id=type_id, + document_type_name=name, + document_type_description=desc, + ) + ) + _ok(f"Created DocumentType {out.document_type_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_doctypes_get(client: AdmsClient, document_type_id: str) -> None: + print(f"\nFetching DocumentType {document_type_id} …") + try: + out = client.config.get_document_type(document_type_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_doctypes_update(client: AdmsClient, document_type_id: str) -> None: + print("\n── Update DocumentType ─────────────────────────────────────") + print(" Leave any field blank to leave it unchanged.") + update = UpdateDocumentTypeInput( + document_type_name=_prompt_optional("New DocumentTypeName"), + document_type_description=_prompt_optional("New description"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + out = client.config.update_document_type(document_type_id, update) + _ok(f"Updated DocumentType {out.document_type_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_config_doctypes_delete(client: AdmsClient, document_type_id: str) -> None: + if not _confirm(f"Delete DocumentType {document_type_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_document_type(document_type_id) + _ok(f"Deleted {document_type_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_botypes(client: AdmsClient) -> None: + print("\nFetching BusinessObjectNodeTypes …") + items = client.config.get_all_business_object_types() + _print_list(items, "BusinessObjectNodeTypes") + + +def cmd_config_botypes_create(client: AdmsClient) -> None: + print("\n── Create BusinessObjectNodeType ───────────────────────────") + bo_type = _prompt("BusinessObjectNodeType (short code, e.g. PO)") + bo_name = _prompt("BusinessObjectNodeTypeName") + tenant_id = _prompt("ApplicationTenantID (UUID of the owning tenant)") + if not bo_type or not bo_name or not tenant_id: + _err("BusinessObjectNodeType, name, and ApplicationTenantID are required.") + return + try: + out = client.config.create_business_object_type( + CreateBusinessObjectNodeTypeInput( + business_object_node_type=bo_type, + business_object_node_type_name=bo_name, + application_tenant_id=tenant_id, + ) + ) + _ok( + f"Created BusinessObjectNodeType {out.business_object_node_type_unique_id}." + ) + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_botypes_get(client: AdmsClient, bo_type_unique_id: str) -> None: + print(f"\nFetching BusinessObjectNodeType {bo_type_unique_id} …") + try: + out = client.config.get_business_object_type(bo_type_unique_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_botypes_update(client: AdmsClient, bo_type_unique_id: str) -> None: + print("\n── Update BusinessObjectNodeType ───────────────────────────") + print(" Leave any field blank to leave it unchanged.") + update = UpdateBusinessObjectNodeTypeInput( + business_object_node_type=_prompt_optional("New BusinessObjectNodeType code"), + business_object_node_type_name=_prompt_optional("New BusinessObjectNodeTypeName"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + out = client.config.update_business_object_type(bo_type_unique_id, update) + _ok(f"Updated BusinessObjectNodeType {out.business_object_node_type_unique_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_config_botypes_delete(client: AdmsClient, bo_type_unique_id: str) -> None: + if not _confirm(f"Delete BusinessObjectNodeType {bo_type_unique_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_business_object_type(bo_type_unique_id) + _ok(f"Deleted {bo_type_unique_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_maps(client: AdmsClient) -> None: + print("\nFetching DocumentType ↔ BusinessObjectNodeType mappings …") + items = client.config.get_type_mappings() + _print_list(items, "DocumentTypeBusinessObjectTypeMaps") + + +def cmd_config_maps_create(client: AdmsClient) -> None: + print("\n── Create DocumentType ↔ BO type mapping ───────────────────") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + doc_type_id = _prompt("DocumentTypeID") + is_default = _prompt_bool("Mark as default for this BO type?", default=False) + if not bo_unique_id or not doc_type_id: + _err("Both IDs are required.") + return + try: + out = client.config.create_type_mapping( + CreateDocumentTypeBoTypeMapInput( + business_object_node_type_unique_id=bo_unique_id, + document_type_id=doc_type_id, + is_default=is_default, + ) + ) + _ok(f"Created mapping {out.document_type_bo_type_map_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_maps_get(client: AdmsClient, mapping_id: str) -> None: + print(f"\nFetching mapping {mapping_id} …") + try: + out = client.config.get_type_mapping(mapping_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_maps_delete(client: AdmsClient, mapping_id: str) -> None: + if not _confirm(f"Delete mapping {mapping_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_type_mapping(mapping_id) + _ok(f"Deleted {mapping_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_maps_mark_default(client: AdmsClient, mapping_id: str) -> None: + try: + client.config.mark_default(mapping_id) + _ok(f"Mapping {mapping_id} marked as default.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +# ── CONFIG: FileExtensionPolicy ─────────────────────────────────────────────── + + +def cmd_config_file_ext_list(client: AdmsClient) -> None: + print("\nFetching FileExtensionPolicies …") + items = client.config.get_all_file_extension_policies() + _print_list(items, "FileExtensionPolicies") + + +def cmd_config_file_ext_create(client: AdmsClient) -> None: + print("\n── Create FileExtensionPolicy ──────────────────────────────") + ext = _prompt("File extension (e.g. pdf, exe)") + print(" Policy option:") + print(" A — Allow") + print(" B — Block") + policy_raw = _prompt("Policy (A/B)", default="A").upper() + try: + policy = MimeTypePolicy(policy_raw) + except ValueError: + _err(f"Invalid policy {policy_raw!r} — must be A or B.") + return + if not ext: + _err("File extension is required.") + return + try: + out = client.config.create_file_extension_policy( + CreateFileExtensionPolicyInput( + file_extension_policy_option=policy, + file_extension=ext, + ) + ) + _ok(f"Created FileExtensionPolicy {out.file_extension_policy_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_file_ext_get(client: AdmsClient, policy_id: str) -> None: + print(f"\nFetching FileExtensionPolicy {policy_id} …") + try: + out = client.config.get_file_extension_policy(policy_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_file_ext_delete(client: AdmsClient, policy_id: str) -> None: + if not _confirm(f"Delete FileExtensionPolicy {policy_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_file_extension_policy(policy_id) + _ok(f"Deleted {policy_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +# ── CONFIG: ApplicationTenant ───────────────────────────────────────────────── + + +def cmd_config_tenant_list(client: AdmsClient) -> None: + subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") + print("\nFetching ApplicationTenants …") + items = client.config.get_all_application_tenants(subaccount_id=subaccount_id) + _print_list(items, "ApplicationTenants") + + +def cmd_config_tenant_create(client: AdmsClient) -> None: + print("\n── Create ApplicationTenant ────────────────────────────────") + tenant_id = _prompt("ApplicationTenantID") + tenant_name = _prompt("ApplicationTenantName") + subaccount_id = _prompt("Subaccount ID (x-subaccount-id header)") + if not tenant_id or not tenant_name or not subaccount_id: + _err("ApplicationTenantID, name, and Subaccount ID are required.") + return + try: + out = client.config.create_application_tenant( + CreateApplicationTenantInput( + application_tenant_id=tenant_id, + application_tenant_name=tenant_name, + ), + subaccount_id=subaccount_id, + ) + _ok(f"Created ApplicationTenant {out.application_tenant_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_tenant_get(client: AdmsClient, tenant_id: str) -> None: + subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") + print(f"\nFetching ApplicationTenant {tenant_id} …") + try: + out = client.config.get_application_tenant(tenant_id, subaccount_id=subaccount_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_tenant_delete(client: AdmsClient, tenant_id: str) -> None: + subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") + if not _confirm(f"Delete ApplicationTenant {tenant_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_application_tenant(tenant_id, subaccount_id=subaccount_id) + _ok(f"Deleted {tenant_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +# ── JOBS handlers ──────────────────────────────────────────────────────────── + + +def cmd_jobs_status( + client: AdmsClient, job_id: str, *, use_admin_service: bool = False +) -> None: + service = "AdminService" if use_admin_service else "DocumentService" + print(f"\nFetching {service} status for job {job_id} …") + try: + output = client.jobs.get_status(job_id, use_admin_service=use_admin_service) + _print_json(output) + if output.job_status and output.job_status.is_terminal(): + _ok(f"Job is in terminal state: {output.job_status.value}") + else: + print(f" ⟳ Job still running: {output.job_status}") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_jobs_zip(client: AdmsClient) -> None: + print("\n── Start ZIP_DOWNLOAD job ──────────────────────────────────") + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both fields are required.") + return + rel_ids_raw = _prompt_optional( + "DocumentRelationIDs (comma-separated UUIDs, blank = all)" + ) + rel_ids = ( + [r.strip() for r in rel_ids_raw.split(",") if r.strip()] + if rel_ids_raw + else [] + ) + print(f"\nStarting ZIP_DOWNLOAD job for {bo_node_id} …") + try: + output = client.jobs.start_zip_download( + ZipDownloadJobParameters( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + document_relation_ids=rel_ids, + ) + ) + _ok(f"Job started: {output.job_id} status={output.job_status}") + _print_json(output) + print(f" → Poll with: js (Job ID: {output.job_id})") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_jobs_delete_user_data(client: AdmsClient) -> None: + print("\n── Start DELETE_USER_DATA job (AdminService) ───────────────") + print(" ⚠ This is a GDPR erasure operation and is irreversible.") + user_id = _prompt("UserID to erase") + if not user_id: + _err("UserID is required.") + return + replacement = _prompt_optional("ReplacementUserID (default: SYSTEM)") + if not _confirm(f"Erase all references to user {user_id!r}? This is irreversible."): + print(" Aborted.") + return + try: + output = client.jobs.start_delete_user_data( + DeleteUserDataJobParameters( + user_id=user_id, + replacement_user_id=replacement, + ) + ) + _ok(f"Job started: {output.job_id} status={output.job_status}") + _print_json(output) + print(f" → Poll with: js (Job ID: {output.job_id})") + except AdmsError as exc: + _err_exc("Failed", exc) + + +# ── interactive menu ────────────────────────────────────────────────────────── + +_MENU = textwrap.dedent(""" + ┌──────────────────────────────────────────────────────────────────┐ + │ ADMS Interactive CLI │ + ├──────────────────────────────────────────────────────────────────┤ + │ RELATIONS (AdmsRelationsClientApi) │ + │ rl — list all DocumentRelations │ + │ rg — get relation by ID │ + │ rd — delete relation by ID │ + │ rcd — createDraft (BO type + node ID) │ + │ rvd — validateDraft (BO type + node ID) │ + │ rad — activateDraft (BO type + node ID) │ + │ rdd — discardDraft (BO type + node ID) │ + │ ru — generateUploadUrls (relation ID) │ + │ rfu — fullUpload: generate URL + PUT file + complete │ + │ rcu — completeMultipartUpload (relation ID) │ + │ rlk — lock relation │ + │ ruk — unlock relation │ + │ rbn — deleteBusinessObjectNode [irreversible!] │ + │ rcl — getChangeLog (all changes) │ + │ rbl — getBusinessObjectNodeChangeLog │ + ├──────────────────────────────────────────────────────────────────┤ + │ DOCUMENTS (AdmsDocumentsClientApi) │ + │ dl — list all Documents │ + │ dg — get Document by relation ID │ + │ du — update Document (rename) │ + │ dd — getDownloadUrl (pre-signed URL) │ + │ drv — restoreContentVersion │ + │ ddv — deleteContentVersion │ + ├──────────────────────────────────────────────────────────────────┤ + │ CONFIGURATION (AdmsConfigClientApi) │ + │ cd — list AllowedDomains │ + │ cdg — get AllowedDomain by ID │ + │ cda — create AllowedDomain │ + │ cdu — update AllowedDomain │ + │ cdd — delete AllowedDomain │ + │ ct — list DocumentTypes │ + │ ctg — get DocumentType by ID │ + │ cta — create DocumentType │ + │ ctu — update DocumentType │ + │ ctd — delete DocumentType │ + │ cb — list BusinessObjectNodeTypes │ + │ cbg — get BusinessObjectNodeType by ID │ + │ cba — create BusinessObjectNodeType │ + │ cbu — update BusinessObjectNodeType │ + │ cbd — delete BusinessObjectNodeType │ + │ cm — list DocType ↔ BOType mappings │ + │ cmg — get mapping by ID │ + │ cma — create mapping │ + │ cmd — delete mapping │ + │ cmk — markDefault (mapping ID) │ + │ cfl — list FileExtensionPolicies │ + │ cfg — get FileExtensionPolicy by ID │ + │ cfc — create FileExtensionPolicy │ + │ cfd — delete FileExtensionPolicy │ + │ cal — list ApplicationTenants │ + │ cag — get ApplicationTenant by ID │ + │ cac — create ApplicationTenant │ + │ cad — delete ApplicationTenant │ + ├──────────────────────────────────────────────────────────────────┤ + │ JOBS (AdmsJobsClientApi) │ + │ js — getStatus (job ID) │ + │ jz — startZipDownload (relation IDs CSV) │ + │ jd — startDeleteUserData (user ID) [GDPR — irreversible!] │ + ├──────────────────────────────────────────────────────────────────┤ + │ OTHER │ + │ raw — toggle raw wire JSON output (shows exact Postman response) │ + │ ? — re-print this menu │ + │ q — quit │ + └──────────────────────────────────────────────────────────────────┘ +""") + + +def _interactive(client: AdmsClient) -> None: + global _raw_mode + print(_MENU) + while True: + try: + choice = input("adms> ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nBye.") + break + + if not choice: + continue + elif choice in ("q", "quit", "exit"): + print("Bye.") + break + elif choice in ("?", "help", "h"): + print(_MENU) + elif choice == "raw": + _raw_mode = not _raw_mode + state = "ON — wire JSON printed after each response" if _raw_mode else "OFF — SDK model printed only" + print(f"\n Raw mode: {state}\n") + continue + + _clear_raw_captures() + + # ── relations ── + if choice == "rl": + cmd_relations_list(client) + elif choice == "rg": + rid = _prompt("Relation ID") + if rid: + cmd_relations_get(client, rid) + elif choice == "rd": + rid = _prompt("Relation ID to delete") + if rid: + cmd_relations_delete(client, rid) + elif choice == "rcd": + cmd_relations_create_draft(client) + elif choice == "rvd": + cmd_relations_validate_draft(client) + elif choice == "rad": + cmd_relations_activate_draft(client) + elif choice == "rdd": + cmd_relations_discard_draft(client) + elif choice == "ru": + rid = _prompt("Relation ID") + if rid: + cmd_relations_upload_urls(client, rid) + elif choice == "rcu": + rid = _prompt("Relation ID") + if rid: + cmd_relations_complete_upload(client, rid) + elif choice == "rlk": + rid = _prompt("Relation ID to lock") + if rid: + cmd_relations_lock(client, rid) + elif choice == "ruk": + rid = _prompt("Relation ID to unlock") + if rid: + cmd_relations_unlock(client, rid) + elif choice == "rfu": + rid = _prompt("Relation ID") + if rid: + cmd_relations_full_upload(client, rid) + elif choice == "rbn": + cmd_relations_delete_bo_node(client) + elif choice == "rcl": + cmd_relations_change_logs(client) + elif choice == "rbl": + cmd_relations_bo_change_logs(client) + + # ── documents ── + elif choice == "dl": + cmd_documents_list(client) + elif choice == "dg": + rid = _prompt("Relation ID") + if rid: + cmd_documents_get(client, rid) + elif choice == "du": + rid = _prompt("Relation ID") + if rid: + cmd_documents_update(client, rid) + elif choice == "dd": + rid = _prompt("Relation ID") + if rid: + cmd_documents_download(client, rid) + elif choice == "drv": + rid = _prompt("Relation ID") + ver = _prompt("DocContentVersionID to restore", default="1.0") + if rid and ver: + cmd_documents_restore(client, rid, ver) + elif choice == "ddv": + rid = _prompt("Relation ID") + ver = _prompt("DocContentVersionID to delete") + if rid and ver: + cmd_documents_delete_version(client, rid, ver) + + # ── config: AllowedDomain ── + elif choice == "cd": + cmd_config_domains(client) + elif choice == "cdg": + did = _prompt("AllowedDomainID") + if did: + cmd_config_domains_get(client, did) + elif choice == "cda": + cmd_config_domains_create(client) + elif choice == "cdu": + did = _prompt("AllowedDomainID to update") + if did: + cmd_config_domains_update(client, did) + elif choice == "cdd": + did = _prompt("AllowedDomainID to delete") + if did: + cmd_config_domains_delete(client, did) + + # ── config: DocumentType ── + elif choice == "ct": + cmd_config_doctypes(client) + elif choice == "ctg": + tid = _prompt("DocumentTypeID") + if tid: + cmd_config_doctypes_get(client, tid) + elif choice == "cta": + cmd_config_doctypes_create(client) + elif choice == "ctu": + tid = _prompt("DocumentTypeID to update") + if tid: + cmd_config_doctypes_update(client, tid) + elif choice == "ctd": + tid = _prompt("DocumentTypeID to delete") + if tid: + cmd_config_doctypes_delete(client, tid) + + # ── config: BusinessObjectNodeType ── + elif choice == "cb": + cmd_config_botypes(client) + elif choice == "cbg": + bid = _prompt("BusinessObjectNodeTypeUniqueID") + if bid: + cmd_config_botypes_get(client, bid) + elif choice == "cba": + cmd_config_botypes_create(client) + elif choice == "cbu": + bid = _prompt("BusinessObjectNodeTypeUniqueID to update") + if bid: + cmd_config_botypes_update(client, bid) + elif choice == "cbd": + bid = _prompt("BusinessObjectNodeTypeUniqueID to delete") + if bid: + cmd_config_botypes_delete(client, bid) + + # ── config: type mappings ── + elif choice == "cm": + cmd_config_maps(client) + elif choice == "cmg": + mid = _prompt("DocumentTypeBOTypeMapID") + if mid: + cmd_config_maps_get(client, mid) + elif choice == "cma": + cmd_config_maps_create(client) + elif choice == "cmd": + mid = _prompt("DocumentTypeBOTypeMapID to delete") + if mid: + cmd_config_maps_delete(client, mid) + elif choice == "cmk": + mid = _prompt("DocumentTypeBOTypeMapID to mark as default") + if mid: + cmd_config_maps_mark_default(client, mid) + + # ── config: FileExtensionPolicy ── + elif choice == "cfl": + cmd_config_file_ext_list(client) + elif choice == "cfg": + pid = _prompt("FileExtensionPolicyID") + if pid: + cmd_config_file_ext_get(client, pid) + elif choice == "cfc": + cmd_config_file_ext_create(client) + elif choice == "cfd": + pid = _prompt("FileExtensionPolicyID to delete") + if pid: + cmd_config_file_ext_delete(client, pid) + + # ── config: ApplicationTenant ── + elif choice == "cal": + cmd_config_tenant_list(client) + elif choice == "cag": + tid = _prompt("ApplicationTenantID") + if tid: + cmd_config_tenant_get(client, tid) + elif choice == "cac": + cmd_config_tenant_create(client) + elif choice == "cad": + tid = _prompt("ApplicationTenantID to delete") + if tid: + cmd_config_tenant_delete(client, tid) + + # ── jobs ── + elif choice == "js": + job_id = _prompt("Job ID") + if job_id: + cmd_jobs_status(client, job_id, use_admin_service=False) + elif choice == "jz": + cmd_jobs_zip(client) + elif choice == "jd": + cmd_jobs_delete_user_data(client) + + else: + print( + f" Unknown command: {choice!r} (type '?' for the menu, 'q' to quit)" + ) + continue + + _print_raw_if_enabled() + + +# ── CLI argument dispatch ───────────────────────────────────────────────────── + + +def _cli(client: AdmsClient, args: list[str]) -> None: + if not args: + _interactive(client) + return + + cmd = args[0] + + if cmd == "relations": + sub = args[1] if len(args) > 1 else "" + if sub == "list": + cmd_relations_list(client) + elif sub == "get" and len(args) > 2: + cmd_relations_get(client, args[2]) + elif sub == "delete" and len(args) > 2: + cmd_relations_delete(client, args[2]) + elif sub == "create-draft": + cmd_relations_create_draft(client) + elif sub == "validate-draft": + cmd_relations_validate_draft(client) + elif sub == "activate-draft": + cmd_relations_activate_draft(client) + elif sub == "discard-draft": + cmd_relations_discard_draft(client) + elif sub == "upload-urls" and len(args) > 2: + cmd_relations_upload_urls(client, args[2]) + elif sub == "complete-upload" and len(args) > 2: + cmd_relations_complete_upload(client, args[2]) + elif sub == "lock" and len(args) > 2: + cmd_relations_lock(client, args[2]) + elif sub == "unlock" and len(args) > 2: + cmd_relations_unlock(client, args[2]) + else: + _err( + "Usage: relations list | get | delete " + "| create-draft | validate-draft | activate-draft | discard-draft " + "| upload-urls | complete-upload | lock | unlock " + ) + + elif cmd == "documents": + sub = args[1] if len(args) > 1 else "" + if sub == "list": + cmd_documents_list(client) + elif sub == "get" and len(args) > 2: + cmd_documents_get(client, args[2]) + elif sub == "update" and len(args) > 2: + cmd_documents_update(client, args[2]) + elif sub == "download" and len(args) > 2: + cmd_documents_download(client, args[2]) + elif sub == "restore" and len(args) > 3: + cmd_documents_restore(client, args[2], args[3]) + elif sub == "delete-version" and len(args) > 3: + cmd_documents_delete_version(client, args[2], args[3]) + else: + _err( + "Usage: documents list | get | update | download " + "| restore | delete-version " + ) + + elif cmd == "config": + sub = args[1] if len(args) > 1 else "" + if sub == "domains": + cmd_config_domains(client) + elif sub == "domains-create": + cmd_config_domains_create(client) + elif sub == "domains-delete" and len(args) > 2: + cmd_config_domains_delete(client, args[2]) + elif sub == "doctypes": + cmd_config_doctypes(client) + elif sub == "doctypes-create": + cmd_config_doctypes_create(client) + elif sub == "doctypes-delete" and len(args) > 2: + cmd_config_doctypes_delete(client, args[2]) + elif sub == "botypes": + cmd_config_botypes(client) + elif sub == "botypes-create": + cmd_config_botypes_create(client) + elif sub == "botypes-delete" and len(args) > 2: + cmd_config_botypes_delete(client, args[2]) + elif sub == "maps": + cmd_config_maps(client) + elif sub == "maps-create": + cmd_config_maps_create(client) + elif sub == "maps-delete" and len(args) > 2: + cmd_config_maps_delete(client, args[2]) + else: + _err( + "Usage: config domains[-create|-delete ] " + "| doctypes[-create|-delete ] " + "| botypes[-create|-delete ] | maps[-create|-delete ]" + ) + + elif cmd == "jobs": + sub = args[1] if len(args) > 1 else "" + if sub == "status" and len(args) > 2: + cmd_jobs_status(client, args[2], use_admin_service=False) + elif sub == "zip": + cmd_jobs_zip(client) + elif sub == "delete-user-data": + cmd_jobs_delete_user_data(client) + else: + _err( + "Usage: jobs status | jobs zip | jobs delete-user-data" + ) + + else: + _err(f"Unknown command: {cmd!r}") + print("Run without arguments for the interactive menu.") + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + _client = _build_client() + _patch_http_for_raw(_client) + _cli(_client, sys.argv[1:]) From d2a52c09c286b281fea6309903366113f3353bb1 Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 1 Jul 2026 10:45:23 +0530 Subject: [PATCH 3/5] fix(adms): align API surface with live ADMS wire format - Remove MimeTypePolicy from __init__.py (renamed to FileExtensionPolicy in _models.py) - Rename FileExtensionPolicy entity set to DocumentTypeFileExtensionPolicy - Drop get_file_extension_policy by UUID; delete now uses composite key (DocumentTypeID + FileExtension) - Fix build_doctype_botype_map_key_path to use composite key (DocumentTypeID + BusinessObjectNodeTypeUniqueID) - Update get_type_mapping / delete_type_mapping / mark_default to accept composite key params - Make doc_content_version_id optional in get_download_url (omit parentheses content when None) - Drop post-update GET in update_document; return partial response from UpdateDocument action directly - Fix X-SubaccountId header casing - Align job API namespace constants with canonical server wire format - Expand adms_cli.py with rfu (full upload) command and additional fixes from live tenant testing --- scripts/adms_cli.py | 415 +++++++++++-------- src/sap_cloud_sdk/adms/__init__.py | 2 - src/sap_cloud_sdk/adms/_configuration_api.py | 77 ++-- src/sap_cloud_sdk/adms/_document_api.py | 48 +-- src/sap_cloud_sdk/adms/_http.py | 7 +- src/sap_cloud_sdk/adms/_job_api.py | 23 +- src/sap_cloud_sdk/adms/_models.py | 62 +-- 7 files changed, 327 insertions(+), 307 deletions(-) diff --git a/scripts/adms_cli.py b/scripts/adms_cli.py index a2409e64..8523330a 100755 --- a/scripts/adms_cli.py +++ b/scripts/adms_cli.py @@ -36,8 +36,6 @@ relations lock — lock document & relation relations unlock — release lock DOCUMENTS - documents list — list all Documents - documents get — Document linked to a relation documents update — update document metadata documents download — presigned download URL documents restore — restore a previous content version @@ -94,7 +92,7 @@ DeleteUserDataJobParameters, DraftActivateInput, DraftInput, - MimeTypePolicy, + FileExtensionPolicy, UpdateAllowedDomainInput, UpdateBusinessObjectNodeTypeInput, UpdateDocumentInput, @@ -216,6 +214,37 @@ def _print_json(obj) -> None: print(json.dumps(_to_jsonable(obj), indent=2)) +def _field(key: str, val: object) -> None: + print(f" {key + ':':<42} {val}") + + +def _print_document(doc) -> None: + _field("DocumentID", doc.document_id) + _field("IsActiveEntity", doc.is_active_entity) + _field("DocumentName", doc.document_name) + _field("DocumentBaseType", getattr(doc.document_base_type, "value", doc.document_base_type)) + _field("DocumentTypeID", doc.document_type_id) + _field("DocumentMimeType", doc.document_mime_type) + _field("DocumentDescription", doc.document_description) + _field("DocumentSizeInByte", doc.document_size_in_byte) + _field("DocumentContentStreamURI", doc.document_content_stream_uri) + _field("DocumentExternalContentURL", doc.document_external_content_url) + _field("DocumentIsLocked", doc.document_is_locked) + _field("DocumentIsSoftDeleted", doc.document_is_soft_deleted) + _field("HasActiveDocumentEntity", doc.has_active_document_entity) + _field("HasDraftDocumentEntity", doc.has_draft_document_entity) + _field("DraftUUID", doc.draft_uuid) + _field("DocumentContentUploadURLs", doc.document_content_upload_urls) + _field("DocumentIsMultiReferenced", doc.document_is_multi_referenced) + _field("DocumentCreatedByUserName", doc.document_created_by_user_name) + _field("DocumentCreatedAtDateTime", doc.document_created_at_date_time) + _field("DocumentChangedByUserName", doc.document_changed_by_user_name) + _field("DocumentChangedAtDateTime", doc.document_changed_at_date_time) + _field("DocumentState", getattr(doc.document_state, "value", doc.document_state)) + _field("DocumentStateText", doc.document_state_text) + _field("DocumentContentHash", doc.document_content_hash) + + def _print_list(items, label: str) -> None: print(f"\n{'─' * 62}") print(f" {label} ({len(items)} items)") @@ -240,6 +269,7 @@ def _patch_http_for_raw(client: AdmsClient) -> None: original_request = AdmsHttp._request def _capturing_request(self, method, path, **kwargs): + _last_raw_responses.clear() resp = original_request(self, method, path, **kwargs) try: body = resp.json() @@ -261,12 +291,22 @@ def _print_raw_if_enabled(label: str = "Raw API response") -> None: print() +def _print_last_raw() -> None: + """Print the last captured raw wire response (always, no mode check).""" + if _last_raw_responses: + print(_last_raw_responses[-1]) + + def _clear_raw_captures() -> None: _last_raw_responses.clear() def _prompt(prompt: str, default: Optional[str] = None) -> str: - value = input(f" {prompt}: ").strip() + if default: + label = f"{prompt} (optional — press Enter to skip, default {default})" + else: + label = prompt + value = input(f" {label}: ").strip() if not value and default: return default return value @@ -274,13 +314,16 @@ def _prompt(prompt: str, default: Optional[str] = None) -> str: def _prompt_optional(prompt: str) -> Optional[str]: """Prompt for an optional value — empty input returns None.""" - value = input(f" {prompt} (optional): ").strip() + value = input(f" {prompt} (optional — press Enter to skip): ").strip() return value or None def _prompt_int(prompt: str, default: Optional[int] = None) -> Optional[int]: - label = f" (optional, default {default})" if default is not None else " (optional)" - raw = input(f" {prompt}{label}: ").strip() + if default is not None: + label = f"{prompt} (optional — press Enter to skip, default {default})" + else: + label = f"{prompt} (optional — press Enter to skip)" + raw = input(f" {label}: ").strip() if not raw: return default try: @@ -484,23 +527,67 @@ def cmd_relations_unlock(client: AdmsClient, relation_id: str) -> None: _err_exc("Unlock failed", exc) -def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: - """Generate upload URLs, PUT file to GCS, then complete the upload.""" +def cmd_relations_full_upload(client: AdmsClient) -> None: + """Create relation + document, generate upload URL, PUT file to GCS, then complete.""" import os - file_path = _prompt("Local file path (absolute or relative)") - if not file_path or not os.path.isfile(file_path): - _err(f"File not found: {file_path!r}") + print("\n── Full upload: create relation → generate URL → PUT file → complete ──") + + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both BO fields are required.") + return + bo_display_id = _prompt_optional("HostBusinessObjNodeDisplayID") + doc_name = _prompt("DocumentName (e.g. test.pdf)") + doc_type_id = _prompt("DocumentTypeID") + if not doc_name or not doc_type_id: + _err("DocumentName and DocumentTypeID are required.") + return + doc_desc = _prompt_optional("DocumentDescription") + doc_base_type_raw = _prompt("DocumentBaseType (D=file, F=folder, U=URL)", default="D").upper() + try: + doc_base_type = BaseType(doc_base_type_raw) + except ValueError: + _err(f"Invalid DocumentBaseType {doc_base_type_raw!r} — must be D, F, or U.") return - file_name = os.path.basename(file_path) is_active = _prompt_bool("Active entity? (No = draft)", default=True) is_multipart = _prompt_bool("Multipart upload?", default=False) no_of_parts = _prompt_int("Number of parts", default=1) or 1 + file_path = _prompt("Local file path") + if not file_path or not os.path.isfile(file_path): + _err(f"File not found: {file_path!r}") + return + file_name = os.path.basename(file_path) + + print(f"\nStep 1 — Creating relation + document …") + try: + rel = client.relations.create( + CreateDocumentRelationInput( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + host_business_obj_node_display_id=bo_display_id, + is_active_entity=is_active, + document=CreateDocumentInput( + document_name=doc_name, + document_type_id=doc_type_id, + document_description=doc_desc, + document_base_type=doc_base_type, + document_is_multipart=is_multipart, + document_no_of_parts=no_of_parts if is_multipart else None, + ), + ) + ) + except AdmsError as exc: + _err_exc("Create failed", exc) + return + _ok(f"Relation created: {rel.document_relation_id}") + _print_json(rel) - print(f"\nStep 1 — Generating upload URL(s) for relation {relation_id} …") + print(f"\nStep 2 — Generating upload URL(s) for {rel.document_relation_id} …") try: doc = client.relations.generate_upload_urls( - relation_id, + rel.document_relation_id, is_active_entity=is_active, is_multipart=is_multipart, no_of_parts=no_of_parts, @@ -516,26 +603,36 @@ def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: _ok(f"Got {len(urls)} URL(s).") upload_url = urls[0] - # The x-goog-meta-filename value was baked into the GCS signature by ADM - # at URL-generation time. We must send the document name ADM registered, - # not the local file name — a mismatch causes SignatureDoesNotMatch. adm_filename = doc.document_name or file_name - print(f"\nStep 2 — Uploading {file_name!r} as '{adm_filename}' to GCS …") + print(f"\nStep 3 — Uploading {file_name!r} to GCS …") try: import urllib.request as _urllib_request + import ssl as _ssl + import mimetypes + + _ssl_ctx = _ssl.create_default_context() + _ssl_ctx.check_hostname = False + _ssl_ctx.verify_mode = _ssl.CERT_NONE with open(file_path, "rb") as f: file_bytes = f.read() - req = _urllib_request.Request( - upload_url, - data=file_bytes, - method="PUT", - headers={"x-goog-meta-filename": adm_filename}, - ) + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "application/octet-stream" + + put_url = urls[0] + + headers = {"Content-Type": mime_type} + if not is_multipart: + headers["x-goog-meta-filename"] = adm_filename + + print(f" → Content-Type: {mime_type}") + + req = _urllib_request.Request(put_url, data=file_bytes, method="PUT", headers=headers) try: - with _urllib_request.urlopen(req, timeout=120) as response: + with _urllib_request.urlopen(req, timeout=120, context=_ssl_ctx) as response: status = response.status except _urllib_request.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") @@ -550,9 +647,9 @@ def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: return if is_multipart: - print(f"\nStep 3 — Completing multipart upload for {relation_id} …") + print(f"\nStep 4 — Completing multipart upload for {rel.document_relation_id} …") try: - client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) + client.relations.complete_multipart_upload(rel.document_relation_id, is_active_entity=is_active) _ok("Multipart upload completed.") except AdmsError as exc: _err_exc("Complete upload failed", exc) @@ -578,9 +675,9 @@ def cmd_relations_delete_bo_node(client: AdmsClient) -> None: def cmd_relations_change_logs(client: AdmsClient) -> None: - print("\nFetching ChangeLog …") - items = client.relations.get_change_logs() - _print_list(items, "ChangeLog") + print("\nFetching BusinessObjectNodeChangeLog …") + items = client.relations.get_bo_node_change_logs() + _print_list(items, "BusinessObjectNodeChangeLog") def cmd_relations_bo_change_logs(client: AdmsClient) -> None: @@ -592,34 +689,23 @@ def cmd_relations_bo_change_logs(client: AdmsClient) -> None: # ── DOCUMENTS handlers ─────────────────────────────────────────────────────── -def cmd_documents_list(client: AdmsClient) -> None: - print("\nFetching all Documents (via DocumentRelation?$expand=Document) …") - items = client.documents.get_all() - _print_list(items, "Documents") - - -def cmd_documents_get(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(f"\nFetching Document via relation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") - try: - doc = client.documents.get(relation_id, is_active_entity=is_active) - _print_json(doc) - except DocumentNotFoundError: - _err(f"No document found for relation {relation_id!r}.") - except AdmsError as exc: - _err_exc("Failed", exc) - - def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: - print("\n── Update Document metadata ─────────────────────────────────") - print(" Leave any field blank to leave it unchanged.") is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(" Leave any field blank to leave it unchanged.") + document_name = _prompt_optional("New document name") + document_description = _prompt_optional("New description") + document_type_id = _prompt_optional("New DocumentTypeID") + doc_content_version_comment = _prompt_optional("Content version comment") + document_external_content_url = _prompt_optional("New external URL") + is_content_update_raw = _prompt_bool("Is content update? (new file version)", default=False) + is_content_update = is_content_update_raw if is_content_update_raw else None update = UpdateDocumentInput( - document_name=_prompt_optional("New document name"), - document_description=_prompt_optional("New description"), - document_type_id=_prompt_optional("New DocumentTypeID"), - doc_content_version_comment=_prompt_optional("Content version comment"), - document_external_content_url=_prompt_optional("New external URL"), + document_name=document_name, + document_description=document_description, + document_type_id=document_type_id, + doc_content_version_comment=doc_content_version_comment, + document_external_content_url=document_external_content_url, + is_content_update=is_content_update, ) if not any(v is not None for v in update.__dict__.values()): _err("Nothing to update — all fields blank.") @@ -634,13 +720,13 @@ def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: def cmd_documents_download(client: AdmsClient, relation_id: str) -> None: is_active = _prompt_bool("Active entity? (No = draft)", default=True) - version = _prompt("DocContentVersionID", default="1.0") + version = _prompt_optional("DocContentVersionID (blank = latest)") print("\nFetching presigned download URL …") try: url = client.documents.get_download_url( document_relation_id=relation_id, is_active_entity=is_active, - doc_content_version_id=version, + doc_content_version_id=version or None, ) _ok("Presigned URL (valid for a short time — do not cache):") print(f" {url}\n") @@ -693,9 +779,9 @@ def cmd_config_domains(client: AdmsClient) -> None: def cmd_config_domains_create(client: AdmsClient) -> None: print("\n── Create AllowedDomain ────────────────────────────────────") - host = _prompt("Hostname (e.g. storage.example.com)") - proto = _prompt("Protocol", default="https") - port = _prompt_int("Port (blank = protocol default)") + host = _prompt("AllowedDomainHostName * (mandatory, e.g. storage.example.com)") + proto = _prompt("AllowedDomainProtocol", default="https") + port = _prompt_int("AllowedDomainPort") if not host or not proto: _err("Hostname and protocol are required.") return @@ -704,7 +790,7 @@ def cmd_config_domains_create(client: AdmsClient) -> None: CreateAllowedDomainInput(host_name=host, protocol=proto, port=port) ) _ok(f"Created AllowedDomain {out.allowed_domain_id}") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -713,7 +799,7 @@ def cmd_config_domains_get(client: AdmsClient, allowed_domain_id: str) -> None: print(f"\nFetching AllowedDomain {allowed_domain_id} …") try: out = client.config.get_allowed_domain(allowed_domain_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -732,7 +818,7 @@ def cmd_config_domains_update(client: AdmsClient, allowed_domain_id: str) -> Non try: out = client.config.update_allowed_domain(allowed_domain_id, update) _ok(f"Updated AllowedDomain {out.allowed_domain_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Update failed", exc) @@ -756,9 +842,9 @@ def cmd_config_doctypes(client: AdmsClient) -> None: def cmd_config_doctypes_create(client: AdmsClient) -> None: print("\n── Create DocumentType ─────────────────────────────────────") - type_id = _prompt("DocumentTypeID (max 10 chars)") - name = _prompt("DocumentTypeName") - desc = _prompt_optional("Description") + type_id = _prompt("DocumentTypeID * (mandatory, max 10 chars)") + name = _prompt("DocumentTypeName * (mandatory)") + desc = _prompt_optional("DocumentTypeDescription") if not type_id or not name: _err("DocumentTypeID and DocumentTypeName are required.") return @@ -771,7 +857,7 @@ def cmd_config_doctypes_create(client: AdmsClient) -> None: ) ) _ok(f"Created DocumentType {out.document_type_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -780,7 +866,7 @@ def cmd_config_doctypes_get(client: AdmsClient, document_type_id: str) -> None: print(f"\nFetching DocumentType {document_type_id} …") try: out = client.config.get_document_type(document_type_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -798,7 +884,7 @@ def cmd_config_doctypes_update(client: AdmsClient, document_type_id: str) -> Non try: out = client.config.update_document_type(document_type_id, update) _ok(f"Updated DocumentType {out.document_type_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Update failed", exc) @@ -822,9 +908,10 @@ def cmd_config_botypes(client: AdmsClient) -> None: def cmd_config_botypes_create(client: AdmsClient) -> None: print("\n── Create BusinessObjectNodeType ───────────────────────────") - bo_type = _prompt("BusinessObjectNodeType (short code, e.g. PO)") - bo_name = _prompt("BusinessObjectNodeTypeName") - tenant_id = _prompt("ApplicationTenantID (UUID of the owning tenant)") + bo_type = _prompt("BusinessObjectNodeType * (mandatory, short code, e.g. PO)") + bo_name = _prompt("BusinessObjectNodeTypeName * (mandatory)") + tenant_id = _prompt("ApplicationTenantID * (mandatory, UUID)") + odm_name = _prompt_optional("ODMEntityName") if not bo_type or not bo_name or not tenant_id: _err("BusinessObjectNodeType, name, and ApplicationTenantID are required.") return @@ -834,12 +921,13 @@ def cmd_config_botypes_create(client: AdmsClient) -> None: business_object_node_type=bo_type, business_object_node_type_name=bo_name, application_tenant_id=tenant_id, + odm_entity_name=odm_name, ) ) _ok( f"Created BusinessObjectNodeType {out.business_object_node_type_unique_id}." ) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -848,7 +936,7 @@ def cmd_config_botypes_get(client: AdmsClient, bo_type_unique_id: str) -> None: print(f"\nFetching BusinessObjectNodeType {bo_type_unique_id} …") try: out = client.config.get_business_object_type(bo_type_unique_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -866,7 +954,7 @@ def cmd_config_botypes_update(client: AdmsClient, bo_type_unique_id: str) -> Non try: out = client.config.update_business_object_type(bo_type_unique_id, update) _ok(f"Updated BusinessObjectNodeType {out.business_object_node_type_unique_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Update failed", exc) @@ -890,9 +978,8 @@ def cmd_config_maps(client: AdmsClient) -> None: def cmd_config_maps_create(client: AdmsClient) -> None: print("\n── Create DocumentType ↔ BO type mapping ───────────────────") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - doc_type_id = _prompt("DocumentTypeID") - is_default = _prompt_bool("Mark as default for this BO type?", default=False) + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + doc_type_id = _prompt("DocumentTypeID * (mandatory)") if not bo_unique_id or not doc_type_id: _err("Both IDs are required.") return @@ -901,39 +988,53 @@ def cmd_config_maps_create(client: AdmsClient) -> None: CreateDocumentTypeBoTypeMapInput( business_object_node_type_unique_id=bo_unique_id, document_type_id=doc_type_id, - is_default=is_default, ) ) - _ok(f"Created mapping {out.document_type_bo_type_map_id}.") - _print_json(out) + _ok(f"Created mapping {out.document_type_id}:{out.business_object_node_type_unique_id}.") + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) -def cmd_config_maps_get(client: AdmsClient, mapping_id: str) -> None: - print(f"\nFetching mapping {mapping_id} …") +def cmd_config_maps_get(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + if not doc_type_id or not bo_unique_id: + _err("Both fields are required.") + return + print(f"\nFetching mapping {doc_type_id}:{bo_unique_id} …") try: - out = client.config.get_type_mapping(mapping_id) - _print_json(out) + out = client.config.get_type_mapping(doc_type_id, bo_unique_id) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) -def cmd_config_maps_delete(client: AdmsClient, mapping_id: str) -> None: - if not _confirm(f"Delete mapping {mapping_id!r}?"): +def cmd_config_maps_delete(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + if not doc_type_id or not bo_unique_id: + _err("Both fields are required.") + return + if not _confirm(f"Delete mapping {doc_type_id}:{bo_unique_id}?"): print(" Aborted.") return try: - client.config.delete_type_mapping(mapping_id) - _ok(f"Deleted {mapping_id}.") + client.config.delete_type_mapping(doc_type_id, bo_unique_id) + _ok(f"Deleted mapping {doc_type_id}:{bo_unique_id}.") except AdmsError as exc: _err_exc("Delete failed", exc) -def cmd_config_maps_mark_default(client: AdmsClient, mapping_id: str) -> None: +def cmd_config_maps_mark_default(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + if not doc_type_id or not bo_unique_id: + _err("Both fields are required.") + return try: - client.config.mark_default(mapping_id) - _ok(f"Mapping {mapping_id} marked as default.") + client.config.mark_default(doc_type_id, bo_unique_id) + _ok(f"Mapping {doc_type_id}:{bo_unique_id} marked as default.") except AdmsError as exc: _err_exc("Failed", exc) @@ -948,49 +1049,49 @@ def cmd_config_file_ext_list(client: AdmsClient) -> None: def cmd_config_file_ext_create(client: AdmsClient) -> None: - print("\n── Create FileExtensionPolicy ──────────────────────────────") - ext = _prompt("File extension (e.g. pdf, exe)") - print(" Policy option:") - print(" A — Allow") - print(" B — Block") - policy_raw = _prompt("Policy (A/B)", default="A").upper() - try: - policy = MimeTypePolicy(policy_raw) - except ValueError: - _err(f"Invalid policy {policy_raw!r} — must be A or B.") - return - if not ext: - _err("File extension is required.") + print("\n── Create DocumentTypeFileExtensionPolicy ──────────────────") + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + ext = _prompt("FileExtension * (mandatory, e.g. pdf, exe)") + if not doc_type_id or not ext: + _err("Both fields are required.") return try: out = client.config.create_file_extension_policy( CreateFileExtensionPolicyInput( - file_extension_policy_option=policy, + document_type_id=doc_type_id, file_extension=ext, ) ) - _ok(f"Created FileExtensionPolicy {out.file_extension_policy_id}.") - _print_json(out) + _ok(f"Created DocumentTypeFileExtensionPolicy {out.document_type_id}:{out.file_extension}.") + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) -def cmd_config_file_ext_get(client: AdmsClient, policy_id: str) -> None: - print(f"\nFetching FileExtensionPolicy {policy_id} …") +def cmd_config_global_file_extensions(client: AdmsClient) -> None: + print("\nFetching global allowed file extensions …") try: - out = client.config.get_file_extension_policy(policy_id) - _print_json(out) + resp = client.config._http.get( + "GetGlobalAllowedFileExtensions()", + service_base="/odata/v4/ConfigurationService", + ) + _print_json(resp.json()) except AdmsError as exc: _err_exc("Failed", exc) -def cmd_config_file_ext_delete(client: AdmsClient, policy_id: str) -> None: - if not _confirm(f"Delete FileExtensionPolicy {policy_id!r}?"): +def cmd_config_file_ext_delete(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + ext = _prompt("FileExtension * (mandatory)") + if not doc_type_id or not ext: + _err("Both fields are required.") + return + if not _confirm(f"Delete DocumentTypeFileExtensionPolicy {doc_type_id}:{ext}?"): print(" Aborted.") return try: - client.config.delete_file_extension_policy(policy_id) - _ok(f"Deleted {policy_id}.") + client.config.delete_file_extension_policy(doc_type_id, ext) + _ok(f"Deleted {doc_type_id}:{ext}.") except AdmsError as exc: _err_exc("Delete failed", exc) @@ -1007,9 +1108,9 @@ def cmd_config_tenant_list(client: AdmsClient) -> None: def cmd_config_tenant_create(client: AdmsClient) -> None: print("\n── Create ApplicationTenant ────────────────────────────────") - tenant_id = _prompt("ApplicationTenantID") - tenant_name = _prompt("ApplicationTenantName") - subaccount_id = _prompt("Subaccount ID (x-subaccount-id header)") + tenant_id = _prompt("ApplicationTenantID * (mandatory)") + tenant_name = _prompt("ApplicationTenantName * (mandatory)") + subaccount_id = _prompt("Subaccount ID * (mandatory, x-subaccount-id header)") if not tenant_id or not tenant_name or not subaccount_id: _err("ApplicationTenantID, name, and Subaccount ID are required.") return @@ -1022,7 +1123,7 @@ def cmd_config_tenant_create(client: AdmsClient) -> None: subaccount_id=subaccount_id, ) _ok(f"Created ApplicationTenant {out.application_tenant_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -1032,7 +1133,7 @@ def cmd_config_tenant_get(client: AdmsClient, tenant_id: str) -> None: print(f"\nFetching ApplicationTenant {tenant_id} …") try: out = client.config.get_application_tenant(tenant_id, subaccount_id=subaccount_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -1139,7 +1240,7 @@ def cmd_jobs_delete_user_data(client: AdmsClient) -> None: │ rad — activateDraft (BO type + node ID) │ │ rdd — discardDraft (BO type + node ID) │ │ ru — generateUploadUrls (relation ID) │ - │ rfu — fullUpload: generate URL + PUT file + complete │ + │ rfu — fullUpload: create relation + PUT file + complete │ │ rcu — completeMultipartUpload (relation ID) │ │ rlk — lock relation │ │ ruk — unlock relation │ @@ -1148,8 +1249,6 @@ def cmd_jobs_delete_user_data(client: AdmsClient) -> None: │ rbl — getBusinessObjectNodeChangeLog │ ├──────────────────────────────────────────────────────────────────┤ │ DOCUMENTS (AdmsDocumentsClientApi) │ - │ dl — list all Documents │ - │ dg — get Document by relation ID │ │ du — update Document (rename) │ │ dd — getDownloadUrl (pre-signed URL) │ │ drv — restoreContentVersion │ @@ -1177,7 +1276,7 @@ def cmd_jobs_delete_user_data(client: AdmsClient) -> None: │ cmd — delete mapping │ │ cmk — markDefault (mapping ID) │ │ cfl — list FileExtensionPolicies │ - │ cfg — get FileExtensionPolicy by ID │ + │ cgf — GetGlobalAllowedFileExtensions │ │ cfc — create FileExtensionPolicy │ │ cfd — delete FileExtensionPolicy │ │ cal — list ApplicationTenants │ @@ -1227,11 +1326,11 @@ def _interactive(client: AdmsClient) -> None: if choice == "rl": cmd_relations_list(client) elif choice == "rg": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_get(client, rid) elif choice == "rd": - rid = _prompt("Relation ID to delete") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_delete(client, rid) elif choice == "rcd": @@ -1243,25 +1342,23 @@ def _interactive(client: AdmsClient) -> None: elif choice == "rdd": cmd_relations_discard_draft(client) elif choice == "ru": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_upload_urls(client, rid) elif choice == "rcu": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_complete_upload(client, rid) elif choice == "rlk": - rid = _prompt("Relation ID to lock") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_lock(client, rid) elif choice == "ruk": - rid = _prompt("Relation ID to unlock") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_unlock(client, rid) elif choice == "rfu": - rid = _prompt("Relation ID") - if rid: - cmd_relations_full_upload(client, rid) + cmd_relations_full_upload(client) elif choice == "rbn": cmd_relations_delete_bo_node(client) elif choice == "rcl": @@ -1270,27 +1367,21 @@ def _interactive(client: AdmsClient) -> None: cmd_relations_bo_change_logs(client) # ── documents ── - elif choice == "dl": - cmd_documents_list(client) - elif choice == "dg": - rid = _prompt("Relation ID") - if rid: - cmd_documents_get(client, rid) elif choice == "du": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_documents_update(client, rid) elif choice == "dd": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_documents_download(client, rid) elif choice == "drv": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") ver = _prompt("DocContentVersionID to restore", default="1.0") if rid and ver: cmd_documents_restore(client, rid, ver) elif choice == "ddv": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") ver = _prompt("DocContentVersionID to delete") if rid and ver: cmd_documents_delete_version(client, rid, ver) @@ -1353,33 +1444,23 @@ def _interactive(client: AdmsClient) -> None: elif choice == "cm": cmd_config_maps(client) elif choice == "cmg": - mid = _prompt("DocumentTypeBOTypeMapID") - if mid: - cmd_config_maps_get(client, mid) + cmd_config_maps_get(client) elif choice == "cma": cmd_config_maps_create(client) elif choice == "cmd": - mid = _prompt("DocumentTypeBOTypeMapID to delete") - if mid: - cmd_config_maps_delete(client, mid) + cmd_config_maps_delete(client) elif choice == "cmk": - mid = _prompt("DocumentTypeBOTypeMapID to mark as default") - if mid: - cmd_config_maps_mark_default(client, mid) + cmd_config_maps_mark_default(client) # ── config: FileExtensionPolicy ── elif choice == "cfl": cmd_config_file_ext_list(client) - elif choice == "cfg": - pid = _prompt("FileExtensionPolicyID") - if pid: - cmd_config_file_ext_get(client, pid) + elif choice == "cgf": + cmd_config_global_file_extensions(client) elif choice == "cfc": cmd_config_file_ext_create(client) elif choice == "cfd": - pid = _prompt("FileExtensionPolicyID to delete") - if pid: - cmd_config_file_ext_delete(client, pid) + cmd_config_file_ext_delete(client) # ── config: ApplicationTenant ── elif choice == "cal": @@ -1457,11 +1538,7 @@ def _cli(client: AdmsClient, args: list[str]) -> None: elif cmd == "documents": sub = args[1] if len(args) > 1 else "" - if sub == "list": - cmd_documents_list(client) - elif sub == "get" and len(args) > 2: - cmd_documents_get(client, args[2]) - elif sub == "update" and len(args) > 2: + if sub == "update" and len(args) > 2: cmd_documents_update(client, args[2]) elif sub == "download" and len(args) > 2: cmd_documents_download(client, args[2]) @@ -1471,7 +1548,7 @@ def _cli(client: AdmsClient, args: list[str]) -> None: cmd_documents_delete_version(client, args[2], args[3]) else: _err( - "Usage: documents list | get | update | download " + "Usage: documents update | download " "| restore | delete-version " ) @@ -1499,8 +1576,8 @@ def _cli(client: AdmsClient, args: list[str]) -> None: cmd_config_maps(client) elif sub == "maps-create": cmd_config_maps_create(client) - elif sub == "maps-delete" and len(args) > 2: - cmd_config_maps_delete(client, args[2]) + elif sub == "maps-delete": + cmd_config_maps_delete(client) else: _err( "Usage: config domains[-create|-delete ] " diff --git a/src/sap_cloud_sdk/adms/__init__.py b/src/sap_cloud_sdk/adms/__init__.py index 13f69c43..d1df3308 100644 --- a/src/sap_cloud_sdk/adms/__init__.py +++ b/src/sap_cloud_sdk/adms/__init__.py @@ -85,7 +85,6 @@ JobOutput, JobStatus, JobType, - MimeTypePolicy, ScanStatus, UpdateAllowedDomainInput, UpdateBusinessObjectNodeTypeInput, @@ -137,7 +136,6 @@ "JobOutput", "JobStatus", "JobType", - "MimeTypePolicy", "ScanStatus", "UpdateDocumentInput", "ZipDownloadJobParameters", diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index a537378b..ef2bf0d5 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,42 @@ 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.""" 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: + def delete_type_mapping(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Delete a DocumentType ↔ BusinessObjectNodeType mapping.""" 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: + def mark_default(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default.""" self._http.post( - f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/com.sap.adm.ConfigurationService.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 +289,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, ) @@ -395,8 +385,8 @@ def delete_application_tenant( def _subaccount_header(subaccount_id: str | None) -> dict[str, str] | None: - """Build the x-subaccount-id header dict, or None if not provided.""" - return {"x-subaccount-id": subaccount_id} if subaccount_id else 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: @@ -620,28 +610,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)}/com.sap.adm.ConfigurationService.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, ) @@ -653,7 +643,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", []) @@ -665,28 +655,17 @@ 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, ) diff --git a/src/sap_cloud_sdk/adms/_document_api.py b/src/sap_cloud_sdk/adms/_document_api.py index 0ccf7ba9..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}/com.sap.adm.DocumentService.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) + "/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) @@ -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}/com.sap.adm.DocumentService.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", "") @@ -360,12 +359,7 @@ async def update( + "/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) diff --git a/src/sap_cloud_sdk/adms/_http.py b/src/sap_cloud_sdk/adms/_http.py index f08109b4..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)})" ) diff --git a/src/sap_cloud_sdk/adms/_job_api.py b/src/sap_cloud_sdk/adms/_job_api.py index 181d0db3..18941a5a 100644 --- a/src/sap_cloud_sdk/adms/_job_api.py +++ b/src/sap_cloud_sdk/adms/_job_api.py @@ -17,12 +17,9 @@ from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics # Fully-qualified OData V4 unbound action / function paths. -# The Java SDK (cloud-sdk-java contrib-java/adms commit e5dd1da) confirmed -# the canonical wire format includes the namespace prefix. -_START_JOB_DOC_SERVICE = "com.sap.adm.DocumentService.StartJob" -_START_JOB_ADMIN_SERVICE = "com.sap.adm.AdminService.StartJob" -_JOB_STATUS_DOC_SERVICE_NS = "com.sap.adm.DocumentService" -_JOB_STATUS_ADMIN_SERVICE_NS = "com.sap.adm.AdminService" +# Unbound action/function import names — no namespace prefix (per EDMX ActionImport/FunctionImport). +_START_JOB_DOC_SERVICE = "StartJob" +_START_JOB_ADMIN_SERVICE = "StartJob" class _JobApi: @@ -96,12 +93,7 @@ def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - ns = ( - _JOB_STATUS_ADMIN_SERVICE_NS - if use_admin_service - else _JOB_STATUS_DOC_SERVICE_NS - ) - path = f"{ns}.{build_job_status_key_path(job_id)}" + path = build_job_status_key_path(job_id) resp = self._http.get(path, service_base=service) return JobOutput.from_dict(resp.json()) @@ -165,11 +157,6 @@ async def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - ns = ( - _JOB_STATUS_ADMIN_SERVICE_NS - if use_admin_service - else _JOB_STATUS_DOC_SERVICE_NS - ) - path = f"{ns}.{build_job_status_key_path(job_id)}" + path = build_job_status_key_path(job_id) resp = await self._http.get(path, service_base=service) 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, } From 5206f23b20ae0e855c37e347b3baafdb74c4187b Mon Sep 17 00:00:00 2001 From: I766515 Date: Thu, 2 Jul 2026 09:21:46 +0530 Subject: [PATCH 4/5] remove scripts/adms_cli.py from branch (keep local only) --- scripts/adms_cli.py | 1611 ------------------------------------------- 1 file changed, 1611 deletions(-) delete mode 100755 scripts/adms_cli.py diff --git a/scripts/adms_cli.py b/scripts/adms_cli.py deleted file mode 100755 index 8523330a..00000000 --- a/scripts/adms_cli.py +++ /dev/null @@ -1,1611 +0,0 @@ -#!/usr/bin/env python -"""Interactive CLI for testing the ADMS SDK. - -Exposes every public API on :class:`AdmsClient`: - - * ``client.documents`` — Document entity (list/get/update/download URL, - restore + soft-delete content versions). - * ``client.relations`` — DocumentRelation entity (list/get/delete, - presigned upload URLs, multipart completion, lock/unlock, - full draft lifecycle: create / validate / activate / discard). - * ``client.config`` — Tenant configuration (AllowedDomain, DocumentType, - BusinessObjectNodeType, DocumentType↔BO type maps — full CRUD). - * ``client.jobs`` — Async jobs (ZIP_DOWNLOAD, DELETE_USER_DATA, status poll). - -Usage: - # Load creds from .env.adms and run interactively - set -a && source .env.adms && set +a - .venv/bin/python scripts/adms_cli.py - - # Or pass a specific command directly (see "Commands" below) - .venv/bin/python scripts/adms_cli.py relations list - .venv/bin/python scripts/adms_cli.py documents update - .venv/bin/python scripts/adms_cli.py config maps - -Commands: - RELATIONS - relations list — list all DocumentRelations - relations get — get single relation - relations delete — delete a relation - relations create-draft — start a draft for a BO node - relations validate-draft — validate a draft before activation - relations activate-draft — activate a draft - relations discard-draft — discard a draft without activation - relations upload-urls — generate presigned upload URLs - relations complete-upload — finalise a multipart upload - relations lock — lock document & relation - relations unlock — release lock - DOCUMENTS - documents update — update document metadata - documents download — presigned download URL - documents restore — restore a previous content version - documents delete-version — soft-delete a content version - CONFIG - config domains — list AllowedDomains - config domains-create — register an AllowedDomain - config domains-delete — remove an AllowedDomain - config doctypes — list DocumentTypes - config doctypes-create — create a DocumentType - config doctypes-delete — delete a DocumentType - config botypes — list BusinessObjectNodeTypes - config botypes-create — register a BusinessObjectNodeType - config botypes-delete — delete a BusinessObjectNodeType - config maps — list DocumentType ↔ BO type mappings - config maps-create — create a mapping - config maps-delete — delete a mapping - JOBS - jobs status — poll DocumentService job status - jobs zip — start ZIP_DOWNLOAD job - jobs delete-user-data — start DELETE_USER_DATA job (admin) -""" - -from __future__ import annotations - -import json -import os -import sys -import textwrap -from typing import Optional - -# ── ensure src/ is on the path when run from the repo root ────────────────── -_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(_REPO_ROOT, "src")) - -from sap_cloud_sdk.adms.client import AdmsClient, create_client -from sap_cloud_sdk.adms.config import load_from_env_or_mount -from sap_cloud_sdk.adms.exceptions import ( - AdmsError, - DocumentNotFoundError, - HttpError, - ScanNotCleanError, -) -from sap_cloud_sdk.adms._models import ( - BaseType, - CreateAllowedDomainInput, - CreateApplicationTenantInput, - CreateBusinessObjectNodeTypeInput, - CreateDocumentInput, - CreateDocumentRelationInput, - CreateDocumentTypeBoTypeMapInput, - CreateDocumentTypeInput, - CreateFileExtensionPolicyInput, - DeleteUserDataJobParameters, - DraftActivateInput, - DraftInput, - FileExtensionPolicy, - UpdateAllowedDomainInput, - UpdateBusinessObjectNodeTypeInput, - UpdateDocumentInput, - UpdateDocumentTypeInput, - ZipDownloadJobParameters, -) - -# ── pretty-printing helpers ────────────────────────────────────────────────── - -# Maps Python snake_case field names → OData PascalCase wire key names -# so CLI output matches Postman / Bruno responses exactly. -_WIRE_KEYS: dict[str, str] = { - # DocumentRelation - "document_relation_id": "DocumentRelationID", - "business_object_node_type_unique_id": "BusinessObjectNodeTypeUniqueID", - "host_business_object_node_id": "HostBusinessObjectNodeID", - "host_business_obj_node_display_id": "HostBusinessObjNodeDisplayID", - "document_id": "DocumentID", - "document_is_active_entity": "DocumentIsActiveEntity", - "is_active_entity": "IsActiveEntity", - "has_active_entity": "HasActiveEntity", - "has_draft_entity": "HasDraftEntity", - "document_relation_is_locked": "DocumentRelationIsLocked", - "document_relation_is_deleted": "DocumentRelationIsDeleted", - "document_relation_is_output_relevant": "DocumentRelationIsOutputRelevant", - "draft_messages": "DraftMessages", - "draft_administrative_data": "DraftAdministrativeData", - # DraftAdministrativeData fields - "draft_uuid": "DraftUUID", - "creation_date_time": "CreationDateTime", - "created_by_user": "CreatedByUser", - "draft_is_created_by_me": "DraftIsCreatedByMe", - "last_change_date_time": "LastChangeDateTime", - "last_changed_by_user": "LastChangedByUser", - "in_process_by_user": "InProcessByUser", - "draft_is_processed_by_me": "DraftIsProcessedByMe", - "doc_relation_created_by_user_name": "DocRelationCreatedByUserName", - "doc_relation_created_at_date_time": "DocRelationCreatedAtDateTime", - "doc_relation_changed_by_user_name": "DocRelationChangedByUserName", - "doc_relation_changed_at_date_time": "DocRelationChangedAtDateTime", - "document": "Document", # expanded DocumentRelation → Document nav property - # Document - "document_name": "DocumentName", - "document_base_type": "DocumentBaseType", - "document_type_id": "DocumentTypeID", - "document_state": "DocumentState", - "document_mime_type": "DocumentMimeType", - "document_description": "DocumentDescription", - "document_size_in_byte": "DocumentSizeInByte", - "document_content_stream_uri": "DocumentContentStreamURI", - "document_external_content_url": "DocumentExternalContentURL", - "document_is_locked": "DocumentIsLocked", - "document_is_soft_deleted": "DocumentIsSoftDeleted", - "has_active_document_entity": "HasActiveDocumentEntity", - "has_draft_document_entity": "HasDraftDocumentEntity", - "draft_uuid": "DraftUUID", - "document_content_upload_urls": "DocumentContentUploadURLs", - "document_is_multi_referenced": "DocumentIsMultiReferenced", - "document_created_by_user_name": "DocumentCreatedByUserName", - "document_created_at_date_time": "DocumentCreatedAtDateTime", - "document_changed_by_user_name": "DocumentChangedByUserName", - "document_changed_at_date_time": "DocumentChangedAtDateTime", - # DocumentContentVersion - "doc_content_version_id": "DocContentVersionID", - "doc_content_version_state": "DocContentVersionState", - "doc_content_version_name": "DocContentVersionName", - "doc_content_version_comment": "DocContentVersionComment", - "doc_content_version_is_latest": "DocContentVersionIsLatest", - "doc_content_version_mime_type": "DocContentVersionMimeType", - "doc_content_version_size_in_byte": "DocContentVersionSizeInByte", - "doc_content_version_stream_uri": "DocContentVersionStreamURI", - "doc_content_version_content_hash": "DocContentVersionContentHash", - "doc_content_version_upload_id": "DocContentVersionUploadID", - "doc_content_version_is_soft_deleted": "DocContentVersionIsSoftDeleted", - # AllowedDomain - "allowed_domain_id": "AllowedDomainID", - "allowed_domain_host_name": "AllowedDomainHostName", - "allowed_domain_protocol": "AllowedDomainProtocol", - "allowed_domain_port": "AllowedDomainPort", - # DocumentType - "document_type_name": "DocumentTypeName", - "document_type_description": "DocumentTypeDescription", - # BusinessObjectNodeType - "business_object_node_type": "BusinessObjectNodeType", - "business_object_node_type_name": "BusinessObjectNodeTypeName", - "odm_entity_name": "ODMEntityName", - "application_tenant_id": "ApplicationTenantID", - # DocumentTypeBusinessObjectTypeMap - "document_type_bo_type_map_id": "DocumentTypeBOTypeMapID", - "is_default": "IsDefault", - # JobOutput - "job_id": "JobID", - "job_status": "JobStatus", - "job_result": "JobResult", - "job_error_details": "JobErrorDetails", - "job_progress_percentage": "JobProgressPercentage", -} - - -def _to_jsonable(obj): - """Recursively convert dataclasses / enums to JSON-serialisable dicts. - - Uses Python snake_case field names — the SDK model as application code sees it. - """ - from enum import Enum - from dataclasses import fields, is_dataclass - - if isinstance(obj, Enum): - return obj.value - elif is_dataclass(obj) and not isinstance(obj, type): - return {f.name: _to_jsonable(getattr(obj, f.name)) for f in fields(obj)} - elif isinstance(obj, list): - return [_to_jsonable(i) for i in obj] - return obj - - -def _print_json(obj) -> None: - """Print a dataclass or dict as indented JSON.""" - print(json.dumps(_to_jsonable(obj), indent=2)) - - -def _field(key: str, val: object) -> None: - print(f" {key + ':':<42} {val}") - - -def _print_document(doc) -> None: - _field("DocumentID", doc.document_id) - _field("IsActiveEntity", doc.is_active_entity) - _field("DocumentName", doc.document_name) - _field("DocumentBaseType", getattr(doc.document_base_type, "value", doc.document_base_type)) - _field("DocumentTypeID", doc.document_type_id) - _field("DocumentMimeType", doc.document_mime_type) - _field("DocumentDescription", doc.document_description) - _field("DocumentSizeInByte", doc.document_size_in_byte) - _field("DocumentContentStreamURI", doc.document_content_stream_uri) - _field("DocumentExternalContentURL", doc.document_external_content_url) - _field("DocumentIsLocked", doc.document_is_locked) - _field("DocumentIsSoftDeleted", doc.document_is_soft_deleted) - _field("HasActiveDocumentEntity", doc.has_active_document_entity) - _field("HasDraftDocumentEntity", doc.has_draft_document_entity) - _field("DraftUUID", doc.draft_uuid) - _field("DocumentContentUploadURLs", doc.document_content_upload_urls) - _field("DocumentIsMultiReferenced", doc.document_is_multi_referenced) - _field("DocumentCreatedByUserName", doc.document_created_by_user_name) - _field("DocumentCreatedAtDateTime", doc.document_created_at_date_time) - _field("DocumentChangedByUserName", doc.document_changed_by_user_name) - _field("DocumentChangedAtDateTime", doc.document_changed_at_date_time) - _field("DocumentState", getattr(doc.document_state, "value", doc.document_state)) - _field("DocumentStateText", doc.document_state_text) - _field("DocumentContentHash", doc.document_content_hash) - - -def _print_list(items, label: str) -> None: - print(f"\n{'─' * 62}") - print(f" {label} ({len(items)} items)") - print(f"{'─' * 62}") - for item in items: - _print_json(item) - print() - - -# ── raw response capture ────────────────────────────────────────────────────── -# When raw mode is on, every HTTP response body is printed verbatim (wire JSON) -# before the parsed SDK model is shown — identical to Postman output. - -_raw_mode = False -_last_raw_responses: list[str] = [] - - -def _patch_http_for_raw(client: AdmsClient) -> None: - """Monkey-patch AdmsHttp._request to capture raw response bodies.""" - from sap_cloud_sdk.adms._http import AdmsHttp - - original_request = AdmsHttp._request - - def _capturing_request(self, method, path, **kwargs): - _last_raw_responses.clear() - resp = original_request(self, method, path, **kwargs) - try: - body = resp.json() - _last_raw_responses.append(json.dumps(body, indent=2)) - except Exception: - _last_raw_responses.append(resp.text) - return resp - - AdmsHttp._request = _capturing_request # type: ignore[method-assign] - - -def _print_raw_if_enabled(label: str = "Raw API response") -> None: - """Print the most recent captured raw response(s) if raw mode is on.""" - if not _raw_mode or not _last_raw_responses: - return - print(f"\n ── {label} (wire JSON) ──") - for body in _last_raw_responses: - print(body) - print() - - -def _print_last_raw() -> None: - """Print the last captured raw wire response (always, no mode check).""" - if _last_raw_responses: - print(_last_raw_responses[-1]) - - -def _clear_raw_captures() -> None: - _last_raw_responses.clear() - - -def _prompt(prompt: str, default: Optional[str] = None) -> str: - if default: - label = f"{prompt} (optional — press Enter to skip, default {default})" - else: - label = prompt - value = input(f" {label}: ").strip() - if not value and default: - return default - return value - - -def _prompt_optional(prompt: str) -> Optional[str]: - """Prompt for an optional value — empty input returns None.""" - value = input(f" {prompt} (optional — press Enter to skip): ").strip() - return value or None - - -def _prompt_int(prompt: str, default: Optional[int] = None) -> Optional[int]: - if default is not None: - label = f"{prompt} (optional — press Enter to skip, default {default})" - else: - label = f"{prompt} (optional — press Enter to skip)" - raw = input(f" {label}: ").strip() - if not raw: - return default - try: - return int(raw) - except ValueError: - _err(f" Invalid integer: {raw!r}. Skipping.") - return None - - -def _prompt_bool(prompt: str, default: bool = False) -> bool: - default_label = "y" if default else "n" - raw = input(f" {prompt} (y/n, default {default_label}): ").strip().lower() - if not raw: - return default - return raw in ("y", "yes", "1", "true") - - -def _confirm(question: str) -> bool: - return input(f"\n {question} (y/n): ").strip().lower() == "y" - - -def _ok(msg: str) -> None: - print(f"\n ✓ {msg}\n") - - -def _err(msg: str) -> None: - print(f"\n ✗ {msg}\n", file=sys.stderr) - - -def _err_exc(label: str, exc: Exception) -> None: - """Print a user-friendly error, including the raw ADM response body when available.""" - _err(f"{label}: {exc}") - if isinstance(exc, HttpError) and exc.response_text: - print(f" ADM response: {exc.response_text}\n", file=sys.stderr) - - -# ── build client ───────────────────────────────────────────────────────────── - - -def _build_client() -> AdmsClient: - try: - config = load_from_env_or_mount("default") - except Exception as exc: - _err(f"Could not load ADMS config: {exc}") - _err("Make sure you ran: set -a && source .env.adms && set +a") - sys.exit(1) - client = create_client(config=config) - print(f" Connected to: {config.service_url}") - return client - - -# ── RELATIONS handlers ─────────────────────────────────────────────────────── - - -def cmd_relations_list(client: AdmsClient) -> None: - print("\nFetching all DocumentRelations …") - items = client.relations.get_all() - _print_list(items, "DocumentRelations") - - -def cmd_relations_get(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(f"\nFetching DocumentRelation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") - try: - rel = client.relations.get(relation_id, is_active_entity=is_active) - _print_json(rel) - except DocumentNotFoundError: - _err(f"Relation {relation_id!r} not found.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_delete(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - if not _confirm(f"Delete relation {relation_id!r} (IsActiveEntity={str(is_active).lower()})?"): - print(" Aborted.") - return - try: - client.relations.delete(relation_id, is_active_entity=is_active) - _ok(f"Deleted {relation_id}") - except DocumentNotFoundError: - _err(f"Relation {relation_id!r} not found.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def _prompt_draft_input() -> Optional[DraftInput]: - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both fields are required.") - return None - return DraftInput( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - ) - - -def cmd_relations_create_draft(client: AdmsClient) -> None: - print("\n── Create draft DocumentRelations for a BO node ────────────") - draft = _prompt_draft_input() - if not draft: - return - try: - items = client.relations.create_draft(draft) - _print_list(items, "Draft DocumentRelations") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_validate_draft(client: AdmsClient) -> None: - print("\n── Validate draft DocumentRelations ────────────────────────") - draft = _prompt_draft_input() - if not draft: - return - try: - items = client.relations.validate_draft(draft) - _print_list(items, "Validated draft DocumentRelations") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_activate_draft(client: AdmsClient) -> None: - print("\n── Activate draft DocumentRelations ────────────────────────") - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both fields are required.") - return - late = _prompt_optional("LateHostBusinessObjectNodeID") - activate = DraftActivateInput( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - late_host_business_object_node_id=late, - ) - try: - items = client.relations.activate_draft(activate) - _print_list(items, "Activated DocumentRelations") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_discard_draft(client: AdmsClient) -> None: - print("\n── Discard draft DocumentRelations ─────────────────────────") - draft = _prompt_draft_input() - if not draft: - return - if not _confirm("Discard the draft? This cannot be undone."): - print(" Aborted.") - return - try: - client.relations.discard_draft(draft) - _ok("Draft discarded.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_upload_urls(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - is_multipart = _prompt_bool("Multipart upload?", default=False) - no_of_parts = _prompt_int("Number of parts", default=1) or 1 - print(f"\nGenerating upload URLs for {relation_id} …") - try: - doc = client.relations.generate_upload_urls( - relation_id, - is_active_entity=is_active, - is_multipart=is_multipart, - no_of_parts=no_of_parts, - ) - _ok(f"Generated {len(doc.document_content_upload_urls)} upload URL(s).") - _print_json(doc) - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_complete_upload(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(f"\nCompleting multipart upload for {relation_id} …") - try: - client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) - _ok("Multipart upload completed.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_lock(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - try: - client.relations.lock(relation_id, is_active_entity=is_active) - _ok(f"Locked {relation_id}.") - except AdmsError as exc: - _err_exc("Lock failed", exc) - - -def cmd_relations_unlock(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - try: - client.relations.unlock(relation_id, is_active_entity=is_active) - _ok(f"Unlocked {relation_id}.") - except AdmsError as exc: - _err_exc("Unlock failed", exc) - - -def cmd_relations_full_upload(client: AdmsClient) -> None: - """Create relation + document, generate upload URL, PUT file to GCS, then complete.""" - import os - - print("\n── Full upload: create relation → generate URL → PUT file → complete ──") - - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both BO fields are required.") - return - bo_display_id = _prompt_optional("HostBusinessObjNodeDisplayID") - doc_name = _prompt("DocumentName (e.g. test.pdf)") - doc_type_id = _prompt("DocumentTypeID") - if not doc_name or not doc_type_id: - _err("DocumentName and DocumentTypeID are required.") - return - doc_desc = _prompt_optional("DocumentDescription") - doc_base_type_raw = _prompt("DocumentBaseType (D=file, F=folder, U=URL)", default="D").upper() - try: - doc_base_type = BaseType(doc_base_type_raw) - except ValueError: - _err(f"Invalid DocumentBaseType {doc_base_type_raw!r} — must be D, F, or U.") - return - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - is_multipart = _prompt_bool("Multipart upload?", default=False) - no_of_parts = _prompt_int("Number of parts", default=1) or 1 - file_path = _prompt("Local file path") - if not file_path or not os.path.isfile(file_path): - _err(f"File not found: {file_path!r}") - return - file_name = os.path.basename(file_path) - - print(f"\nStep 1 — Creating relation + document …") - try: - rel = client.relations.create( - CreateDocumentRelationInput( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - host_business_obj_node_display_id=bo_display_id, - is_active_entity=is_active, - document=CreateDocumentInput( - document_name=doc_name, - document_type_id=doc_type_id, - document_description=doc_desc, - document_base_type=doc_base_type, - document_is_multipart=is_multipart, - document_no_of_parts=no_of_parts if is_multipart else None, - ), - ) - ) - except AdmsError as exc: - _err_exc("Create failed", exc) - return - _ok(f"Relation created: {rel.document_relation_id}") - _print_json(rel) - - print(f"\nStep 2 — Generating upload URL(s) for {rel.document_relation_id} …") - try: - doc = client.relations.generate_upload_urls( - rel.document_relation_id, - is_active_entity=is_active, - is_multipart=is_multipart, - no_of_parts=no_of_parts, - ) - except AdmsError as exc: - _err_exc("Failed to generate upload URLs", exc) - return - - urls = doc.document_content_upload_urls - if not urls: - _err("No upload URLs returned by ADM.") - return - _ok(f"Got {len(urls)} URL(s).") - upload_url = urls[0] - - adm_filename = doc.document_name or file_name - - print(f"\nStep 3 — Uploading {file_name!r} to GCS …") - try: - import urllib.request as _urllib_request - import ssl as _ssl - import mimetypes - - _ssl_ctx = _ssl.create_default_context() - _ssl_ctx.check_hostname = False - _ssl_ctx.verify_mode = _ssl.CERT_NONE - - with open(file_path, "rb") as f: - file_bytes = f.read() - - mime_type, _ = mimetypes.guess_type(file_path) - if not mime_type: - mime_type = "application/octet-stream" - - put_url = urls[0] - - headers = {"Content-Type": mime_type} - if not is_multipart: - headers["x-goog-meta-filename"] = adm_filename - - print(f" → Content-Type: {mime_type}") - - req = _urllib_request.Request(put_url, data=file_bytes, method="PUT", headers=headers) - try: - with _urllib_request.urlopen(req, timeout=120, context=_ssl_ctx) as response: - status = response.status - except _urllib_request.HTTPError as exc: - body = exc.read().decode("utf-8", errors="replace") - _err(f"GCS upload failed: HTTP {exc.code} — {body[:400]}") - return - if status not in (200, 204): - _err(f"GCS upload failed: HTTP {status}") - return - _ok(f"Uploaded {len(file_bytes):,} bytes.") - except Exception as exc: - _err(f"Upload error: {exc}") - return - - if is_multipart: - print(f"\nStep 4 — Completing multipart upload for {rel.document_relation_id} …") - try: - client.relations.complete_multipart_upload(rel.document_relation_id, is_active_entity=is_active) - _ok("Multipart upload completed.") - except AdmsError as exc: - _err_exc("Complete upload failed", exc) - else: - _ok("Single-part upload complete — no rcu step needed.") - - -def cmd_relations_delete_bo_node(client: AdmsClient) -> None: - print("\n── Delete all DocumentRelations for a BO node ──────────────") - print(" ⚠ This permanently deletes ALL relations for the given BO node.") - draft = _prompt_draft_input() - if not draft: - return - if not _confirm("Delete ALL relations for this BO node? This is irreversible."): - print(" Aborted.") - return - try: - result = client.relations.delete_business_object_node(draft) - _ok(f"Deleted {result.relations_deleted} relation(s).") - _print_json(result) - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_relations_change_logs(client: AdmsClient) -> None: - print("\nFetching BusinessObjectNodeChangeLog …") - items = client.relations.get_bo_node_change_logs() - _print_list(items, "BusinessObjectNodeChangeLog") - - -def cmd_relations_bo_change_logs(client: AdmsClient) -> None: - print("\nFetching BusinessObjectNodeChangeLog …") - items = client.relations.get_bo_node_change_logs() - _print_list(items, "BusinessObjectNodeChangeLog") - - -# ── DOCUMENTS handlers ─────────────────────────────────────────────────────── - - -def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(" Leave any field blank to leave it unchanged.") - document_name = _prompt_optional("New document name") - document_description = _prompt_optional("New description") - document_type_id = _prompt_optional("New DocumentTypeID") - doc_content_version_comment = _prompt_optional("Content version comment") - document_external_content_url = _prompt_optional("New external URL") - is_content_update_raw = _prompt_bool("Is content update? (new file version)", default=False) - is_content_update = is_content_update_raw if is_content_update_raw else None - update = UpdateDocumentInput( - document_name=document_name, - document_description=document_description, - document_type_id=document_type_id, - doc_content_version_comment=doc_content_version_comment, - document_external_content_url=document_external_content_url, - is_content_update=is_content_update, - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - doc = client.documents.update(relation_id, update, is_active_entity=is_active) - _ok("Document updated.") - _print_json(doc) - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_documents_download(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - version = _prompt_optional("DocContentVersionID (blank = latest)") - print("\nFetching presigned download URL …") - try: - url = client.documents.get_download_url( - document_relation_id=relation_id, - is_active_entity=is_active, - doc_content_version_id=version or None, - ) - _ok("Presigned URL (valid for a short time — do not cache):") - print(f" {url}\n") - except ScanNotCleanError as exc: - _err_exc("Download blocked — scan not CLEAN", exc) - except DocumentNotFoundError: - _err(f"Relation {relation_id!r} not found.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_documents_restore( - client: AdmsClient, relation_id: str, version_id: str -) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - comment = _prompt_optional("Comment (optional)") - print(f"\nRestoring version {version_id} on {relation_id} …") - try: - doc = client.documents.restore_content_version( - relation_id, version_id, is_active_entity=is_active, comment=comment - ) - _ok(f"Restored version {version_id}.") - _print_json(doc) - except AdmsError as exc: - _err_exc("Restore failed", exc) - - -def cmd_documents_delete_version( - client: AdmsClient, relation_id: str, version_id: str -) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - if not _confirm(f"Soft-delete version {version_id} on relation {relation_id} (IsActiveEntity={str(is_active).lower()})?"): - print(" Aborted.") - return - try: - client.documents.delete_content_version(relation_id, version_id, is_active_entity=is_active) - _ok(f"Deleted version {version_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -# ── CONFIGURATION handlers ─────────────────────────────────────────────────── - - -def cmd_config_domains(client: AdmsClient) -> None: - print("\nFetching AllowedDomains …") - items = client.config.get_all_allowed_domains() - _print_list(items, "AllowedDomains") - - -def cmd_config_domains_create(client: AdmsClient) -> None: - print("\n── Create AllowedDomain ────────────────────────────────────") - host = _prompt("AllowedDomainHostName * (mandatory, e.g. storage.example.com)") - proto = _prompt("AllowedDomainProtocol", default="https") - port = _prompt_int("AllowedDomainPort") - if not host or not proto: - _err("Hostname and protocol are required.") - return - try: - out = client.config.create_allowed_domain( - CreateAllowedDomainInput(host_name=host, protocol=proto, port=port) - ) - _ok(f"Created AllowedDomain {out.allowed_domain_id}") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_domains_get(client: AdmsClient, allowed_domain_id: str) -> None: - print(f"\nFetching AllowedDomain {allowed_domain_id} …") - try: - out = client.config.get_allowed_domain(allowed_domain_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_domains_update(client: AdmsClient, allowed_domain_id: str) -> None: - print("\n── Update AllowedDomain ────────────────────────────────────") - print(" Leave any field blank to leave it unchanged.") - update = UpdateAllowedDomainInput( - host_name=_prompt_optional("New hostname"), - protocol=_prompt_optional("New protocol (https/http)"), - port=_prompt_int("New port (blank = unchanged)"), - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - out = client.config.update_allowed_domain(allowed_domain_id, update) - _ok(f"Updated AllowedDomain {out.allowed_domain_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_config_domains_delete(client: AdmsClient, allowed_domain_id: str) -> None: - if not _confirm(f"Delete AllowedDomain {allowed_domain_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_allowed_domain(allowed_domain_id) - _ok(f"Deleted {allowed_domain_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_doctypes(client: AdmsClient) -> None: - print("\nFetching DocumentTypes …") - items = client.config.get_all_document_types() - _print_list(items, "DocumentTypes") - - -def cmd_config_doctypes_create(client: AdmsClient) -> None: - print("\n── Create DocumentType ─────────────────────────────────────") - type_id = _prompt("DocumentTypeID * (mandatory, max 10 chars)") - name = _prompt("DocumentTypeName * (mandatory)") - desc = _prompt_optional("DocumentTypeDescription") - if not type_id or not name: - _err("DocumentTypeID and DocumentTypeName are required.") - return - try: - out = client.config.create_document_type( - CreateDocumentTypeInput( - document_type_id=type_id, - document_type_name=name, - document_type_description=desc, - ) - ) - _ok(f"Created DocumentType {out.document_type_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_doctypes_get(client: AdmsClient, document_type_id: str) -> None: - print(f"\nFetching DocumentType {document_type_id} …") - try: - out = client.config.get_document_type(document_type_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_doctypes_update(client: AdmsClient, document_type_id: str) -> None: - print("\n── Update DocumentType ─────────────────────────────────────") - print(" Leave any field blank to leave it unchanged.") - update = UpdateDocumentTypeInput( - document_type_name=_prompt_optional("New DocumentTypeName"), - document_type_description=_prompt_optional("New description"), - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - out = client.config.update_document_type(document_type_id, update) - _ok(f"Updated DocumentType {out.document_type_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_config_doctypes_delete(client: AdmsClient, document_type_id: str) -> None: - if not _confirm(f"Delete DocumentType {document_type_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_document_type(document_type_id) - _ok(f"Deleted {document_type_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_botypes(client: AdmsClient) -> None: - print("\nFetching BusinessObjectNodeTypes …") - items = client.config.get_all_business_object_types() - _print_list(items, "BusinessObjectNodeTypes") - - -def cmd_config_botypes_create(client: AdmsClient) -> None: - print("\n── Create BusinessObjectNodeType ───────────────────────────") - bo_type = _prompt("BusinessObjectNodeType * (mandatory, short code, e.g. PO)") - bo_name = _prompt("BusinessObjectNodeTypeName * (mandatory)") - tenant_id = _prompt("ApplicationTenantID * (mandatory, UUID)") - odm_name = _prompt_optional("ODMEntityName") - if not bo_type or not bo_name or not tenant_id: - _err("BusinessObjectNodeType, name, and ApplicationTenantID are required.") - return - try: - out = client.config.create_business_object_type( - CreateBusinessObjectNodeTypeInput( - business_object_node_type=bo_type, - business_object_node_type_name=bo_name, - application_tenant_id=tenant_id, - odm_entity_name=odm_name, - ) - ) - _ok( - f"Created BusinessObjectNodeType {out.business_object_node_type_unique_id}." - ) - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_botypes_get(client: AdmsClient, bo_type_unique_id: str) -> None: - print(f"\nFetching BusinessObjectNodeType {bo_type_unique_id} …") - try: - out = client.config.get_business_object_type(bo_type_unique_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_botypes_update(client: AdmsClient, bo_type_unique_id: str) -> None: - print("\n── Update BusinessObjectNodeType ───────────────────────────") - print(" Leave any field blank to leave it unchanged.") - update = UpdateBusinessObjectNodeTypeInput( - business_object_node_type=_prompt_optional("New BusinessObjectNodeType code"), - business_object_node_type_name=_prompt_optional("New BusinessObjectNodeTypeName"), - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - out = client.config.update_business_object_type(bo_type_unique_id, update) - _ok(f"Updated BusinessObjectNodeType {out.business_object_node_type_unique_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_config_botypes_delete(client: AdmsClient, bo_type_unique_id: str) -> None: - if not _confirm(f"Delete BusinessObjectNodeType {bo_type_unique_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_business_object_type(bo_type_unique_id) - _ok(f"Deleted {bo_type_unique_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_maps(client: AdmsClient) -> None: - print("\nFetching DocumentType ↔ BusinessObjectNodeType mappings …") - items = client.config.get_type_mappings() - _print_list(items, "DocumentTypeBusinessObjectTypeMaps") - - -def cmd_config_maps_create(client: AdmsClient) -> None: - print("\n── Create DocumentType ↔ BO type mapping ───────────────────") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - if not bo_unique_id or not doc_type_id: - _err("Both IDs are required.") - return - try: - out = client.config.create_type_mapping( - CreateDocumentTypeBoTypeMapInput( - business_object_node_type_unique_id=bo_unique_id, - document_type_id=doc_type_id, - ) - ) - _ok(f"Created mapping {out.document_type_id}:{out.business_object_node_type_unique_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_maps_get(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - if not doc_type_id or not bo_unique_id: - _err("Both fields are required.") - return - print(f"\nFetching mapping {doc_type_id}:{bo_unique_id} …") - try: - out = client.config.get_type_mapping(doc_type_id, bo_unique_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_maps_delete(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - if not doc_type_id or not bo_unique_id: - _err("Both fields are required.") - return - if not _confirm(f"Delete mapping {doc_type_id}:{bo_unique_id}?"): - print(" Aborted.") - return - try: - client.config.delete_type_mapping(doc_type_id, bo_unique_id) - _ok(f"Deleted mapping {doc_type_id}:{bo_unique_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_maps_mark_default(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - if not doc_type_id or not bo_unique_id: - _err("Both fields are required.") - return - try: - client.config.mark_default(doc_type_id, bo_unique_id) - _ok(f"Mapping {doc_type_id}:{bo_unique_id} marked as default.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -# ── CONFIG: FileExtensionPolicy ─────────────────────────────────────────────── - - -def cmd_config_file_ext_list(client: AdmsClient) -> None: - print("\nFetching FileExtensionPolicies …") - items = client.config.get_all_file_extension_policies() - _print_list(items, "FileExtensionPolicies") - - -def cmd_config_file_ext_create(client: AdmsClient) -> None: - print("\n── Create DocumentTypeFileExtensionPolicy ──────────────────") - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - ext = _prompt("FileExtension * (mandatory, e.g. pdf, exe)") - if not doc_type_id or not ext: - _err("Both fields are required.") - return - try: - out = client.config.create_file_extension_policy( - CreateFileExtensionPolicyInput( - document_type_id=doc_type_id, - file_extension=ext, - ) - ) - _ok(f"Created DocumentTypeFileExtensionPolicy {out.document_type_id}:{out.file_extension}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_global_file_extensions(client: AdmsClient) -> None: - print("\nFetching global allowed file extensions …") - try: - resp = client.config._http.get( - "GetGlobalAllowedFileExtensions()", - service_base="/odata/v4/ConfigurationService", - ) - _print_json(resp.json()) - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_file_ext_delete(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - ext = _prompt("FileExtension * (mandatory)") - if not doc_type_id or not ext: - _err("Both fields are required.") - return - if not _confirm(f"Delete DocumentTypeFileExtensionPolicy {doc_type_id}:{ext}?"): - print(" Aborted.") - return - try: - client.config.delete_file_extension_policy(doc_type_id, ext) - _ok(f"Deleted {doc_type_id}:{ext}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -# ── CONFIG: ApplicationTenant ───────────────────────────────────────────────── - - -def cmd_config_tenant_list(client: AdmsClient) -> None: - subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") - print("\nFetching ApplicationTenants …") - items = client.config.get_all_application_tenants(subaccount_id=subaccount_id) - _print_list(items, "ApplicationTenants") - - -def cmd_config_tenant_create(client: AdmsClient) -> None: - print("\n── Create ApplicationTenant ────────────────────────────────") - tenant_id = _prompt("ApplicationTenantID * (mandatory)") - tenant_name = _prompt("ApplicationTenantName * (mandatory)") - subaccount_id = _prompt("Subaccount ID * (mandatory, x-subaccount-id header)") - if not tenant_id or not tenant_name or not subaccount_id: - _err("ApplicationTenantID, name, and Subaccount ID are required.") - return - try: - out = client.config.create_application_tenant( - CreateApplicationTenantInput( - application_tenant_id=tenant_id, - application_tenant_name=tenant_name, - ), - subaccount_id=subaccount_id, - ) - _ok(f"Created ApplicationTenant {out.application_tenant_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_tenant_get(client: AdmsClient, tenant_id: str) -> None: - subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") - print(f"\nFetching ApplicationTenant {tenant_id} …") - try: - out = client.config.get_application_tenant(tenant_id, subaccount_id=subaccount_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_tenant_delete(client: AdmsClient, tenant_id: str) -> None: - subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") - if not _confirm(f"Delete ApplicationTenant {tenant_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_application_tenant(tenant_id, subaccount_id=subaccount_id) - _ok(f"Deleted {tenant_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -# ── JOBS handlers ──────────────────────────────────────────────────────────── - - -def cmd_jobs_status( - client: AdmsClient, job_id: str, *, use_admin_service: bool = False -) -> None: - service = "AdminService" if use_admin_service else "DocumentService" - print(f"\nFetching {service} status for job {job_id} …") - try: - output = client.jobs.get_status(job_id, use_admin_service=use_admin_service) - _print_json(output) - if output.job_status and output.job_status.is_terminal(): - _ok(f"Job is in terminal state: {output.job_status.value}") - else: - print(f" ⟳ Job still running: {output.job_status}") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_jobs_zip(client: AdmsClient) -> None: - print("\n── Start ZIP_DOWNLOAD job ──────────────────────────────────") - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both fields are required.") - return - rel_ids_raw = _prompt_optional( - "DocumentRelationIDs (comma-separated UUIDs, blank = all)" - ) - rel_ids = ( - [r.strip() for r in rel_ids_raw.split(",") if r.strip()] - if rel_ids_raw - else [] - ) - print(f"\nStarting ZIP_DOWNLOAD job for {bo_node_id} …") - try: - output = client.jobs.start_zip_download( - ZipDownloadJobParameters( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - document_relation_ids=rel_ids, - ) - ) - _ok(f"Job started: {output.job_id} status={output.job_status}") - _print_json(output) - print(f" → Poll with: js (Job ID: {output.job_id})") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_jobs_delete_user_data(client: AdmsClient) -> None: - print("\n── Start DELETE_USER_DATA job (AdminService) ───────────────") - print(" ⚠ This is a GDPR erasure operation and is irreversible.") - user_id = _prompt("UserID to erase") - if not user_id: - _err("UserID is required.") - return - replacement = _prompt_optional("ReplacementUserID (default: SYSTEM)") - if not _confirm(f"Erase all references to user {user_id!r}? This is irreversible."): - print(" Aborted.") - return - try: - output = client.jobs.start_delete_user_data( - DeleteUserDataJobParameters( - user_id=user_id, - replacement_user_id=replacement, - ) - ) - _ok(f"Job started: {output.job_id} status={output.job_status}") - _print_json(output) - print(f" → Poll with: js (Job ID: {output.job_id})") - except AdmsError as exc: - _err_exc("Failed", exc) - - -# ── interactive menu ────────────────────────────────────────────────────────── - -_MENU = textwrap.dedent(""" - ┌──────────────────────────────────────────────────────────────────┐ - │ ADMS Interactive CLI │ - ├──────────────────────────────────────────────────────────────────┤ - │ RELATIONS (AdmsRelationsClientApi) │ - │ rl — list all DocumentRelations │ - │ rg — get relation by ID │ - │ rd — delete relation by ID │ - │ rcd — createDraft (BO type + node ID) │ - │ rvd — validateDraft (BO type + node ID) │ - │ rad — activateDraft (BO type + node ID) │ - │ rdd — discardDraft (BO type + node ID) │ - │ ru — generateUploadUrls (relation ID) │ - │ rfu — fullUpload: create relation + PUT file + complete │ - │ rcu — completeMultipartUpload (relation ID) │ - │ rlk — lock relation │ - │ ruk — unlock relation │ - │ rbn — deleteBusinessObjectNode [irreversible!] │ - │ rcl — getChangeLog (all changes) │ - │ rbl — getBusinessObjectNodeChangeLog │ - ├──────────────────────────────────────────────────────────────────┤ - │ DOCUMENTS (AdmsDocumentsClientApi) │ - │ du — update Document (rename) │ - │ dd — getDownloadUrl (pre-signed URL) │ - │ drv — restoreContentVersion │ - │ ddv — deleteContentVersion │ - ├──────────────────────────────────────────────────────────────────┤ - │ CONFIGURATION (AdmsConfigClientApi) │ - │ cd — list AllowedDomains │ - │ cdg — get AllowedDomain by ID │ - │ cda — create AllowedDomain │ - │ cdu — update AllowedDomain │ - │ cdd — delete AllowedDomain │ - │ ct — list DocumentTypes │ - │ ctg — get DocumentType by ID │ - │ cta — create DocumentType │ - │ ctu — update DocumentType │ - │ ctd — delete DocumentType │ - │ cb — list BusinessObjectNodeTypes │ - │ cbg — get BusinessObjectNodeType by ID │ - │ cba — create BusinessObjectNodeType │ - │ cbu — update BusinessObjectNodeType │ - │ cbd — delete BusinessObjectNodeType │ - │ cm — list DocType ↔ BOType mappings │ - │ cmg — get mapping by ID │ - │ cma — create mapping │ - │ cmd — delete mapping │ - │ cmk — markDefault (mapping ID) │ - │ cfl — list FileExtensionPolicies │ - │ cgf — GetGlobalAllowedFileExtensions │ - │ cfc — create FileExtensionPolicy │ - │ cfd — delete FileExtensionPolicy │ - │ cal — list ApplicationTenants │ - │ cag — get ApplicationTenant by ID │ - │ cac — create ApplicationTenant │ - │ cad — delete ApplicationTenant │ - ├──────────────────────────────────────────────────────────────────┤ - │ JOBS (AdmsJobsClientApi) │ - │ js — getStatus (job ID) │ - │ jz — startZipDownload (relation IDs CSV) │ - │ jd — startDeleteUserData (user ID) [GDPR — irreversible!] │ - ├──────────────────────────────────────────────────────────────────┤ - │ OTHER │ - │ raw — toggle raw wire JSON output (shows exact Postman response) │ - │ ? — re-print this menu │ - │ q — quit │ - └──────────────────────────────────────────────────────────────────┘ -""") - - -def _interactive(client: AdmsClient) -> None: - global _raw_mode - print(_MENU) - while True: - try: - choice = input("adms> ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\nBye.") - break - - if not choice: - continue - elif choice in ("q", "quit", "exit"): - print("Bye.") - break - elif choice in ("?", "help", "h"): - print(_MENU) - elif choice == "raw": - _raw_mode = not _raw_mode - state = "ON — wire JSON printed after each response" if _raw_mode else "OFF — SDK model printed only" - print(f"\n Raw mode: {state}\n") - continue - - _clear_raw_captures() - - # ── relations ── - if choice == "rl": - cmd_relations_list(client) - elif choice == "rg": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_get(client, rid) - elif choice == "rd": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_delete(client, rid) - elif choice == "rcd": - cmd_relations_create_draft(client) - elif choice == "rvd": - cmd_relations_validate_draft(client) - elif choice == "rad": - cmd_relations_activate_draft(client) - elif choice == "rdd": - cmd_relations_discard_draft(client) - elif choice == "ru": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_upload_urls(client, rid) - elif choice == "rcu": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_complete_upload(client, rid) - elif choice == "rlk": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_lock(client, rid) - elif choice == "ruk": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_unlock(client, rid) - elif choice == "rfu": - cmd_relations_full_upload(client) - elif choice == "rbn": - cmd_relations_delete_bo_node(client) - elif choice == "rcl": - cmd_relations_change_logs(client) - elif choice == "rbl": - cmd_relations_bo_change_logs(client) - - # ── documents ── - elif choice == "du": - rid = _prompt("DocumentRelationID") - if rid: - cmd_documents_update(client, rid) - elif choice == "dd": - rid = _prompt("DocumentRelationID") - if rid: - cmd_documents_download(client, rid) - elif choice == "drv": - rid = _prompt("DocumentRelationID") - ver = _prompt("DocContentVersionID to restore", default="1.0") - if rid and ver: - cmd_documents_restore(client, rid, ver) - elif choice == "ddv": - rid = _prompt("DocumentRelationID") - ver = _prompt("DocContentVersionID to delete") - if rid and ver: - cmd_documents_delete_version(client, rid, ver) - - # ── config: AllowedDomain ── - elif choice == "cd": - cmd_config_domains(client) - elif choice == "cdg": - did = _prompt("AllowedDomainID") - if did: - cmd_config_domains_get(client, did) - elif choice == "cda": - cmd_config_domains_create(client) - elif choice == "cdu": - did = _prompt("AllowedDomainID to update") - if did: - cmd_config_domains_update(client, did) - elif choice == "cdd": - did = _prompt("AllowedDomainID to delete") - if did: - cmd_config_domains_delete(client, did) - - # ── config: DocumentType ── - elif choice == "ct": - cmd_config_doctypes(client) - elif choice == "ctg": - tid = _prompt("DocumentTypeID") - if tid: - cmd_config_doctypes_get(client, tid) - elif choice == "cta": - cmd_config_doctypes_create(client) - elif choice == "ctu": - tid = _prompt("DocumentTypeID to update") - if tid: - cmd_config_doctypes_update(client, tid) - elif choice == "ctd": - tid = _prompt("DocumentTypeID to delete") - if tid: - cmd_config_doctypes_delete(client, tid) - - # ── config: BusinessObjectNodeType ── - elif choice == "cb": - cmd_config_botypes(client) - elif choice == "cbg": - bid = _prompt("BusinessObjectNodeTypeUniqueID") - if bid: - cmd_config_botypes_get(client, bid) - elif choice == "cba": - cmd_config_botypes_create(client) - elif choice == "cbu": - bid = _prompt("BusinessObjectNodeTypeUniqueID to update") - if bid: - cmd_config_botypes_update(client, bid) - elif choice == "cbd": - bid = _prompt("BusinessObjectNodeTypeUniqueID to delete") - if bid: - cmd_config_botypes_delete(client, bid) - - # ── config: type mappings ── - elif choice == "cm": - cmd_config_maps(client) - elif choice == "cmg": - cmd_config_maps_get(client) - elif choice == "cma": - cmd_config_maps_create(client) - elif choice == "cmd": - cmd_config_maps_delete(client) - elif choice == "cmk": - cmd_config_maps_mark_default(client) - - # ── config: FileExtensionPolicy ── - elif choice == "cfl": - cmd_config_file_ext_list(client) - elif choice == "cgf": - cmd_config_global_file_extensions(client) - elif choice == "cfc": - cmd_config_file_ext_create(client) - elif choice == "cfd": - cmd_config_file_ext_delete(client) - - # ── config: ApplicationTenant ── - elif choice == "cal": - cmd_config_tenant_list(client) - elif choice == "cag": - tid = _prompt("ApplicationTenantID") - if tid: - cmd_config_tenant_get(client, tid) - elif choice == "cac": - cmd_config_tenant_create(client) - elif choice == "cad": - tid = _prompt("ApplicationTenantID to delete") - if tid: - cmd_config_tenant_delete(client, tid) - - # ── jobs ── - elif choice == "js": - job_id = _prompt("Job ID") - if job_id: - cmd_jobs_status(client, job_id, use_admin_service=False) - elif choice == "jz": - cmd_jobs_zip(client) - elif choice == "jd": - cmd_jobs_delete_user_data(client) - - else: - print( - f" Unknown command: {choice!r} (type '?' for the menu, 'q' to quit)" - ) - continue - - _print_raw_if_enabled() - - -# ── CLI argument dispatch ───────────────────────────────────────────────────── - - -def _cli(client: AdmsClient, args: list[str]) -> None: - if not args: - _interactive(client) - return - - cmd = args[0] - - if cmd == "relations": - sub = args[1] if len(args) > 1 else "" - if sub == "list": - cmd_relations_list(client) - elif sub == "get" and len(args) > 2: - cmd_relations_get(client, args[2]) - elif sub == "delete" and len(args) > 2: - cmd_relations_delete(client, args[2]) - elif sub == "create-draft": - cmd_relations_create_draft(client) - elif sub == "validate-draft": - cmd_relations_validate_draft(client) - elif sub == "activate-draft": - cmd_relations_activate_draft(client) - elif sub == "discard-draft": - cmd_relations_discard_draft(client) - elif sub == "upload-urls" and len(args) > 2: - cmd_relations_upload_urls(client, args[2]) - elif sub == "complete-upload" and len(args) > 2: - cmd_relations_complete_upload(client, args[2]) - elif sub == "lock" and len(args) > 2: - cmd_relations_lock(client, args[2]) - elif sub == "unlock" and len(args) > 2: - cmd_relations_unlock(client, args[2]) - else: - _err( - "Usage: relations list | get | delete " - "| create-draft | validate-draft | activate-draft | discard-draft " - "| upload-urls | complete-upload | lock | unlock " - ) - - elif cmd == "documents": - sub = args[1] if len(args) > 1 else "" - if sub == "update" and len(args) > 2: - cmd_documents_update(client, args[2]) - elif sub == "download" and len(args) > 2: - cmd_documents_download(client, args[2]) - elif sub == "restore" and len(args) > 3: - cmd_documents_restore(client, args[2], args[3]) - elif sub == "delete-version" and len(args) > 3: - cmd_documents_delete_version(client, args[2], args[3]) - else: - _err( - "Usage: documents update | download " - "| restore | delete-version " - ) - - elif cmd == "config": - sub = args[1] if len(args) > 1 else "" - if sub == "domains": - cmd_config_domains(client) - elif sub == "domains-create": - cmd_config_domains_create(client) - elif sub == "domains-delete" and len(args) > 2: - cmd_config_domains_delete(client, args[2]) - elif sub == "doctypes": - cmd_config_doctypes(client) - elif sub == "doctypes-create": - cmd_config_doctypes_create(client) - elif sub == "doctypes-delete" and len(args) > 2: - cmd_config_doctypes_delete(client, args[2]) - elif sub == "botypes": - cmd_config_botypes(client) - elif sub == "botypes-create": - cmd_config_botypes_create(client) - elif sub == "botypes-delete" and len(args) > 2: - cmd_config_botypes_delete(client, args[2]) - elif sub == "maps": - cmd_config_maps(client) - elif sub == "maps-create": - cmd_config_maps_create(client) - elif sub == "maps-delete": - cmd_config_maps_delete(client) - else: - _err( - "Usage: config domains[-create|-delete ] " - "| doctypes[-create|-delete ] " - "| botypes[-create|-delete ] | maps[-create|-delete ]" - ) - - elif cmd == "jobs": - sub = args[1] if len(args) > 1 else "" - if sub == "status" and len(args) > 2: - cmd_jobs_status(client, args[2], use_admin_service=False) - elif sub == "zip": - cmd_jobs_zip(client) - elif sub == "delete-user-data": - cmd_jobs_delete_user_data(client) - else: - _err( - "Usage: jobs status | jobs zip | jobs delete-user-data" - ) - - else: - _err(f"Unknown command: {cmd!r}") - print("Run without arguments for the interactive menu.") - - -# ── entry point ─────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - _client = _build_client() - _patch_http_for_raw(_client) - _cli(_client, sys.argv[1:]) From ea278e3a46ffccdc6fd574b6830d353eec9ac89d Mon Sep 17 00:00:00 2001 From: I766515 Date: Thu, 2 Jul 2026 09:25:10 +0530 Subject: [PATCH 5/5] fix(adms): document composite-key rationale for get/delete/mark_default type mapping The ADM DocumentTypeBusinessObjectTypeMap entity uses a composite key (DocumentTypeID + BusinessObjectNodeTypeUniqueID). The previous single-argument signature used a fabricated DocumentTypeBOTypeMapID that was never accepted by the service (HTTP 400), so this signature change is a bug-fix, not a breaking API change. Added note in docstrings to clarify. --- src/sap_cloud_sdk/adms/_configuration_api.py | 48 +++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index ef2bf0d5..842672c5 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -244,9 +244,23 @@ def create_type_mapping( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCTYPE_BOTYPE_MAP) def get_type_mapping( - self, document_type_id: str, business_object_node_type_unique_id: str + self, + document_type_id: str, + business_object_node_type_unique_id: str, ) -> DocumentTypeBusinessObjectTypeMap: - """Fetch a single DocumentType ↔ BusinessObjectNodeType mapping by its composite key.""" + """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_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, @@ -254,16 +268,38 @@ def get_type_mapping( return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCTYPE_BOTYPE_MAP) - def delete_type_mapping(self, document_type_id: str, business_object_node_type_unique_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_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_id: str, business_object_node_type_unique_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_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", json={},