pyxecm 3.8.2__tar.gz → 3.8.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {pyxecm-3.8.2 → pyxecm-3.8.3}/PKG-INFO +1 -1
  2. {pyxecm-3.8.2 → pyxecm-3.8.3}/pyproject.toml +1 -1
  3. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/guidewire.py +1 -3
  4. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/sap.py +328 -1
  5. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/sap_models.py +56 -0
  6. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/settings.py +3 -2
  7. {pyxecm-3.8.2 → pyxecm-3.8.3}/.gitignore +0 -0
  8. {pyxecm-3.8.2 → pyxecm-3.8.3}/README.md +0 -0
  9. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/__init__.py +0 -0
  10. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/avts.py +0 -0
  11. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/coreshare.py +0 -0
  12. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/helper/__init__.py +0 -0
  13. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/helper/assoc.py +0 -0
  14. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/helper/data.py +0 -0
  15. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/helper/logadapter.py +0 -0
  16. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/helper/web.py +0 -0
  17. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/helper/xml.py +0 -0
  18. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otac.py +0 -0
  19. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otawp.py +0 -0
  20. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otca.py +0 -0
  21. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otcs.py +0 -0
  22. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otds.py +0 -0
  23. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otiv.py +0 -0
  24. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otkd.py +0 -0
  25. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otmm.py +0 -0
  26. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/otpd.py +0 -0
  27. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm/py.typed +0 -0
  28. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/__init__.py +0 -0
  29. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/__main__.py +0 -0
  30. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/app.py +0 -0
  31. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/auth/__init__.py +0 -0
  32. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/auth/functions.py +0 -0
  33. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/auth/models.py +0 -0
  34. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/auth/router.py +0 -0
  35. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/common/__init__.py +0 -0
  36. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/common/functions.py +0 -0
  37. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/common/metrics.py +0 -0
  38. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/common/models.py +0 -0
  39. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/common/router.py +0 -0
  40. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/settings.py +0 -0
  41. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/terminal/__init__.py +0 -0
  42. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/terminal/router.py +0 -0
  43. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_maintenance/__init__.py +0 -0
  44. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_maintenance/functions.py +0 -0
  45. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_maintenance/models.py +0 -0
  46. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_maintenance/router.py +0 -0
  47. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_otcs/__init__.py +0 -0
  48. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_otcs/functions.py +0 -0
  49. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_otcs/router.py +0 -0
  50. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_payload/__init__.py +0 -0
  51. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_payload/functions.py +0 -0
  52. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_payload/log_reader.py +0 -0
  53. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_payload/models.py +0 -0
  54. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_api/v1_payload/router.py +0 -0
  55. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/__init__.py +0 -0
  56. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/__main__.py +0 -0
  57. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/browser_automation.py +0 -0
  58. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/customizer.py +0 -0
  59. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/exceptions.py +0 -0
  60. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/k8s.py +0 -0
  61. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/log.py +0 -0
  62. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/m365.py +0 -0
  63. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/manager_push.py +0 -0
  64. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/payload.py +0 -0
  65. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/payload_list.py +0 -0
  66. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/salesforce.py +0 -0
  67. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/servicenow.py +0 -0
  68. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/successfactors.py +0 -0
  69. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/translate.py +0 -0
  70. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_customizer/version.py +0 -0
  71. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_maintenance_page/__init__.py +0 -0
  72. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_maintenance_page/__main__.py +0 -0
  73. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_maintenance_page/app.py +0 -0
  74. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_maintenance_page/settings.py +0 -0
  75. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_maintenance_page/static/favicon.avif +0 -0
  76. {pyxecm-3.8.2 → pyxecm-3.8.3}/src/pyxecm_maintenance_page/templates/maintenance.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyxecm
3
- Version: 3.8.2
3
+ Version: 3.8.3
4
4
  Summary: A Python library to interact with Opentext Content Management Rest API
5
5
  Project-URL: Homepage, https://github.com/opentext/pyxecm
6
6
  Author-email: Kai Gatzweiler <kgatzweiler@opentext.com>, "Dr. Marc Diefenbruch" <mdiefenb@opentext.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyxecm"
3
- version = "3.8.2"
3
+ version = "3.8.3"
4
4
  description = 'A Python library to interact with Opentext Content Management Rest API'
5
5
  keywords = [
6
6
  'opentext',
@@ -1541,9 +1541,7 @@ class Guidewire:
1541
1541
  policies = response.get("data", [])
1542
1542
  if filter_field and filter_value is not None and isinstance(policies, list):
1543
1543
  response["data"] = [
1544
- policy
1545
- for policy in policies
1546
- if policy.get("attributes", {}).get(filter_field) == filter_value
1544
+ policy for policy in policies if policy.get("attributes", {}).get(filter_field) == filter_value
1547
1545
  ]
1548
1546
  response["count"] = len(response["data"])
1549
1547
 
@@ -119,6 +119,8 @@ from lxml import etree
119
119
  from pydantic import BaseModel, ValidationError
120
120
 
121
121
  from .sap_models import (
122
+ SAPAuthCheckState,
123
+ SAPAuthCheckStatusModel,
122
124
  SAPBusinessPartnerQueryModel,
123
125
  SAPBusinessPartnerResponseModel,
124
126
  SAPCompanyCodeQueryModel,
@@ -302,7 +304,8 @@ ODATA_V4_SERVICE_ROOT_PATTERN = re.compile(r"^(.*?/sap/opu/odata4/(?:[^/]+/){5}\
302
304
 
303
305
 
304
306
  # Registry of the OData services this module uses. Extend this if
305
- # new SAP APIs are supported by the SAP class:
307
+ # new SAP APIs are supported by the SAP class. The OAuth scope IDs are
308
+ # generated by SAP and can differ between SAP systems - see get_oauth_scope():
306
309
  SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
307
310
  "API_MAINTENANCEORDER": SAPODataServiceModel(
308
311
  name="API_MAINTENANCEORDER",
@@ -310,6 +313,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
310
313
  # deprecated version 0001 - both are identical for the fields we use:
311
314
  path="/sap/opu/odata/sap/API_MAINTENANCEORDER;v=0002",
312
315
  odata_version=2,
316
+ oauth_scope="ZAPI_MAINTENANCEORDER_0002",
313
317
  entity_sets=["MaintenanceOrder", "MaintenanceOrderOperation"],
314
318
  description="Plant Maintenance - Maintenance Order (Create, Read, Update)",
315
319
  ),
@@ -318,6 +322,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
318
322
  name="API_MAINTNOTIFICATION",
319
323
  path="/sap/opu/odata/sap/API_MAINTNOTIFICATION",
320
324
  odata_version=2,
325
+ oauth_scope="ZAPI_MAINTNOTIFICATION_0001",
321
326
  entity_sets=["MaintenanceNotification", "MaintenanceNotificationItem"],
322
327
  description="Plant Maintenance - Maintenance Notification (Create, Read, Update)",
323
328
  ),
@@ -325,6 +330,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
325
330
  name="API_SALES_ORDER_SRV",
326
331
  path="/sap/opu/odata/sap/API_SALES_ORDER_SRV",
327
332
  odata_version=2,
333
+ oauth_scope="ZAPI_SALES_ORDER_SRV_0001",
328
334
  entity_sets=["A_SalesOrder", "A_SalesOrderItem"],
329
335
  description="Sales - Sales Order (Create, Read, Update, Delete)",
330
336
  ),
@@ -332,6 +338,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
332
338
  name="API_SALES_CONTRACT_SRV",
333
339
  path="/sap/opu/odata/sap/API_SALES_CONTRACT_SRV",
334
340
  odata_version=2,
341
+ oauth_scope="ZAPI_SALES_CONTRACT_SRV_0001",
335
342
  entity_sets=["A_SalesContract", "A_SalesContractItem"],
336
343
  description="Sales - Sales Contract (Create, Read, Update, Delete)",
337
344
  ),
@@ -341,6 +348,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
341
348
  name="API_PURCHASECONTRACT_PROCESS_SRV",
342
349
  path="/sap/opu/odata/sap/API_PURCHASECONTRACT_PROCESS_SRV",
343
350
  odata_version=2,
351
+ oauth_scope="ZAPI_PURCHASECONTRACT_PROCESS_SRV_0001",
344
352
  entity_sets=["A_PurchaseContract", "A_PurchaseContractItem"],
345
353
  description="Purchasing - Purchase Contract (Create, Read, Update)",
346
354
  ),
@@ -350,6 +358,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
350
358
  name="API_PURCHASEREQUISITION_2",
351
359
  path="/sap/opu/odata4/sap/api_purchaserequisition_2/srvd_a2x/sap/purchaserequisition/0001",
352
360
  odata_version=4,
361
+ oauth_scope="OV4:API_PURCHASEREQUISITION_2",
353
362
  entity_sets=["PurchaseReqn", "PurchaseReqnItem", "PurchaseReqnAcctAssgmt"],
354
363
  description="Purchasing - Purchase Requisition (Create, Read, Update)",
355
364
  ),
@@ -357,6 +366,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
357
366
  name="API_MATERIAL_DOCUMENT_SRV",
358
367
  path="/sap/opu/odata/sap/API_MATERIAL_DOCUMENT_SRV",
359
368
  odata_version=2,
369
+ oauth_scope="ZAPI_MATERIAL_DOCUMENT_SRV_0001",
360
370
  entity_sets=["A_MaterialDocumentHeader", "A_MaterialDocumentItem"],
361
371
  description="Inventory Management - Goods Movement / Material Document (Create, Read, Cancel)",
362
372
  ),
@@ -365,6 +375,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
365
375
  name="API_MAINTORDERCONFIRMATION",
366
376
  path="/sap/opu/odata/sap/API_MAINTORDERCONFIRMATION",
367
377
  odata_version=2,
378
+ oauth_scope="ZAPI_MAINTORDERCONFIRMATION_0001",
368
379
  entity_sets=["MaintOrderConfirmation"],
369
380
  description="Plant Maintenance - Maintenance Order Confirmation (Create, Read, Cancel)",
370
381
  ),
@@ -372,6 +383,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
372
383
  name="API_MAINTENANCEPLAN",
373
384
  path="/sap/opu/odata/sap/API_MAINTENANCEPLAN",
374
385
  odata_version=2,
386
+ oauth_scope="ZAPI_MAINTENANCEPLAN_0001",
375
387
  entity_sets=["MaintenancePlan", "MaintenancePlanCycle", "MaintenanceItem"],
376
388
  description="Plant Maintenance - Maintenance Plan (Create, Read, Update, Scheduling)",
377
389
  ),
@@ -381,6 +393,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
381
393
  name="API_PLANNEDORDER",
382
394
  path="/sap/opu/odata4/sap/api_plannedorder/srvd_a2x/sap/plannedorder/0001",
383
395
  odata_version=4,
396
+ oauth_scope="OV4:API_PLANNEDORDER",
384
397
  entity_sets=["PlannedOrderHeader"],
385
398
  description="Production Planning - Planned Order (Create, Read, Update, Delete, Schedule)",
386
399
  ),
@@ -388,6 +401,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
388
401
  name="API_PRODUCTION_ORDER_2_SRV",
389
402
  path="/sap/opu/odata/sap/API_PRODUCTION_ORDER_2_SRV",
390
403
  odata_version=2,
404
+ oauth_scope="ZAPI_PRODUCTION_ORDER_2_SRV_0001",
391
405
  entity_sets=["A_ProductionOrder_2", "A_ProductionOrderOperation_2", "A_ProductionOrderStatus_2"],
392
406
  description="Production Planning - Production Order (Create, Read, Update, Convert, Status)",
393
407
  ),
@@ -396,6 +410,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
396
410
  name="API_PLANT_2",
397
411
  path="/sap/opu/odata4/sap/api_plant_2/srvd_a2x/sap/plant/0001",
398
412
  odata_version=4,
413
+ oauth_scope="OV4:API_PLANT_2",
399
414
  entity_sets=["Plant"],
400
415
  description="Master Data - Plant (Read)",
401
416
  ),
@@ -403,6 +418,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
403
418
  name="API_STORAGELOCATION_SRV",
404
419
  path="/sap/opu/odata/sap/API_STORAGELOCATION_SRV",
405
420
  odata_version=2,
421
+ oauth_scope="ZAPI_STORAGELOCATION_SRV_0001",
406
422
  entity_sets=["StorageLocation"],
407
423
  description="Master Data - Storage Location (Read)",
408
424
  ),
@@ -410,6 +426,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
410
426
  name="API_PRODUCT_SRV",
411
427
  path="/sap/opu/odata/sap/API_PRODUCT_SRV",
412
428
  odata_version=2,
429
+ oauth_scope="ZAPI_PRODUCT_SRV_0001",
413
430
  entity_sets=["A_Product", "A_ProductPlant", "A_ProductDescription"],
414
431
  description="Master Data - Product / Material (Read)",
415
432
  ),
@@ -417,6 +434,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
417
434
  name="API_PRODUCTION_VERSION",
418
435
  path="/sap/opu/odata4/sap/api_production_version/srvd_a2x/sap/productionversion/0001",
419
436
  odata_version=4,
437
+ oauth_scope="OV4:API_PRODUCTION_VERSION",
420
438
  entity_sets=["ProductionVersion"],
421
439
  description="Master Data - Production Version (Read)",
422
440
  ),
@@ -424,6 +442,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
424
442
  name="API_WORK_CENTERS",
425
443
  path="/sap/opu/odata/sap/API_WORK_CENTERS",
426
444
  odata_version=2,
445
+ oauth_scope="ZAPI_WORK_CENTERS_0001",
427
446
  entity_sets=["A_WorkCenters"],
428
447
  description="Master Data - Work Center (Read)",
429
448
  ),
@@ -431,6 +450,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
431
450
  name="API_EQUIPMENT",
432
451
  path="/sap/opu/odata/sap/API_EQUIPMENT",
433
452
  odata_version=2,
453
+ oauth_scope="ZAPI_EQUIPMENT_0001",
434
454
  entity_sets=["Equipment"],
435
455
  description="Master Data - Equipment (Read)",
436
456
  ),
@@ -438,6 +458,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
438
458
  name="API_FUNCTIONALLOCATION",
439
459
  path="/sap/opu/odata/sap/API_FUNCTIONALLOCATION",
440
460
  odata_version=2,
461
+ oauth_scope="ZAPI_FUNCTIONALLOCATION_0001",
441
462
  entity_sets=["FunctionalLocation"],
442
463
  description="Master Data - Functional Location (Read)",
443
464
  ),
@@ -445,6 +466,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
445
466
  name="API_BUSINESS_PARTNER",
446
467
  path="/sap/opu/odata/sap/API_BUSINESS_PARTNER",
447
468
  odata_version=2,
469
+ oauth_scope="ZAPI_BUSINESS_PARTNER_0001",
448
470
  entity_sets=["A_BusinessPartner", "A_BusinessPartnerRole"],
449
471
  description="Master Data - Business Partner (Read)",
450
472
  ),
@@ -452,6 +474,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
452
474
  name="API_COMPANYCODE_SRV",
453
475
  path="/sap/opu/odata/sap/API_COMPANYCODE_SRV",
454
476
  odata_version=2,
477
+ oauth_scope="ZAPI_COMPANYCODE_SRV_0001",
455
478
  entity_sets=["A_CompanyCode"],
456
479
  description="Master Data - Company Code (Read)",
457
480
  ),
@@ -459,6 +482,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
459
482
  name="API_SALESORGANIZATION_SRV",
460
483
  path="/sap/opu/odata/sap/API_SALESORGANIZATION_SRV",
461
484
  odata_version=2,
485
+ oauth_scope="ZAPI_SALESORGANIZATION_SRV_0001",
462
486
  entity_sets=["A_SalesOrganization"],
463
487
  description="Master Data - Sales Organization (Read)",
464
488
  ),
@@ -466,6 +490,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
466
490
  name="API_PURCHASINGORGANIZATION",
467
491
  path="/sap/opu/odata4/sap/api_purchasingorganization/srvd_a2x/sap/purchasingorganization/0001",
468
492
  odata_version=4,
493
+ oauth_scope="OV4:API_PURCHASINGORGANIZATION",
469
494
  entity_sets=["A_PurchasingOrganization"],
470
495
  description="Master Data - Purchasing Organization (Read)",
471
496
  ),
@@ -473,6 +498,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
473
498
  name="API_COSTCENTER_SRV",
474
499
  path="/sap/opu/odata/sap/API_COSTCENTER_SRV",
475
500
  odata_version=2,
501
+ oauth_scope="ZAPI_COSTCENTER_SRV_0001",
476
502
  entity_sets=["A_CostCenter"],
477
503
  description="Master Data - Cost Center (Read)",
478
504
  ),
@@ -480,6 +506,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
480
506
  name="API_PROFITCENTER_SRV",
481
507
  path="/sap/opu/odata/sap/API_PROFITCENTER_SRV",
482
508
  odata_version=2,
509
+ oauth_scope="ZAPI_PROFITCENTER_SRV_0001",
483
510
  entity_sets=["A_ProfitCenter"],
484
511
  description="Master Data - Profit Center (Read)",
485
512
  ),
@@ -487,6 +514,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
487
514
  name="API_GLACCOUNTINCHARTOFACCOUNTS_SRV",
488
515
  path="/sap/opu/odata/sap/API_GLACCOUNTINCHARTOFACCOUNTS_SRV",
489
516
  odata_version=2,
517
+ oauth_scope="ZAPI_GLACCOUNTINCHARTOFACCOUNTS_SRV_0001",
490
518
  entity_sets=["A_GLAccountInChartOfAccounts"],
491
519
  description="Master Data - G/L Account in Chart of Accounts (Read)",
492
520
  ),
@@ -497,6 +525,7 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
497
525
  name="API_PURCHASEORDER_2",
498
526
  path="/sap/opu/odata4/sap/api_purchaseorder_2/srvd_a2x/sap/purchaseorder/0001",
499
527
  odata_version=4,
528
+ oauth_scope="OV4:API_PURCHASEORDER_2",
500
529
  entity_sets=[
501
530
  "PurchaseOrder",
502
531
  "PurchaseOrderItem",
@@ -507,6 +536,32 @@ SAP_ODATA_SERVICES: dict[str, SAPODataServiceModel] = {
507
536
  ),
508
537
  }
509
538
 
539
+
540
+ def get_oauth_scope(services: list[str] | None = None) -> str:
541
+ """Build the scope parameter of the OAuth token request from SAP_ODATA_SERVICES.
542
+
543
+ Args:
544
+ services (list[str] | None, optional):
545
+ Names of the OData services in SAP_ODATA_SERVICES to get the scopes for.
546
+ Defaults to None (all services in SAP_ODATA_SERVICES).
547
+
548
+ Returns:
549
+ str:
550
+ Space-separated scope IDs, e.g. "ZAPI_MAINTENANCEORDER_0002 ZAPI_MAINTNOTIFICATION_0001".
551
+ Services that are not registered or have no scope are skipped.
552
+
553
+ """
554
+
555
+ if services is None:
556
+ services = list(SAP_ODATA_SERVICES)
557
+
558
+ return " ".join(
559
+ SAP_ODATA_SERVICES[service].oauth_scope
560
+ for service in services
561
+ if service in SAP_ODATA_SERVICES and SAP_ODATA_SERVICES[service].oauth_scope
562
+ )
563
+
564
+
510
565
  # OData V4 transmits Edm.Decimal values (quantities, prices) as JSON numbers by default.
511
566
  # With IEEE754Compatible=true they are transmitted as strings, which avoids any loss of
512
567
  # precision and matches Python's Decimal (pydantic serializes Decimal as string):
@@ -1998,6 +2053,278 @@ class SAP:
1998
2053
 
1999
2054
  # end method definition
2000
2055
 
2056
+ def check_oauth_token_endpoint(self, timeout: float = REQUEST_TIMEOUT) -> SAPAuthCheckStatusModel:
2057
+ """Check if SAP's OAuth 2.0 token endpoint is activated and reachable.
2058
+
2059
+ This is the first step of the SAML 2.0 bearer assertion flow used for principal
2060
+ propagation (see get_oauth_access_token()) - before any OAuth client, SAML trust
2061
+ or user is involved. An intentionally incomplete token request without client
2062
+ credentials and grant_type is sent. A live endpoint answers it with HTTP 401 (the
2063
+ ICF node requires the client authentication) or a well-formed OAuth error (e.g.
2064
+ "invalid_request"), which is enough to tell apart the typical setup problems:
2065
+ * The ICF node /sap/bc/sec/oauth2/token is not active (transaction SICF).
2066
+ * The SAP system is not reachable at all (DNS, firewall, TLS, timeout).
2067
+ * The base URL points to something else (e.g. a login page of a reverse proxy).
2068
+
2069
+ Args:
2070
+ timeout (float, optional):
2071
+ Timeout for the check request in seconds. Defaults to REQUEST_TIMEOUT.
2072
+
2073
+ Returns:
2074
+ SAPAuthCheckStatusModel:
2075
+ The result of the check. Use status.ok to check the result.
2076
+
2077
+ Example:
2078
+ >>> status = sap.check_oauth_token_endpoint()
2079
+ >>> if not status.ok:
2080
+ ... print(status.hint)
2081
+
2082
+ """
2083
+
2084
+ name = "OAuth 2.0 token endpoint"
2085
+
2086
+ if not self._base_url:
2087
+ return SAPAuthCheckStatusModel(
2088
+ name=name,
2089
+ state=SAPAuthCheckState.NOT_CONFIGURED,
2090
+ message="The SAP object has been initialized without a base URL.",
2091
+ hint="Set base_url to enable OData / OAuth checks.",
2092
+ )
2093
+
2094
+ token_url = self.oauth_token_url()
2095
+ self.logger.info("Checking SAP OAuth 2.0 token endpoint at -> %s ...", token_url)
2096
+
2097
+ try:
2098
+ response = requests.post(
2099
+ url=token_url,
2100
+ data={},
2101
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
2102
+ verify=self._verify_ssl,
2103
+ timeout=timeout,
2104
+ )
2105
+ except requests.exceptions.SSLError as ssl_error:
2106
+ status = SAPAuthCheckStatusModel(
2107
+ name=name,
2108
+ state=SAPAuthCheckState.UNREACHABLE,
2109
+ message=str(ssl_error),
2110
+ hint="TLS handshake failed. Check the certificate of the SAP system or set verify_ssl=False "
2111
+ "for test systems with self-signed certificates.",
2112
+ )
2113
+ except requests.exceptions.RequestException as request_error:
2114
+ status = SAPAuthCheckStatusModel(
2115
+ name=name,
2116
+ state=SAPAuthCheckState.UNREACHABLE,
2117
+ message=str(request_error),
2118
+ hint="Check the base URL (protocol, hostname, HTTPS port), DNS resolution and firewall rules.",
2119
+ )
2120
+ else:
2121
+ try:
2122
+ body = response.json()
2123
+ except ValueError:
2124
+ body = {}
2125
+ if not isinstance(body, dict):
2126
+ body = {}
2127
+
2128
+ if response.status_code == HTTPStatus.NOT_FOUND or (
2129
+ response.status_code == HTTPStatus.FORBIDDEN and "service cannot be reached" in response.text.lower()
2130
+ ):
2131
+ # SAP answers 404 for an unknown path and 403 "Service cannot be reached" for an inactive ICF node:
2132
+ status = SAPAuthCheckStatusModel(
2133
+ name=name,
2134
+ state=SAPAuthCheckState.NOT_ACTIVATED,
2135
+ http_status=response.status_code,
2136
+ message="HTTP {} - endpoint not found or not active".format(response.status_code),
2137
+ hint="Activate the ICF node /sap/bc/sec/oauth2/token in transaction SICF.",
2138
+ )
2139
+ elif response.status_code == HTTPStatus.UNAUTHORIZED:
2140
+ # The ICF node authenticates the OAuth client before the OAuth handler runs, so a live
2141
+ # endpoint rejects this request without credentials:
2142
+ status = SAPAuthCheckStatusModel(
2143
+ name=name,
2144
+ state=SAPAuthCheckState.OK,
2145
+ http_status=response.status_code,
2146
+ message="HTTP 401 - endpoint is active and requires the OAuth client authentication",
2147
+ )
2148
+ elif "error" in body:
2149
+ # Any well-formed OAuth error (e.g. "invalid_request" for the missing grant_type)
2150
+ # proves the endpoint itself is live and speaking OAuth 2.0:
2151
+ status = SAPAuthCheckStatusModel(
2152
+ name=name,
2153
+ state=SAPAuthCheckState.OK,
2154
+ http_status=response.status_code,
2155
+ message="{} - {}".format(body.get("error"), body.get("error_description", "")),
2156
+ )
2157
+ else:
2158
+ status = SAPAuthCheckStatusModel(
2159
+ name=name,
2160
+ state=SAPAuthCheckState.ERROR,
2161
+ http_status=response.status_code,
2162
+ message="HTTP {} - unexpected response (not a JSON OAuth error)".format(response.status_code),
2163
+ hint="Check that the base URL points to the SAP Gateway and not to a login page or reverse proxy.",
2164
+ )
2165
+
2166
+ if status.ok:
2167
+ self.logger.info("SAP OAuth 2.0 token endpoint is active; message -> %s", status.message)
2168
+ else:
2169
+ self.logger.error(
2170
+ "SAP OAuth 2.0 token endpoint is not usable; state -> '%s'; message -> %s; hint -> %s",
2171
+ status.state.value,
2172
+ status.message,
2173
+ status.hint,
2174
+ )
2175
+
2176
+ return status
2177
+
2178
+ # end method definition
2179
+
2180
+ def check_principal_propagation(
2181
+ self,
2182
+ user_id: str,
2183
+ client_id: str,
2184
+ client_secret: str,
2185
+ scope: str | None = None,
2186
+ nameid_format: str = SAML_NAMEID_FORMAT_UNSPECIFIED,
2187
+ recipient: str | None = None,
2188
+ ) -> SAPAuthCheckStatusModel:
2189
+ """Check the full SAML 2.0 bearer assertion / OAuth 2.0 principal propagation round trip.
2190
+
2191
+ Runs exactly what get_oauth_access_token_for_user() does for a real user: mint a
2192
+ SAML 2.0 assertion for user_id (get_saml_assertion()) and exchange it for an SAP
2193
+ access token (get_oauth_access_token()). Use this with a dedicated test user to
2194
+ verify the setup end-to-end - unlike check_oauth_token_endpoint() this needs a working OAuth
2195
+ client (SOAUTH2), a trusted SAML 2.0 identity provider (SAML2) and a valid NameID
2196
+ mapping for user_id. The message of a failed check (SAP's own OAuth error, e.g.
2197
+ "invalid_client", "invalid_grant", "unauthorized_client" or "invalid_scope") is kept
2198
+ as returned - see the hint for what it typically means.
2199
+
2200
+ Args:
2201
+ user_id (str):
2202
+ The user to propagate, e.g. a dedicated technical test user. See
2203
+ get_saml_assertion() for the security implications.
2204
+ client_id (str):
2205
+ The OAuth 2.0 client ID registered in SOAUTH2.
2206
+ client_secret (str):
2207
+ The password of the SU01 system user behind that OAuth client.
2208
+ scope (str | None, optional):
2209
+ Space-separated scope IDs to request, e.g. "API_MAINTENANCEORDER_0002".
2210
+ Defaults to None (the default scopes of the OAuth client).
2211
+ nameid_format (str, optional):
2212
+ The NameID format - must match transaction SAML2. Defaults to
2213
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified".
2214
+ recipient (str | None, optional):
2215
+ The Recipient of the assertion - only needed behind a reverse proxy (see
2216
+ get_saml_assertion()). Defaults to None = oauth_token_url().
2217
+
2218
+ Returns:
2219
+ SAPAuthCheckStatusModel:
2220
+ The result of the check. Use status.ok to check the result.
2221
+
2222
+ Example:
2223
+ >>> status = sap.check_principal_propagation(user_id="jane.smith", client_id="PYXECM_OAUTH", client_secret="...")
2224
+ >>> if not status.ok:
2225
+ ... print(status.message, "-", status.hint)
2226
+
2227
+ """
2228
+
2229
+ name = "SAML 2.0 / OAuth 2.0 principal propagation"
2230
+
2231
+ if not self._base_url:
2232
+ return SAPAuthCheckStatusModel(
2233
+ name=name,
2234
+ state=SAPAuthCheckState.NOT_CONFIGURED,
2235
+ message="The SAP object has been initialized without a base URL.",
2236
+ hint="Set base_url to enable OData / OAuth checks.",
2237
+ )
2238
+
2239
+ # Same two steps as get_oauth_access_token_for_user() - separately, so a problem of
2240
+ # pyxecm's own identity provider configuration is not mistaken for an SAP-side error:
2241
+ assertion = self.get_saml_assertion(user_id=user_id, nameid_format=nameid_format, recipient=recipient)
2242
+ if not assertion:
2243
+ status = SAPAuthCheckStatusModel(
2244
+ name=name,
2245
+ state=SAPAuthCheckState.NOT_CONFIGURED
2246
+ if self._saml_idp_private_key is None
2247
+ else SAPAuthCheckState.ERROR,
2248
+ message=self.last_error or "unknown error",
2249
+ hint="Check saml_idp_private_key, saml_idp_certificate, saml_issuer and saml_audience of the SAP "
2250
+ "object and the user_id - no request has been sent to SAP.",
2251
+ )
2252
+ self.logger.error(
2253
+ "%s failed; state -> '%s'; message -> %s; hint -> %s",
2254
+ name,
2255
+ status.state.value,
2256
+ status.message,
2257
+ status.hint,
2258
+ )
2259
+ return status
2260
+
2261
+ token = self.get_oauth_access_token(
2262
+ assertion=assertion, client_id=client_id, client_secret=client_secret, scope=scope
2263
+ )
2264
+ if token:
2265
+ status = SAPAuthCheckStatusModel(
2266
+ name=name,
2267
+ state=SAPAuthCheckState.OK,
2268
+ message="Received access token for user -> '{}' (scope -> '{}', expires in -> {}s)".format(
2269
+ user_id,
2270
+ token.get("scope", ""),
2271
+ token.get("expires_in", "?"),
2272
+ ),
2273
+ )
2274
+ self.logger.info("%s succeeded; message -> %s", name, status.message)
2275
+ return status
2276
+
2277
+ error = self.last_error or "unknown error"
2278
+ error_lower = error.lower()
2279
+ # Wrong client credentials fail the logon of the ICF node already - SAP then answers
2280
+ # HTTP 401 without an OAuth error (see get_oauth_access_token() for the format):
2281
+ if "invalid_client" in error_lower or "status -> 401/" in error_lower:
2282
+ state, hint = (
2283
+ SAPAuthCheckState.REJECTED,
2284
+ (
2285
+ "Check client_id/client_secret - they must match an SU01 system user with grant type "
2286
+ "'SAML 2.0 Bearer Assertion' configured in transaction SOAUTH2."
2287
+ ),
2288
+ )
2289
+ elif "invalid_grant" in error_lower:
2290
+ state, hint = (
2291
+ SAPAuthCheckState.REJECTED,
2292
+ (
2293
+ "SAP rejected the SAML assertion. Check the trusted identity provider (signing certificate, "
2294
+ "issuer, audience) in transaction SAML2 and that user_id resolves to an SAP user (NameID mapping)."
2295
+ ),
2296
+ )
2297
+ elif "unauthorized_client" in error_lower:
2298
+ state, hint = (
2299
+ SAPAuthCheckState.REJECTED,
2300
+ (
2301
+ "The OAuth client is not authorized for the 'SAML 2.0 Bearer Assertion' grant type - enable it "
2302
+ "in transaction SOAUTH2."
2303
+ ),
2304
+ )
2305
+ elif "invalid_scope" in error_lower:
2306
+ state, hint = (
2307
+ SAPAuthCheckState.REJECTED,
2308
+ (
2309
+ "The requested scope is not assigned to the OAuth client. Enable OAuth for the OData service(s) "
2310
+ "in /IWFND/MAINT_SERVICE and assign the scope(s) to the client in SOAUTH2."
2311
+ ),
2312
+ )
2313
+ else:
2314
+ state, hint = (
2315
+ SAPAuthCheckState.ERROR,
2316
+ "Check the SAP Gateway error log (transaction /IWFND/ERROR_LOG) and the system log (SM21).",
2317
+ )
2318
+
2319
+ status = SAPAuthCheckStatusModel(name=name, state=state, message=error, hint=hint)
2320
+ self.logger.error(
2321
+ "%s failed; state -> '%s'; message -> %s; hint -> %s", name, status.state.value, status.message, status.hint
2322
+ )
2323
+
2324
+ return status
2325
+
2326
+ # end method definition
2327
+
2001
2328
  @staticmethod
2002
2329
  def _quote_key(value: str) -> str:
2003
2330
  """Escape a value for use in an OData key or filter string literal (single quotes are doubled).
@@ -906,6 +906,10 @@ class SAPODataServiceModel(BaseModel):
906
906
  e.g. "/sap/opu/odata/sap/API_MAINTENANCEORDER".
907
907
  odata_version (int):
908
908
  OData protocol version of the service (2 or 4).
909
+ oauth_scope (str):
910
+ OAuth 2.0 scope ID of the service, e.g. "ZAPI_MAINTENANCEORDER_0002". SAP generates
911
+ it when OAuth is enabled for the service - it can differ between SAP systems.
912
+ Empty if the service has no scope.
909
913
  entity_sets (list[str]):
910
914
  Entity sets that must be exposed by the service. Used by check_odata_apis()
911
915
  to detect services that are active but do not expose what we need
@@ -918,6 +922,7 @@ class SAPODataServiceModel(BaseModel):
918
922
  name: str
919
923
  path: str
920
924
  odata_version: int = Field(default=2, ge=2, le=4)
925
+ oauth_scope: str = ""
921
926
  entity_sets: list[str] = Field(default_factory=list)
922
927
  description: str = ""
923
928
 
@@ -980,6 +985,57 @@ class SAPODataServiceStatusModel(BaseModel):
980
985
  # end method definition
981
986
 
982
987
 
988
+ class SAPAuthCheckState(StrEnum):
989
+ """Possible results of an authentication / principal propagation health check."""
990
+
991
+ OK = "ok" # the checked step succeeded
992
+ NOT_CONFIGURED = (
993
+ "not_configured" # required constructor parameters are missing (e.g. base_url, saml_idp_private_key)
994
+ )
995
+ NOT_ACTIVATED = "not_activated" # the SAP endpoint is not activated/provisioned (ICF node, SOAUTH2, SAML2)
996
+ REJECTED = "rejected" # SAP rejected the request (wrong credentials, invalid client, invalid grant, ...)
997
+ UNREACHABLE = "unreachable" # network / DNS / TLS / timeout problem
998
+ ERROR = "error" # any other error
999
+
1000
+
1001
+ class SAPAuthCheckStatusModel(BaseModel):
1002
+ """Result of a single step of an authentication / principal propagation health check.
1003
+
1004
+ Used by SAP.check_oauth_token_endpoint() and SAP.check_principal_propagation() - see there for the
1005
+ checks they perform.
1006
+
1007
+ Attributes:
1008
+ name (str):
1009
+ Short name of the checked step, e.g. "OAuth 2.0 token endpoint".
1010
+ state (SAPAuthCheckState):
1011
+ The result of the check.
1012
+ http_status (int | None):
1013
+ The HTTP status code of the check request, if one was sent.
1014
+ message (str):
1015
+ The (error) message returned by SAP or the request library.
1016
+ hint (str):
1017
+ A hint how to fix the problem (empty if the check succeeded).
1018
+ ok (bool):
1019
+ True if the check succeeded (computed - also included in model_dump() / model_dump_json()).
1020
+
1021
+ """
1022
+
1023
+ name: str
1024
+ state: SAPAuthCheckState
1025
+ http_status: int | None = None
1026
+ message: str = ""
1027
+ hint: str = ""
1028
+
1029
+ @computed_field
1030
+ @property
1031
+ def ok(self) -> bool:
1032
+ """Return True if the check succeeded."""
1033
+
1034
+ return self.state == SAPAuthCheckState.OK
1035
+
1036
+ # end method definition
1037
+
1038
+
983
1039
  class SAPODataErrorDetailModel(BaseModel):
984
1040
  """A single detail message of an SAP OData error response."""
985
1041
 
@@ -500,8 +500,9 @@ class CustomizerSettingsSAP(BaseModel):
500
500
  default="",
501
501
  description=(
502
502
  "Space-separated SAP OAuth 2.0 scope IDs requested for the access tokens, e.g. "
503
- "'API_MAINTENANCEORDER_0002 API_MAINTNOTIFICATION_0001'. Empty = the default scopes "
504
- "of the OAuth client in SOAUTH2."
503
+ "'API_MAINTENANCEORDER_0002 API_MAINTNOTIFICATION_0001'. Only required if the scope IDs "
504
+ "of the SAP system differ from the ones in SAP_ODATA_SERVICES (sap.py). Empty = the "
505
+ "scopes of all services in SAP_ODATA_SERVICES."
505
506
  ),
506
507
  )
507
508
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes