Technical Articles
Mass-updating Solution Documentation via the Process Management API
Summary
This article describes an approach to update Elements in Solution Documentation via the SAP Solution Manager’s Process Management API and concludes with a basic prototype of a mass-update script in Python.
This is a continuation of the previous article Process Management API in SAP Solution Manager, which covers how to read from Solution Documentation via the API.
What is the Process Management API?
The Process Management API offers all the necessary building blocks to read/write to almost any solution documentation elements (Test Steps Test cases are not supported though). Obviously the solution manager’s authorization/change control mechanisms still apply.
Resource Model + Content Model (List of API Calls): https://wiki.scn.sap.com/wiki/display/SM/Process+Management+API
Description of Exchange Format: https://wiki.scn.sap.com/wiki/display/SM/Exchange+format
How does updating Solution Documentation Elements work?
In a nutshell, updating SolDoc Elements requires the following steps:
- Download branch content as json (API Call: BranchContentSet)
- Json decode branch content
- Modify branch content
- Json re-encode modified branch content
- Upload modified branch content back to Solution Manager (API Call: BranchContentImporterSet)
In order to make any changes to Solution Documentation Elements a token is required.
This token can be “fetched” on successful authentication and retrieved from the response header.
Simple Python example:
# example: authenticate + obtain token (http-get)
headers = {'X-CSRF-Token': 'Fetch', 'Accept': 'application/json', 'Content-Type': 'application/json'}
response = client.get('http://' + host + ':' + port + '/sap/opu/odata/sap/ProcessManagement?sap-client=' + sap_client, auth=(user, passwd), headers=headers)
token = response.headers['x-csrf-token']
The branch content can be retrieved via the BranchContentSet Call and then json-decoded:
# example: retrieve and json-decode branch content (http-get)
headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
response = client.get('http://'+ host + ':' + port + '/sap/opu/odata/sap/ProcessManagement/BranchContentSet(BranchId=\'' + branch_id + '\',ScopeId=\'SAP_DEFAULT_SCOPE\',SiteId=\'\',SystemRole=\'D\')/$value?sap-client=' + sap_client ,auth=(user, passwd),headers=headers)
branch_content = response.json()
It will contain multiple sections, such as NODES (all the SolDoc Elements: Documents, Folders, Processes, … and their properties) and NODES-STRUCTURE (the hierarchic parent/child relationships linking the NODES).
In order to make changes to specific Nodes we simply need to check each node for whether it matches our selection criteria and (if so) modify its properties it accordingly.
# example: loop through nodes
for section in branch_content['sections']:
if section['section-id'] == 'NODES':
nodes_list = json.loads(section['section-content']) # json-de-code section-content, as it is a string with an extra layer of json-encoding
for my_node in nodes_list:
# --- check & modify node here ---
section['section-content'] = json.dumps(nodes_list) # json-re-encode modified section-content
Simplified JSON representation of a NODE (A Test Case, in this example):
After that we will json-re-encode the modified branch content and upload it (as put-data) back to solution manager, this time supplying the token via the request header:
# example: json-re-encode branch content and upload to solman (http-put)
data = json.dumps(branch_content)
headers = {'X-CSRF-Token': token, 'Accept': 'application/json', 'Content-Type': 'application/json'}
url = 'http://'+ host + ':' + port + '/sap/opu/odata/sap/ProcessManagement/BranchContentImporterSet(BranchId=\'' + branch_id + '\',ChangeDocumentId=\'' + change_document_id + '\')/$value'
response = client.put(url, data=data, headers=headers)
Prototype for a Mass-Update:
In order to keep things simple and, this prototype covers only the most basic selection criteria (all nodes matching these criteria will be updated) and only one attribute to be updated:
Selection Criteria:
- Object Type
- Description
- Status
Attributes to be updated:
- Status
For this prototype, we want to select all Test Documents, which have a Description starting with “New” and Status “Copy Editing”. We want to update the status of all matching nodes to “Released”:
# selection criteria (which nodes to update?)
select_obj_type = 'TESTDOCUMENT' # object type i.e. DOCUMENT, TESTDOCUMENT, PROC, ...
select_attr_description = re.compile('New.*') # description - Regular Expression or exact match (case sensitive!)
select_attr_smd_state = '0COPY_EDITING' # status i.e. 0IN_PROGRESS, 0COPY_EDITING, 0RELEASED, ...
# new attribute value(s) to be set (for nodes matching the selection criteria)
update_attr_smd_state = '0RELEASED' # status (see above)
If everything works correctly, the output looks like this:
Basically we are printing 2 lines per matching node (before and after update) to the console.
In our Demo Solution, 3 matching Test Documents have been found and updated:
(My apologies for the sliced screenshot, but posting it as a single image would make it unreadable).
“New Testcase B2” has not been updated, because it had Status “In Progress” and thus did not match our selection criteria.
Full Source Code:
Before attempting to make changes to a productive solution manager, please consider:
- This code will update all matching documents of a branch, including the library.
- There is no undo, and it is rather easy to make unintended changes, so use at your own risk.
- I strongly recommend to simulate first:
If the flag SIMULATE_ONLY is set, the changes will not be uploaded to Solution Manager.
Only turn SIMULATE_ONLY off, after the simulation looks good.
import json
import requests
import re
import pprint
host = 'SOLMAN.HOSTNAME' #SolMan Hostname or IP
port = '50000' #Port (most likely 5000)
sap_client = '800' #Client
user = 'USERNAME' #Username
passwd = 'PASSWORD' #Password
solution_name = 'DELETE_ME' #Solution Name
branch_name = 'DEVELOPMENT' #Branch Name
change_document_id = '' # only needed if branch is under change control
SIMULATION_ONLY = False # simulate first (no import) then change this flag to False to perform actual import
# selection criteria (which nodes to update?)
select_obj_type = 'TESTDOCUMENT' # object type i.e. DOCUMENT, TESTDOCUMENT, PROC, ...
select_attr_description = re.compile('New.*') # description - Regular Expression or exact match (case sensitive!)
select_attr_smd_state = '0COPY_EDITING' # status i.e. 0IN_PROGRESS, 0COPY_EDITING, 0RELEASED, ...
# new attribute value(s) to be set (for nodes matching the selection criteria)
update_attr_smd_state = '0RELEASED' # status (see above)
def get_solution_by_name (solution_name):
# returns a list of solutions by name
response = requests.get('http://'+ host + ':' + port + '/sap/opu/odata/sap/ProcessManagement/SolutionSet?sap-client=' + sap_client, auth=(user, passwd), headers=headers)
return list(filter(lambda s: s['SolutionName'] == solution_name, response.json()['d']['results']))
def get_branch_by_name (solution_id, branch_name):
# returns a list of branches by name and solution_id
response = requests.get('http://'+ host + ':' + port + '/sap/opu/odata/sap/ProcessManagement/BranchSet?sap-client=' + sap_client, auth=(user, passwd), headers=headers)
return list(filter(lambda b: b['BranchName'] == branch_name and b['SolutionId'] == solution_id, response.json()['d']['results']))
def get_node_attribute_values(my_node, attribute_name):
# returns the value of an attribute of a node_id
res = []
for my_attribute in my_node['attributes']:
if my_attribute['attr_type'] == attribute_name: res = my_attribute['values']
return res
#Check connection and retrieve token
client = requests.session()
headers = {'X-CSRF-Token': 'Fetch', 'Accept': 'application/json', 'Content-Type': 'application/json'}
try:
response = client.get('http://' + host + ':' + port + '/sap/opu/odata/sap/ProcessManagement?sap-client=' + sap_client, auth=(user, passwd), headers=headers)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print('Connection unsuccessful:\n', e)
exit(1)
token = response.headers['x-csrf-token']
print('Connection successful. Received token:', token)
# get solution id (by name), branch id (by name)
solution_id = get_solution_by_name(solution_name)[0]['SolutionId']
branch_id = get_branch_by_name(solution_id, branch_name)[0]['BranchId']
print('Fetching branch content...', end='')
response = client.get('http://'+ host + ':' + port + '/sap/opu/odata/sap/ProcessManagement/BranchContentSet(BranchId=\'' + branch_id + '\',ScopeId=\'SAP_DEFAULT_SCOPE\',SiteId=\'\',SystemRole=\'D\')/$value?sap-client=' + sap_client ,auth=(user, passwd),headers=headers)
branch_content = response.json()
print('done.')
match_counter = 0
# get the section-content of the NODES section...
for section in branch_content['sections']:
if section['section-id'] == 'NODES':
nodes_list = json.loads(section['section-content']) # json-de-code section-content, as it is a string with an extra layer of json-encoding
# ... and check for each node whether it matches the selection criteria
for my_node in nodes_list:
if my_node['obj_type'] == select_obj_type: # object tye matches selection criteria?
if get_node_attribute_values(my_node, '_SMD_STATE')[0] == select_attr_smd_state: # status matches selection criteria?
if re.search(select_attr_description, get_node_attribute_values(my_node, '_DESCRIPTION')[0]): # description matches selection criteria?
#pprint.pprint(my_node)
# ...if there is a match: update the node
match_counter-=-1 #;-)
print('Match ' + str(match_counter) + ' (before): \t' + str(my_node))
# each node's "attributes" is a list of dictionaries (each one having an attr_type and a value). To target the dictionary we want (having attr__type = _SMD_STATE) we need its index in the list.
attr_smd_state_index = next((index for (index, attribute) in enumerate(my_node['attributes']) if
attribute["attr_type"] == "_SMD_STATE"), None)
my_node['attributes'][attr_smd_state_index]['values'] = [update_attr_smd_state]
print('Match ' + str(match_counter) + ' (after ): \t' + str(my_node))
print('Total matching nodes found: \t' + str (match_counter))
section['section-content'] = json.dumps(nodes_list) # json-re-encode modified section-content
if SIMULATION_ONLY:
print('Simulation only --> skipping Import.')
exit(0)
# if this is not a simulation run: import changes to solution manager
url = 'http://'+ host + ':' + port + '/sap/opu/odata/sap/ProcessManagement/BranchContentImporterSet(BranchId=\'' + branch_id + '\',ChangeDocumentId=\'' + change_document_id + '\')/$value'
data = json.dumps(branch_content)
headers = {'X-CSRF-Token': token, 'Accept': 'application/json', 'Content-Type': 'application/json'}
print('Importing modified branch content (expecting http 204)...: ', end='')
response = client.put(url, data=data, headers=headers)
print(response)
Hi Thomas,
Thank you very much for providing the process description and the source code for changing test documents. Very helpful!
Some thoughts/ideas for future examples:
BR, Klaus
Hi Thomas
I am having problems getting this to work in my system.
My plan is to make a option to masschange owner on documents when a person leaves the company.
As I haven't seen any other way to do this in SAP Solution Manager 7.2 I'm looking in to your solution. I started by replicating your solution with changing the SMD_STATE on documents, but I can't get it to work.
I always get "NONE" from the call if re.search(select_attr_description, get_node_attribute_values(my_node, '_DESCRIPTION')[0]):
select_attr_description returns "re.compile('New.*')"
and get_node_attribute_values(my_node, '_DESCRIPTION')[0]) returns "My Test Document" (document name)
I did try to figure out how and why re.compile('New.*') is returned as a string and why the re.search() call is not working, but I'm lacking skills.
Can you help?
Hello Thomas
Great blog! I already managed to solve a big part of my requirements, but I am facing the following problem. When an attribute does not have a value (e.g. _SMD_RESPONSIBLE) then the index cannot be computed, since there is no entry in my_node:
attr_smd_responsible_index = next((index for (index, attribute) in enumerate(my_node['attributes']) if
attribute["attr_type"] == "_SMD_RESPONSIBLE"), None)
Is there a possibility to ADD an attribute to my_node instead of updating its value?
Best regards
Markus