From 3a19579812ffb2bc72f674d9a593edd31df7df18 Mon Sep 17 00:00:00 2001 From: Wambere Date: Tue, 30 Apr 2024 18:32:52 +0300 Subject: [PATCH 01/34] Add support for importing inventory and products (#179) * Add functionality to import products as Group resources * Update read me * Create images folder if it doesn't already exist * Mock image binary * Add support for importing inventories * Update product-group template * Update inventory-group * Update inventory csv and code * Use GROUP_INDEX_MAPPING in group_extras * black formatting * move deletion to outside the condition --- importer/README.md | 15 +- importer/csv/import/inventory.csv | 2 + importer/csv/import/product.csv | 3 + .../inventory_group_payload.json | 128 ++++ .../json_payloads/product_group_payload.json | 145 ++++ importer/main.py | 631 ++++++++++++++---- importer/test_main.py | 62 ++ 7 files changed, 854 insertions(+), 132 deletions(-) create mode 100644 importer/csv/import/inventory.csv create mode 100644 importer/csv/import/product.csv create mode 100644 importer/json_payloads/inventory_group_payload.json create mode 100644 importer/json_payloads/product_group_payload.json diff --git a/importer/README.md b/importer/README.md index 63d532a5..e58b7f1f 100644 --- a/importer/README.md +++ b/importer/README.md @@ -133,4 +133,17 @@ The coverage report `coverage.html` will be at the working directory - The `value` is where you pass the actual parameter value to filter the resources. The set default value is "gt2023-01-01", other examples include, "Good Health Clinic 1" - The `limit` is the number of resources exported at a time. The set default value is '1000' - Specify the `resource_type` you want to export, different resource_types are exported to different csv_files -- The csv_file containing the exported resources is labelled using the current time, to know when the resources were exported for example, csv/exports/2024-02-21-12-21-export_Location.csv \ No newline at end of file +- The csv_file containing the exported resources is labelled using the current time, to know when the resources were exported for example, csv/exports/2024-02-21-12-21-export_Location.csv + +### 10. Import products from openSRP 1 +- Run `python3 main.py --csv_file csv/import/product.csv --setup products --log_level info` +- See example csv [here](/importer/csv/import/product.csv) +- This creates a Group resource for each product imported +- The first two columns __name__ and __active__ is the minimum required +- The last column __imageSourceUrl__ contains a url to the product image. If this source requires authentication, then you need to provide the `product_access_token` in the config file. The image is added as a binary resource and referenced in the product's Group resource + +### 11. Import inventories from openSRP 1 +- Run `python3 main.py --csv_file csv/import/inventory.csv --setup inventories --log_level info` +- See example csv [here](/importer/csv/import/inventory.csv) +- This creates a Group resource for each inventory imported +- The first two columns __name__ and __active__ is the minimum required diff --git a/importer/csv/import/inventory.csv b/importer/csv/import/inventory.csv new file mode 100644 index 00000000..7e5c08af --- /dev/null +++ b/importer/csv/import/inventory.csv @@ -0,0 +1,2 @@ +name,active,method,id,poNumber,serialNumber,usualId,actual,productId,deliveryDate,accountabilityDate,quantity,unicefSection,donor +Bishop Magua - Bed nets,true,create,8adfcfe0-41d0-4f0a-9a89-909c72fbf330,123523,989682,a065c211-cf3e-4b5b-972f-fdac0e45fef7,false,1d86d0e2-bac8-4424-90ae-e2298900ac3c,2024-02-01T00:00:00.00Z,2025-02-01T00:00:00.00Z,34,Health,GAVI \ No newline at end of file diff --git a/importer/csv/import/product.csv b/importer/csv/import/product.csv new file mode 100644 index 00000000..7f49f22f --- /dev/null +++ b/importer/csv/import/product.csv @@ -0,0 +1,3 @@ +name,active,method,id,previousId,isAttractiveItem,availability,condition,appropriateUsage,accountabilityPeriod,imageSourceUrl +thermometer,true,create,1d86d0e2-bac8-4424-90ae-e2298900ac3c,10,true,yes,good,ok,12,https://ona.io/home/wp-content//uploads/2022/06/spotlight-fhir.png +sterilizer,true,create,,53209452,true,no,,,, \ No newline at end of file diff --git a/importer/json_payloads/inventory_group_payload.json b/importer/json_payloads/inventory_group_payload.json new file mode 100644 index 00000000..c436fc9b --- /dev/null +++ b/importer/json_payloads/inventory_group_payload.json @@ -0,0 +1,128 @@ +{ + "request": { + "method": "PUT", + "url": "Group/$unique_uuid", + "ifMatch": "$version" + }, + "resource": { + "resourceType": "Group", + "id": "$unique_uuid", + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "SERNUM", + "display": "Serial Number" + } + ], + "text": "Serial Number" + }, + "use": "official", + "value": "$serial_number" + }, + { + "type": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "PONUM", + "display": "PO Number" + } + ], + "text": "PO Number" + }, + "use": "secondary", + "value": "$po_number" + }, + { + "use": "usual", + "value": "$usual_id" + } + ], + "active": "$active", + "type": "substance", + "actual": "$actual", + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "78991122", + "display": "Supply Inventory" + } + ] + }, + "name": "$name", + "member": [ + { + "entity": { + "reference": "Group/$product_id" + }, + "period": { + "start": "$delivery_date", + "end": "$accountability_date" + }, + "inactive": false + } + ], + "characteristic": [ + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "33467722", + "display": "Quantity" + } + ] + }, + "valueQuantity": { + "value": "$quantity" + } + }, + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "98734231", + "display": "Unicef Section" + } + ] + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "98734231-1", + "display": "Value entered on the unicef section" + } + ], + "text": "$unicef_section" + } + }, + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "45981276", + "display": "Donor" + } + ] + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "45981276-1", + "display": "Value entered on the donor" + } + ], + "text": "$donor" + } + } + ] + } +} \ No newline at end of file diff --git a/importer/json_payloads/product_group_payload.json b/importer/json_payloads/product_group_payload.json new file mode 100644 index 00000000..a41d494e --- /dev/null +++ b/importer/json_payloads/product_group_payload.json @@ -0,0 +1,145 @@ +{ + "request": { + "method": "PUT", + "url": "Group/$unique_uuid", + "ifMatch" : "$version" + }, + "resource": { + "resourceType": "Group", + "id": "$unique_uuid", + "identifier": [ + { + "type":{ + "coding": { + "system" : "http://smartregister.org/codes", + "code" : "MATNUM" , + "display": "Material Number" + } + }, + "use": "official", + "value": "$unique_uuid" + }, + { + "use": "secondary", + "value": "$previous_id" + } + ], + "active": "$active", + "type": "substance", + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "386452003", + "display": "Supply management" + } + ] + }, + "name": "$name", + "characteristic": [ + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "23435363", + "display": "Attractive Item code" + } + ] + }, + "valueBoolean": "$isAttractiveItem" + }, + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "34536373", + "display": "Is it there code" + } + ] + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "34536373-1", + "display": "Value entered on the It is there code" + } + ], + "text": "$availability" + } + }, + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "45647484", + "display": "Is it in good condition? (optional)" + } + ] + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "45647484-1", + "display": "Value entered on the Is it in good condition? (optional)" + } + ], + "text": "$condition" + } + }, + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "56758595", + "display": "Is it being used appropriately?" + } + ] + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "56758595-1", + "display": "Value entered on the Is it being used appropriately?" + } + ], + "text": "$appropriateUsage" + } + }, + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "67869606", + "display": "Accountability period (in months)" + } + ] + }, + "valueQuantity": { + "value": "$accountabilityPeriod" + } + }, + { + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "12314156", + "display": "Product Image code" + } + ] + }, + "valueReference": { + "reference": "Binary/$image-binary" + } + } + ] + } +} \ No newline at end of file diff --git a/importer/main.py b/importer/main.py index 5a421198..fe013daa 100644 --- a/importer/main.py +++ b/importer/main.py @@ -21,6 +21,7 @@ global_access_token = "" + # This function takes in a csv file # reads it and returns a list of strings/lines # It ignores the first line (assumes headers) @@ -32,7 +33,9 @@ def read_csv(csv_file): next(records) all_records = [] - with click.progressbar(records, label='Progress::Reading csv ') as read_csv_progress: + with click.progressbar( + records, label="Progress::Reading csv " + ) as read_csv_progress: for record in read_csv_progress: all_records.append(record) @@ -117,13 +120,25 @@ def handle_request(request_type, payload, url): def get_keycloak_url(): return config.keycloak_url + # This function builds the user payload and posts it to # the keycloak api to create a new user # it also adds the user to the provided keycloak group # and sets the user password def create_user(user): - (firstName, lastName, username, email, userId, userType, _, keycloakGroupId, - keycloakGroupName, appId, password) = user + ( + firstName, + lastName, + username, + email, + userId, + userType, + _, + keycloakGroupId, + keycloakGroupName, + appId, + password, + ) = user with open("json_payloads/keycloak_user_payload.json") as json_file: payload_string = json_file.read() @@ -145,7 +160,9 @@ def create_user(user): user_id = (new_user_location.split("/"))[-1] # add user to group - payload = '{"id": "' + keycloakGroupId + '", "name": "' + keycloakGroupName + '"}' + payload = ( + '{"id": "' + keycloakGroupId + '", "name": "' + keycloakGroupName + '"}' + ) group_endpoint = user_id + "/groups/" + keycloakGroupId url = keycloak_url + "/users/" + group_endpoint logging.info("Adding user to Keycloak group: " + keycloakGroupName) @@ -167,8 +184,19 @@ def create_user(user): # new user and posts them to the FHIR api for creation def create_user_resources(user_id, user): logging.info("Creating user resources") - (firstName, lastName, username, email, id, userType, - enableUser, keycloakGroupId, keycloakGroupName, _, password) = user + ( + firstName, + lastName, + username, + email, + id, + userType, + enableUser, + keycloakGroupId, + keycloakGroupName, + _, + password, + ) = user # generate uuids if len(str(id).strip()) == 0: @@ -280,8 +308,19 @@ def check_parent_admin_level(locationParentId): # custom extras for locations def location_extras(resource, payload_string): try: - (locationName, *_, locationParentName, locationParentId, locationType, locationTypeCode, - locationAdminLevel, locationPhysicalType, locationPhysicalTypeCode, longitude, latitude) = resource + ( + locationName, + *_, + locationParentName, + locationParentId, + locationType, + locationTypeCode, + locationAdminLevel, + locationPhysicalType, + locationPhysicalTypeCode, + longitude, + latitude, + ) = resource except ValueError: locationParentName = "parentName" locationParentId = "ParentId" @@ -294,9 +333,9 @@ def location_extras(resource, payload_string): try: if locationParentName and locationParentName != "parentName": - payload_string = payload_string.replace("$parentName", locationParentName).replace( - "$parentID", locationParentId - ) + payload_string = payload_string.replace( + "$parentName", locationParentName + ).replace("$parentID", locationParentId) else: obj = json.loads(payload_string) del obj["resource"]["partOf"] @@ -330,12 +369,16 @@ def location_extras(resource, payload_string): try: if len(locationAdminLevel.strip()) > 0 and locationAdminLevel != "adminLevel": - payload_string = payload_string.replace("$adminLevelCode", locationAdminLevel) + payload_string = payload_string.replace( + "$adminLevelCode", locationAdminLevel + ) else: if locationAdminLevel in resource: admin_level = check_parent_admin_level(locationParentId) if admin_level: - payload_string = payload_string.replace("$adminLevelCode", admin_level) + payload_string = payload_string.replace( + "$adminLevelCode", admin_level + ) else: obj = json.loads(payload_string) obj_type = obj["resource"]["type"] @@ -371,10 +414,18 @@ def location_extras(resource, payload_string): payload_string = json.dumps(obj, indent=4) try: - if len(locationPhysicalType.strip()) > 0 and locationPhysicalType != "physicalType": + if ( + len(locationPhysicalType.strip()) > 0 + and locationPhysicalType != "physicalType" + ): payload_string = payload_string.replace("$pt_display", locationPhysicalType) - if len(locationPhysicalTypeCode.strip()) > 0 and locationPhysicalTypeCode != "physicalTypeCode": - payload_string = payload_string.replace("$pt_code", locationPhysicalTypeCode) + if ( + len(locationPhysicalTypeCode.strip()) > 0 + and locationPhysicalTypeCode != "physicalTypeCode" + ): + payload_string = payload_string.replace( + "$pt_code", locationPhysicalTypeCode + ) else: obj = json.loads(payload_string) del obj["resource"]["physicalType"] @@ -387,7 +438,8 @@ def location_extras(resource, payload_string): try: if longitude and longitude != "longitude": payload_string = payload_string.replace('"$longitude"', longitude).replace( - '"$latitude"', latitude) + '"$latitude"', latitude + ) else: obj = json.loads(payload_string) del obj["resource"]["position"] @@ -401,9 +453,7 @@ def location_extras(resource, payload_string): # custom extras for careTeams -def care_team_extras( - resource, payload_string, ftype -): +def care_team_extras(resource, payload_string, ftype): orgs_list = [] participant_list = [] elements = [] @@ -475,9 +525,225 @@ def care_team_extras( return payload_string +# custom extras for product import +def group_extras(resource, payload_string, group_type): + payload_obj = json.loads(payload_string) + item_name = resource[0] + del_indexes = [] + + GROUP_INDEX_MAPPING = { + "product_secondary_id_index": 1, + "product_is_attractive_index": 0, + "product_is_available_index": 1, + "product_condition_index": 2, + "product_appropriate_usage_index": 3, + "product_accountability_period_index": 4, + "product_image_index": 5, + "inventory_official_id_index": 0, + "inventory_secondary_id_index": 1, + "inventory_usual_id_index": 2, + "inventory_member_index": 0, + "inventory_quantity_index": 0, + "inventory_unicef_section_index": 1, + "inventory_donor_index": 2, + } + + if group_type == "product": + ( + _, + active, + *_, + previous_id, + is_attractive_item, + availability, + condition, + appropriate_usage, + accountability_period, + image_source_url, + ) = resource + + if active: + payload_obj["resource"]["active"] = active + else: + del payload_obj["resource"]["active"] + + if previous_id: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["product_secondary_id_index"] + ]["value"] = previous_id + else: + del payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["product_secondary_id_index"] + ] + + if is_attractive_item: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_is_attractive_index"] + ]["valueBoolean"] = is_attractive_item + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_is_attractive_index"]) + + if availability: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_is_available_index"] + ]["valueCodeableConcept"]["text"] = availability + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_is_available_index"]) + + if condition: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_condition_index"] + ]["valueCodeableConcept"]["text"] = condition + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_condition_index"]) + + if appropriate_usage: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_appropriate_usage_index"] + ]["valueCodeableConcept"]["text"] = appropriate_usage + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_appropriate_usage_index"]) + + if accountability_period: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_accountability_period_index"] + ]["valueQuantity"]["value"] = accountability_period + else: + del_indexes.append( + GROUP_INDEX_MAPPING["product_accountability_period_index"] + ) + + if image_source_url: + image_binary = save_image(image_source_url) + if image_binary != 0: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_image_index"] + ]["valueReference"]["reference"] = ("Binary/" + image_binary) + else: + logging.error( + "Unable to link the image Binary resource for product " + item_name + ) + del_indexes.append(GROUP_INDEX_MAPPING["product_image_index"]) + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_image_index"]) + + elif group_type == "inventory": + ( + _, + active, + *_, + po_number, + serial_number, + usual_id, + actual, + product_id, + delivery_date, + accountability_date, + quantity, + unicef_section, + donor, + ) = resource + + if active: + payload_obj["resource"]["active"] = bool(active) + else: + del payload_obj["resource"]["active"] + + if serial_number: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_official_id_index"] + ]["value"] = serial_number + else: + del payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_official_id_index"] + ] + + if po_number: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_secondary_id_index"] + ]["value"] = po_number + else: + del payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_secondary_id_index"] + ] + + if usual_id: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_usual_id_index"] + ]["value"] = usual_id + else: + del payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_usual_id_index"] + ] + + if actual: + payload_obj["resource"]["actual"] = bool(actual) + else: + del payload_obj["resource"]["actual"] + + if product_id: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["entity"]["reference"] = ("Group/" + product_id) + else: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["entity"]["reference"] = "Group/" + + if delivery_date: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["start"] = delivery_date + else: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["start"] = "" + + if accountability_date: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["end"] = accountability_date + else: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["end"] = "" + + if quantity: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["inventory_quantity_index"] + ]["valueQuantity"]["value"] = int(quantity) + else: + del_indexes.append(GROUP_INDEX_MAPPING["inventory_quantity_index"]) + + if unicef_section: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["inventory_unicef_section_index"] + ]["valueCodeableConcept"]["text"] = unicef_section + else: + del_indexes.append(GROUP_INDEX_MAPPING["inventory_unicef_section_index"]) + + if donor: + payload_obj["resource"]["characteristic"][2]["valueCodeableConcept"][ + "text" + ] = donor + else: + del_indexes.append(GROUP_INDEX_MAPPING["inventory_donor_index"]) + + else: + logging.info("Group type not defined") + + for x in reversed(del_indexes): + del payload_obj["resource"]["characteristic"][x] + + payload_string = json.dumps(payload_obj, indent=4) + return payload_string + + def extract_matches(resource_list): teamMap = {} - with click.progressbar(resource_list, label='Progress::Extract matches ') as extract_progress: + with click.progressbar( + resource_list, label="Progress::Extract matches " + ) as extract_progress: for resource in extract_progress: group_name, group_id, item_name, item_id = resource if group_id.strip() and item_id.strip(): @@ -498,8 +764,13 @@ def build_assign_payload(rows, resource_type): # check if already exists base_url = get_base_url() - check_url = (base_url + "/" + resource_type + "/_search?_count=1&practitioner=Practitioner/" - + practitioner_id) + check_url = ( + base_url + + "/" + + resource_type + + "/_search?_count=1&practitioner=Practitioner/" + + practitioner_id + ) response = handle_request("GET", "", check_url) json_response = json.loads(response[0]) @@ -508,13 +779,15 @@ def build_assign_payload(rows, resource_type): resource = json_response["entry"][0]["resource"] try: - resource["organization"]["reference"] = "Organization/" + organization_id + resource["organization"]["reference"] = ( + "Organization/" + organization_id + ) resource["organization"]["display"] = organization_name except KeyError: org = { "organization": { "reference": "Organization/" + organization_id, - "display": organization_name + "display": organization_name, } } resource.update(org) @@ -527,9 +800,13 @@ def build_assign_payload(rows, resource_type): logging.info("Creating a new resource") # generate a new id - new_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, practitioner_id + organization_id)) + new_id = str( + uuid.uuid5(uuid.NAMESPACE_DNS, practitioner_id + organization_id) + ) - with open("json_payloads/practitioner_organization_payload.json") as json_file: + with open( + "json_payloads/practitioner_organization_payload.json" + ) as json_file: payload_string = json_file.read() # replace the variables in payload @@ -545,15 +822,17 @@ def build_assign_payload(rows, resource_type): resource = json.loads(payload_string) else: - raise ValueError ("The number of practitioner references should only be 0 or 1") + raise ValueError( + "The number of practitioner references should only be 0 or 1" + ) payload = { "request": { "method": "PUT", "url": resource_type + "/" + practitioner_role_id, - "ifMatch": version + "ifMatch": version, }, - "resource": resource + "resource": resource, } full_string = json.dumps(payload, indent=4) final_string = final_string + full_string + "," @@ -576,7 +855,9 @@ def build_org_affiliation(resources, resource_list): with open("json_payloads/organization_affiliation_payload.json") as json_file: payload_string = json_file.read() - with click.progressbar(resources, label='Progress::Build payload ') as build_progress: + with click.progressbar( + resources, label="Progress::Build payload " + ) as build_progress: for key in build_progress: rp = "" unique_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, key)) @@ -618,12 +899,22 @@ def get_valid_resource_type(resource_type): # This function gets the current resource version from the API def get_resource(resource_id, resource_type): - resource_type = get_valid_resource_type(resource_type) + if resource_type != "Group": + resource_type = get_valid_resource_type(resource_type) resource_url = "/".join([config.fhir_base_url, resource_type, resource_id]) response = handle_request("GET", "", resource_url) return json.loads(response[0])["meta"]["versionId"] if response[1] == 200 else "0" +def check_for_nulls(resource: list) -> list: + for index, value in enumerate(resource): + if len(value.strip()) < 1: + resource[index] = None + else: + resource[index] = value.strip() + return resource + + # This function builds a json payload # which is posted to the api to create resources def build_payload(resource_type, resources, resource_payload_file): @@ -633,10 +924,14 @@ def build_payload(resource_type, resources, resource_payload_file): with open(resource_payload_file) as json_file: payload_string = json_file.read() - with click.progressbar(resources, label='Progress::Building payload ') as build_payload_progress: + with click.progressbar( + resources, label="Progress::Building payload " + ) as build_payload_progress: for resource in build_payload_progress: logging.info("\t") + resource = check_for_nulls(resource) + try: name, status, method, id, *_ = resource except ValueError: @@ -645,39 +940,27 @@ def build_payload(resource_type, resources, resource_payload_file): method = "create" id = str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) - try: - if method == "create": - version = "1" - if len(id.strip()) > 0: - # use the provided id - unique_uuid = id.strip() - identifier_uuid = id.strip() - else: - # generate a new uuid - unique_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) - identifier_uuid = unique_uuid - except IndexError: - # default if method is not provided - unique_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) - identifier_uuid = unique_uuid + if method == "create": version = "1" + if id: + unique_uuid = identifier_uuid = id + else: + unique_uuid = identifier_uuid = str( + uuid.uuid5(uuid.NAMESPACE_DNS, name) + ) - try: - if method == "update": - if len(id.strip()) > 0: - version = get_resource(id, resource_type) - if version != "0": - # use the provided id - unique_uuid = id.strip() - identifier_uuid = id.strip() - else: - logging.info("Failed to get resource!") - raise ValueError("Trying to update a Non-existent resource") + if method == "update": + if id: + version = get_resource(id, resource_type) + + if version != "0": + unique_uuid = identifier_uuid = id else: - logging.info("The id is required!") - raise ValueError("The id is required to update a resource") - except IndexError: - raise ValueError("The id is required to update a resource") + logging.info("Failed to get resource!") + raise ValueError("Trying to update a Non-existent resource") + else: + logging.info("The id is required!") + raise ValueError("The id is required to update a resource") # ps = payload_string ps = ( @@ -698,6 +981,14 @@ def build_payload(resource_type, resources, resource_payload_file): ps = location_extras(resource, ps) elif resource_type == "careTeams": ps = care_team_extras(resource, ps, "orgs & users") + elif resource_type == "Group": + if "inventory" in resource_payload_file: + group_type = "inventory" + elif "product" in resource_payload_file: + group_type = "product" + else: + logging.error("Undefined group type") + ps = group_extras(resource, ps, group_type) final_string = final_string + ps + "," @@ -746,9 +1037,7 @@ def confirm_practitioner(user, user_id): base_url = get_base_url() if not practitioner_uuid: # If practitioner uuid not provided in csv, check if any practitioners exist linked to the keycloak user_id - r = handle_request( - "GET", "", base_url + "/Practitioner?identifier=" + user_id - ) + r = handle_request("GET", "", base_url + "/Practitioner?identifier=" + user_id) json_r = json.loads(r[0]) counter = json_r["total"] if counter > 0: @@ -759,9 +1048,7 @@ def confirm_practitioner(user, user_id): else: return False - r = handle_request( - "GET", "", base_url + "/Practitioner/" + practitioner_uuid - ) + r = handle_request("GET", "", base_url + "/Practitioner/" + practitioner_uuid) if r[1] == 404: logging.info("Practitioner does not exist, proceed to creation") @@ -994,16 +1281,18 @@ def clean_duplicates(users, cascade_delete): # Create a csv file and initialize the CSV writer def write_csv(data, resource_type, fieldnames): logging.info("Writing to csv file") - path = 'csv/exports' + path = "csv/exports" if not os.path.exists(path): os.makedirs(path) current_time = datetime.now().strftime("%Y-%m-%d-%H-%M") csv_file = f"{path}/{current_time}-export_{resource_type}.csv" - with open(csv_file, 'w', newline='') as file: + with open(csv_file, "w", newline="") as file: csv_writer = csv.writer(file) csv_writer.writerow(fieldnames) - with click.progressbar(data, label='Progress:: Writing csv') as write_csv_progress: + with click.progressbar( + data, label="Progress:: Writing csv" + ) as write_csv_progress: for row in write_csv_progress: csv_writer.writerow(row) return csv_file @@ -1019,7 +1308,7 @@ def export_resources_to_csv(resource_type, parameter, value, limit): resource_url = "/".join([str(base_url), resource_type]) if len(parameter) > 0: resource_url = ( - resource_url + "?" + parameter + "=" + value + "&_count=" + str(limit) + resource_url + "?" + parameter + "=" + value + "&_count=" + str(limit) ) response = handle_request("GET", "", resource_url) if response[1] == 200: @@ -1028,17 +1317,36 @@ def export_resources_to_csv(resource_type, parameter, value, limit): try: if resources["entry"]: if resource_type == "Location": - elements = ["name", "status", "method", "id", "identifier", "parentName", "parentID", "type", - "typeCode", - "physicalType", "physicalTypeCode"] + elements = [ + "name", + "status", + "method", + "id", + "identifier", + "parentName", + "parentID", + "type", + "typeCode", + "physicalType", + "physicalTypeCode", + ] elif resource_type == "Organization": elements = ["name", "active", "method", "id", "identifier"] elif resource_type == "CareTeam": - elements = ["name", "status", "method", "id", "identifier", "organizations", "participants"] + elements = [ + "name", + "status", + "method", + "id", + "identifier", + "organizations", + "participants", + ] else: elements = [] - with click.progressbar(resources["entry"], - label='Progress:: Extracting resource') as extract_resources_progress: + with click.progressbar( + resources["entry"], label="Progress:: Extracting resource" + ) as extract_resources_progress: for x in extract_resources_progress: rl = [] orgs_list = [] @@ -1052,21 +1360,33 @@ def export_resources_to_csv(resource_type, parameter, value, limit): elif element == "identifier": value = x["resource"]["identifier"][0]["value"] elif element == "organizations": - organizations = x["resource"]["managingOrganization"] + organizations = x["resource"][ + "managingOrganization" + ] for index, value in enumerate(organizations): - reference = x["resource"]["managingOrganization"][index]["reference"] + reference = x["resource"][ + "managingOrganization" + ][index]["reference"] new_reference = reference.split("/", 1)[1] - display = x["resource"]["managingOrganization"][index]["display"] - organization = ":".join([new_reference, display]) + display = x["resource"]["managingOrganization"][ + index + ]["display"] + organization = ":".join( + [new_reference, display] + ) orgs_list.append(organization) string = "|".join(map(str, orgs_list)) value = string elif element == "participants": participants = x["resource"]["participant"] for index, value in enumerate(participants): - reference = x["resource"]["participant"][index]["member"]["reference"] + reference = x["resource"]["participant"][index][ + "member" + ]["reference"] new_reference = reference.split("/", 1)[1] - display = x["resource"]["participant"][index]["member"]["display"] + display = x["resource"]["participant"][index][ + "member" + ]["display"] participant = ":".join([new_reference, display]) participants_list.append(participant) string = "|".join(map(str, participants_list)) @@ -1077,13 +1397,21 @@ def export_resources_to_csv(resource_type, parameter, value, limit): reference = x["resource"]["partOf"]["reference"] value = reference.split("/", 1)[1] elif element == "type": - value = x["resource"]["type"][0]["coding"][0]["display"] + value = x["resource"]["type"][0]["coding"][0][ + "display" + ] elif element == "typeCode": - value = x["resource"]["type"][0]["coding"][0]["code"] + value = x["resource"]["type"][0]["coding"][0][ + "code" + ] elif element == "physicalType": - value = x["resource"]["physicalType"]["coding"][0]["display"] + value = x["resource"]["physicalType"]["coding"][0][ + "display" + ] elif element == "physicalTypeCode": - value = x["resource"]["physicalType"]["coding"][0]["code"] + value = x["resource"]["physicalType"]["coding"][0][ + "code" + ] else: value = x["resource"][element] except KeyError: @@ -1097,11 +1425,13 @@ def export_resources_to_csv(resource_type, parameter, value, limit): except KeyError: logging.info("No Resources Found") else: - logging.error(f"Failed to retrieve resource. Status code: {response[1]} response: {response[0]}") + logging.error( + f"Failed to retrieve resource. Status code: {response[1]} response: {response[0]}" + ) def encode_image(image_file): - with open(image_file, 'rb') as image: + with open(image_file, "rb") as image: image_b64_data = base64.b64encode(image.read()) return image_b64_data @@ -1110,34 +1440,43 @@ def encode_image(image_file): # and saves it as a Binary resource. It returns the id of the Binary resource if # successful and 0 if failed def save_image(image_source_url): - headers = {"Authorization": "Bearer " + config.product_access_token} + try: + headers = {"Authorization": "Bearer " + config.product_access_token} + except AttributeError: + headers = {} + data = requests.get(url=image_source_url, headers=headers) + if not os.path.exists("images"): + os.makedirs("images") + if data.status_code == 200: - with open('images/image_file', 'wb') as image_file: + with open("images/image_file", "wb") as image_file: image_file.write(data.content) # get file type mime = magic.Magic(mime=True) - file_type = mime.from_file('images/image_file') + file_type = mime.from_file("images/image_file") - encoded_image = encode_image('images/image_file') + encoded_image = encode_image("images/image_file") resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, image_source_url)) payload = { "resourceType": "Bundle", "type": "transaction", - "entry": [{ - "request": { - "method": "PUT", - "url": "Binary/" + resource_id, - "ifMatch": "1" - }, - "resource": { - "resourceType": "Binary", - "id": resource_id, - "contentType": file_type, - "data": str(encoded_image) + "entry": [ + { + "request": { + "method": "PUT", + "url": "Binary/" + resource_id, + "ifMatch": "1", + }, + "resource": { + "resourceType": "Binary", + "id": resource_id, + "contentType": file_type, + "data": str(encoded_image), + }, } - }] + ], } payload_string = json.dumps(payload, indent=4) response = handle_request("POST", payload_string, get_base_url()) @@ -1168,23 +1507,17 @@ def filter(self, record): LOGGING = { - 'version': 1, - 'filters': { - 'custom-filter': { - '()': ResponseFilter, - 'param': 'final-response', + "version": 1, + "filters": { + "custom-filter": { + "()": ResponseFilter, + "param": "final-response", } }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'filters': ['custom-filter'] - } - }, - 'root': { - 'level': 'INFO', - 'handlers': ['console'] + "handlers": { + "console": {"class": "logging.StreamHandler", "filters": ["custom-filter"]} }, + "root": {"level": "INFO", "handlers": ["console"]}, } @@ -1198,21 +1531,41 @@ def filter(self, record): @click.option("--roles_max", required=False, default=500) @click.option("--cascade_delete", required=False, default=False) @click.option("--only_response", required=False) -@click.option("--log_level", type=click.Choice(["DEBUG", "INFO", "ERROR"], case_sensitive=False)) +@click.option( + "--log_level", type=click.Choice(["DEBUG", "INFO", "ERROR"], case_sensitive=False) +) @click.option("--export_resources", required=False) @click.option("--parameter", required=False, default="_lastUpdated") @click.option("--value", required=False, default="gt2023-01-01") @click.option("--limit", required=False, default=1000) def main( - csv_file, access_token, resource_type, assign, setup, group, roles_max, cascade_delete, only_response, log_level, - export_resources, parameter, value, limit + csv_file, + access_token, + resource_type, + assign, + setup, + group, + roles_max, + cascade_delete, + only_response, + log_level, + export_resources, + parameter, + value, + limit, ): if log_level == "DEBUG": - logging.basicConfig(filename='importer.log', encoding='utf-8', level=logging.DEBUG) + logging.basicConfig( + filename="importer.log", encoding="utf-8", level=logging.DEBUG + ) elif log_level == "INFO": - logging.basicConfig(filename='importer.log', encoding='utf-8', level=logging.INFO) + logging.basicConfig( + filename="importer.log", encoding="utf-8", level=logging.INFO + ) elif log_level == "ERROR": - logging.basicConfig(filename='importer.log', encoding='utf-8', level=logging.ERROR) + logging.basicConfig( + filename="importer.log", encoding="utf-8", level=logging.ERROR + ) logging.getLogger().addHandler(logging.StreamHandler()) if only_response: @@ -1239,7 +1592,9 @@ def main( if resource_list: if resource_type == "users": logging.info("Processing users") - with click.progressbar(resource_list, label="Progress:Processing users ") as process_user_progress: + with click.progressbar( + resource_list, label="Progress:Processing users " + ) as process_user_progress: for user in process_user_progress: user_id = create_user(user) if user_id == 0: @@ -1251,7 +1606,9 @@ def main( practitioner_exists = confirm_practitioner(user, user_id) if not practitioner_exists: payload = create_user_resources(user_id, user) - final_response = handle_request("POST", payload, config.fhir_base_url) + final_response = handle_request( + "POST", payload, config.fhir_base_url + ) logging.info("Processing complete!") elif resource_type == "locations": logging.info("Processing locations") @@ -1294,24 +1651,36 @@ def main( assign_group_roles(resource_list, group, roles_max) logging.info("Processing complete") elif setup == "clean_duplicates": - logging.info("=========================================") logging.info( "You are about to clean/delete Practitioner resources on the HAPI server" ) click.confirm("Do you want to continue?", abort=True) clean_duplicates(resource_list, cascade_delete) logging.info("Processing complete!") + elif setup == "products": + logging.info("Importing products as FHIR Group resources") + json_payload = build_payload( + "Group", resource_list, "json_payloads/product_group_payload.json" + ) + final_response = handle_request("POST", json_payload, config.fhir_base_url) + elif setup == "inventories": + logging.info("Importing inventories as FHIR Group resources") + json_payload = build_payload( + "Group", resource_list, "json_payloads/inventory_group_payload.json" + ) + final_response = handle_request("POST", json_payload, config.fhir_base_url) else: logging.error("Unsupported request!") else: logging.error("Empty csv file!") - logging.info("{ \"final-response\": " + final_response.text + "}") + logging.info('{ "final-response": ' + final_response.text + "}") end_time = datetime.now() logging.info("End time: " + end_time.strftime("%H:%M:%S")) total_time = end_time - start_time logging.info("Total time: " + str(total_time.total_seconds()) + " seconds") + if __name__ == "__main__": main() diff --git a/importer/test_main.py b/importer/test_main.py index ec3289b0..1bda7696 100644 --- a/importer/test_main.py +++ b/importer/test_main.py @@ -382,6 +382,68 @@ def test_build_payload_care_teams(self, mock_get_resource): } validate(payload_obj["entry"][0]["request"], request_schema) + @patch("main.save_image") + @patch("main.get_resource") + def test_build_payload_group(self, mock_get_resource, mock_save_image): + mock_get_resource.return_value = "1" + mock_save_image.return_value = "f374a23a-3c6a-4167-9970-b10c16a91bbd" + + csv_file = "csv/import/product.csv" + resource_list = read_csv(csv_file) + payload = build_payload( + "Group", resource_list, "json_payloads/product_group_payload.json") + payload_obj = json.loads(payload) + + self.assertIsInstance(payload_obj, dict) + self.assertEqual(payload_obj["resourceType"], "Bundle") + self.assertEqual(len(payload_obj["entry"]), 2) + + resource_schema_0 = { + "type": "object", + "properties": { + "resourceType": {"const": "Group"}, + "id": {"const": "1d86d0e2-bac8-4424-90ae-e2298900ac3c"}, + "identifier": {"type": "array", "items": {"type": "object"}}, + "active": {"const": "true"}, + "name": {"const": "thermometer"}, + "characteristic": { + "type": "array", + "minItems": 6, + "maxItems": 6 + } + }, + "required": ["resourceType", "id", "identifier", "active", "name"] + } + validate(payload_obj["entry"][0]["resource"], resource_schema_0) + + resource_schema_1 = { + "type": "object", + "properties": { + "resourceType": {"const": "Group"}, + "id": {"const": "334ec316-b44b-5678-b110-4d7ad6b1972f"}, + "identifier": {"type": "array", "items": {"type": "object"}}, + "active": {"const": "true"}, + "name": {"const": "sterilizer"}, + "characteristic": { + "type": "array", + "minItems": 2, + "maxItems": 2 + } + }, + "required": ["resourceType", "id", "identifier", "active", "name"] + } + validate(payload_obj["entry"][1]["resource"], resource_schema_1) + + request_schema = { + "type": "object", + "properties": { + "method": {"const": "PUT"}, + "url": {"const": "Group/1d86d0e2-bac8-4424-90ae-e2298900ac3c"}, + "ifMatch": {"const": "1"}, + }, + } + validate(payload_obj["entry"][0]["request"], request_schema) + def test_extract_matches(self): csv_file = "csv/organizations/organizations_locations.csv" resource_list = read_csv(csv_file) From 861d9f6ec380c9e1a5710258febdcb6645eb5e61 Mon Sep 17 00:00:00 2001 From: Wambere Date: Tue, 30 Apr 2024 19:08:20 +0300 Subject: [PATCH 02/34] FCT: Update resource.meta.tag array if it exists, otherwise create a new object (#186) * Update resource.meta.tag array if it exists, otherwise create a new object * Formatting --------- Co-authored-by: Peter Lubell-Doughtie --- .../command/PublishFhirResourcesCommand.java | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java index 672826a2..e0d938f1 100644 --- a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java +++ b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java @@ -29,6 +29,7 @@ import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; +import org.json.JSONArray; import org.json.JSONObject; import org.smartregister.domain.FctFile; import org.smartregister.fhircore_tooling.BuildConfig; @@ -275,15 +276,24 @@ JSONObject buildResourceObject(FctFile inputFile) { request.put("method", "PUT"); request.put("url", resourceType + "/" + resourceID); - ArrayList tags = new ArrayList<>(); JSONObject version = new JSONObject(); version.put("system", "https://smartregister.org/fct-release-version"); version.put("code", BuildConfig.RELEASE_VERSION); - tags.add(version); - JSONObject meta = new JSONObject(); - meta.put("tag", tags); - resource.put("meta", meta); + if (resource.has("meta")) { + JSONObject resource_meta = (JSONObject) resource.get("meta"); + if (resource_meta.has("tag")) { + JSONArray resource_tags = resource_meta.getJSONArray("tag"); + resource_tags.put(version); + } + } else { + ArrayList tags = new ArrayList<>(); + tags.add(version); + + JSONObject meta = new JSONObject(); + meta.put("tag", tags); + resource.put("meta", meta); + } JSONObject object = new JSONObject(); object.put("resource", resource); From bbaa8ae1d78ee5f459e9faff4214da1b84d65675 Mon Sep 17 00:00:00 2001 From: Wambere Date: Tue, 21 May 2024 18:22:59 +0300 Subject: [PATCH 03/34] Bulk import JSON FHIR resources (#187) * Initial commit for json fhir resources bulk import * Update sync strategy with option to sort based on resource type * Handle long resource lists * Update split_index to work with resources not separated by a new line * Add progressbar to show progress as chunks are processed * Make number of resources configurable * Add documentation for json array resource import * Clean up * Add tests * sample json * Formatting --- importer/README.md | 11 + importer/main.py | 166 +++++++++- importer/test_main.py | 560 +++++++++++++++++++++----------- importer/tests/json/sample.json | 1 + 4 files changed, 551 insertions(+), 187 deletions(-) create mode 100644 importer/tests/json/sample.json diff --git a/importer/README.md b/importer/README.md index e58b7f1f..54cb6eab 100644 --- a/importer/README.md +++ b/importer/README.md @@ -147,3 +147,14 @@ The coverage report `coverage.html` will be at the working directory - See example csv [here](/importer/csv/import/inventory.csv) - This creates a Group resource for each inventory imported - The first two columns __name__ and __active__ is the minimum required + +### 12. Import JSON resources from file +- Run `python3 main.py --bulk_import True --json_file tests/fhir_sample.json --chunk_size 500000 --sync sort --resources_count 100 --log_level info` +- This takes in a file with a JSON array, reads the resources from the array in the file and posts them to the FHIR server +- `bulk_import` (Required) must be set to True +- `json_file` (Required) points to the file with the json array. The resources in the array need to be separated by a single comma (no spaces) and the **"id"** must always be the first attribute in the resource object. This is what the code uses to identify the beginning and end of resources +- `chunk_size` (Not required) is the number of characters to read from the JSON file at a time. The size of this file can potentially be very large, so we do not want to read it all at once, we read it in chunks. This number **MUST** be at least the size of the largest single resource in the array. The default is set to 1,000,000 +- `sync` (Not required) defines the sync strategy. This can be either **direct** (which is the default) or **sort** + - **Direct** will read the resources one chunk at a time, while building a payload and posting to the server before reading the next chunk. This works if you have referential integrity turned off in the FHIR server + - **Sort** will read all the resources in the file first and sort them into different resource types. It will then build separate payloads for the different resource types and try to post them to the FHIR server in the order that the resources first appear in the JSON file. For example, if you want Patients to be synced first, then make sure that the first resource is a Patient resource +- `resources_count` (Not required) is the number of resources put in a bundle when posting the resources to the FHIR server. The default is set to 100 diff --git a/importer/main.py b/importer/main.py index fe013daa..dc2f8640 100644 --- a/importer/main.py +++ b/importer/main.py @@ -82,7 +82,7 @@ def get_access_token(): # This function makes the request to the provided url # to create resources @backoff.on_exception(backoff.expo, requests.exceptions.RequestException, max_time=180) -def post_request(request_type, payload, url): +def post_request(request_type, payload, url, json_payload): logging.info("Posting request") logging.info("Request type: " + request_type) logging.info("Url: " + url) @@ -92,9 +92,9 @@ def post_request(request_type, payload, url): headers = {"Content-type": "application/json", "Authorization": access_token} if request_type == "POST": - return requests.post(url, data=payload, headers=headers) + return requests.post(url, data=payload, json=json_payload, headers=headers) elif request_type == "PUT": - return requests.put(url, data=payload, headers=headers) + return requests.put(url, data=payload, json=json_payload, headers=headers) elif request_type == "GET": return requests.get(url, headers=headers) elif request_type == "DELETE": @@ -103,9 +103,9 @@ def post_request(request_type, payload, url): logging.error("Unsupported request type!") -def handle_request(request_type, payload, url): +def handle_request(request_type, payload, url, json_payload=None): try: - response = post_request(request_type, payload, url) + response = post_request(request_type, payload, url, json_payload) if response.status_code == 200 or response.status_code == 201: logging.info("[" + str(response.status_code) + "]" + ": SUCCESS!") @@ -1494,6 +1494,136 @@ def save_image(image_source_url): return 0 +def process_chunk(resources_array: list, resource_type: str): + new_arr = [] + with click.progressbar( + resources_array, label="Progress::Processing chunks ... " + ) as resources_array_progress: + for resource in resources_array_progress: + if not resource_type: + resource_type = resource["resourceType"] + try: + resource_id = resource["id"] + except KeyError: + if "identifier" in resource: + resource_identifier = resource["identifier"][0]["value"] + resource_id = str( + uuid.uuid5(uuid.NAMESPACE_DNS, resource_identifier) + ) + else: + resource_id = str(uuid.uuid4()) + + item = {"resource": resource, "request": {}} + item["request"]["method"] = "PUT" + item["request"]["url"] = "/".join([resource_type, resource_id]) + new_arr.append(item) + + json_payload = {"resourceType": "Bundle", "type": "transaction", "entry": new_arr} + + r = handle_request("POST", "", config.fhir_base_url, json_payload) + logging.info(r.text) + # TODO handle failures + + +def set_resource_list( + objs: str = None, + json_list: list = None, + resource_type: str = None, + number_of_resources: int = 100, +): + if objs: + resources_array = json.loads(objs) + process_chunk(resources_array, resource_type) + if json_list: + if len(json_list) > number_of_resources: + for i in range(0, len(json_list), number_of_resources): + sub_list = json_list[i : i + number_of_resources] + process_chunk(sub_list, resource_type) + else: + process_chunk(json_list, resource_type) + + +def build_mapped_payloads(resource_mapping, json_file, resources_count): + with open(json_file, "r") as file: + data_dict = json.load(file) + with click.progressbar( + resource_mapping, label="Progress::Setting up ... " + ) as resource_mapping_progress: + for resource_type in resource_mapping_progress: + index_positions = resource_mapping[resource_type] + resource_list = [data_dict[i] for i in index_positions] + set_resource_list(None, resource_list, resource_type, resources_count) + + +def build_resource_type_map(resources: str, mapping: dict, index_tracker: int = 0): + resource_list = json.loads(resources) + for index, resource in enumerate(resource_list): + resource_type = resource["resourceType"] + if resource_type in mapping.keys(): + mapping[resource_type].append(index + index_tracker) + else: + mapping[resource_type] = [index + index_tracker] + + global import_counter + import_counter = len(resource_list) + import_counter + + +def split_chunk( + chunk: str, + left_over_chunk: str, + size: int, + mapping: dict = None, + sync: str = None, + import_counter: int = 0, +): + if len(chunk) + len(left_over_chunk) < int(size): + # load can fit in one chunk, so remove closing bracket + last_bracket = chunk.rfind("}") + current_chunk = chunk[: int(last_bracket)] + next_left_over_chunk = "-" + if len(chunk.strip()) == 0: + last_bracket = left_over_chunk.rfind("}") + left_over_chunk = left_over_chunk[: int(last_bracket)] + else: + # load can't fit, so split on last full resource + split_index = chunk.rfind( + '},{"id"' + ) # Assumption that this string will find the last full resource + current_chunk = chunk[:split_index] + next_left_over_chunk = chunk[int(split_index) + 2 :] + if len(chunk.strip()) == 0: + last_bracket = left_over_chunk.rfind("}") + left_over_chunk = left_over_chunk[: int(last_bracket)] + + if len(left_over_chunk.strip()) == 0: + current_chunk = current_chunk[1:] + + chunk_list = "[" + left_over_chunk + current_chunk + "}]" + + if sync.lower() == "direct": + set_resource_list(chunk_list) + if sync.lower() == "sort": + build_resource_type_map(chunk_list, mapping, import_counter) + return next_left_over_chunk + + +def read_file_in_chunks(json_file: str, chunk_size: int, sync: str): + logging.info("Reading file in chunks ...") + incomplete_load = "" + mapping = {} + global import_counter + import_counter = 0 + with open(json_file, "r") as file: + while True: + chunk = file.read(chunk_size) + if not chunk: + break + incomplete_load = split_chunk( + chunk, incomplete_load, chunk_size, mapping, sync, import_counter + ) + return mapping + + class ResponseFilter(logging.Filter): def __init__(self, param=None): self.param = param @@ -1523,6 +1653,7 @@ def filter(self, record): @click.command() @click.option("--csv_file", required=False) +@click.option("--json_file", required=False) @click.option("--access_token", required=False) @click.option("--resource_type", required=False) @click.option("--assign", required=False) @@ -1538,8 +1669,18 @@ def filter(self, record): @click.option("--parameter", required=False, default="_lastUpdated") @click.option("--value", required=False, default="gt2023-01-01") @click.option("--limit", required=False, default=1000) +@click.option("--bulk_import", required=False, default=False) +@click.option("--chunk_size", required=False, default=1000000) +@click.option("--resources_count", required=False, default=100) +@click.option( + "--sync", + type=click.Choice(["DIRECT", "SORT"], case_sensitive=False), + required=False, + default="DIRECT", +) def main( csv_file, + json_file, access_token, resource_type, assign, @@ -1553,6 +1694,10 @@ def main( parameter, value, limit, + bulk_import, + chunk_size, + resources_count, + sync, ): if log_level == "DEBUG": logging.basicConfig( @@ -1580,6 +1725,17 @@ def main( export_resources_to_csv(resource_type, parameter, value, limit) exit() + if bulk_import: + logging.info("Starting bulk import...") + resource_mapping = read_file_in_chunks(json_file, chunk_size, sync) + if sync.lower() == "sort": + build_mapped_payloads(resource_mapping, json_file, resources_count) + end_time = datetime.now() + logging.info("End time: " + end_time.strftime("%H:%M:%S")) + total_time = end_time - start_time + logging.info("Total time: " + str(total_time.total_seconds()) + " seconds") + exit() + # set access token if access_token: global global_access_token diff --git a/importer/test_main.py b/importer/test_main.py index 1bda7696..e11fd9eb 100644 --- a/importer/test_main.py +++ b/importer/test_main.py @@ -15,6 +15,8 @@ confirm_keycloak_user, confirm_practitioner, check_parent_admin_level, + split_chunk, + read_file_in_chunks, ) @@ -44,7 +46,9 @@ def test_write_csv(self): ] self.test_resource_type = "test_organization" self.test_fieldnames = ["name", "active", "method", "id", "identifier"] - csv_file = write_csv(self.test_data, self.test_resource_type, self.test_fieldnames) + csv_file = write_csv( + self.test_data, self.test_resource_type, self.test_fieldnames + ) csv_content = read_csv(csv_file) self.assertEqual(csv_content, self.test_data) @@ -103,14 +107,9 @@ def test_build_payload_organizations(self, mock_get_resource): "id": {"const": "3da051e0-d743-5574-8f0e-6cb8798551f5"}, "identifier": {"type": "array", "items": {"type": "object"}}, "active": {"const": "true"}, - "name": {"const": "Min Organization"} + "name": {"const": "Min Organization"}, }, - "required": [ - "id", - "identifier", - "active", - "name" - ], + "required": ["id", "identifier", "active", "name"], } validate(payload_obj["entry"][0]["resource"], resource_schema) @@ -126,7 +125,9 @@ def test_build_payload_organizations(self, mock_get_resource): @patch("main.check_parent_admin_level") @patch("main.get_resource") - def test_build_payload_locations(self, mock_get_resource, mock_check_parent_admin_level): + def test_build_payload_locations( + self, mock_get_resource, mock_check_parent_admin_level + ): mock_get_resource.return_value = "1" mock_check_parent_admin_level.return_value = "3" @@ -190,9 +191,9 @@ def test_build_payload_locations(self, mock_get_resource, mock_check_parent_admi "type": "object", "properties": { "longitude": {"const": 36.81}, - "latitude": {"const": -1.28} - } - } + "latitude": {"const": -1.28}, + }, + }, }, "required": [ "resourceType", @@ -236,14 +237,9 @@ def test_build_payload_locations(self, mock_get_resource, mock_check_parent_admi "id": {"const": "c4336f73-4450-566b-b381-d07a6e857d72"}, "identifier": {"type": "array", "items": {"type": "object"}}, "status": {"const": "active"}, - "name": {"const": "City1"} + "name": {"const": "City1"}, }, - "required": [ - "id", - "identifier", - "status", - "name" - ], + "required": ["id", "identifier", "status", "name"], } validate(payload_obj["entry"][0]["resource"], resource_schema) @@ -265,10 +261,7 @@ def test_check_parent_admin_level(self, mock_get_base_url, mock_handle_request): "resourceType": "Location", "id": "18fcbc2e-4240-4a84-a270-7a444523d7b6", "identifier": [ - { - "use": "official", - "value": "18fcbc2e-4240-4a84-a270-7a444523d7b6" - } + {"use": "official", "value": "18fcbc2e-4240-4a84-a270-7a444523d7b6"} ], "status": "active", "name": "test location-1", @@ -278,11 +271,11 @@ def test_check_parent_admin_level(self, mock_get_base_url, mock_handle_request): { "system": "https://smartregister.org/codes/administrative-level", "code": "2", - "display": "Level 2" + "display": "Level 2", } ] } - ] + ], } string_mocked_response_text = json.dumps(mocked_response_text) mock_handle_request.return_value = (string_mocked_response_text, 200) @@ -327,28 +320,32 @@ def test_build_payload_care_teams(self, mock_get_resource): "items": { "type": "object", "properties": { - "system": {"const": "http://snomed.info/sct"}, + "system": { + "const": "http://snomed.info/sct" + }, "code": {"const": "394730007"}, - "display": {"const": "Healthcare related organization"} - } - } + "display": { + "const": "Healthcare related organization" + }, + }, + }, } - } - } + }, + }, }, "member": { "type": "object", "properties": { "reference": {"type": "string"}, - "display": {"type": "string"} - } - } + "display": {"type": "string"}, + }, + }, }, "anyOf": [ {"required": ["role", "member"]}, - {"required": ["member"]} - ] - } + {"required": ["member"]}, + ], + }, }, "managingOrganization": { "type": "array", @@ -356,19 +353,20 @@ def test_build_payload_care_teams(self, mock_get_resource): "type": "object", "properties": { "reference": {"type": "string"}, - "display": {"type": "string"} - } - } - } + "display": {"type": "string"}, + }, + }, + }, }, - "required": ["resourceType", - "id", - "identifier", - "status", - "name", - "participant", - "managingOrganization" - ], + "required": [ + "resourceType", + "id", + "identifier", + "status", + "name", + "participant", + "managingOrganization", + ], } validate(payload_obj["entry"][0]["resource"], resource_schema) @@ -391,7 +389,8 @@ def test_build_payload_group(self, mock_get_resource, mock_save_image): csv_file = "csv/import/product.csv" resource_list = read_csv(csv_file) payload = build_payload( - "Group", resource_list, "json_payloads/product_group_payload.json") + "Group", resource_list, "json_payloads/product_group_payload.json" + ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -406,13 +405,9 @@ def test_build_payload_group(self, mock_get_resource, mock_save_image): "identifier": {"type": "array", "items": {"type": "object"}}, "active": {"const": "true"}, "name": {"const": "thermometer"}, - "characteristic": { - "type": "array", - "minItems": 6, - "maxItems": 6 - } + "characteristic": {"type": "array", "minItems": 6, "maxItems": 6}, }, - "required": ["resourceType", "id", "identifier", "active", "name"] + "required": ["resourceType", "id", "identifier", "active", "name"], } validate(payload_obj["entry"][0]["resource"], resource_schema_0) @@ -424,13 +419,9 @@ def test_build_payload_group(self, mock_get_resource, mock_save_image): "identifier": {"type": "array", "items": {"type": "object"}}, "active": {"const": "true"}, "name": {"const": "sterilizer"}, - "characteristic": { - "type": "array", - "minItems": 2, - "maxItems": 2 - } + "characteristic": {"type": "array", "minItems": 2, "maxItems": 2}, }, - "required": ["resourceType", "id", "identifier", "active", "name"] + "required": ["resourceType", "id", "identifier", "active", "name"], } validate(payload_obj["entry"][1]["resource"], resource_schema_1) @@ -738,7 +729,9 @@ def test_export_resource_to_csv( @patch("main.handle_request") @patch("main.get_base_url") - def test_build_assign_payload_update_assigned_org(self, mock_get_base_url, mock_handle_request): + def test_build_assign_payload_update_assigned_org( + self, mock_get_base_url, mock_handle_request + ): mock_get_base_url.return_value = "https://example.smartregister.org/fhir" mock_response_data = { "resourceType": "Bundle", @@ -751,22 +744,27 @@ def test_build_assign_payload_update_assigned_org(self, mock_get_base_url, mock_ "meta": {"versionId": "2"}, "practitioner": { "reference": "Practitioner/f5d49ba0-50d7-4491-bd6c-62e429707a03", - "display": "Jenn" + "display": "Jenn", }, "organization": { "reference": "Organization/8342dd77-aecd-48ab-826b-75c7c33039ed", - "display": "Health Organization" - } + "display": "Health Organization", + }, } } - ] + ], } string_response = json.dumps(mock_response_data) mock_response = (string_response, 200) mock_handle_request.return_value = mock_response resource_list = [ - ["Jenn", "f5d49ba0-50d7-4491-bd6c-62e429707a03", "New Org", "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc"] + [ + "Jenn", + "f5d49ba0-50d7-4491-bd6c-62e429707a03", + "New Org", + "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", + ] ] payload = build_assign_payload(resource_list, "PractitionerRole") payload_obj = json.loads(payload) @@ -777,18 +775,25 @@ def test_build_assign_payload_update_assigned_org(self, mock_get_base_url, mock_ self.assertEqual( payload_obj["entry"][0]["resource"]["practitioner"], - mock_response_data["entry"][0]["resource"]["practitioner"]) + mock_response_data["entry"][0]["resource"]["practitioner"], + ) self.assertNotEqual( payload_obj["entry"][0]["resource"]["organization"], - mock_response_data["entry"][0]["resource"]["organization"]) + mock_response_data["entry"][0]["resource"]["organization"], + ) self.assertEqual( payload_obj["entry"][0]["resource"]["organization"]["reference"], - "Organization/98199caa-4455-4b2f-a5cf-cb9c89b6bbdc") - self.assertEqual(payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org") + "Organization/98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org" + ) @patch("main.handle_request") @patch("main.get_base_url") - def test_build_assign_payload_create_org_assignment(self, mock_get_base_url, mock_handle_request): + def test_build_assign_payload_create_org_assignment( + self, mock_get_base_url, mock_handle_request + ): mock_get_base_url.return_value = "https://example.smartregister.org/fhir" mock_response_data = { "resourceType": "Bundle", @@ -801,18 +806,23 @@ def test_build_assign_payload_create_org_assignment(self, mock_get_base_url, moc "meta": {"versionId": "2"}, "practitioner": { "reference": "Practitioner/f5d49ba0-50d7-4491-bd6c-62e429707a03", - "display": "Jenn" - } + "display": "Jenn", + }, } } - ] + ], } string_response = json.dumps(mock_response_data) mock_response = (string_response, 200) mock_handle_request.return_value = mock_response resource_list = [ - ["Jenn", "f5d49ba0-50d7-4491-bd6c-62e429707a03", "New Org", "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc"] + [ + "Jenn", + "f5d49ba0-50d7-4491-bd6c-62e429707a03", + "New Org", + "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", + ] ] payload = build_assign_payload(resource_list, "PractitionerRole") payload_obj = json.loads(payload) @@ -823,26 +833,34 @@ def test_build_assign_payload_create_org_assignment(self, mock_get_base_url, moc self.assertEqual( payload_obj["entry"][0]["resource"]["practitioner"], - mock_response_data["entry"][0]["resource"]["practitioner"]) + mock_response_data["entry"][0]["resource"]["practitioner"], + ) self.assertEqual( payload_obj["entry"][0]["resource"]["organization"]["reference"], - "Organization/98199caa-4455-4b2f-a5cf-cb9c89b6bbdc") - self.assertEqual(payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org") + "Organization/98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org" + ) @patch("main.handle_request") @patch("main.get_base_url") - def test_build_assign_payload_create_new_practitioner_role(self, mock_get_base_url, mock_handle_request): + def test_build_assign_payload_create_new_practitioner_role( + self, mock_get_base_url, mock_handle_request + ): mock_get_base_url.return_value = "https://example.smartregister.org/fhir" - mock_response_data = { - "resourceType": "Bundle", - "total": 0 - } + mock_response_data = {"resourceType": "Bundle", "total": 0} string_response = json.dumps(mock_response_data) mock_response = (string_response, 200) mock_handle_request.return_value = mock_response resource_list = [ - ["Jenn", "f5d49ba0-50d7-4491-bd6c-62e429707a03", "New Org", "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc"] + [ + "Jenn", + "f5d49ba0-50d7-4491-bd6c-62e429707a03", + "New Org", + "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", + ] ] payload = build_assign_payload(resource_list, "PractitionerRole") payload_obj = json.loads(payload) @@ -853,63 +871,112 @@ def test_build_assign_payload_create_new_practitioner_role(self, mock_get_base_u self.assertEqual( payload_obj["entry"][0]["resource"]["practitioner"]["reference"], - "Practitioner/f5d49ba0-50d7-4491-bd6c-62e429707a03") + "Practitioner/f5d49ba0-50d7-4491-bd6c-62e429707a03", + ) self.assertEqual( - payload_obj["entry"][0]["resource"]["practitioner"]["display"], "Jenn") + payload_obj["entry"][0]["resource"]["practitioner"]["display"], "Jenn" + ) self.assertEqual( payload_obj["entry"][0]["resource"]["organization"]["reference"], - "Organization/98199caa-4455-4b2f-a5cf-cb9c89b6bbdc") - self.assertEqual(payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org") - - @patch('main.logging') - @patch('main.handle_request') - @patch('main.get_keycloak_url') - def test_create_user(self, mock_get_keycloak_url, mock_handle_request, mock_logging): - mock_get_keycloak_url.return_value = "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + "Organization/98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org" + ) + + @patch("main.logging") + @patch("main.handle_request") + @patch("main.get_keycloak_url") + def test_create_user( + self, mock_get_keycloak_url, mock_handle_request, mock_logging + ): + mock_get_keycloak_url.return_value = ( + "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + ) mock_handle_request.return_value.status_code = 201 - mock_handle_request.return_value.headers = {"Location": "https://keycloak.smartregister.org/auth/admin/realms" - "/example-realm/users/6cd50351-3ddb-4296-b1db" - "-aac2273e35f3"} + mock_handle_request.return_value.headers = { + "Location": "https://keycloak.smartregister.org/auth/admin/realms" + "/example-realm/users/6cd50351-3ddb-4296-b1db" + "-aac2273e35f3" + } mocked_user_data = ( - 'Jenn', 'Doe', 'Jenny', 'jeendoe@example.com', '431cb523-253f-4c44-9ded-af42c55c0bbb', 'Supervisor', 'TRUE', - 'a715b562-27f2-432a-b1ba-e57db35e0f93', 'test', 'demo', 'pa$$word' + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "431cb523-253f-4c44-9ded-af42c55c0bbb", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", ) user_id = create_user(mocked_user_data) self.assertEqual(user_id, "6cd50351-3ddb-4296-b1db-aac2273e35f3") - mock_logging.info.assert_called_with('Setting user password') + mock_logging.info.assert_called_with("Setting user password") - @patch('main.handle_request') - @patch('main.get_keycloak_url') - def test_create_user_already_exists(self, mock_get_keycloak_url, mock_handle_request): - mock_get_keycloak_url.return_value = "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + @patch("main.handle_request") + @patch("main.get_keycloak_url") + def test_create_user_already_exists( + self, mock_get_keycloak_url, mock_handle_request + ): + mock_get_keycloak_url.return_value = ( + "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + ) mock_handle_request.return_value.status_code = 409 mocked_user_data = ( - 'Jenn', 'Doe', 'Jenn', 'jendoe@example.com', ' 99d54e3c-c26f-4500-a7f9-3f4cb788673f', 'Supervisor', 'false', - 'a715b562-27f2-432a-b1ba-e57db35e0f93', 'test', 'demo', 'pa$$word' + "Jenn", + "Doe", + "Jenn", + "jendoe@example.com", + " 99d54e3c-c26f-4500-a7f9-3f4cb788673f", + "Supervisor", + "false", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", ) user_id = create_user(mocked_user_data) self.assertEqual(user_id, 0) # Test the confirm_keycloak function - @patch('main.logging') - @patch('main.handle_request') - @patch('main.get_keycloak_url') - def test_confirm_keycloak_user(self, mock_get_keycloak_url, mock_handle_request, mock_logging): - mock_get_keycloak_url.return_value = "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + @patch("main.logging") + @patch("main.handle_request") + @patch("main.get_keycloak_url") + def test_confirm_keycloak_user( + self, mock_get_keycloak_url, mock_handle_request, mock_logging + ): + mock_get_keycloak_url.return_value = ( + "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + ) mocked_user_data = ( - 'Jenn', 'Doe', 'Jenny', 'jeendoe@example.com', '431cb523-253f-4c44-9ded-af42c55c0bbb', 'Supervisor', 'TRUE', - 'a715b562-27f2-432a-b1ba-e57db35e0f93', 'test', 'demo', 'pa$$word' + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "431cb523-253f-4c44-9ded-af42c55c0bbb", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", ) user_id = create_user(mocked_user_data) self.assertEqual(user_id, 0) - mock_response = ('[{"id":"6cd50351-3ddb-4296-b1db-aac2273e35f3","createdTimestamp":1710151827166,' - '"username":"Jenny","enabled":true,"totp":false,"emailVerified":false,"firstName":"Jenn",' - '"lastName":"Doe","email":"jeendoe@example.com","attributes":{"fhir_core_app_id":["demo"]},' - '"disableableCredentialTypes":[],"requiredActions":[],"notBefore":0,"access":{' - '"manageGroupMembership":true,"view":true,"mapRoles":true,"impersonate":true,' - '"manage":true}}]', 200) + mock_response = ( + '[{"id":"6cd50351-3ddb-4296-b1db-aac2273e35f3","createdTimestamp":1710151827166,' + '"username":"Jenny","enabled":true,"totp":false,"emailVerified":false,"firstName":"Jenn",' + '"lastName":"Doe","email":"jeendoe@example.com","attributes":{"fhir_core_app_id":["demo"]},' + '"disableableCredentialTypes":[],"requiredActions":[],"notBefore":0,"access":{' + '"manageGroupMembership":true,"view":true,"mapRoles":true,"impersonate":true,' + '"manage":true}}]', + 200, + ) mock_handle_request.return_value = mock_response mock_json_response = json.loads(mock_response[0]) keycloak_id = confirm_keycloak_user(mocked_user_data) @@ -919,13 +986,24 @@ def test_confirm_keycloak_user(self, mock_get_keycloak_url, mock_handle_request, mock_logging.info.assert_called_with("User confirmed with id: " + keycloak_id) # Test confirm_practitioner function - @patch('main.handle_request') - @patch('main.get_base_url') - def test_confirm_practitioner_if_practitioner_uuid_not_provided(self, mock_get_base_url, mock_handle_request): - mock_get_base_url.return_value = 'https://example.smartregister.org/fhir' + @patch("main.handle_request") + @patch("main.get_base_url") + def test_confirm_practitioner_if_practitioner_uuid_not_provided( + self, mock_get_base_url, mock_handle_request + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" mocked_user = ( - 'Jenn', 'Doe', 'Jenny', 'jeendoe@example.com', '', 'Supervisor', 'TRUE', - 'a715b562-27f2-432a-b1ba-e57db35e0f93', 'test', 'demo', 'pa$$word' + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", ) mocked_response_data = { "resourceType": "Bundle", @@ -935,44 +1013,70 @@ def test_confirm_practitioner_if_practitioner_uuid_not_provided(self, mock_get_b string_response = json.dumps(mocked_response_data) mock_response = (string_response, 200) mock_handle_request.return_value = mock_response - practitioner_exists = confirm_practitioner(mocked_user, "431cb523-253f-4c44-9ded-af42c55c0bbb") - self.assertTrue(practitioner_exists, "Practitioner exist, linked to the provided user") - - @patch('main.logging') - @patch('main.handle_request') - @patch('main.get_base_url') - def test_confirm_practitioner_linked_keycloak_user_and_practitioner(self, mock_get_base_url, mock_handle_request, - mock_logging): - mock_get_base_url.return_value = 'https://example.smartregister.org/fhir' + practitioner_exists = confirm_practitioner( + mocked_user, "431cb523-253f-4c44-9ded-af42c55c0bbb" + ) + self.assertTrue( + practitioner_exists, "Practitioner exist, linked to the provided user" + ) + + @patch("main.logging") + @patch("main.handle_request") + @patch("main.get_base_url") + def test_confirm_practitioner_linked_keycloak_user_and_practitioner( + self, mock_get_base_url, mock_handle_request, mock_logging + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" mocked_user = ( - 'Jenn', 'Doe', 'Jenny', 'jeendoe@example.com', '6cd50351-3ddb-4296-b1db-aac2273e35f3', 'Supervisor', 'TRUE', - 'a715b562-27f2-432a-b1ba-e57db35e0f93', 'test', 'demo', 'pa$$word' + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "6cd50351-3ddb-4296-b1db-aac2273e35f3", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", ) mocked_response_data = { "resourceType": "Practitioner", "identifier": [ - { - "use": "official", - "value": "431cb523-253f-4c44-9ded-af42c55c0bbb" - }, - { - "use": "secondary", - "value": "6cd50351-3ddb-4296-b1db-aac2273e35f3" - } + {"use": "official", "value": "431cb523-253f-4c44-9ded-af42c55c0bbb"}, + {"use": "secondary", "value": "6cd50351-3ddb-4296-b1db-aac2273e35f3"}, ], } string_response = json.dumps(mocked_response_data) mock_response = (string_response, 200) mock_handle_request.return_value = mock_response - practitioner_exists = confirm_practitioner(mocked_user, "6cd50351-3ddb-4296-b1db-aac2273e35f3") + practitioner_exists = confirm_practitioner( + mocked_user, "6cd50351-3ddb-4296-b1db-aac2273e35f3" + ) self.assertTrue(practitioner_exists) - self.assertEqual(mocked_response_data["identifier"][1]["value"], "6cd50351-3ddb-4296-b1db-aac2273e35f3") - mock_logging.info.assert_called_with("The Keycloak user and Practitioner are linked as expected") + self.assertEqual( + mocked_response_data["identifier"][1]["value"], + "6cd50351-3ddb-4296-b1db-aac2273e35f3", + ) + mock_logging.info.assert_called_with( + "The Keycloak user and Practitioner are linked as expected" + ) # Test create_user_resources function def test_create_user_resources(self): - user = ('Jenn', 'Doe', 'Jenn', 'jendoe@example.com', '99d54e3c-c26f-4500-a7f9-3f4cb788673f', 'Supervisor', - 'false', 'a715b562-27f2-432a-b1ba-e57db35e0f93', 'test', 'demo', 'pa$$word') + user = ( + "Jenn", + "Doe", + "Jenn", + "jendoe@example.com", + "99d54e3c-c26f-4500-a7f9-3f4cb788673f", + "Supervisor", + "false", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ) user_id = "99d54e3c-c26f-4500-a7f9-3f4cb788673f" payload = create_user_resources(user_id, user) payload_obj = json.loads(payload) @@ -992,7 +1096,7 @@ def test_create_user_resources(self): "properties": { "use": { "type": "string", - "enum": ["official", "secondary"] + "enum": ["official", "secondary"], }, "type": { "type": "object", @@ -1002,18 +1106,22 @@ def test_create_user_resources(self): "items": { "type": "object", "properties": { - "system": {"const": "http://hl7.org/fhir/identifier-type"}, + "system": { + "const": "http://hl7.org/fhir/identifier-type" + }, "code": {"const": "KUID"}, - "display": {"const": "Keycloak user ID"} - } - } + "display": { + "const": "Keycloak user ID" + }, + }, + }, }, - "text": {"const": "Keycloak user ID"} - } + "text": {"const": "Keycloak user ID"}, + }, }, - "value": {"const": "99d54e3c-c26f-4500-a7f9-3f4cb788673f"} - } - } + "value": {"const": "99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, + }, + }, }, "name": { "type": "array", @@ -1022,14 +1130,10 @@ def test_create_user_resources(self): "properties": { "use": {"const": "official"}, "family": {"const": "Doe"}, - "given": { - "type": "array", - "items": { - "type": "string" - } - } - } - }}, + "given": {"type": "array", "items": {"type": "string"}}, + }, + }, + }, }, "required": ["resourceType", "id", "identifier", "name"], } @@ -1060,12 +1164,14 @@ def test_create_user_resources(self): "entity": { "type": "object", "properties": { - "reference": {"const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f"} - } + "reference": { + "const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f" + } + }, } - } - } - } + }, + }, + }, }, "required": ["resourceType", "id", "identifier", "name", "member"], } @@ -1090,9 +1196,11 @@ def test_create_user_resources(self): "practitioner": { "type": "object", "properties": { - "reference": {"const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, - "display": {"const": "Jenn Doe"} - } + "reference": { + "const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f" + }, + "display": {"const": "Jenn Doe"}, + }, }, "code": { "type": "object", @@ -1104,12 +1212,12 @@ def test_create_user_resources(self): "properties": { "system": {"const": "http://snomed.info/sct"}, "code": {"const": "236321002"}, - "display": {"const": "Supervisor (occupation)"} + "display": {"const": "Supervisor (occupation)"}, }, - } + }, } - } - } + }, + }, }, "required": ["resourceType", "id", "identifier", "practitioner", "code"], } @@ -1119,12 +1227,100 @@ def test_create_user_resources(self): "type": "object", "properties": { "method": {"const": "PUT"}, - "url": {"const": "PractitionerRole/f08e0373-932e-5bcb-bdf2-0c28a3c8fdd3"}, + "url": { + "const": "PractitionerRole/f08e0373-932e-5bcb-bdf2-0c28a3c8fdd3" + }, "ifMatch": {"const": "1"}, }, } validate(payload_obj["entry"][2]["request"], request_schema) + @patch("main.set_resource_list") + def test_split_chunk_direct_sync_first_chunk_less_than_size( + self, mock_set_resource_list + ): + chunk = '[{"id": "10", "resourceType": "Patient"}' + next_left_over = split_chunk(chunk, "", 50, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, "-") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("main.set_resource_list") + def test_split_chunk_direct_sync_middle_chunk_less_than_size( + self, mock_set_resource_list + ): + chunk = ' "resourceType": "Patient"}' + left_over_chunk = '{"id": "10",' + next_left_over = split_chunk(chunk, left_over_chunk, 50, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, "-") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("main.set_resource_list") + def test_split_chunk_direct_sync_last_chunk_less_than_size( + self, mock_set_resource_list + ): + left_over_chunk = '{"id": "10", "resourceType": "Patient"}]' + next_left_over = split_chunk("", left_over_chunk, 50, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, "-") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("main.set_resource_list") + def test_split_chunk_direct_sync_first_chunk_greater_than_size( + self, mock_set_resource_list + ): + chunk = '[{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType":' + next_left_over = split_chunk(chunk, "", 40, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, '{"id": "11", "resourceType":') + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("main.set_resource_list") + def test_split_chunk_direct_sync_middle_chunk_greater_than_size( + self, mock_set_resource_list + ): + chunk = ': "Task"},{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType":' + left_over_chunk = '{"id": "09", "resourceType"' + next_left_over = split_chunk(chunk, left_over_chunk, 80, {}, "direct") + chunk_list = '[{"id": "09", "resourceType": "Task"},{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, '{"id": "11", "resourceType":') + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("main.set_resource_list") + def test_split_chunk_direct_sync_last_chunk_greater_than_size( + self, mock_set_resource_list + ): + left_over_chunk = '{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType": "Task"}]' + next_left_over = split_chunk("", left_over_chunk, 43, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType": "Task"}]' + self.assertEqual(next_left_over, "") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("main.set_resource_list") + @patch("main.build_resource_type_map") + def test_split_chunk_sort_sync_first_chunk_less_than_size( + self, mock_build_resource_type_map, mock_set_resource_list + ): + chunk = '[{"id": "10", "resourceType": "Patient"},{"id": "11"' + next_left_over = split_chunk(chunk, "", 50, {}, "sort") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, '{"id": "11"') + mock_set_resource_list.assert_not_called() + mock_build_resource_type_map.assert_called_once_with(chunk_list, {}, 0) + + def test_build_resource_type_map(self): + json_file = "tests/json/sample.json" + mapping = read_file_in_chunks(json_file, 300, "sort") + mapped_resources = { + "Patient": [0], + "Practitioner": [1, 5], + "Location": [2, 4], + "Observation": [3], + } + self.assertIsInstance(mapping, dict) + self.assertEqual(mapping, mapped_resources) + if __name__ == "__main__": unittest.main() diff --git a/importer/tests/json/sample.json b/importer/tests/json/sample.json new file mode 100644 index 00000000..bdbf0305 --- /dev/null +++ b/importer/tests/json/sample.json @@ -0,0 +1 @@ +[{"id":"d41204ad-8284-4131-b21e-d200d191ff3e","resourceType":"Patient","active":true,"name":[{"family":"Brown"}],"gender":"male","birthDate":"1995-07-01"},{"id":"878d85b3-2856-4568-9429-346fa054de46","resourceType":"Practitioner","active":true,"name":[{"use":"official","family":"Doe"}]},{"id":"3a536e47-ae09-463f-9df2-64221db82c96","resourceType":"Location","status":"active","name":"Nairobi"},{"id":"b3088bb3-dbcc-4019-92a8-2ca93682b63b","resourceType":"Observation","code":{"coding":[{"system":"https://www.snomed.org","code":"75753009","display":"Blood clots"}]}},{"id":"1a95c0b2-f903-4f32-be4f-6270ef604c5f","resourceType":"Location","status":"active","name":"Mombasa"},{"id":"62a2755b-5136-4bc0-882a-16aafa1c2083","resourceType":"Practitioner","active":true,"name":[{"use":"official","family":"Doe"}]}] \ No newline at end of file From 386f9e486cad5ce5c3a70ec15427e01b6960f5bd Mon Sep 17 00:00:00 2001 From: Wambere Date: Mon, 3 Jun 2024 20:08:05 +0300 Subject: [PATCH 04/34] Publish binary resources (#194) * Publish binary resources * Add docstrings for clarity * Update the read me file --- efsity/README.md | 5 + .../command/PublishFhirResourcesCommand.java | 184 +++++++++++++++--- .../PublishFhirResourcesCommandTest.java | 125 +++++++++++- 3 files changed, 279 insertions(+), 35 deletions(-) diff --git a/efsity/README.md b/efsity/README.md index 4a77118a..43a87282 100644 --- a/efsity/README.md +++ b/efsity/README.md @@ -101,6 +101,7 @@ $ fct publish -e /path/to/env.properties ``` -i or --input : Path to the project folder with the resources to be published -bu or --fhir-base-url : The base url of the FHIR server to post resources to + -c or --composition-file : The path to the composition file -at or --access-token : Access token to grant access to the FHIR server -ci or --client-id : The client identifier for authentication -cs or --client-secret :The client secret for authentication @@ -117,6 +118,10 @@ take precedence over anything in the properties file. You can either pass the actual accessToken as a variable or pass in the client credentials which will be used to get an accessToken from the accessToken url provided +You must pass the path to your composition file if you want to publish any binary resources. +The binary resources listed in the composition files are the ones that will be published. +For the publishing of binary resources to work correctly, ensure that you are using the correct/recommended file/folder structure and that the file names in the composition file are in camel case. + ### Validating your app configurations The tool supports some validations for the FHIRCore app configurations. To validate you can run the command: ```console diff --git a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java index e0d938f1..3cd3363e 100644 --- a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java +++ b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java @@ -12,16 +12,12 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Properties; -import java.util.TimeZone; -import java.util.UUID; +import java.util.*; import net.jimblackler.jsonschemafriend.GenerationException; import net.jimblackler.jsonschemafriend.ValidationException; import org.apache.http.HttpResponse; @@ -98,6 +94,12 @@ public class PublishFhirResourcesCommand implements Runnable { required = false) String validateResource = "true"; + @CommandLine.Option( + names = {"-c", "--composition"}, + description = "path of the composition configuration file", + required = false) + String compositionFilePath; + @Override public void run() { long start = System.currentTimeMillis(); @@ -111,7 +113,11 @@ public void run() { } } try { - publishResources(); + if (compositionFilePath != null) { + ArrayList resourceObjects = buildBinaries(compositionFilePath, projectFolder); + buildBundle(resourceObjects); + } + buildResources(); stateManagement(); } catch (IOException | ValidationException | GenerationException e) { throw new RuntimeException(e); @@ -171,32 +177,119 @@ void setProperties(Properties properties) { } } - void publishResources() throws IOException, ValidationException, GenerationException { - ArrayList resourceFiles = getResourceFiles(projectFolder); + /** + * This function takes in the name of a binary component/resource, converts it from camel case to + * separated by underscores. It then appends a string depending on the name, to return the actual + * file name of the binary file. For example the binaryName 'ancRegister' will be converted to + * 'anc_register_config.json' + * + * @param binaryName This is the name of a binary component/resource as it appears in the + * composition resource. Usually in camel case and matches the start of the actual file name + * @return filename This is the actual file name of the binary resource in the project folder + */ + String getFileName(String binaryName) { + String filename; + if ((binaryName.endsWith("Register")) || (binaryName.endsWith("Profile"))) { + String regex = "([a-z])([A-Z]+)"; + String replacer = "$1_$2"; + binaryName = binaryName.replaceAll(regex, replacer).toLowerCase(); + } + if (binaryName.startsWith("strings")) { + filename = binaryName + "_config.properties"; + } else { + filename = binaryName + "_config.json"; + } + return filename; + } + + HashMap getDetails(JSONObject jsonObject) { + JSONObject focus = jsonObject.getJSONObject("focus"); + String reference = focus.getString("reference"); + JSONObject identifier = focus.getJSONObject("identifier"); + String name = identifier.getString("value"); + + HashMap map = new HashMap<>(); + map.put("reference", reference); + map.put("name", getFileName(name)); + return map; + } + + /** + * This function takes in a binary file name and project folder, it then opens the filename in the + * folder ( assuming the recommended folder structure ), reads the content and returns a base64 + * encoded version of the content + * + * @param fileName This is the name of the json binary file + * @param projectFolder This is the folder with all the config files + * @return base64 encoded version of the content in the binary json file + * @throws IOException + */ + String getBinaryContent(String fileName, String projectFolder) throws IOException { + String pathToFile; + if (fileName.contains("register")) { + pathToFile = projectFolder + "/registers/" + fileName; + } else if (fileName.contains("profile")) { + pathToFile = projectFolder + "/profiles/" + fileName; + } else if (fileName.startsWith("strings_")) { + pathToFile = projectFolder + "/translations/" + fileName; + } else { + pathToFile = projectFolder + "/" + fileName; + } + + String fileContent = FctUtils.readFile(pathToFile).getContent(); + return Base64.getEncoder().encodeToString(fileContent.getBytes(StandardCharsets.UTF_8)); + } + + ArrayList buildBinaries(String compositionFilePath, String projectFolder) + throws IOException { + FctFile compositionFile = FctUtils.readFile(compositionFilePath); + JSONObject compositionResource = new JSONObject(compositionFile.getContent()); + List> mapList = new ArrayList<>(); + Map detailsMap = new HashMap<>(); ArrayList resourceObjects = new ArrayList<>(); - boolean validateResourceBoolean = Boolean.parseBoolean(validateResource); - for (String f : resourceFiles) { - if (validateResourceBoolean) { - FctUtils.printInfo(String.format("Validating file \u001b[35m%s\u001b[0m", f)); - ValidateFhirResourcesCommand.validateFhirResources(f); - } else { - FctUtils.printInfo(String.format("Publishing \u001b[35m%s\u001b[0m Without Validation", f)); + if (compositionResource.has("section")) { + JSONArray compositionObjects = compositionResource.getJSONArray("section"); + + for (Object obj : compositionObjects) { + JSONObject jsonObject = new JSONObject(obj.toString()); + if (jsonObject.has("section")) { + JSONArray section = jsonObject.getJSONArray("section"); + for (Object subObj : section) { + JSONObject jo = new JSONObject(subObj.toString()); + detailsMap = getDetails(jo); + } + } else { + detailsMap = getDetails(jsonObject); + } + mapList.add(detailsMap); } - FctFile inputFile = FctUtils.readFile(f); - JSONObject resourceObject = buildResourceObject(inputFile); - resourceObjects.add(resourceObject); - } + for (Map e : mapList) { + String filename = e.get("name"); + String binaryContent = getBinaryContent(filename, projectFolder); + String contentType; - // build the bundle - JSONObject bundle = new JSONObject(); - bundle.put("resourceType", "Bundle"); - bundle.put("type", "transaction"); - bundle.put("entry", resourceObjects); - FctUtils.printToConsole("Full Payload to POST: "); - FctUtils.printToConsole(bundle.toString()); + if (filename.startsWith("strings_")) { + contentType = "text/plain"; + } else { + contentType = "application/json"; + } + + JSONObject binaryResourceObject = new JSONObject(); + binaryResourceObject.put("resourceType", "Binary"); + binaryResourceObject.put("id", e.get("reference").substring(7)); + binaryResourceObject.put("contentType", contentType); + binaryResourceObject.put("data", binaryContent); + JSONObject finalResourceObject = buildResourceObject(binaryResourceObject.toString()); + resourceObjects.add(finalResourceObject); + } + } + return resourceObjects; + } + + String getToken() { if (accessToken == null || accessToken.isBlank()) { if (clientId == null || clientId.isBlank()) { throw new IllegalArgumentException( @@ -217,7 +310,38 @@ void publishResources() throws IOException, ValidationException, GenerationExcep accessToken = getAccessToken(clientId, clientSecret, accessTokenUrl, grantType, username, password); } - postRequest(bundle.toString(), accessToken); + return accessToken; + } + + void buildBundle(ArrayList resourceObjects) throws IOException { + JSONObject bundle = new JSONObject(); + bundle.put("resourceType", "Bundle"); + bundle.put("type", "transaction"); + bundle.put("entry", resourceObjects); + FctUtils.printToConsole("Full Payload to POST: "); + FctUtils.printToConsole(bundle.toString()); + + postRequest(bundle.toString(), getToken()); + } + + void buildResources() throws IOException, ValidationException, GenerationException { + ArrayList resourceFiles = getResourceFiles(projectFolder); + ArrayList resourceObjects = new ArrayList<>(); + boolean validateResourceBoolean = Boolean.parseBoolean(validateResource); + + for (String f : resourceFiles) { + if (validateResourceBoolean) { + FctUtils.printInfo(String.format("Validating file \u001b[35m%s\u001b[0m", f)); + ValidateFhirResourcesCommand.validateFhirResources(f); + } else { + FctUtils.printInfo(String.format("Publishing \u001b[35m%s\u001b[0m Without Validation", f)); + } + + FctFile inputFile = FctUtils.readFile(f); + JSONObject resourceObject = buildResourceObject(inputFile.getContent()); + resourceObjects.add(resourceObject); + } + buildBundle(resourceObjects); } static ArrayList getResourceFiles(String pathToFolder) throws IOException { @@ -259,8 +383,8 @@ private static void addFhirResource(String filePath, List filesArray) { } } - JSONObject buildResourceObject(FctFile inputFile) { - JSONObject resource = new JSONObject(inputFile.getContent()); + JSONObject buildResourceObject(String fileContent) { + JSONObject resource = new JSONObject(fileContent); String resourceType = null; String resourceID; if (resource.has("resourceType")) { diff --git a/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java b/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java index 3011eb9e..2389b507 100644 --- a/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java +++ b/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashMap; import net.jimblackler.jsonschemafriend.GenerationException; import net.jimblackler.jsonschemafriend.ValidationException; import org.json.JSONObject; @@ -89,7 +90,8 @@ void testBuildResourceObject() throws IOException { writer.close(); FctFile testFile = FctUtils.readFile(resourceFile.toString()); - JSONObject resourceObject = publishFhirResourcesCommand.buildResourceObject(testFile); + JSONObject resourceObject = + publishFhirResourcesCommand.buildResourceObject(testFile.getContent()); // assert that object has request assertEquals( @@ -127,8 +129,8 @@ void testPublishResourcesValidationFalse() doNothing() .when(mockPublishFhirResourcesCommand) .postRequest(Mockito.anyString(), Mockito.anyString()); - doCallRealMethod().when(mockPublishFhirResourcesCommand).publishResources(); - mockPublishFhirResourcesCommand.publishResources(); + doCallRealMethod().when(mockPublishFhirResourcesCommand).buildResources(); + mockPublishFhirResourcesCommand.buildResources(); } System.setOut(System.out); String printedOutput = outputStream.toString().trim(); @@ -156,11 +158,124 @@ void testPublishResourcesValidationTrue() doNothing() .when(mockPublishFhirResourcesCommand) .postRequest(Mockito.anyString(), Mockito.anyString()); - doCallRealMethod().when(mockPublishFhirResourcesCommand).publishResources(); - mockPublishFhirResourcesCommand.publishResources(); + doCallRealMethod().when(mockPublishFhirResourcesCommand).buildResources(); + mockPublishFhirResourcesCommand.buildResources(); } System.setOut(System.out); String printedOutput = outputStream.toString().trim(); assertTrue(printedOutput.contains("Validating file")); } + + @Test + void testGetFileName() { + String profileFile = "householdProfile"; + assertEquals( + publishFhirResourcesCommand.getFileName(profileFile), "household_profile_config.json"); + + String registerFile = "sickChildRegister"; + assertEquals( + publishFhirResourcesCommand.getFileName(registerFile), "sick_child_register_config.json"); + + String translationFile = "strings_fr"; + assertEquals( + publishFhirResourcesCommand.getFileName(translationFile), "strings_fr_config.properties"); + + String basicFile = "navigation"; + assertEquals(publishFhirResourcesCommand.getFileName(basicFile), "navigation_config.json"); + } + + @Test + void testGetDetails() { + String obj = + "{" + + "\n \"title\": \"PNC register configuration\"," + + "\n \"focus\": {" + + "\n \"reference\": \"Binary/9aa6bbb6-df76-42a4-bdbe-72dc197378ca\"," + + "\n \"identifier\": {" + + "\n \"value\": \"pncRegister\"" + + "\n }}}"; + JSONObject testObject = new JSONObject(obj); + + HashMap result = new HashMap<>(); + result.put("reference", "Binary/9aa6bbb6-df76-42a4-bdbe-72dc197378ca"); + result.put("name", "pnc_register_config.json"); + assertEquals(publishFhirResourcesCommand.getDetails(testObject), result); + } + + @Test + void testGetBinaryContent() throws IOException { + String filename = "sample.json"; + Path testFolder = Files.createDirectory(tempDirectory.resolve("testBinaryFolder")); + Path resourceFile = Files.createFile(testFolder.resolve(filename)); + String sampleResource = + "{\"appId\":\"testApp\",\"configType\":\"application\",\"theme\":\"DEFAULT\",\"appTitle\":\"TestApp\"}"; + FileWriter writer = new FileWriter(String.valueOf(resourceFile)); + writer.write(sampleResource); + writer.flush(); + writer.close(); + + String actualResult = + publishFhirResourcesCommand.getBinaryContent(filename, String.valueOf(testFolder)); + String expectedResult = + "eyJhcHBJZCI6InRlc3RBcHAiLCJjb25maWdUeXBlIjoiYXBwbGljYXRpb24iLCJ0aGVtZSI6IkRFRkFVTFQiLCJhcHBUaXRsZSI6IlRlc3RBcHAifQo="; + assertEquals(expectedResult, actualResult); + } + + @Test + void testPublishBinaries() throws IOException { + Path testFolder = Files.createDirectory(tempDirectory.resolve("testPublishBinaryFolder")); + Path compositionFile = Files.createFile(testFolder.resolve("composition_config.json")); + Path applicationFile = Files.createFile(testFolder.resolve("application_config.json")); + Path profilesFolder = Files.createDirectory(testFolder.resolve("profiles")); + Path householdProfile = + Files.createFile(profilesFolder.resolve("household_profile_config.json")); + Path registersFolder = Files.createDirectory(testFolder.resolve("registers")); + Path ancRegister = Files.createFile(registersFolder.resolve("anc_register_config.json")); + + String compositionString = + "{\"resourceType\":\"Composition\",\"id\":\"bf131f26-5c94-4bd2-9c88-bfc673dcd27d\",\"section\":[{\"title\":\"Application configuration\",\"focus\":{\"reference\":\"Binary/98cc3379-454c-4820-bec3-06c1e0aee45e\",\"identifier\":{\"value\":\"application\"}}},{\"title\":\"Register configurations\",\"section\":[{\"title\":\"ANC register configuration\",\"focus\":{\"reference\":\"Binary/5321c50d-fd84-45ba-a1a9-7424c8f9e1fc\",\"identifier\":{\"value\":\"ancRegister\"}}}]},{\"title\":\"Profile configurations\",\"section\":[{\"title\":\"Household profile configuration\",\"focus\":{\"reference\":\"Binary/64c10d9b-ac39-4b85-8d00-f82e8f9f2211\",\"identifier\":{\"value\":\"householdProfile\"}}}]}]}"; + String applicationString = + "{\"appId\":\"test\",\"configType\":\"application\",\"theme\":\"DEFAULT\",\"appTitle\":\"TestApp\",\"useDarkTheme\":false}"; + String householdProfileString = + "{\"appId\":\"test\",\"configType\":\"profile\",\"id\":\"householdProfile\",\"fhirResource\":{\"baseResource\":{\"resource\":\"Group\"}}}"; + String ancRegisterString = + "{\"appId\":\"test\",\"configType\":\"register\",\"id\":\"ancRegister\",\"fhirResource\":{\"baseResource\":{\"resource\":\"Patient\"}}}"; + + FileWriter writer = new FileWriter(String.valueOf(compositionFile)); + writer.write(compositionString); + writer.flush(); + writer = new FileWriter(String.valueOf(applicationFile)); + writer.write(applicationString); + writer.flush(); + writer = new FileWriter(String.valueOf(householdProfile)); + writer.write(householdProfileString); + writer.flush(); + writer = new FileWriter(String.valueOf(ancRegister)); + writer.write(ancRegisterString); + writer.flush(); + writer.close(); + + ArrayList resources = + publishFhirResourcesCommand.buildBinaries( + String.valueOf(compositionFile), String.valueOf(testFolder)); + assertEquals(3, resources.size()); + assertEquals( + resources.get(0).getJSONObject("request").getString("url"), + "Binary/98cc3379-454c-4820-bec3-06c1e0aee45e"); + assertTrue( + resources + .get(1) + .getJSONObject("resource") + .getString("data") + .startsWith("eyJhcHBJZCI6InRlc3Qi")); + assertEquals( + resources + .get(2) + .getJSONObject("resource") + .getJSONObject("meta") + .getJSONArray("tag") + .getJSONObject(0) + .getString("code"), + "2.3.4-SNAPSHOT"); + } } From f17e47efef8dc44485f6c4048d2fce94b79967b2 Mon Sep 17 00:00:00 2001 From: Wambere Date: Sat, 8 Jun 2024 06:19:11 +0300 Subject: [PATCH 05/34] 161 link location inventories (#196) * Add support for linking locations to inventories * Update documentation * Formatting --- importer/README.md | 1 + importer/csv/import/inventory.csv | 4 +- .../inventory_location_list_payload.json | 33 +++ importer/main.py | 227 +++++++++++++----- importer/test_main.py | 171 ++++++++++++- 5 files changed, 375 insertions(+), 61 deletions(-) create mode 100644 importer/json_payloads/inventory_location_list_payload.json diff --git a/importer/README.md b/importer/README.md index 54cb6eab..c1dc4b4c 100644 --- a/importer/README.md +++ b/importer/README.md @@ -147,6 +147,7 @@ The coverage report `coverage.html` will be at the working directory - See example csv [here](/importer/csv/import/inventory.csv) - This creates a Group resource for each inventory imported - The first two columns __name__ and __active__ is the minimum required +- Adding a value to the Location column will create a separate List resource (or update) that links the inventory to the provided location resource ### 12. Import JSON resources from file - Run `python3 main.py --bulk_import True --json_file tests/fhir_sample.json --chunk_size 500000 --sync sort --resources_count 100 --log_level info` diff --git a/importer/csv/import/inventory.csv b/importer/csv/import/inventory.csv index 7e5c08af..3e53458f 100644 --- a/importer/csv/import/inventory.csv +++ b/importer/csv/import/inventory.csv @@ -1,2 +1,2 @@ -name,active,method,id,poNumber,serialNumber,usualId,actual,productId,deliveryDate,accountabilityDate,quantity,unicefSection,donor -Bishop Magua - Bed nets,true,create,8adfcfe0-41d0-4f0a-9a89-909c72fbf330,123523,989682,a065c211-cf3e-4b5b-972f-fdac0e45fef7,false,1d86d0e2-bac8-4424-90ae-e2298900ac3c,2024-02-01T00:00:00.00Z,2025-02-01T00:00:00.00Z,34,Health,GAVI \ No newline at end of file +name,active,method,id,poNumber,serialNumber,usualId,actual,productId,deliveryDate,accountabilityDate,quantity,unicefSection,donor,Location +Bishop Magua - Bed nets,true,create,8adfcfe0-41d0-4f0a-9a89-909c72fbf330,123523,989682,a065c211-cf3e-4b5b-972f-fdac0e45fef7,false,1d86d0e2-bac8-4424-90ae-e2298900ac3c,2024-02-01T00:00:00.00Z,2025-02-01T00:00:00.00Z,34,Health,GAVI,18fcbc2e-4240-4a84-a270-7a444523d7b6 \ No newline at end of file diff --git a/importer/json_payloads/inventory_location_list_payload.json b/importer/json_payloads/inventory_location_list_payload.json new file mode 100644 index 00000000..80a4179c --- /dev/null +++ b/importer/json_payloads/inventory_location_list_payload.json @@ -0,0 +1,33 @@ +{ + "resourceType": "List", + "id": "$id", + "status": "current", + "title": "$title", + "code": { + "coding": [ + { + "system" : "http://smartregister.org/codes", + "code" : "22138876", + "display": "Supply Inventory List" + } + ], + "text": "Supply Inventory List" + }, + "subject": {"reference": "Location/$location_id"}, + "entry": [ + { + "flag": { + "coding": [ + { + "system" : "http://smartregister.org/codes", + "code" : "22138876" , + "display": "Supply Inventory List" + } + ], + "text": "Supply Inventory List" + }, + "date": "$supply_date", + "item": {"reference": "Group/$inventory_id"} + } + ] +} \ No newline at end of file diff --git a/importer/main.py b/importer/main.py index dc2f8640..0923336c 100644 --- a/importer/main.py +++ b/importer/main.py @@ -642,6 +642,7 @@ def group_extras(resource, payload_string, group_type): quantity, unicef_section, donor, + location, ) = resource if active: @@ -756,89 +757,170 @@ def extract_matches(resource_list): return teamMap -def build_assign_payload(rows, resource_type): - initial_string = """{"resourceType": "Bundle","type": "transaction","entry": [ """ - final_string = "" - for row in rows: - practitioner_name, practitioner_id, organization_name, organization_id = row +def update_practitioner_role(resource, organization_id, organization_name): + try: + resource["organization"]["reference"] = "Organization/" + organization_id + resource["organization"]["display"] = organization_name + except KeyError: + org = { + "organization": { + "reference": "Organization/" + organization_id, + "display": organization_name, + } + } + resource.update(org) + return resource + + +def update_list(resource, inventory_id, supply_date): + with open("json_payloads/inventory_location_list_payload.json") as json_file: + payload_string = json_file.read() - # check if already exists + payload_string = (payload_string.replace("$supply_date", supply_date) + .replace("$inventory_id", inventory_id)) + json_payload = json.loads(payload_string) + + try: + entries = resource["entry"] + if inventory_id not in str(entries): + entry = json_payload["entry"][0] + entries.append(entry) + + except KeyError: + entry = {"entry": json_payload["entry"]} + resource.update(entry) + return resource + + +def create_new_practitioner_role( + new_id, practitioner_name, practitioner_id, organization_name, organization_id +): + with open("json_payloads/practitioner_organization_payload.json") as json_file: + payload_string = json_file.read() + + payload_string = ( + payload_string.replace("$id", new_id) + .replace("$practitioner_id", practitioner_id) + .replace("$practitioner_name", practitioner_name) + .replace("$organization_id", organization_id) + .replace("$organization_name", organization_name) + ) + resource = json.loads(payload_string) + return resource + + +def create_new_list(new_id, location_id, inventory_id, title, supply_date): + with open("json_payloads/inventory_location_list_payload.json") as json_file: + payload_string = json_file.read() + + payload_string = ( + payload_string.replace("$id", new_id) + .replace("$title", title) + .replace("$location_id", location_id) + .replace("$supply_date", supply_date) + .replace("$inventory_id", inventory_id) + ) + resource = json.loads(payload_string) + return resource + + +def check_resource(subject, entries, resource_type, url_filter): + if subject not in entries.keys(): base_url = get_base_url() check_url = ( - base_url - + "/" - + resource_type - + "/_search?_count=1&practitioner=Practitioner/" - + practitioner_id + base_url + "/" + resource_type + "/_search?_count=1&" + url_filter + subject ) response = handle_request("GET", "", check_url) json_response = json.loads(response[0]) + entries[subject] = json_response + + return entries + + +def build_assign_payload(rows, resource_type, url_filter): + bundle = {"resourceType": "Bundle", "type": "transaction", "entry": []} + + subject_id = item_id = organization_name = practitioner_name = inventory_name = ( + supply_date + ) = resource_id = version = "" + entries = {} + resource = {} + results = {} + + for row in rows: + if resource_type == "List": + # inventory_name, inventory_id, supply_date, location_id + inventory_name, item_id, supply_date, subject_id = row + if resource_type == "PractitionerRole": + # practitioner_name, practitioner_id, organization_name, organization_id + practitioner_name, subject_id, organization_name, item_id = row + + get_content = check_resource(subject_id, entries, resource_type, url_filter) + json_response = get_content[subject_id] + if json_response["total"] == 1: logging.info("Updating existing resource") resource = json_response["entry"][0]["resource"] - try: - resource["organization"]["reference"] = ( - "Organization/" + organization_id + if resource_type == "PractitionerRole": + resource = update_practitioner_role( + resource, item_id, organization_name ) - resource["organization"]["display"] = organization_name - except KeyError: - org = { - "organization": { - "reference": "Organization/" + organization_id, - "display": organization_name, - } - } - resource.update(org) + if resource_type == "List": + resource = update_list(resource, item_id, supply_date) - version = resource["meta"]["versionId"] - practitioner_role_id = resource["id"] - del resource["meta"] + if "meta" in resource: + version = resource["meta"]["versionId"] + resource_id = resource["id"] + del resource["meta"] elif json_response["total"] == 0: logging.info("Creating a new resource") - - # generate a new id - new_id = str( - uuid.uuid5(uuid.NAMESPACE_DNS, practitioner_id + organization_id) - ) - - with open( - "json_payloads/practitioner_organization_payload.json" - ) as json_file: - payload_string = json_file.read() - - # replace the variables in payload - payload_string = ( - payload_string.replace("$id", new_id) - .replace("$practitioner_id", practitioner_id) - .replace("$practitioner_name", practitioner_name) - .replace("$organization_id", organization_id) - .replace("$organization_name", organization_name) - ) + resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, subject_id + item_id)) + + if resource_type == "PractitionerRole": + resource = create_new_practitioner_role( + resource_id, + practitioner_name, + subject_id, + organization_name, + item_id, + ) + if resource_type == "List": + resource = create_new_list( + resource_id, subject_id, item_id, inventory_name, supply_date + ) version = "1" - practitioner_role_id = new_id - resource = json.loads(payload_string) + + try: + resource["entry"] = ( + entries[subject_id]["resource"]["resource"]["entry"] + + resource["entry"] + ) + except KeyError: + logging.debug("No existing entries") else: - raise ValueError( - "The number of practitioner references should only be 0 or 1" - ) + raise ValueError("The number of references should only be 0 or 1") payload = { "request": { "method": "PUT", - "url": resource_type + "/" + practitioner_role_id, + "url": resource_type + "/" + resource_id, "ifMatch": version, }, "resource": resource, } - full_string = json.dumps(payload, indent=4) - final_string = final_string + full_string + "," + entries[subject_id]["resource"] = payload + results[subject_id] = payload - final_string = initial_string + final_string[:-1] + " ] } " - return final_string + final_entries = [] + for entry in results: + final_entries.append(results[entry]) + + bundle["entry"] = final_entries + return json.dumps(bundle, indent=4) def get_org_name(key, resource_list): @@ -996,6 +1078,31 @@ def build_payload(resource_type, resources, resource_payload_file): return final_string +def link_to_location(resource_list): + arr = [] + with click.progressbar( + resource_list, label="Progress::Linking inventory to location" + ) as link_locations_progress: + for resource in link_locations_progress: + try: + if resource[14]: + # name, inventory_id, supply_date, location_id + resource_link = [ + resource[0], + resource[3], + resource[9], + resource[14], + ] + arr.append(resource_link) + except IndexError: + logging.info("No location provided for " + resource[0]) + + if len(arr) > 0: + return build_assign_payload(arr, "List", "subject=Location/") + else: + return "" + + def confirm_keycloak_user(user): # Confirm that the keycloak user details are as expected user_username = str(user[2]).strip() @@ -1797,7 +1904,9 @@ def main( logging.info("Processing complete!") elif assign == "users-organizations": logging.info("Assigning practitioner to Organization") - json_payload = build_assign_payload(resource_list, "PractitionerRole") + json_payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) final_response = handle_request("POST", json_payload, config.fhir_base_url) logging.info("Processing complete!") elif setup == "roles": @@ -1825,6 +1934,12 @@ def main( "Group", resource_list, "json_payloads/inventory_group_payload.json" ) final_response = handle_request("POST", json_payload, config.fhir_base_url) + link_payload = link_to_location(resource_list) + if len(link_payload) > 0: + link_response = handle_request( + "POST", link_payload, config.fhir_base_url + ) + logging.info(link_response.text) else: logging.error("Unsupported request!") else: diff --git a/importer/test_main.py b/importer/test_main.py index e11fd9eb..b82dac74 100644 --- a/importer/test_main.py +++ b/importer/test_main.py @@ -766,7 +766,9 @@ def test_build_assign_payload_update_assigned_org( "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", ] ] - payload = build_assign_payload(resource_list, "PractitionerRole") + payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -824,7 +826,9 @@ def test_build_assign_payload_create_org_assignment( "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", ] ] - payload = build_assign_payload(resource_list, "PractitionerRole") + payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -862,7 +866,9 @@ def test_build_assign_payload_create_new_practitioner_role( "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", ] ] - payload = build_assign_payload(resource_list, "PractitionerRole") + payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -884,6 +890,165 @@ def test_build_assign_payload_create_new_practitioner_role( payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org" ) + @patch("main.handle_request") + @patch("main.get_base_url") + def test_build_assign_payload_create_new_link_location_to_inventory_list( + self, mock_get_base_url, mock_handle_request + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" + mock_response_data = {"resourceType": "Bundle", "total": 0} + string_response = json.dumps(mock_response_data) + mock_response = (string_response, 200) + mock_handle_request.return_value = mock_response + + resource_list = [ + [ + "Nairobi Inventory Items", + "e62a049f-8d48-456c-a387-f52e72c39c74", + "2024-06-01T10:40:10.111Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ] + ] + payload = build_assign_payload(resource_list, "List", "subject=List/") + payload_obj = json.loads(payload) + + self.assertIsInstance(payload_obj, dict) + self.assertEqual(payload_obj["resourceType"], "Bundle") + self.assertEqual(len(payload_obj["entry"]), 1) + + self.assertEqual( + payload_obj["entry"][0]["resource"]["title"], "Nairobi Inventory Items" + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["item"]["reference"], + "Group/e62a049f-8d48-456c-a387-f52e72c39c74", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["date"], + "2024-06-01T10:40:10.111Z", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["subject"]["reference"], + "Location/3af23539-850a-44ed-8fb1-d4999e2145ff", + ) + + @patch("main.handle_request") + @patch("main.get_base_url") + def test_build_assign_payload_update_location_with_new_inventory( + self, mock_get_base_url, mock_handle_request + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" + mock_response_data = { + "resourceType": "Bundle", + "total": 1, + "entry": [ + { + "resource": { + "resourceType": "List", + "id": "6d7d2e70-1c90-11db-861d-0242ac120002", + "meta": {"versionId": "2"}, + "subject": { + "reference": "Location/46bb8a3f-cf50-4cc2-b421-fe4f77c3e75d" + }, + "entry": [ + { + "item": { + "reference": "Group/f2734756-a6bb-4e90-bbc6-1c34f51d3d5c" + } + } + ], + } + } + ], + } + string_response = json.dumps(mock_response_data) + mock_response = (string_response, 200) + mock_handle_request.return_value = mock_response + + resource_list = [ + [ + "Nairobi Inventory Items", + "e62a049f-8d48-456c-a387-f52e72c39c74", + "2024-06-01T10:40:10.111Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ] + ] + + payload = build_assign_payload(resource_list, "List", "subject=List/") + payload_obj = json.loads(payload) + + self.assertIsInstance(payload_obj, dict) + self.assertEqual(payload_obj["resourceType"], "Bundle") + self.assertEqual(len(payload_obj["entry"]), 1) + + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["item"]["reference"], + "Group/f2734756-a6bb-4e90-bbc6-1c34f51d3d5c", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][1]["item"]["reference"], + "Group/e62a049f-8d48-456c-a387-f52e72c39c74", + ) + + @patch("main.handle_request") + @patch("main.get_base_url") + def test_build_assign_payload_create_new_link_location_to_inventory_list_with_multiples( + self, mock_get_base_url, mock_handle_request + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" + mock_response_data = {"resourceType": "Bundle", "total": 0} + string_response = json.dumps(mock_response_data) + mock_response = (string_response, 200) + mock_handle_request.return_value = mock_response + + resource_list = [ + [ + "Nairobi Inventory Items", + "e62a049f-8d48-456c-a387-f52e72c39c74", + "2024-06-01T10:40:10.111Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ], + [ + "Nairobi Inventory Items", + "a36b595c-68a7-4244-91d5-c64be23b1ebd", + "2024-06-05T30:30:30.264Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ], + [ + "Mombasa Inventory Items", + "c0666a5a-00f6-488c-9001-8630560b5810", + "2024-06-06T55:23:19.492Z", + "3cd687a4-a169-45b3-a939-0418470c29db", + ], + ] + payload = build_assign_payload(resource_list, "List", "subject=List/") + payload_obj = json.loads(payload) + + self.assertIsInstance(payload_obj, dict) + self.assertEqual(payload_obj["resourceType"], "Bundle") + self.assertEqual(len(payload_obj["entry"]), 2) + self.assertEqual(len(payload_obj["entry"][0]["resource"]["entry"]), 2) + self.assertEqual(len(payload_obj["entry"][1]["resource"]["entry"]), 1) + + self.assertEqual( + payload_obj["entry"][0]["resource"]["title"], "Nairobi Inventory Items" + ) + self.assertEqual( + payload_obj["entry"][1]["resource"]["title"], "Mombasa Inventory Items" + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["item"]["reference"], + "Group/e62a049f-8d48-456c-a387-f52e72c39c74", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][1]["item"]["reference"], + "Group/a36b595c-68a7-4244-91d5-c64be23b1ebd", + ) + self.assertEqual( + payload_obj["entry"][1]["resource"]["entry"][0]["item"]["reference"], + "Group/c0666a5a-00f6-488c-9001-8630560b5810", + ) + @patch("main.logging") @patch("main.handle_request") @patch("main.get_keycloak_url") From 3cb70b357052108c5243c732f7591686d4e9d980 Mon Sep 17 00:00:00 2001 From: Ephraim Kigamba <31766075+ekigamba@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:53:50 +0300 Subject: [PATCH 06/34] Add StructureMap tool code (#16) * Add StructureMap tool code - StructureMap tool allows one to create a StructureMap from an XLS file * Code cleanup * Add todos for final structuremap presentation * Fix entity variable in StructureMap generation * Fix resourceType inference on the StructureMap tool * Fix bugs and add TODOs - Update remaining TODO tasks and comments - Add resource extraction for testing - Add RiskAssessmentPrediction entity create support with reflection class name - Fix group names - Fix datatype of constant value mapping - Fix dollar sign escaping when mapping QR values to resource elements - Fix bug with empty conversion values * Skip assignments with empty literals during smap generation * Fix property assignment from QR nested items - Fix assigning Coding to an Enumeration element * Draft commit: :hammer: * Draft commit: :hammer: * Convert literal to datatypes (#60) * Draft commit: :hammer: * Add conversion of data types to FHIR --------- Co-authored-by: Ephraim Kigamba * change root folder * Add questionnaire response path extraction * modified old xls * fix old xls not showing structure map * fix old sm patient not generating text sm * edit entering of file path to file name * modified old xls * validates the questionnaire response item id * Write a json file for the generated .map file Signed-off-by: Lentumunai-Mark * validates the resource name. * validates the resource name. * edits the resource name validation * fixes merge conflicts * Clean up * fix resource name validation for generatingGroup function * fix resource name validation for generatingGroup function * adding the xls * setting ref up * provide reference for generated resource WIP * edit sm xls * completes reference generation for a given resource * traces why observation reference is not generated * include resource to reference on xls * include encounter * include patient * add second option to reference resource * removes wild card import and revert file back the file input * minor fixes --------- Signed-off-by: Lentumunai-Mark Co-authored-by: sharon2719 Co-authored-by: Sharon Akinyi <79141719+sharon2719@users.noreply.github.com> Co-authored-by: Peter Lubell-Doughtie Co-authored-by: brandy-kay Co-authored-by: Sebastian <36365043+SebaMutuku@users.noreply.github.com> Co-authored-by: Lentumunai-Mark Co-authored-by: Francis Odhiambo <4540684+f-odhiambo@users.noreply.github.com> --- README.md | 1 + sm-gen/.gitignore | 8 + sm-gen/build.gradle.kts | 55 + sm-gen/gradle.properties | 1 + sm-gen/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + sm-gen/gradlew | 234 ++ sm-gen/gradlew.bat | 89 + sm-gen/local.properties | 8 + sm-gen/settings.gradle.kts | 3 + .../FhirPathEngineHostServices.kt | 58 + .../Main.kt | 338 ++ .../TransformSupportServices.kt | 72 + .../Utils.kt | 521 +++ .../resources/.~lock.StructureMap XLS.xlsx# | 1 + .../main/resources/StructureMap XLS-old.xlsx | Bin 0 -> 12537 bytes .../src/main/resources/StructureMap XLS.xls | Bin 0 -> 12451 bytes .../src/main/resources/StructureMap XLS.xlsx | Bin 0 -> 9086 bytes .../resources/questionnaire-response.json | 184 + sm-gen/src/main/resources/questionnaire.json | 691 ++++ .../src/main/resources/test/child_reg_qr.json | 345 ++ .../main/resources/test/child_regs_sm.json | 3274 +++++++++++++++++ .../test/questionnaire-response.json | 184 + .../main/resources/test/questionnaire.json | 691 ++++ 24 files changed, 6763 insertions(+) create mode 100644 sm-gen/.gitignore create mode 100644 sm-gen/build.gradle.kts create mode 100644 sm-gen/gradle.properties create mode 100644 sm-gen/gradle/wrapper/gradle-wrapper.jar create mode 100644 sm-gen/gradle/wrapper/gradle-wrapper.properties create mode 100644 sm-gen/gradlew create mode 100644 sm-gen/gradlew.bat create mode 100644 sm-gen/local.properties create mode 100644 sm-gen/settings.gradle.kts create mode 100644 sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/FhirPathEngineHostServices.kt create mode 100644 sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt create mode 100644 sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/TransformSupportServices.kt create mode 100644 sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt create mode 100644 sm-gen/src/main/resources/.~lock.StructureMap XLS.xlsx# create mode 100644 sm-gen/src/main/resources/StructureMap XLS-old.xlsx create mode 100644 sm-gen/src/main/resources/StructureMap XLS.xls create mode 100644 sm-gen/src/main/resources/StructureMap XLS.xlsx create mode 100644 sm-gen/src/main/resources/questionnaire-response.json create mode 100644 sm-gen/src/main/resources/questionnaire.json create mode 100644 sm-gen/src/main/resources/test/child_reg_qr.json create mode 100644 sm-gen/src/main/resources/test/child_regs_sm.json create mode 100644 sm-gen/src/main/resources/test/questionnaire-response.json create mode 100644 sm-gen/src/main/resources/test/questionnaire.json diff --git a/README.md b/README.md index ff97ccaf..fa4e1fc1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A repo to hold our FHIR content and configuration creation tools and scripts. - [cleaner](https://github.com/onaio/fhircore-tooling/tree/main/cleaner) - [efsity](https://github.com/onaio/fhircore-tooling/tree/main/efsity) - [importer](https://github.com/onaio/fhircore-tooling/tree/main/importer) +- [sm-gen](https://github.com/onaio/fhircore-tooling/tree/main/sm-gen) - [uploader](https://github.com/onaio/fhircore-tooling/tree/main/uploader) ## License diff --git a/sm-gen/.gitignore b/sm-gen/.gitignore new file mode 100644 index 00000000..b6a7eec4 --- /dev/null +++ b/sm-gen/.gitignore @@ -0,0 +1,8 @@ +build/ +**/build +.idea/ +.gradle/ +generated-structure-map.txt +generated-json-map.txt +generated-json-map.json +.~lock.StructureMap XLS.xlsx# \ No newline at end of file diff --git a/sm-gen/build.gradle.kts b/sm-gen/build.gradle.kts new file mode 100644 index 00000000..dae53268 --- /dev/null +++ b/sm-gen/build.gradle.kts @@ -0,0 +1,55 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.gradle.jvm.tasks.Jar + +plugins { + kotlin("jvm") version "1.6.21" +} + +group = "org.example" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(kotlin("test")) + implementation("com.github.ajalt.clikt:clikt:3.4.0") + implementation("org.apache.poi:poi:3.17") + implementation("org.apache.poi:poi-ooxml:3.17") + implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:5.4.0") + implementation("ca.uhn.hapi.fhir:hapi-fhir-validation:5.4.0") + implementation(kotlin("stdlib-jdk8")) +} + +tasks.test { + useJUnitPlatform() +} + +tasks.withType { + kotlinOptions.jvmTarget = "1.8" +} + +val fatJar = task("fatJar", type = Jar::class) { + baseName = "${project.name}-fat" + // manifest Main-Class attribute is optional. + // (Used only to provide default main class for executable jar) + manifest { + attributes["Main-Class"] = "example.HelloWorldKt" // fully qualified class name of default main class + } + from(configurations.compileClasspath.get().map({ if (it.isDirectory) it else zipTree(it) })) + with(tasks["jar"] as CopySpec) +} + +tasks { + "build" { + dependsOn(fatJar) + } +} + +kotlin { + jvmToolchain { + (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(11)) + } +} + diff --git a/sm-gen/gradle.properties b/sm-gen/gradle.properties new file mode 100644 index 00000000..7fc6f1ff --- /dev/null +++ b/sm-gen/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/sm-gen/gradle/wrapper/gradle-wrapper.jar b/sm-gen/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7454180f2ae8848c63b8b4dea2cb829da983f2fa GIT binary patch literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 0 HcmV?d00001 diff --git a/sm-gen/gradle/wrapper/gradle-wrapper.properties b/sm-gen/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..fe753c9d --- /dev/null +++ b/sm-gen/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/sm-gen/gradlew b/sm-gen/gradlew new file mode 100644 index 00000000..1b6c7873 --- /dev/null +++ b/sm-gen/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/sm-gen/gradlew.bat b/sm-gen/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/sm-gen/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sm-gen/local.properties b/sm-gen/local.properties new file mode 100644 index 00000000..6bda6f38 --- /dev/null +++ b/sm-gen/local.properties @@ -0,0 +1,8 @@ +## This file must *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +#Thu Sep 07 16:05:45 EAT 2023 +sdk.dir=/home/ona/Android/Sdk diff --git a/sm-gen/settings.gradle.kts b/sm-gen/settings.gradle.kts new file mode 100644 index 00000000..91efe3f5 --- /dev/null +++ b/sm-gen/settings.gradle.kts @@ -0,0 +1,3 @@ + +rootProject.name = "structure-map-tool" + diff --git a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/FhirPathEngineHostServices.kt b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/FhirPathEngineHostServices.kt new file mode 100644 index 00000000..918cb767 --- /dev/null +++ b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/FhirPathEngineHostServices.kt @@ -0,0 +1,58 @@ +package org.smartregister.fhir.structuremaptool + + +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.TypeDetails +import org.hl7.fhir.r4.model.ValueSet +import org.hl7.fhir.r4.utils.FHIRPathEngine + +/* +* Resolves constants defined in the fhir path expressions beyond those defined in the specification +*/ +internal object FHIRPathEngineHostServices : FHIRPathEngine.IEvaluationContext { + override fun resolveConstant(appContext: Any?, name: String?, beforeContext: Boolean): Base? = + (appContext as? Map<*, *>)?.get(name) as? Base + + override fun resolveConstantType(appContext: Any?, name: String?): TypeDetails { + throw UnsupportedOperationException() + } + + override fun log(argument: String?, focus: MutableList?): Boolean { + throw UnsupportedOperationException() + } + + override fun resolveFunction( + functionName: String? + ): FHIRPathEngine.IEvaluationContext.FunctionDetails { + throw UnsupportedOperationException() + } + + override fun checkFunction( + appContext: Any?, + functionName: String?, + parameters: MutableList? + ): TypeDetails { + throw UnsupportedOperationException() + } + + override fun executeFunction( + appContext: Any?, + focus: MutableList?, + functionName: String?, + parameters: MutableList>? + ): MutableList { + throw UnsupportedOperationException() + } + + override fun resolveReference(appContext: Any?, url: String?): Base { + throw UnsupportedOperationException() + } + + override fun conformsToProfile(appContext: Any?, item: Base?, url: String?): Boolean { + throw UnsupportedOperationException() + } + + override fun resolveValueSet(appContext: Any?, url: String?): ValueSet { + throw UnsupportedOperationException() + } +} \ No newline at end of file diff --git a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt new file mode 100644 index 00000000..272e90b2 --- /dev/null +++ b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt @@ -0,0 +1,338 @@ +package org.smartregister.fhir.structuremaptool + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.parser.IParser +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.prompt +import org.apache.commons.io.FileUtils +import org.apache.poi.ss.usermodel.CellType +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.WorkbookFactory +import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.utilities.npm.FilesystemPackageCacheManager +import org.hl7.fhir.utilities.npm.ToolsVersion +import java.io.File +import java.io.FileInputStream +import java.nio.charset.Charset +import java.util.* + +fun main(args: Array) { + Application().main(args) +} + +/*fun main(args: Array) { + val values = hashMapOf(Pair("username", "Bilira"), Pair("client_id", "uBr6UUy5VprEcvCnWndSWcALKivttaqk25")) + val digest = MessageDigest.getInstance("MD5") + val bytes = digest.digest(values.toString().toByteArray(charset("UTF-8"))) + val answ = String.format("%032x", BigInteger(1, bytes)) + System.out.println(answ) + +}*/ + + +/* + +REMAINING TASKS +================== + +1. Add support for processing "StructureMap XLS-old.xlsx" file. Allow multiple similar properties eg multiple RelatedPerson.telecom + + */ + +class Application : CliktCommand() { + val xlsfile: String by option(help = "XLS filepath").prompt("Kindly enter the XLS filepath") + val questionnairefile : String by option(help = "Questionnaire filepath").prompt("Kindly enter the questionnaire filepath") + + + override fun run() { + // Create a map of Resource -> questionnaire name or path -> value + // For each resource loop through creating or adding the correct instructions + + lateinit var questionnaireResponse: QuestionnaireResponse + val contextR4 = FhirContext.forR4() + val fhirJsonParser = contextR4.newJsonParser() + val questionnaire : Questionnaire = fhirJsonParser.parseResource(Questionnaire::class.java, FileUtils.readFileToString(File(questionnairefile), Charset.defaultCharset())) + val questionnaireResponseFile = File(javaClass.classLoader.getResource("questionnaire-response.json")?.file.toString()) + if (questionnaireResponseFile.exists()) { + questionnaireResponse = fhirJsonParser.parseResource(QuestionnaireResponse::class.java, questionnaireResponseFile.readText(Charset.defaultCharset())) + } else { + println("File not found: questionnaire-response.json") + } + + // reads the xls + val xlsFile = FileInputStream(xlsfile) + val xlWb = WorkbookFactory.create(xlsFile) + + + // TODO: Check that all the Resource(s) ub the Resource column are the correct name and type eg. RiskFlag in the previous XLSX was not valid + // TODO: Check that all the path's and other entries in the excel sheet are valid + // TODO: Add instructions for adding embedded classes like `RiskAssessment$RiskAssessmentPredictionComponent` to the TransformSupportServices + + /* + + READ THE SETTINGS SHEET + + */ + val settingsWorkbook = xlWb.getSheet("Settings") + var questionnaireId : String? = null + + for (i in 0..settingsWorkbook.lastRowNum) { + val cell = settingsWorkbook.getRow(i).getCell(0) + + if (cell.stringCellValue == "questionnaire-id") { + questionnaireId = settingsWorkbook.getRow(i).getCell(1).stringCellValue + } + } + + /* + + END OF READ SETTINGS SHEET + + */ + + /* + TODO: Fix Groups calling sequence so that Groups that depend on other resources to be generated need to be called first + We can also throw an exception if to figure out cyclic dependency. Good candidate for Floyd's tortoise and/or topological sorting ðŸ˜. Cool!!!! + */ + val questionnaireResponseItemIds = questionnaireResponse.item.map { it.id } + if(questionnaireId != null && questionnaireResponseItemIds.isNotEmpty()){ + + val sb = StringBuilder() + val structureMapHeader = """ + map "http://hl7.org/fhir/StructureMap/$questionnaireId" = '${questionnaireId.clean()}' + + + uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireReponse" as source + uses "http://hl7.org/fhir/StructureDefinition/Bundle" as target + """.trimIndent() + + val structureMapBody = """ + group ${questionnaireId.clean()}(source src : QuestionnaireResponse, target bundle: Bundle) { + src -> bundle.id = uuid() "rule_c"; + src -> bundle.type = 'collection' "rule_b"; + src -> bundle.entry as entry then """.trimIndent() + + /* + + Create a mapping of COLUMN_NAMES to COLUMN indexes + + */ + //val mapColumns + + + val lineNos = 1 + var firstResource = true + val extractionResources = hashMapOf() + val resourceConversionInstructions = hashMapOf>() + + // Group the rules according to the resource + val fieldMappingsSheet = xlWb.getSheet("Field Mappings") + fieldMappingsSheet.forEachIndexed { index, row -> + if (index == 0) return@forEachIndexed + + if (row.isEmpty()) { + return@forEachIndexed + } + + + val instruction = row.getInstruction() + val xlsId = instruction.responseFieldId + val comparedResponseAndXlsId = questionnaireResponseItemIds.contains(xlsId) + if (instruction.resource.isNotEmpty() && comparedResponseAndXlsId) { + resourceConversionInstructions.computeIfAbsent(instruction.searchKey(), { key -> mutableListOf() }) + .add(instruction) + } + } + //val resource = ?: Class.forName("org.hl7.fhir.r4.model.$resourceName").newInstance() as Resource + + + // Perform the extraction for the row + /*generateStructureMapLine(structureMapBody, row, resource, extractionResources) + + extractionResources[resourceName + resourceIndex] = resource*/ + + sb.append(structureMapHeader) + sb.appendNewLine().appendNewLine().appendNewLine() + sb.append(structureMapBody) + + // Fix the questions path + val questionsPath = getQuestionsPath(questionnaire) + + // TODO: Generate the links to the group names here + var index = 0 + var len = resourceConversionInstructions.size + var resourceName = "" + resourceConversionInstructions.forEach { entry -> + resourceName = entry.key.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + if (index++ != 0) sb.append(",") + if(resourceName.isNotEmpty()) sb.append("Extract$resourceName(src, bundle)") + } + sb.append(""" "rule_a";""".trimMargin()) + sb.appendNewLine() + sb.append("}") + + // Add the embedded instructions + val groupNames = mutableListOf() + + sb.appendNewLine().appendNewLine().appendNewLine() + + resourceConversionInstructions.forEach { + Group(it, sb, questionsPath) + .generateGroup(questionnaireResponse) + } + + val structureMapString = sb.toString() + try { + val simpleWorkerContext = SimpleWorkerContext().apply { + setExpansionProfile(Parameters()) + isCanRunWithoutTerminology = true + } + val transformSupportServices = TransformSupportServices(simpleWorkerContext) + val scu = org.hl7.fhir.r4.utils.StructureMapUtilities(simpleWorkerContext, transformSupportServices) + val structureMap = scu.parse(structureMapString, questionnaireId.clean()) + // DataFormatException | FHIRLexerException + + try{ + val bundle = Bundle() + scu.transform(contextR4, questionnaireResponse, structureMap, bundle) + val jsonParser = FhirContext.forR4().newJsonParser() + + println(jsonParser.encodeResourceToString(bundle)) + } catch (e:Exception){ + e.printStackTrace() + } + + } catch (ex: Exception) { + println("The generated StructureMap has a formatting error") + ex.printStackTrace() + } + + var finalStructureMap = sb.toString() + finalStructureMap = finalStructureMap.addIdentation() + println(finalStructureMap) + writeStructureMapOutput(sb.toString().addIdentation()) + } + + } + + fun Row.getInstruction() : Instruction { + return Instruction().apply { + responseFieldId = getCell(0) ?.stringCellValue + constantValue = getCellAsString(1) + resource = getCell(2).stringCellValue + resourceIndex = getCell(3) ?.numericCellValue?.toInt() ?: 0 + fieldPath = getCell(4) ?.stringCellValue ?: "" + fullFieldPath = fieldPath + field = getCell(5) ?.stringCellValue + conversion = getCell(6) ?.stringCellValue + fhirPathStructureMapFunctions = getCell(7) ?.stringCellValue + } + } + + fun Row.getCellAsString(cellnum: Int) : String? { + val cell = getCell(cellnum) ?: return null + return when (cell.cellTypeEnum) { + CellType.STRING -> cell.stringCellValue + CellType.BLANK -> null + CellType.BOOLEAN -> cell.booleanCellValue.toString() + CellType.NUMERIC -> cell.numericCellValue.toString() + else -> null + } + } + + fun Row.isEmpty() : Boolean { + return getCell(0) == null && getCell(1) == null && getCell(2) == null + } + + fun String.clean() : String { + return this.replace("-", "") + .replace("_", "") + .replace(" ", "") + } + +} + +class Instruction { + var responseFieldId : String? = null + var constantValue: String? = null + var resource: String = "" + var resourceIndex: Int = 0 + var fieldPath: String = "" + var field: String? = null + var conversion : String? = null + var fhirPathStructureMapFunctions: String? = null + + + // TODO: Clean the following properties + var fullFieldPath = "" + fun fullPropertyPath() : String = "$resource.$fullFieldPath" + + fun searchKey() = resource + resourceIndex +} + +fun Instruction.copyFrom(instruction: Instruction) { + constantValue = instruction.constantValue + resource = instruction.resource + resourceIndex = instruction.resourceIndex + fieldPath = instruction.fieldPath + fullFieldPath = instruction.fullFieldPath + field = instruction.field + conversion = instruction.conversion + fhirPathStructureMapFunctions = instruction.fhirPathStructureMapFunctions +} + + +fun String.addIdentation() : String { + var currLevel = 0 + + val lines = split("\n") + + val sb = StringBuilder() + lines.forEach { line -> + if (line.endsWith("{")) { + sb.append(line.addIdentation(currLevel)) + sb.appendNewLine() + currLevel++ + } else if (line.startsWith("}")) { + currLevel-- + sb.append(line.addIdentation(currLevel)) + sb.appendNewLine() + } else { + sb.append(line.addIdentation(currLevel)) + sb.appendNewLine() + } + } + + return sb.toString() +} + +fun String.addIdentation(times: Int) : String { + var processedString = "" + for (k in 1..times) { + processedString += "\t" + } + + processedString += this + return processedString +} + +fun writeStructureMapOutput( structureMap: String){ + File("generated-structure-map.txt").writeText(structureMap.addIdentation()) + val pcm = FilesystemPackageCacheManager(true, ToolsVersion.TOOLS_VERSION) + val contextR5 = SimpleWorkerContext.fromPackage(pcm.loadPackage("hl7.fhir.r4.core", "4.0.1")) + contextR5.setExpansionProfile(Parameters()) + contextR5.isCanRunWithoutTerminology = true + val transformSupportServices = TransformSupportServices(contextR5) + val scu = org.hl7.fhir.r4.utils.StructureMapUtilities(contextR5, transformSupportServices) + val map = scu.parse(structureMap, "LocationRegistration") + val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser().setPrettyPrint(true) + val mapString = iParser.encodeResourceToString(map) + File("generated-json-map.json").writeText(mapString) +} diff --git a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/TransformSupportServices.kt b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/TransformSupportServices.kt new file mode 100644 index 00000000..70a007f4 --- /dev/null +++ b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/TransformSupportServices.kt @@ -0,0 +1,72 @@ +package org.smartregister.fhir.structuremaptool + +import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.utils.StructureMapUtilities.ITransformerServices + +import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Encounter +import org.hl7.fhir.r4.model.EpisodeOfCare +import org.hl7.fhir.r4.model.Group +import org.hl7.fhir.r4.model.Immunization +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.PlanDefinition +import org.hl7.fhir.r4.model.ResourceFactory +import org.hl7.fhir.r4.model.RiskAssessment.RiskAssessmentPredictionComponent +import org.hl7.fhir.r4.model.Timing +import org.hl7.fhir.r4.terminologies.ConceptMapEngine + +class TransformSupportServices constructor(val simpleWorkerContext: SimpleWorkerContext) : + ITransformerServices { + + val outputs: MutableList = mutableListOf() + + override fun log(message: String) { + System.out.println(message) + } + + @Throws(FHIRException::class) + override fun createType(appInfo: Any, name: String): Base { + return when (name) { + "RiskAssessment_Prediction" -> RiskAssessmentPredictionComponent() + "RiskAssessment\$RiskAssessmentPredictionComponent" -> RiskAssessmentPredictionComponent() + "Immunization_VaccinationProtocol" -> Immunization.ImmunizationProtocolAppliedComponent() + "Immunization_Reaction" -> Immunization.ImmunizationReactionComponent() + "EpisodeOfCare_Diagnosis" -> EpisodeOfCare.DiagnosisComponent() + "Encounter_Diagnosis" -> Encounter.DiagnosisComponent() + "Encounter_Participant" -> Encounter.EncounterParticipantComponent() + "CarePlan_Activity" -> CarePlan.CarePlanActivityComponent() + "CarePlan_ActivityDetail" -> CarePlan.CarePlanActivityDetailComponent() + "Patient_Link" -> Patient.PatientLinkComponent() + "Timing_Repeat" -> Timing.TimingRepeatComponent() + "PlanDefinition_Action" -> PlanDefinition.PlanDefinitionActionComponent() + "Group_Characteristic" -> Group.GroupCharacteristicComponent() + "Observation_Component" -> Observation.ObservationComponentComponent() + else -> ResourceFactory.createResourceOrType(name) + } + } + + override fun createResource(appInfo: Any, res: Base, atRootofTransform: Boolean): Base { + if (atRootofTransform) outputs.add(res) + return res + } + + @Throws(FHIRException::class) + override fun translate(appInfo: Any, source: Coding, conceptMapUrl: String): Coding { + val cme = ConceptMapEngine(simpleWorkerContext) + return cme.translate(source, conceptMapUrl) + } + + @Throws(FHIRException::class) + override fun resolveReference(appContext: Any, url: String): Base { + throw FHIRException("resolveReference is not supported yet") + } + + @Throws(FHIRException::class) + override fun performSearch(appContext: Any, url: String): List { + throw FHIRException("performSearch is not supported yet") + } +} diff --git a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt new file mode 100644 index 00000000..cdda9b4f --- /dev/null +++ b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt @@ -0,0 +1,521 @@ +package org.smartregister.fhir.structuremaptool + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import org.apache.poi.ss.usermodel.Row +import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext +import org.hl7.fhir.r4.model.Enumeration +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Type +import org.hl7.fhir.r4.utils.FHIRPathEngine +import java.lang.reflect.Field +import java.lang.reflect.ParameterizedType + +// Get the hl7 resources +val contextR4 = FhirContext.forR4() +val fhirResources = contextR4.resourceTypes +fun getQuestionsPath(questionnaire: Questionnaire): HashMap { + val questionsMap = hashMapOf() + + questionnaire.item.forEach { itemComponent -> + getQuestionNames("", itemComponent, questionsMap) + } + return questionsMap +} + +fun getQuestionNames(parentName: String, item: QuestionnaireItemComponent, questionsMap: HashMap) { + val currParentName = if (parentName.isEmpty()) "" else parentName + questionsMap.put(item.linkId, currParentName) + + item.item.forEach { itemComponent -> + getQuestionNames(currParentName + ".where(linkId = '${item.linkId}').item", itemComponent, questionsMap) + } +} + + +class Group( + entry: Map.Entry>, + val stringBuilder: StringBuilder, + val questionsPath: HashMap +) { + + var lineCounter = 0 + var groupName = entry.key + val instructions = entry.value + + private fun generateReference(resourceName: String, resourceIndex: String): String { + // Generate the reference based on the resourceName and resourceIndex + val sb = StringBuilder() + sb.append("create('Reference') as reference then {") + sb.appendNewLine() + sb.append("src-> reference.reference = evaluate(bundle, \$this.entry.where(resourceType = '$resourceName/$resourceIndex'))") + sb.append(""" "rule_d";""".trimMargin()) + sb.appendNewLine() + sb.append("}") + return sb.toString() + } + + fun generateGroup(questionnaireResponse: QuestionnaireResponse) { + if(fhirResources.contains(groupName.dropLast(1))){ + val resourceName = instructions[0].resource + + // add target of reference to function if reference is not null + val structureMapFunctionHead = "group Extract$groupName(source src : QuestionniareResponse, target bundle: Bundle) {" + stringBuilder.appendNewLine() + stringBuilder.append(structureMapFunctionHead) + .appendNewLine() + stringBuilder.append("src -> bundle.entry as entry, entry.resource = create('$resourceName') as entity1 then {") + .appendNewLine() + + val mainNest = Nest() + mainNest.fullPath = "" + mainNest.name = "" + mainNest.resourceName = resourceName + + instructions.forEachIndexed { index, instruction -> + mainNest.add(instruction) + } + + mainNest.buildStructureMap(0, questionnaireResponse) + + + stringBuilder.append("} ") + addRuleNo() + stringBuilder.appendNewLine() + stringBuilder.append("}") + stringBuilder.appendNewLine() + stringBuilder.appendNewLine() + } else{ + println("$groupName is not a valid hl7 resource name") + } + } + + fun addRuleNo() { + stringBuilder.append(""" "${groupName}_${lineCounter++}"; """) + } + + fun Instruction.getPropertyPath(): String { + return questionsPath.getOrDefault(responseFieldId, "") + } + + fun Instruction.getAnswerExpression(questionnaireResponse: QuestionnaireResponse): String { + + //1. If the answer is static/literal, just return it here + // TODO: We should infer the resource element and add the correct conversion or code to assign this correctly + if (constantValue != null) { + return when { + fieldPath == "id" -> "create('id') as id, id.value = '$constantValue'" + fieldPath == "rank" -> { + val constValue = constantValue!!.replace(".0", "") + "create('positiveInt') as rank, rank.value = '$constValue'" + } + else -> "'$constantValue'" + } + } + + // 2. If the answer is from the QuestionnaireResponse, get the ID of the item in the "Questionnaire Response Field Id" and + // get its value using FHIR Path expressions + if (responseFieldId != null) { + // TODO: Fix the 1st param inside the evaluate expression + var expression = "${"$"}this.item${getPropertyPath()}.where(linkId = '$responseFieldId').answer.value" + // TODO: Fix these to use infer + if (fieldPath == "id" || fieldPath == "rank") { + expression = "create('${if (fieldPath == "id") "id" else "positiveInt"}') as $fieldPath, $fieldPath.value = evaluate(src, $expression)" + } else { + + // TODO: Infer the resource property type and answer to perform other conversions + // TODO: Extend this to cover other corner cases + if (expression.isCoding(questionnaireResponse) && fieldPath.isEnumeration(this)) { + expression = expression.replace("answer.value", "answer.value.code") + } else if (inferType(fullPropertyPath()) == "CodeableConcept") { + return "''" + } + expression = "evaluate(src, $expression)" + } + return expression + } + + // 3. If it's a FHIR Path/StructureMap function, add the contents directly from here to the StructureMap + if (fhirPathStructureMapFunctions != null && fhirPathStructureMapFunctions!!.isNotEmpty()) { + // TODO: Fix the 2nd param inside the evaluate expression --> Not sure what this is but check this + return fhirPathStructureMapFunctions!! + } + // 4. If the answer is a conversion, (Assume this means it's being converted to a reference) + if (conversion != null && conversion!!.isNotBlank() && conversion!!.isNotEmpty()) { + println("current resource to reference is $conversion") + + val resourceName = conversion!!.replace("$", "") + var resourceIndex = conversion!!.replace("$$resourceName", "") + if (resourceIndex.isNotEmpty()) { + resourceIndex = "[$resourceIndex]" + } + val reference = generateReference(resourceName = resourceName, resourceIndex = resourceIndex) + return reference + } + + /* + 5. You can use $Resource eg $Patient to reference another resource being extracted here, + but how do we actually get its instance so that we can use it???? - This should be handled elsewhere + */ + + return "''" + } + + + + inner class Nest { + var instruction: Instruction? = null + + // We can change this to a linked list + val nests = ArrayList() + lateinit var name: String + lateinit var fullPath: String + lateinit var resourceName: String + + fun add(instruction: Instruction) { + /*if (instruction.fieldPath.startsWith(fullPath)) { + + }*/ + val remainingPath = instruction.fieldPath.replace(fullPath, "") + + remainingPath.run { + if (contains(".")) { + val parts = split(".") + val partName = parts[0].ifEmpty { + parts[1] + } + + // Search for the correct property to put this nested property + nests.forEach { + if (partName.startsWith(it.name)) { + val nextInstruction = Instruction().apply { + copyFrom(instruction) + var newFieldPath = "" + parts.forEachIndexed { index, s -> + if (index != 0) { + newFieldPath += s + } + + if (index > 0 && index < parts.size - 1) { + newFieldPath += "." + } + } + + fieldPath = newFieldPath + } + + it.add(nextInstruction) + + return@run + } + } + + // If no match is found, let's create a new one + val newNest = Nest().apply { + name = partName + + fullPath = if (this@Nest.fullPath.isNotEmpty()) { + "${this@Nest.fullPath}.$partName" + } else { + partName + } + resourceName = inferType("${this@Nest.resourceName}.$fullPath") ?: "" + + if ((parts[0].isEmpty() && parts.size > 2) || (parts[0].isNotEmpty() && parts.size > 1)) { + val nextInstruction = Instruction().apply { + copyFrom(instruction) + var newFieldPath = "" + parts.forEachIndexed { index, s -> + if (index != 0) { + newFieldPath += s + } + } + + fieldPath = newFieldPath + } + add(nextInstruction) + } else { + this@apply.instruction = instruction + } + } + nests.add(newNest) + } else { + this@Nest.nests.add(Nest().apply { + name = remainingPath + fullPath = instruction.fieldPath + this@apply.instruction = instruction + resourceName = inferType("${this@Nest.resourceName}.$fullPath") ?: "" + }) + } + } + } + + fun buildStructureMap(currLevel: Int, questionnaireResponse: QuestionnaireResponse) { + if (instruction != null) { + val answerExpression = instruction?.getAnswerExpression(questionnaireResponse) + + if (answerExpression != null) { + if (answerExpression.isNotEmpty() && answerExpression.isNotBlank() && answerExpression != "''") { + val propertyType = inferType(instruction!!.fullPropertyPath()) + val answerType = answerExpression.getAnswerType(questionnaireResponse) + + if (propertyType != "Type" && answerType != propertyType && propertyType?.canHandleConversion( + answerType ?: "" + )?.not() == true && answerExpression.startsWith("evaluate") + ) { + println("Failed type matching --> ${instruction!!.fullPropertyPath()} of type $answerType != $propertyType") + stringBuilder.append("src -> entity$currLevel.${instruction!!.fieldPath} = ") + stringBuilder.append("create('${propertyType.getFhirType()}') as randomVal, randomVal.value = ") + stringBuilder.append(answerExpression) + addRuleNo() + stringBuilder.appendNewLine() + return + } + + stringBuilder.append("src -> entity$currLevel.${instruction!!.fieldPath} = ") + stringBuilder.append(answerExpression) + addRuleNo() + stringBuilder.appendNewLine() + } + } + } else if (nests.size > 0) { + //val resourceType = inferType("entity$currLevel.$name", instruction) + + if (!name.equals("")) { + val resourceType = resourceName + stringBuilder.append("src -> entity$currLevel.$name = create('$resourceType') as entity${currLevel + 1} then {") + stringBuilder.appendNewLine() + } else { + //stringBuilder.append("src -> entity$currLevel.$name = create('$resourceType') as entity${currLevel + 1} then {") + + } + + nests.forEach { + it.buildStructureMap(currLevel + 1, questionnaireResponse) + } + + //nest!!.buildStructureMap(currLevel + 1) + + if (!name.equals("")) { + stringBuilder.append("}") + addRuleNo() + } else { + //addRuleNo() + } + stringBuilder.appendNewLine() + } else { + throw Exception("nest & instruction are null inside Nest object") + } + } + } + +} + +fun generateStructureMapLine( + structureMapBody: StringBuilder, + row: Row, + resource: Resource, + extractionResources: HashMap +) { + row.forEachIndexed { index, cell -> + val cellValue = cell.stringCellValue + val fieldPath = row.getCell(4).stringCellValue + val targetDataType = determineFhirDataType(cellValue) + structureMapBody.append("src -> entity.${fieldPath}=") + + when (targetDataType) { + "string" -> { + structureMapBody.append("create('string').value ='$cellValue'") + } + + "integer" -> { + structureMapBody.append("create('integer').value = $cellValue") + } + + "boolean" -> { + val booleanValue = + if (cellValue.equals("true", ignoreCase = true)) "true" else "false" + structureMapBody.append("create('boolean').value = $booleanValue") + } + + else -> { + structureMapBody.append("create('unsupportedDataType').value = '$cellValue'") + } + } + structureMapBody.appendNewLine() + } +} + +fun determineFhirDataType(cellValue: String): String { + val cleanedValue = cellValue.trim().toLowerCase() + + when { + cleanedValue == "true" || cleanedValue == "false" -> return "boolean" + cleanedValue.matches(Regex("-?\\d+")) -> return "boolean" + cleanedValue.matches(Regex("-?\\d*\\.\\d+")) -> return "decimal" + cleanedValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}")) -> return "date" + cleanedValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}")) -> return "dateTime" + else -> { + return "string" + } + } +} + +fun StringBuilder.appendNewLine(): StringBuilder { + append(System.lineSeparator()) + return this +} + + +private val Field.isList: Boolean + get() = isParameterized && type == List::class.java + +private val Field.isParameterized: Boolean + get() = genericType is ParameterizedType + +/** The non-parameterized type of this field (e.g. `String` for a field of type `List`). */ +private val Field.nonParameterizedType: Class<*> + get() = + if (isParameterized) (genericType as ParameterizedType).actualTypeArguments[0] as Class<*> + else type + +private fun Class<*>.getFieldOrNull(name: String): Field? { + return try { + getDeclaredField(name) + } catch (ex: NoSuchFieldException) { + superclass?.getFieldOrNull(name) + } +} + +private fun String.isCoding(questionnaireResponse: QuestionnaireResponse): Boolean { + val answerType = getType(questionnaireResponse) + return if (answerType != null) { + answerType == "org.hl7.fhir.r4.model.Coding" + } else { + false + } +} + +private fun String.getType(questionnaireResponse: QuestionnaireResponse): String? { + val answer = fhirPathEngine.evaluate(questionnaireResponse, this) + + return answer.firstOrNull()?.javaClass?.name +} + + +internal val fhirPathEngine: FHIRPathEngine = + with(FhirContext.forCached(FhirVersionEnum.R4)) { + FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)).apply { + hostServices = FHIRPathEngineHostServices + } + } + +private fun String.isEnumeration(instruction: Instruction): Boolean { + return inferType(instruction.fullPropertyPath())?.contains("Enumeration") ?: false +} + + +fun String.getAnswerType(questionnaireResponse: QuestionnaireResponse): String? { + return if (isEvaluateExpression()) { + val fhirPath = substring(indexOf(",") + 1, length - 1) + + fhirPath.getType(questionnaireResponse) + ?.replace("org.hl7.fhir.r4.model.", "") + } else { + // TODO: WE can run the actual line against StructureMapUtilities.runTransform to get the actual one that is generated and confirm if we need more conversions + "StringType"; + } +} + +// TODO: Confirm and fix this +fun String.isEvaluateExpression(): Boolean = startsWith("evaluate(") + + +/** + * Infer's the type and return the short class name eg `HumanName` for org.fhir.hl7.r4.model.Patient + * when given the path `Patient.name` + */ +fun inferType(propertyPath: String): String? { + // TODO: Handle possible errors + // TODO: Handle inferring nested types + val parts = propertyPath.split(".") + val parentResourceClassName = parts[0] + lateinit var parentClass: Class<*> + + if (fhirResources.contains(parentResourceClassName)) { + parentClass = Class.forName("org.hl7.fhir.r4.model.$parentResourceClassName") + return inferType(parentClass, parts, 1) + } else { + return null + } +} + +fun inferType(parentClass: Class<*>?, parts: List, index: Int): String? { + val resourcePropertyName = parts[index] + val propertyField = parentClass?.getFieldOrNull(resourcePropertyName) + + val propertyType = if (propertyField?.isList == true) + propertyField.nonParameterizedType + // TODO: Check if this is required + else if (propertyField?.type == Enumeration::class.java) + // TODO: Check if this works + propertyField.nonParameterizedType + else + propertyField?.type + + return if (parts.size > index + 1) { + return inferType(propertyType, parts, index + 1) + } else + propertyType?.name + ?.replace("org.hl7.fhir.r4.model.", "") +} + +fun String.isMultipleTypes(): Boolean = this == "Type" + +// TODO: Finish this. Use the annotation @Chid.type +fun String.getPossibleTypes(): List { + return listOf() +} + + +fun String.canHandleConversion(sourceType: String): Boolean { + val propertyClass = Class.forName("org.hl7.fhir.r4.model.$this") + val targetType2 = + if (sourceType == "StringType") String::class.java else Class.forName("org.hl7.fhir.r4.model.$sourceType") + + val possibleConversions = listOf( + "BooleanType" to "StringType", + "DateType" to "StringType", + "DecimalType" to "IntegerType", + "AdministrativeGender" to "CodeType" + ) + + possibleConversions.forEach { + if (this.contains(it.first) && sourceType == it.second) { + return true + } + } + + try { + propertyClass.getDeclaredMethod("fromCode", targetType2) + } catch (ex: NoSuchMethodException) { + return false + } + + return true +} + +fun String.getParentResource(): String? { + return substring(0, lastIndexOf('.')) +} + + +fun String.getResourceProperty(): String? { + return substring(lastIndexOf('.') + 1) +} + +fun String.getFhirType(): String = replace("Type", "") + .lowercase() \ No newline at end of file diff --git a/sm-gen/src/main/resources/.~lock.StructureMap XLS.xlsx# b/sm-gen/src/main/resources/.~lock.StructureMap XLS.xlsx# new file mode 100644 index 00000000..a8dcbaf2 --- /dev/null +++ b/sm-gen/src/main/resources/.~lock.StructureMap XLS.xlsx# @@ -0,0 +1 @@ +,ona-kigamba,onakigamba-Legion-7-16ARHA7,29.08.2023 15:47,file:///home/ona-kigamba/.config/libreoffice/4; \ No newline at end of file diff --git a/sm-gen/src/main/resources/StructureMap XLS-old.xlsx b/sm-gen/src/main/resources/StructureMap XLS-old.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c589a4065d1fcf827c1228814ab40a5778869458 GIT binary patch literal 12537 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 NtT($Iq5t*me*m@j4haAN literal 0 HcmV?d00001 diff --git a/sm-gen/src/main/resources/StructureMap XLS.xls b/sm-gen/src/main/resources/StructureMap XLS.xls new file mode 100644 index 0000000000000000000000000000000000000000..19f4a0f448956fc4aa8c02cfb78817e01f54543c GIT binary patch literal 12451 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 Z25(sj&^M<80ASu;=x=`OME&#A{{W7I2R8r! literal 0 HcmV?d00001 diff --git a/sm-gen/src/main/resources/StructureMap XLS.xlsx b/sm-gen/src/main/resources/StructureMap XLS.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..657c8b694e0b822bbedab8e48091ff7b9fb35574 GIT binary patch literal 9086 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 literal 0 HcmV?d00001 diff --git a/sm-gen/src/main/resources/questionnaire-response.json b/sm-gen/src/main/resources/questionnaire-response.json new file mode 100644 index 00000000..4d826cc1 --- /dev/null +++ b/sm-gen/src/main/resources/questionnaire-response.json @@ -0,0 +1,184 @@ +{ + "resourceType": "QuestionnaireResponse", + "questionnaire": "Questionnaire/client-registration-sample", + "item": [ + { + "linkId": "PR", + "item": [ + { + "linkId": "PR-name", + "item": [ + { + "linkId": "PR-name-given", + "answer": [ + { + "valueString": "Mike" + } + ] + }, + { + "linkId": "PR-name-family", + "answer": [ + { + "valueString": "Doe" + } + ] + } + ] + }, + { + "linkId": "patient-0-birth-date", + "answer": [ + { + "valueDate": "2021-07-01" + } + ] + }, + { + "linkId": "patient-0-gender", + "answer": [ + { + "valueCoding": { + "code": "male", + "display": "Male" + } + } + ] + }, + { + "linkId": "PR-telecom", + "item": [ + { + "linkId": "PR-telecom-system", + "answer": [ + { + "valueString": "phone" + } + ] + }, + { + "linkId": "PR-telecom-value", + "answer": [ + { + "valueString": "0700 000 000" + } + ] + } + ] + }, + { + "linkId": "PR-address", + "item": [ + { + "linkId": "PR-address-city", + "answer": [ + { + "valueString": "Nairobi" + } + ] + }, + { + "linkId": "PR-address-country", + "answer": [ + { + "valueString": "Kenya" + } + ] + } + ] + }, + { + "linkId": "PR-active", + "answer": [ + { + "valueBoolean": true + } + ] + } + ] + }, + { + "linkId": "RP", + "item": [ + { + "linkId": "RP-family-name", + "answer": [ + { + "valueString": "Doe" + } + ] + }, + { + "linkId": "RP-first-name", + "answer": [ + { + "valueString": "Mama-mike" + } + ] + }, + { + "linkId": "RP-relationship", + "answer": [ + { + "valueString": "PRN" + } + ] + }, + { + "linkId": "RP-contact-1", + "answer": [ + { + "valueString": "0700 001 001" + } + ] + }, + { + "linkId": "RP-contact-alternate", + "answer": [ + { + "valueString": "0700 000 012" + } + ] + } + ] + }, + { + "linkId": "comorbidities", + "answer": [ + { + "valueCoding": { + "display": "Cancer", + "system": "https://www.snomed.org", + "code": "363346000" + } + }, + { + "valueCoding": { + "display": "Others", + "system": "https://www.snomed.org", + "code": "74964007" + } + } + ] + }, + { + "linkId": "other_comorbidities", + "answer": [ + { + "valueString": "This is another comorbidity" + } + ] + }, + { + "linkId": "patient-barcode", + "answer": [ + { + "valueString": "scanned-barcode-string" + } + ] + }, + { + "linkId": "RP-id" + } + ] + } \ No newline at end of file diff --git a/sm-gen/src/main/resources/questionnaire.json b/sm-gen/src/main/resources/questionnaire.json new file mode 100644 index 00000000..75bdd911 --- /dev/null +++ b/sm-gen/src/main/resources/questionnaire.json @@ -0,0 +1,691 @@ +{ + "resourceType": "Questionnaire", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap", + "valueCanonical": "https://fhir.labs.smartregister.org/StructureMap/1902" + } + ], + "status": "active", + "subjectType": [ + "Patient" + ], + "date": "2020-11-18T07:24:47.111Z", + "item": [ + { + "linkId": "patient-barcode", + "text": "Barcode", + "type": "text", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "Patient.id" + } + } + ] + }, + { + "linkId": "PR", + "text": "Client Info", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Maelezo ya mteja" + } + ] + } + ] + }, + "type": "group", + "item": [ + { + "linkId": "PR-name", + "type": "group", + "item": [ + { + "linkId": "PR-name-given", + "text": "First Name", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Jina la kwanza" + } + ] + } + ] + }, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientName", + "language": "text/fhirpath", + "expression": "Patient.name.given" + } + } + ], + "type": "string", + "required": true + }, + { + "linkId": "PR-name-family", + "text": "Family Name", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Jina la ukoo" + } + ] + } + ] + }, + "type": "string", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientFamily", + "language": "text/fhirpath", + "expression": "Patient.name.family" + } + } + ] + } + ] + }, + { + "linkId": "patient-0-birth-date", + "text": "Date of Birth", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Tarehe ya kuzaliwa" + } + ] + } + ] + }, + "type": "date", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientBirthDate", + "language": "text/fhirpath", + "expression": "Patient.birthDate" + } + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "radio-button", + "display": "Radio Button" + } + ], + "text": "A control where choices are listed with a button beside them. The button can be toggled to select or de-select a given choice. Selecting one item deselects all others." + } + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation", + "valueCode": "horizontal" + }, + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientGender", + "language": "text/fhirpath", + "expression": "Patient.gender" + } + } + ], + "linkId": "patient-0-gender", + "text": "Gender", + "type": "choice", + "answerOption": [ + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "female", + "display": "Female" + } + }, + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "male", + "display": "Male" + } + } + ] + }, + { + "linkId": "PR-telecom", + "type": "group", + "item": [ + { + "linkId": "PR-telecom-system", + "text": "system", + "type": "string", + "enableWhen": [ + { + "question": "patient-0-gender", + "operator": "=", + "answerString": "ok" + } + ], + "initial": [ + { + "valueString": "phone" + } + ] + }, + { + "linkId": "PR-telecom-value", + "text": "Phone Number", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientTelecom", + "language": "text/fhirpath", + "expression": "Patient.telecom.value" + } + } + ], + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Nambari ya simu" + } + ] + } + ] + }, + "type": "string", + "required": true + } + ] + }, + { + "linkId": "PR-address", + "type": "group", + "item": [ + { + "linkId": "PR-address-city", + "text": "City", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientCity", + "language": "text/fhirpath", + "expression": "Patient.address.city" + } + } + ], + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Mji" + } + ] + } + ] + }, + "type": "string" + }, + { + "linkId": "PR-address-country", + "text": "Country", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Nchi" + } + ] + } + ] + }, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientCountry", + "language": "text/fhirpath", + "expression": "Patient.address.country" + } + } + ], + "type": "string" + } + ] + }, + { + "linkId": "PR-active", + "text": "Is Active?", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Inatumika?" + } + ] + } + ] + }, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientActive", + "language": "text/fhirpath", + "expression": "Patient.active" + } + } + ], + "type": "boolean" + } + ] + }, + { + "linkId": "RP", + "text": "Related person", + "type": "group", + "item": [ + { + "linkId": "RP-family-name", + "text": "Family name", + "type": "text", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.name.family" + } + } + ] + }, + { + "linkId": "RP-first-name", + "text": "First name", + "type": "text", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.name.given" + } + } + ] + }, + { + "linkId": "RP-relationship", + "text": "Relationship to patient", + "type": "text", + "required": true, + "answerValueSet": "http://hl7.org/fhir/ValueSet/relatedperson-relationshiptype", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.relationship.coding.code" + } + } + ] + }, + { + "linkId": "RP-contact-1", + "text": "Phone number", + "type": "text", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.telecom[0].value" + } + } + ] + }, + { + "linkId": "RP-contact-alternate", + "text": "Alternative phone number", + "type": "text", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.telecom[1].value" + } + } + ] + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "check-box" + } + ] + } + } + ], + "linkId": "comorbidities", + "code": [ + { + "system": "https://www.snomed.org", + "code": "991381000000107" + } + ], + "text": "Do you have any of the following conditions?", + "type": "choice", + "required": true, + "repeats": true, + "answerOption": [ + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "73211009", + "display": "Diabetes Mellitus (DM)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "59621000", + "display": "HyperTension (HT)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "414545008", + "display": "Ischemic Heart Disease (IHD / CHD / CCF)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "56717001", + "display": "Tuberculosis (TB)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "195967001", + "display": "Asthma/COPD" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "709044004", + "display": "Chronic Kidney Disease" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "363346000", + "display": "Cancer" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "74964007", + "display": "Others" + } + } + ], + "enableWhen": [ + { + "question": "RP-id", + "operator": "exists", + "answerBoolean": false + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/RiskAssessment", + "valueBoolean": true + } + ], + "linkId": "other_comorbidities", + "code": [ + { + "system": "https://www.snomed.org", + "code": "38651000000103" + } + ], + "text": "If other, specify: ", + "type": "string", + "enableWhen": [ + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "74964007", + "display": "Others" + } + }, + { + "question": "RP-id", + "operator": "exists", + "answerBoolean": false + } + ], + "enableBehavior": "all" + }, + { + "linkId": "risk_assessment", + "code": [ + { + "system": "https://www.snomed.org", + "code": "225338004", + "display": "Risk Assessment" + } + ], + "text": "Client is at risk for serious illness from COVID-19", + "type": "choice", + "enableWhen": [ + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "74964007", + "display": "Others" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "363346000", + "display": "Cancer" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "709044004", + "display": "Chronic Kidney Disease" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "195967001", + "display": "Asthma/COPD" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "56717001", + "display": "Tuberculosis (TB)" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "414545008", + "display": "Ischemic Heart Disease (IHD / CHD / CCF)" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "59621000", + "display": "HyperTension (HT)" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "73211009", + "display": "Diabetes Mellitus (DM)" + } + }, + { + "question": "RP-id", + "operator": "exists", + "answerBoolean": false + } + ], + "enableBehavior": "any", + "initial": [ + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "870577009", + "display": "High Risk for COVID-19" + } + } + ] + }, + { + "linkId": "RP-id", + "text": "Related person id", + "type": "text", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden", + "valueBoolean": true + }, + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.id" + } + } + ] + } + ] + } \ No newline at end of file diff --git a/sm-gen/src/main/resources/test/child_reg_qr.json b/sm-gen/src/main/resources/test/child_reg_qr.json new file mode 100644 index 00000000..2467e299 --- /dev/null +++ b/sm-gen/src/main/resources/test/child_reg_qr.json @@ -0,0 +1,345 @@ +{ + "resourceType": "QuestionnaireResponse", + "id": "fc95357b-1e34-4c3d-9be4-bdd60efb0677", + "meta": { + "lastUpdated": "2023-08-23T15:47:35.512+03:00", + "tag": [ + { + "system": "urn:oid:2.16.578.1.12.4.1.1.8655", + "display": "Child Registration" + }, + { + "system": "https://smartregister.org/care-team-tag-id", + "code": "456cbdda-d3e5-4ce4-b1f7-7ec079cf4914", + "display": "Practitioner CareTeam" + }, + { + "system": "https://smartregister.org/location-tag-id", + "code": "Not defined", + "display": "Practitioner Location" + }, + { + "system": "https://smartregister.org/organisation-tag-id", + "code": "98348d1d-00f4-4118-b4c0-128ab6c91280", + "display": "Practitioner Organization" + }, + { + "system": "https://smartregister.org/practitioner-tag-id", + "code": "64fccd56-d964-455e-a905-8452ff98f0a8", + "display": "Practitioner" + }, + { + "system": "https://smartregister.org/app-version", + "code": "Not defined", + "display": "Application Version" + } + ] + }, + "contained": [ + { + "resourceType": "List", + "id": "a90d6827-df91-4821-9cd1-d565f2096c3c", + "status": "current", + "mode": "working", + "title": "GeneratedResourcesList", + "date": "2023-08-23T15:47:34+03:00", + "entry": [ + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "Patient/a30c4536-1d10-4298-ab62-c28e84351167" + } + }, + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "Patient/a30c4536-1d10-4298-ab62-c28e84351167" + } + }, + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "RelatedPerson/5bb1bb5e-a29c-441f-87c8-b96b5b9e936f" + } + }, + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "Patient/a30c4536-1d10-4298-ab62-c28e84351167" + } + }, + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "RelatedPerson/288ca8d2-fab7-4b5e-829a-a6788dff3392" + } + }, + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "Encounter/92d168c6-816e-4e8f-9941-be268da9fdc0" + } + }, + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "Observation/3ca6e36a-0566-4bdd-ba03-c78a62ff5e23" + } + }, + { + "deleted": false, + "date": "2023-08-23T15:47:34+03:00", + "item": { + "reference": "Observation/3ca6e36a-0566-4bdd-ba03-c78a62ff5e23" + } + } + ] + } + ], + "questionnaire": "Questionnaire/e5155788-8831-4916-a3f5-486915ce34b211-01", + "status": "completed", + "subject": { + "reference": "Patient/a30c4536-1d10-4298-ab62-c28e84351167" + }, + "authored": "2023-08-23T15:47:34+03:00", + "item": [ + { + "linkId": "ed77104e-c279-4030-ab20-8cd99ca99ca9", + "text": "OpenSRP ID", + "answer": [ + { + "valueInteger": 436861 + } + ] + }, + { + "linkId": "f7ee2d12-829e-4ca3-fe30-ddb5e809ac91", + "text": "Child's First Name", + "answer": [ + { + "valueString": "Mary" + } + ] + }, + { + "linkId": "7b41d922-376c-49bf-f6a8-faaa681e9ef6", + "text": "Child's Middle Name", + "answer": [ + { + "valueString": "Middle" + } + ] + }, + { + "linkId": "76bad83a-f061-4b22-8b4f-a95cbd5be4da", + "text": "Child's Last Name", + "answer": [ + { + "valueString": "Baby" + } + ] + }, + { + "linkId": "e79d201a-4e30-40ca-c1ef-060a3d449303", + "answer": [ + { + "valueCoding": { + "id": "2b96ecfc-7159-40ef-ddd9-4fdf845fddb7", + "system": "urn:uuid:c3f41b65-196a-4c9f-87cb-5c3f12030cd5", + "code": "date-of-birth-unknown", + "display": "Date of Birth Unknown" + } + } + ] + }, + { + "linkId": "8ebf5364-6bdc-4e45-89a7-28c65ce019b7", + "text": "Age (months)", + "answer": [ + { + "valueInteger": 45 + } + ] + }, + { + "linkId": "50330b11-c520-4f45-a8f4-44771097788d", + "text": "Child's Gender", + "answer": [ + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "female", + "display": "Female" + } + } + ] + }, + { + "linkId": "57e01310-0470-4832-8900-fe635111f475", + "text": "Birth weight (kg)", + "answer": [ + { + "valueDecimal": 2.5 + } + ] + }, + { + "linkId": "3d453a4e-8548-468f-81b7-e5451abe1106", + "text": "Does the child have a mother/guardian?", + "answer": [ + { + "valueCoding": { + "id": "1e9579f9-b3e2-46a4-e7bd-365d32b45eaa", + "system": "urn:uuid:6bf5b563-47c1-4146-8a9a-492b2d29b8a4", + "code": "yes", + "display": "Yes" + } + } + ] + }, + { + "linkId": "dbf5b992-03fc-457d-93eb-d603cccc9ce5", + "text": "Mother/guardian first name", + "answer": [ + { + "valueString": "Her" + } + ] + }, + { + "linkId": "46a91ed5-1ef8-4afc-8a8c-54d52258343e", + "text": "Mother/guardian last name", + "answer": [ + { + "valueString": "Mother" + } + ] + }, + { + "linkId": "f331b415-9188-4add-cf3f-36019a593690", + "text": "Mother/guardian DOB", + "answer": [ + { + "valueDate": "1990-06-15" + } + ] + }, + { + "linkId": "e5e2aa85-c416-48c6-9caa-b8009c523175", + "text": "Does the mother/guardian have an ID?", + "answer": [ + { + "valueCoding": { + "id": "9d90eabe-9f85-4a06-87a2-0343f397d452", + "system": "urn:uuid:c0c05eac-b4b1-4248-b488-f017fd0dfa22", + "code": "yes", + "display": "Yes" + } + } + ] + }, + { + "linkId": "6dd1b786-dc61-43e7-9cda-711f71024469", + "text": "Mother/Guardian's ID Number", + "answer": [ + { + "valueString": "1111111111111111" + } + ] + }, + { + "linkId": "6c87245c-5d6e-4972-8d83-ea80a8e62ffb", + "text": "Confirm Mother/Guardian's ID Number", + "answer": [ + { + "valueString": "1111111111111111" + } + ] + }, + { + "linkId": "bcbce519-7314-466e-d42c-5107a4b4a687", + "text": "Does the mother/guardian own a phone?", + "answer": [ + { + "valueCoding": { + "id": "ba420974-2a52-415e-81b6-37c02ba8433d", + "system": "urn:uuid:660783e0-0fcc-4e09-967c-322c1dd9405f", + "code": "yes", + "display": "Yes" + } + } + ] + }, + { + "linkId": "f4833bf7-c293-4f38-866d-a89066ef32d1", + "text": "Mother/guardian's phone number", + "answer": [ + { + "valueString": "1111111111" + } + ] + }, + { + "linkId": "ae512786-8123-4711-8e72-e6f43a9d5742", + "text": "Confirm mother/guardian's phone number", + "answer": [ + { + "valueString": "111111111111" + } + ] + }, + { + "linkId": "cd2f6e33-e79d-467d-b200-b008fffe18f5", + "text": "Mother/Guardian's income per month", + "answer": [ + { + "valueCoding": { + "id": "4e2bfb1a-9bbd-4720-9d53-cfb99df05049", + "system": "urn:uuid:a1ac4d6d-2245-457e-8d6f-fbca2cc81ca5", + "code": "<-rp.500.000", + "display": "< Rp.500.000" + } + } + ] + }, + { + "linkId": "1b87fbf5-1330-4e65-8364-10cbf7c0a3e3", + "text": "Current Home Address", + "answer": [ + { + "valueString": "Home" + } + ] + }, + { + "linkId": "d02e3a9e-ede2-4cd5-9c98-cc5824c06ac4", + "text": "Is the child's father present?", + "answer": [ + { + "valueCoding": { + "id": "8d0ec61d-6c79-43cd-ee75-b7982becf32d", + "system": "urn:uuid:a0c601e3-b8e0-49ed-82b0-1579c1c53dfd", + "code": "yes", + "display": "Yes" + } + } + ] + }, + { + "linkId": "945f09f7-0641-4675-8329-b02d99c5425f", + "text": "Father/guardian full name", + "answer": [ + { + "valueString": "Father" + } + ] + } + ] +} \ No newline at end of file diff --git a/sm-gen/src/main/resources/test/child_regs_sm.json b/sm-gen/src/main/resources/test/child_regs_sm.json new file mode 100644 index 00000000..4205cd9e --- /dev/null +++ b/sm-gen/src/main/resources/test/child_regs_sm.json @@ -0,0 +1,3274 @@ +{ + "resourceType": "StructureMap", + "id": "488593", + "meta": { + "versionId": "21", + "lastUpdated": "2023-08-23T12:45:15.026+00:00", + "source": "#f62f205ff6c0b82e" + }, + "url": "https://fhir.demo.smartregister.org/fhir/StructureMap/488593", + "name": "CHW child Patient Registration", + "structure": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/QuestionnaireReponse", + "mode": "source" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/Patient", + "mode": "target" + } + ], + "group": [ + { + "name": "CHildRegistration", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "rule_bundle_id", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "rule_bundle_type", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "type", + "transform": "copy", + "parameter": [ + { + "valueString": "collection" + } + ] + } + ] + }, + { + "name": "rule_bundle_entry", + "source": [ + { + "context": "src" + } + ], + "dependent": [ + { + "name": "ExtractChildResources", + "variable": [ + "src", + "bundle" + ] + } + ] + } + ] + }, + { + "name": "ExtractChildResources", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "r_patient", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "patient", + "transform": "create", + "parameter": [ + { + "valueString": "Patient" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_id_generation", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "rule_patient_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "name", + "variable": "patientName", + "transform": "create", + "parameter": [ + { + "valueString": "HumanName" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_family_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "family", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '76bad83a-f061-4b22-8b4f-a95cbd5be4da').answer.value" + } + ] + } + ] + }, + { + "name": "rule_patient_given_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "given", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = 'f7ee2d12-829e-4ca3-fe30-ddb5e809ac91').answer.value" + } + ] + } + ] + }, + { + "name": "rule_patient_middle_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "text", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '7b41d922-376c-49bf-f6a8-faaa681e9ef6').answer.value" + } + ] + } + ] + }, + { + "name": "rule_patient_name_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + } + ] + }, + { + "name": "rule_patient_identifier_opensrp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "identifier", + "variable": "patientIdentifierOpenSRPId", + "transform": "create", + "parameter": [ + { + "valueString": "Identifier" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_identifier_opensrp_id_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierOpenSRPId", + "contextType": "variable", + "element": "value", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = 'ed77104e-c279-4030-ab20-8cd99ca99ca9').answer.value" + } + ] + } + ] + }, + { + "name": "rule_patient_identifier_opensrp_id_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierOpenSRPId", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + }, + { + "name": "rule_patient_identifier_period", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierOpenSRPId", + "contextType": "variable", + "element": "period", + "variable": "period", + "transform": "create", + "parameter": [ + { + "valueString": "Period" + } + ] + }, + { + "context": "period", + "contextType": "variable", + "element": "start", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.authored" + } + ] + } + ] + } + ] + }, + { + "name": "rule_patient_identifier", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "identifier", + "variable": "patientIdentifier", + "transform": "create", + "parameter": [ + { + "valueString": "Identifier" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_identifier_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifier", + "contextType": "variable", + "element": "value", + "transform": "uuid" + } + ] + }, + { + "name": "rule_patient_identifier_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifier", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "secondary" + } + ] + } + ] + }, + { + "name": "rule_patient_identifier_period", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifier", + "contextType": "variable", + "element": "period", + "variable": "period", + "transform": "create", + "parameter": [ + { + "valueString": "Period" + } + ] + }, + { + "context": "period", + "contextType": "variable", + "element": "start", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.authored" + } + ] + } + ] + } + ] + }, + { + "name": "rule_patient_gender", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "gender", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '50330b11-c520-4f45-a8f4-44771097788d').answer.value.code" + } + ] + } + ] + }, + { + "name": "rule_patient_age", + "source": [ + { + "context": "src", + "element": "item", + "variable": "patient_age", + "condition": "((linkId = '8ebf5364-6bdc-4e45-89a7-28c65ce019b7') and (answer.count() > 0))" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "birthDate", + "transform": "evaluate", + "parameter": [ + { + "valueId": "patient_age" + }, + { + "valueString": "today() - (($this.answer.value * 30).toString() + ' days').toQuantity()" + } + ] + } + ] + }, + { + "name": "rule_patient_dob", + "source": [ + { + "context": "src", + "element": "item", + "variable": "patient_dob", + "condition": "(linkId = 'aa1ddb98-87a4-48a6-9d8c-4c80de1ec277')" + } + ], + "rule": [ + { + "name": "rule__first_patient_dob", + "source": [ + { + "context": "patient_dob", + "element": "answer", + "listMode": "first", + "variable": "patientBirthDateAnswer" + } + ], + "rule": [ + { + "name": "rule_patient_dob_answer", + "source": [ + { + "context": "patientBirthDateAnswer", + "element": "value", + "variable": "val" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "birthDate", + "transform": "copy", + "parameter": [ + { + "valueId": "val" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "rule_patient_active", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "active", + "transform": "copy", + "parameter": [ + { + "valueBoolean": true + } + ] + } + ] + }, + { + "name": "rule_patient_address", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patient", + "contextType": "variable", + "element": "address", + "variable": "patientAddress", + "transform": "create", + "parameter": [ + { + "valueString": "Address" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_address_district", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientAddress", + "contextType": "variable", + "element": "district", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '1b87fbf5-1330-4e65-8364-10cbf7c0a3e3').answer.value" + } + ] + } + ] + }, + { + "name": "rule_patient_address_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientAddress", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "home" + } + ] + } + ] + }, + { + "name": "rule_patient_address_type", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientAddress", + "contextType": "variable", + "element": "type", + "transform": "copy", + "parameter": [ + { + "valueString": "physical" + } + ] + } + ] + } + ] + }, + { + "name": "r_mother", + "source": [ + { + "context": "src", + "element": "item", + "variable": "item", + "condition": "where($this.linkId.where(contains('3d453a4e-8548-468f-81b7-e5451abe1106')).exists() and ($this.answer.value.code = 'yes'))" + } + ], + "rule": [ + { + "name": "r_patient_mother", + "source": [ + { + "context": "src" + } + ], + "dependent": [ + { + "name": "ExtractPatientMother", + "variable": [ + "src", + "bundle" + ] + } + ] + }, + { + "name": "r_relatedPerson_mother", + "source": [ + { + "context": "src" + } + ], + "dependent": [ + { + "name": "ExtractRelatedPersonMother", + "variable": [ + "src", + "patient", + "bundle" + ] + } + ] + } + ] + }, + { + "name": "r_father", + "source": [ + { + "context": "src", + "element": "item", + "variable": "item", + "condition": "where($this.linkId.where(contains('d02e3a9e-ede2-4cd5-9c98-cc5824c06ac4')).exists() and ($this.answer.value.code = 'yes'))" + } + ], + "rule": [ + { + "name": "r_patient_father", + "source": [ + { + "context": "src" + } + ], + "dependent": [ + { + "name": "ExtractPatientFather", + "variable": [ + "src", + "bundle" + ] + } + ] + }, + { + "name": "r_relatedPerson_father", + "source": [ + { + "context": "src" + } + ], + "dependent": [ + { + "name": "ExtractRelatedPersonFather", + "variable": [ + "src", + "patient", + "bundle" + ] + } + ] + } + ] + }, + { + "name": "r_child_encounter", + "source": [ + { + "context": "src" + } + ], + "dependent": [ + { + "name": "ExtractEncounter", + "variable": [ + "src", + "patient", + "bundle" + ] + } + ] + } + ] + } + ] + }, + { + "name": "ExtractPatientMother", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "rule_mother", + "source": [ + { + "context": "src", + "element": "item", + "variable": "item", + "condition": "where($this.linkId.where(contains('3d453a4e-8548-468f-81b7-e5451abe1106')).exists() and ($this.answer.value.code = 'yes'))" + } + ], + "rule": [ + { + "name": "rule_mother", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "mother", + "transform": "create", + "parameter": [ + { + "valueString": "Patient" + } + ] + } + ], + "rule": [ + { + "name": "rule_mother_id_generation", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "mother", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "rule_mother_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "mother", + "contextType": "variable", + "element": "name", + "variable": "patientName", + "transform": "create", + "parameter": [ + { + "valueString": "HumanName" + } + ] + } + ], + "rule": [ + { + "name": "rule_mother_family_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "family", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = 'dbf5b992-03fc-457d-93eb-d603cccc9ce5').answer.value" + } + ] + } + ] + }, + { + "name": "rule_mother_given_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "given", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '46a91ed5-1ef8-4afc-8a8c-54d52258343e').answer.value" + } + ] + } + ] + }, + { + "name": "rule_mother_middle_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "text", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = 'dbf5b992-03fc-457d-93eb-d603cccc9ce5').answer.value" + } + ] + } + ] + }, + { + "name": "rule_mother_name_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + } + ] + }, + { + "name": "rule_mother_identifier", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "mother", + "contextType": "variable", + "element": "identifier", + "variable": "patientIdentifier", + "transform": "create", + "parameter": [ + { + "valueString": "Identifier" + } + ] + } + ], + "rule": [ + { + "name": "rule_mother_identifier_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifier", + "contextType": "variable", + "element": "value", + "transform": "uuid" + } + ] + }, + { + "name": "rule_mother_identifier_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifier", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + }, + { + "name": "rule_mother_identifier_period", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifier", + "contextType": "variable", + "element": "period", + "variable": "period", + "transform": "create", + "parameter": [ + { + "valueString": "Period" + } + ] + }, + { + "context": "period", + "contextType": "variable", + "element": "start", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.authored" + } + ] + } + ] + } + ] + }, + { + "name": "rule_mother_identifier_national_id", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "mother", + "contextType": "variable", + "element": "identifier", + "variable": "patientIdentifierNationalId", + "transform": "create", + "parameter": [ + { + "valueString": "Identifier" + } + ] + } + ], + "rule": [ + { + "name": "rule_mother_identifier_national_id_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierNationalId", + "contextType": "variable", + "element": "value", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '6dd1b786-dc61-43e7-9cda-711f71024469').answer.value" + } + ] + } + ] + }, + { + "name": "rule_mother_identifier_national_id_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierNationalId", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "secondary" + } + ] + } + ] + }, + { + "name": "rule_mother_identifier_period", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierNationalId", + "contextType": "variable", + "element": "period", + "variable": "period", + "transform": "create", + "parameter": [ + { + "valueString": "Period" + } + ] + }, + { + "context": "period", + "contextType": "variable", + "element": "start", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.authored" + } + ] + } + ] + } + ] + }, + { + "name": "rule_mother_gender", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "mother", + "contextType": "variable", + "element": "gender", + "transform": "copy", + "parameter": [ + { + "valueString": "female" + } + ] + } + ] + }, + { + "name": "rule_mother_dob", + "source": [ + { + "context": "src", + "element": "item", + "variable": "patient_dob", + "condition": "(linkId = 'f331b415-9188-4add-cf3f-36019a593690')" + } + ], + "rule": [ + { + "name": "rule__first_mother_dob", + "source": [ + { + "context": "patient_dob", + "element": "answer", + "listMode": "first", + "variable": "patientBirthDateAnswer" + } + ], + "rule": [ + { + "name": "rule_patient_dob_answer", + "source": [ + { + "context": "patientBirthDateAnswer", + "element": "value", + "variable": "val" + } + ], + "target": [ + { + "context": "mother", + "contextType": "variable", + "element": "birthDate", + "transform": "copy", + "parameter": [ + { + "valueId": "val" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "rule_mother_active", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "mother", + "contextType": "variable", + "element": "active", + "transform": "copy", + "parameter": [ + { + "valueBoolean": true + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ExtractPatientFather", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "rule_patient", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "father", + "transform": "create", + "parameter": [ + { + "valueString": "Patient" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_id_generation", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "father", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "rule_patient_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "father", + "contextType": "variable", + "element": "name", + "variable": "patientName", + "transform": "create", + "parameter": [ + { + "valueString": "HumanName" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_family_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "family", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '945f09f7-0641-4675-8329-b02d99c5425f').answer.value" + } + ] + } + ] + }, + { + "name": "rule_patient_middle_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "text", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '945f09f7-0641-4675-8329-b02d99c5425f').answer.value" + } + ] + } + ] + }, + { + "name": "rule_patient_name_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientName", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + } + ] + }, + { + "name": "rule_patient_identifier_opensrp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "father", + "contextType": "variable", + "element": "identifier", + "variable": "patientIdentifierOpenSRPId", + "transform": "create", + "parameter": [ + { + "valueString": "Identifier" + } + ] + } + ], + "rule": [ + { + "name": "rule_patient_identifier_opensrp_id_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierOpenSRPId", + "contextType": "variable", + "element": "value", + "transform": "uuid" + } + ] + }, + { + "name": "rule_patient_identifier_opensrp_id_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierOpenSRPId", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + }, + { + "name": "rule_patient_identifier_period", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "patientIdentifierOpenSRPId", + "contextType": "variable", + "element": "period", + "variable": "period", + "transform": "create", + "parameter": [ + { + "valueString": "Period" + } + ] + }, + { + "context": "period", + "contextType": "variable", + "element": "start", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.authored" + } + ] + } + ] + } + ] + }, + { + "name": "rule_patient_active", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "father", + "contextType": "variable", + "element": "active", + "transform": "copy", + "parameter": [ + { + "valueBoolean": true + } + ] + } + ] + }, + { + "name": "rule_gender", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "father", + "contextType": "variable", + "element": "gender", + "transform": "copy", + "parameter": [ + { + "valueString": "male" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ExtractRelatedPersonMother", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireReponse", + "mode": "source" + }, + { + "name": "patient", + "type": "Patient", + "mode": "target" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "rule_entry", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "relatedPMother", + "transform": "create", + "parameter": [ + { + "valueString": "RelatedPerson" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPMother", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "rule_related_relationship", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPMother", + "contextType": "variable", + "element": "relationship", + "variable": "concept", + "transform": "create", + "parameter": [ + { + "valueString": "CodeableConcept" + } + ] + } + ], + "rule": [ + { + "name": "rule_text_disp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "text", + "transform": "copy", + "parameter": [ + { + "valueString": "Mother" + } + ] + } + ] + }, + { + "name": "r_en_cc_cod", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "coding", + "variable": "coding", + "transform": "c", + "parameter": [ + { + "valueString": "http://snomed.info/sct" + }, + { + "valueString": "72705000" + } + ] + } + ], + "rule": [ + { + "name": "r_en_cod_disp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "coding", + "contextType": "variable", + "element": "display", + "transform": "copy", + "parameter": [ + { + "valueString": "Mother" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "rule_related_person_identifier", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPMother", + "contextType": "variable", + "element": "identifier", + "variable": "relatedPersonIdentifier", + "transform": "create", + "parameter": [ + { + "valueString": "Identifier" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person_identifier_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonIdentifier", + "contextType": "variable", + "element": "value", + "transform": "uuid" + } + ] + }, + { + "name": "rule_related_person_identifier_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonIdentifier", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "secondary" + } + ] + } + ] + } + ] + }, + { + "name": "rule_related_person_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPMother", + "contextType": "variable", + "element": "name", + "variable": "relatedPersonName", + "transform": "create", + "parameter": [ + { + "valueString": "HumanName" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person_family_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonName", + "contextType": "variable", + "element": "family", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = 'dbf5b992-03fc-457d-93eb-d603cccc9ce5').answer.value" + } + ] + } + ] + }, + { + "name": "rule_related_person_given_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonName", + "contextType": "variable", + "element": "given", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '46a91ed5-1ef8-4afc-8a8c-54d52258343e').answer.value" + } + ] + } + ] + }, + { + "name": "rule_related_person_middle_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonName", + "contextType": "variable", + "element": "text", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = 'dbf5b992-03fc-457d-93eb-d603cccc9ce5').answer.value" + } + ] + } + ] + }, + { + "name": "rule_related_person_name_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonName", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + } + ] + }, + { + "name": "rule_related_person_active", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPMother", + "contextType": "variable", + "element": "active", + "transform": "copy", + "parameter": [ + { + "valueBoolean": true + } + ] + } + ] + }, + { + "name": "rule_related_person_phone", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPMother", + "contextType": "variable", + "element": "telecom", + "variable": "phone", + "transform": "create", + "parameter": [ + { + "valueString": "ContactPoint" + } + ] + } + ], + "rule": [ + { + "name": "r_en_cc_cod", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "phone", + "contextType": "variable", + "element": "value", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = 'ae512786-8123-4711-8e72-e6f43a9d5742').answer.value" + } + ] + } + ] + } + ] + }, + { + "name": "rule_related_person_patient", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPMother", + "contextType": "variable", + "element": "patient", + "variable": "ref", + "transform": "create", + "parameter": [ + { + "valueString": "Reference" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person_patient_ref", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "ref", + "contextType": "variable", + "element": "reference", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "'Patient/' + patient.id" + } + ] + } + ] + }, + { + "name": "rule_related_person_patient_ref_display", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "ref", + "contextType": "variable", + "element": "display", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "relatedPerson.name.given + ' ' + relatedPerson.name.text + ' ' + relatedPerson.name.family" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ExtractRelatedPersonFather", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireReponse", + "mode": "source" + }, + { + "name": "patient", + "type": "Patient", + "mode": "target" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "rule_bundle_entry", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "relatedPFather", + "transform": "create", + "parameter": [ + { + "valueString": "RelatedPerson" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPFather", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "rule_related_father_relationship", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPFather", + "contextType": "variable", + "element": "relationship", + "variable": "concept", + "transform": "create", + "parameter": [ + { + "valueString": "CodeableConcept" + } + ] + } + ], + "rule": [ + { + "name": "rule_text_disp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "text", + "transform": "copy", + "parameter": [ + { + "valueString": "Father" + } + ] + } + ] + }, + { + "name": "r_en_cc_cod", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "coding", + "variable": "coding", + "transform": "c", + "parameter": [ + { + "valueString": "http://snomed.info/sct" + }, + { + "valueString": "72705000" + } + ] + } + ], + "rule": [ + { + "name": "r_en_cod_disp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "coding", + "contextType": "variable", + "element": "display", + "transform": "copy", + "parameter": [ + { + "valueString": "Father" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "rule_related_person_identifier", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPFather", + "contextType": "variable", + "element": "identifier", + "variable": "relatedPersonIdentifier", + "transform": "create", + "parameter": [ + { + "valueString": "Identifier" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person_identifier_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonIdentifier", + "contextType": "variable", + "element": "value", + "transform": "uuid" + } + ] + }, + { + "name": "rule_related_person_identifier_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonIdentifier", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "secondary" + } + ] + } + ] + } + ] + }, + { + "name": "rule_related_person_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPFather", + "contextType": "variable", + "element": "name", + "variable": "relatedPersonName", + "transform": "create", + "parameter": [ + { + "valueString": "HumanName" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person_family_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonName", + "contextType": "variable", + "element": "family", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '945f09f7-0641-4675-8329-b02d99c5425f').answer.value" + } + ] + } + ] + }, + { + "name": "rule_related_person_middle_name", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonName", + "contextType": "variable", + "element": "text", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '945f09f7-0641-4675-8329-b02d99c5425f').answer.value" + } + ] + } + ] + }, + { + "name": "rule_related_person_name_use", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPersonName", + "contextType": "variable", + "element": "use", + "transform": "copy", + "parameter": [ + { + "valueString": "official" + } + ] + } + ] + } + ] + }, + { + "name": "rule_related_person_active", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPFather", + "contextType": "variable", + "element": "active", + "transform": "copy", + "parameter": [ + { + "valueBoolean": true + } + ] + } + ] + }, + { + "name": "rule_related_person_patient", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "relatedPFather", + "contextType": "variable", + "element": "patient", + "variable": "ref", + "transform": "create", + "parameter": [ + { + "valueString": "Reference" + } + ] + } + ], + "rule": [ + { + "name": "rule_related_person_patient_ref", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "ref", + "contextType": "variable", + "element": "reference", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "'Patient/' + patient.id" + } + ] + } + ] + }, + { + "name": "rule_related_person_patient_ref_display", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "ref", + "contextType": "variable", + "element": "display", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "relatedPerson.name.given + ' ' + relatedPerson.name.text + ' ' + relatedPerson.name.family" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ExtractObservationForWeight", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "encounter", + "type": "Encounter", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "rule_obs", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "weightObservation", + "transform": "create", + "parameter": [ + { + "valueString": "Observation" + } + ] + } + ], + "rule": [ + { + "name": "r_item_child_weight_obs_id", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "weightObservation", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "r_obs_value_dbs_viral_load_results", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "weightObservation", + "contextType": "variable", + "element": "value", + "variable": "codeableConcept", + "transform": "create", + "parameter": [ + { + "valueString": "Quantity" + } + ] + } + ], + "rule": [ + { + "name": "rule_obs_value", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "codeableConcept", + "contextType": "variable", + "element": "value", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.item.where(linkId = '57e01310-0470-4832-8900-fe635111f475').answer.value" + } + ] + } + ] + }, + { + "name": "rule_obs_unit", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "codeableConcept", + "contextType": "variable", + "element": "unit", + "transform": "copy", + "parameter": [ + { + "valueString": "kg" + } + ] + } + ] + }, + { + "name": "rule_obs_system", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "codeableConcept", + "contextType": "variable", + "element": "system", + "transform": "copy", + "parameter": [ + { + "valueString": "http://unitsofmeasure.org" + } + ] + } + ] + }, + { + "name": "rule_obs_code", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "codeableConcept", + "contextType": "variable", + "element": "code", + "transform": "copy", + "parameter": [ + { + "valueString": "weight" + } + ] + } + ] + } + ] + }, + { + "name": "r_item_child_weight_obs_ref", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "weightObservation", + "contextType": "variable", + "element": "subject", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "$this.subject" + } + ] + } + ] + }, + { + "name": "r_item_child_weight_obs_eff", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "weightObservation", + "contextType": "variable", + "element": "effective", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "now()" + } + ] + } + ] + }, + { + "name": "r_weight_obs_encounter", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "weightObservation", + "contextType": "variable", + "element": "encounter", + "variable": "encRef", + "transform": "create", + "parameter": [ + { + "valueString": "Reference" + } + ] + }, + { + "context": "encRef", + "contextType": "variable", + "element": "reference", + "transform": "evaluate", + "parameter": [ + { + "valueId": "encounter" + }, + { + "valueString": "'Encounter/' + $this.id" + } + ] + } + ] + }, + { + "name": "r_weight_obs", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "transform": "copy", + "parameter": [ + { + "valueId": "weightObservation" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "ExtractEncounter", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "patient", + "type": "Patient", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "r_en", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + }, + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "encounter", + "transform": "create", + "parameter": [ + { + "valueString": "Encounter" + } + ] + } + ], + "rule": [ + { + "name": "r_en_id", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ] + }, + { + "name": "r_en_st", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "status", + "transform": "copy", + "parameter": [ + { + "valueString": "finished" + } + ] + } + ] + }, + { + "name": "r_en_cls", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "class", + "transform": "c", + "parameter": [ + { + "valueString": "http://terminology.hl7.org/CodeSystem/v3-ActCode" + }, + { + "valueString": "HH" + }, + { + "valueString": "Home Health" + } + ] + } + ] + }, + { + "name": "r_en_typ", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "type", + "variable": "concept", + "transform": "create", + "parameter": [ + { + "valueString": "CodeableConcept" + } + ] + } + ], + "rule": [ + { + "name": "r_en_cc_cod", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "coding", + "variable": "coding", + "transform": "c", + "parameter": [ + { + "valueString": "http://snomed.info/sct" + }, + { + "valueString": "184048005" + } + ] + } + ], + "rule": [ + { + "name": "r_en_cod_disp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "coding", + "contextType": "variable", + "element": "display", + "transform": "copy", + "parameter": [ + { + "valueString": "Registration" + } + ] + } + ] + } + ] + }, + { + "name": "r_en_typ_text", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "text", + "transform": "copy", + "parameter": [ + { + "valueString": "Registration" + } + ] + } + ] + } + ] + }, + { + "name": "r_en_prio", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "priority", + "variable": "concept", + "transform": "create", + "parameter": [ + { + "valueString": "CodeableConcept" + } + ] + } + ], + "rule": [ + { + "name": "r_en_cc_cod", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "coding", + "variable": "coding", + "transform": "c", + "parameter": [ + { + "valueString": "http://terminology.hl7.org/ValueSet/v3-ActPriority" + }, + { + "valueString": "EL" + } + ] + } + ], + "rule": [ + { + "name": "r_en_cod_disp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "coding", + "contextType": "variable", + "element": "display", + "transform": "copy", + "parameter": [ + { + "valueString": "elective" + } + ] + } + ] + } + ] + }, + { + "name": "r_en_prio_text", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "text", + "transform": "copy", + "parameter": [ + { + "valueString": "elective" + } + ] + } + ] + } + ] + }, + { + "name": "r_en_sub", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "subject", + "transform": "reference", + "parameter": [ + { + "valueId": "patient" + } + ] + } + ] + }, + { + "name": "r_en_per", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "period", + "variable": "enPeriod", + "transform": "create", + "parameter": [ + { + "valueString": "Period" + } + ] + } + ], + "rule": [ + { + "name": "r_en_per_start", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "enPeriod", + "contextType": "variable", + "element": "start", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "now()" + } + ] + } + ] + }, + { + "name": "r_en_per_end", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "enPeriod", + "contextType": "variable", + "element": "end", + "transform": "evaluate", + "parameter": [ + { + "valueId": "src" + }, + { + "valueString": "now()" + } + ] + } + ] + } + ] + }, + { + "name": "r_en_reason", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "encounter", + "contextType": "variable", + "element": "reasonCode", + "variable": "concept", + "transform": "create", + "parameter": [ + { + "valueString": "CodeableConcept" + } + ] + } + ], + "rule": [ + { + "name": "r_en_rc_cod", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "coding", + "variable": "coding", + "transform": "c", + "parameter": [ + { + "valueString": "http://smartregsiter.org/" + }, + { + "valueString": "patient_registration" + } + ] + } + ], + "rule": [ + { + "name": "r_en_rc_cod_disp", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "coding", + "contextType": "variable", + "element": "display", + "transform": "copy", + "parameter": [ + { + "valueString": "CHW Child Registration" + } + ] + } + ] + } + ] + }, + { + "name": "r_en_text", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "concept", + "contextType": "variable", + "element": "text", + "transform": "copy", + "parameter": [ + { + "valueString": "CHW Child Registration" + } + ] + } + ] + } + ] + }, + { + "name": "rule_obs", + "source": [ + { + "context": "src" + } + ], + "dependent": [ + { + "name": "ExtractObservationForWeight", + "variable": [ + "src", + "encounter", + "bundle" + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/sm-gen/src/main/resources/test/questionnaire-response.json b/sm-gen/src/main/resources/test/questionnaire-response.json new file mode 100644 index 00000000..4d826cc1 --- /dev/null +++ b/sm-gen/src/main/resources/test/questionnaire-response.json @@ -0,0 +1,184 @@ +{ + "resourceType": "QuestionnaireResponse", + "questionnaire": "Questionnaire/client-registration-sample", + "item": [ + { + "linkId": "PR", + "item": [ + { + "linkId": "PR-name", + "item": [ + { + "linkId": "PR-name-given", + "answer": [ + { + "valueString": "Mike" + } + ] + }, + { + "linkId": "PR-name-family", + "answer": [ + { + "valueString": "Doe" + } + ] + } + ] + }, + { + "linkId": "patient-0-birth-date", + "answer": [ + { + "valueDate": "2021-07-01" + } + ] + }, + { + "linkId": "patient-0-gender", + "answer": [ + { + "valueCoding": { + "code": "male", + "display": "Male" + } + } + ] + }, + { + "linkId": "PR-telecom", + "item": [ + { + "linkId": "PR-telecom-system", + "answer": [ + { + "valueString": "phone" + } + ] + }, + { + "linkId": "PR-telecom-value", + "answer": [ + { + "valueString": "0700 000 000" + } + ] + } + ] + }, + { + "linkId": "PR-address", + "item": [ + { + "linkId": "PR-address-city", + "answer": [ + { + "valueString": "Nairobi" + } + ] + }, + { + "linkId": "PR-address-country", + "answer": [ + { + "valueString": "Kenya" + } + ] + } + ] + }, + { + "linkId": "PR-active", + "answer": [ + { + "valueBoolean": true + } + ] + } + ] + }, + { + "linkId": "RP", + "item": [ + { + "linkId": "RP-family-name", + "answer": [ + { + "valueString": "Doe" + } + ] + }, + { + "linkId": "RP-first-name", + "answer": [ + { + "valueString": "Mama-mike" + } + ] + }, + { + "linkId": "RP-relationship", + "answer": [ + { + "valueString": "PRN" + } + ] + }, + { + "linkId": "RP-contact-1", + "answer": [ + { + "valueString": "0700 001 001" + } + ] + }, + { + "linkId": "RP-contact-alternate", + "answer": [ + { + "valueString": "0700 000 012" + } + ] + } + ] + }, + { + "linkId": "comorbidities", + "answer": [ + { + "valueCoding": { + "display": "Cancer", + "system": "https://www.snomed.org", + "code": "363346000" + } + }, + { + "valueCoding": { + "display": "Others", + "system": "https://www.snomed.org", + "code": "74964007" + } + } + ] + }, + { + "linkId": "other_comorbidities", + "answer": [ + { + "valueString": "This is another comorbidity" + } + ] + }, + { + "linkId": "patient-barcode", + "answer": [ + { + "valueString": "scanned-barcode-string" + } + ] + }, + { + "linkId": "RP-id" + } + ] + } \ No newline at end of file diff --git a/sm-gen/src/main/resources/test/questionnaire.json b/sm-gen/src/main/resources/test/questionnaire.json new file mode 100644 index 00000000..75bdd911 --- /dev/null +++ b/sm-gen/src/main/resources/test/questionnaire.json @@ -0,0 +1,691 @@ +{ + "resourceType": "Questionnaire", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap", + "valueCanonical": "https://fhir.labs.smartregister.org/StructureMap/1902" + } + ], + "status": "active", + "subjectType": [ + "Patient" + ], + "date": "2020-11-18T07:24:47.111Z", + "item": [ + { + "linkId": "patient-barcode", + "text": "Barcode", + "type": "text", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "Patient.id" + } + } + ] + }, + { + "linkId": "PR", + "text": "Client Info", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Maelezo ya mteja" + } + ] + } + ] + }, + "type": "group", + "item": [ + { + "linkId": "PR-name", + "type": "group", + "item": [ + { + "linkId": "PR-name-given", + "text": "First Name", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Jina la kwanza" + } + ] + } + ] + }, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientName", + "language": "text/fhirpath", + "expression": "Patient.name.given" + } + } + ], + "type": "string", + "required": true + }, + { + "linkId": "PR-name-family", + "text": "Family Name", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Jina la ukoo" + } + ] + } + ] + }, + "type": "string", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientFamily", + "language": "text/fhirpath", + "expression": "Patient.name.family" + } + } + ] + } + ] + }, + { + "linkId": "patient-0-birth-date", + "text": "Date of Birth", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Tarehe ya kuzaliwa" + } + ] + } + ] + }, + "type": "date", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientBirthDate", + "language": "text/fhirpath", + "expression": "Patient.birthDate" + } + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "radio-button", + "display": "Radio Button" + } + ], + "text": "A control where choices are listed with a button beside them. The button can be toggled to select or de-select a given choice. Selecting one item deselects all others." + } + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation", + "valueCode": "horizontal" + }, + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientGender", + "language": "text/fhirpath", + "expression": "Patient.gender" + } + } + ], + "linkId": "patient-0-gender", + "text": "Gender", + "type": "choice", + "answerOption": [ + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "female", + "display": "Female" + } + }, + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "male", + "display": "Male" + } + } + ] + }, + { + "linkId": "PR-telecom", + "type": "group", + "item": [ + { + "linkId": "PR-telecom-system", + "text": "system", + "type": "string", + "enableWhen": [ + { + "question": "patient-0-gender", + "operator": "=", + "answerString": "ok" + } + ], + "initial": [ + { + "valueString": "phone" + } + ] + }, + { + "linkId": "PR-telecom-value", + "text": "Phone Number", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientTelecom", + "language": "text/fhirpath", + "expression": "Patient.telecom.value" + } + } + ], + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Nambari ya simu" + } + ] + } + ] + }, + "type": "string", + "required": true + } + ] + }, + { + "linkId": "PR-address", + "type": "group", + "item": [ + { + "linkId": "PR-address-city", + "text": "City", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientCity", + "language": "text/fhirpath", + "expression": "Patient.address.city" + } + } + ], + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Mji" + } + ] + } + ] + }, + "type": "string" + }, + { + "linkId": "PR-address-country", + "text": "Country", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Nchi" + } + ] + } + ] + }, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientCountry", + "language": "text/fhirpath", + "expression": "Patient.address.country" + } + } + ], + "type": "string" + } + ] + }, + { + "linkId": "PR-active", + "text": "Is Active?", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/translation", + "extension": [ + { + "url": "lang", + "valueCode": "sw" + }, + { + "url": "content", + "valueString": "Inatumika?" + } + ] + } + ] + }, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "name": "patientActive", + "language": "text/fhirpath", + "expression": "Patient.active" + } + } + ], + "type": "boolean" + } + ] + }, + { + "linkId": "RP", + "text": "Related person", + "type": "group", + "item": [ + { + "linkId": "RP-family-name", + "text": "Family name", + "type": "text", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.name.family" + } + } + ] + }, + { + "linkId": "RP-first-name", + "text": "First name", + "type": "text", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.name.given" + } + } + ] + }, + { + "linkId": "RP-relationship", + "text": "Relationship to patient", + "type": "text", + "required": true, + "answerValueSet": "http://hl7.org/fhir/ValueSet/relatedperson-relationshiptype", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.relationship.coding.code" + } + } + ] + }, + { + "linkId": "RP-contact-1", + "text": "Phone number", + "type": "text", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.telecom[0].value" + } + } + ] + }, + { + "linkId": "RP-contact-alternate", + "text": "Alternative phone number", + "type": "text", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.telecom[1].value" + } + } + ] + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "check-box" + } + ] + } + } + ], + "linkId": "comorbidities", + "code": [ + { + "system": "https://www.snomed.org", + "code": "991381000000107" + } + ], + "text": "Do you have any of the following conditions?", + "type": "choice", + "required": true, + "repeats": true, + "answerOption": [ + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "73211009", + "display": "Diabetes Mellitus (DM)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "59621000", + "display": "HyperTension (HT)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "414545008", + "display": "Ischemic Heart Disease (IHD / CHD / CCF)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "56717001", + "display": "Tuberculosis (TB)" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "195967001", + "display": "Asthma/COPD" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "709044004", + "display": "Chronic Kidney Disease" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "363346000", + "display": "Cancer" + } + }, + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "74964007", + "display": "Others" + } + } + ], + "enableWhen": [ + { + "question": "RP-id", + "operator": "exists", + "answerBoolean": false + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/RiskAssessment", + "valueBoolean": true + } + ], + "linkId": "other_comorbidities", + "code": [ + { + "system": "https://www.snomed.org", + "code": "38651000000103" + } + ], + "text": "If other, specify: ", + "type": "string", + "enableWhen": [ + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "74964007", + "display": "Others" + } + }, + { + "question": "RP-id", + "operator": "exists", + "answerBoolean": false + } + ], + "enableBehavior": "all" + }, + { + "linkId": "risk_assessment", + "code": [ + { + "system": "https://www.snomed.org", + "code": "225338004", + "display": "Risk Assessment" + } + ], + "text": "Client is at risk for serious illness from COVID-19", + "type": "choice", + "enableWhen": [ + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "74964007", + "display": "Others" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "363346000", + "display": "Cancer" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "709044004", + "display": "Chronic Kidney Disease" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "195967001", + "display": "Asthma/COPD" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "56717001", + "display": "Tuberculosis (TB)" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "414545008", + "display": "Ischemic Heart Disease (IHD / CHD / CCF)" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "59621000", + "display": "HyperTension (HT)" + } + }, + { + "question": "comorbidities", + "operator": "=", + "answerCoding": { + "system": "https://www.snomed.org", + "code": "73211009", + "display": "Diabetes Mellitus (DM)" + } + }, + { + "question": "RP-id", + "operator": "exists", + "answerBoolean": false + } + ], + "enableBehavior": "any", + "initial": [ + { + "valueCoding": { + "system": "https://www.snomed.org", + "code": "870577009", + "display": "High Risk for COVID-19" + } + } + ] + }, + { + "linkId": "RP-id", + "text": "Related person id", + "type": "text", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden", + "valueBoolean": true + }, + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "RelatedPerson.id" + } + } + ] + } + ] + } \ No newline at end of file From 0eef0ce0548389bcb087176b19ba004490c80923 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Tue, 18 Jun 2024 20:32:17 +0300 Subject: [PATCH 07/34] =?UTF-8?q?Efsity=20|=20Fix=20character=20encoding?= =?UTF-8?q?=20for=20special=20UTF-8=20characters=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/smartregister/command/TranslateCommand.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java index 98f0bbe2..9c4e5921 100644 --- a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java +++ b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java @@ -224,8 +224,10 @@ private static void mergeContent( // Load the translation properties Properties translationProperties = new Properties(); - try (InputStream input = new FileInputStream(translationFile)) { - translationProperties.load(input); + try (FileInputStream fileInputStream = new FileInputStream(translationFile); + InputStreamReader reader = new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { + + translationProperties.load(reader); } // Traverse and update the JSON structure @@ -409,7 +411,10 @@ private static void extractContent( Properties existingProperties = new Properties(); if (Files.exists(propertiesFilePath)) { - try (InputStream input = new FileInputStream(propertiesFilePath.toFile())) { + + try (FileInputStream fileInputStream = new FileInputStream(propertiesFilePath.toFile()); + InputStreamReader input = + new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { existingProperties.load(input); } } From f8ba5a831aae84221a584094f6e62ebf8fe1ee7e Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Wed, 19 Jun 2024 10:28:45 +0300 Subject: [PATCH 08/34] Release version 2.3.5 --- efsity/build.gradle.kts | 2 +- .../smartregister/command/PublishFhirResourcesCommandTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/efsity/build.gradle.kts b/efsity/build.gradle.kts index 0a20f8c2..f1224b04 100644 --- a/efsity/build.gradle.kts +++ b/efsity/build.gradle.kts @@ -18,7 +18,7 @@ repositories { group = "org.smartregister" -version = "2.3.4-SNAPSHOT" +version = "2.3.5-SNAPSHOT" description = "fhircore-tooling (efsity)" diff --git a/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java b/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java index 2389b507..5695769d 100644 --- a/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java +++ b/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java @@ -276,6 +276,6 @@ void testPublishBinaries() throws IOException { .getJSONArray("tag") .getJSONObject(0) .getString("code"), - "2.3.4-SNAPSHOT"); + "2.3.5-SNAPSHOT"); } } From 854eb2013fcc32fd5c3f7b8e41342e5a47e0ed4c Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Wed, 19 Jun 2024 12:03:46 +0300 Subject: [PATCH 09/34] =?UTF-8?q?Fix=20build=20=F0=9F=92=9A=20-=20Fix=20fa?= =?UTF-8?q?iling=20test=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/TranslateCommand.java | 32 +++++++++---------- .../src/test/resources/strings_fr.properties | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java index 9c4e5921..678c5ff6 100644 --- a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java +++ b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java @@ -223,12 +223,7 @@ private static void mergeContent( objectMapper.readTree(Files.newBufferedReader(inputFilePath, StandardCharsets.UTF_8)); // Load the translation properties - Properties translationProperties = new Properties(); - try (FileInputStream fileInputStream = new FileInputStream(translationFile); - InputStreamReader reader = new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { - - translationProperties.load(reader); - } + Properties translationProperties = getPropertiesFile(translationFile); // Traverse and update the JSON structure JsonNode updatedJson = updateJson(rootNode, translationProperties, locale, targetFields); @@ -240,6 +235,20 @@ private static void mergeContent( String.format("Merged JSON saved to \u001b[36m%s\u001b[0m", inputFilePath.toString())); } + public static Properties getPropertiesFile(String propertiesFilePath) throws IOException { + Properties properties = new Properties(); + // Read existing properties file, if it exists + if (Files.exists(Paths.get(propertiesFilePath))) { + try (FileInputStream fileInputStream = new FileInputStream(propertiesFilePath); + InputStreamReader reader = + new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { + + properties.load(reader); + } + } + return properties; + } + private static JsonNode updateJson( JsonNode node, Properties translationProperties, String locale, Set targetFields) throws NoSuchAlgorithmException { @@ -407,17 +416,8 @@ private static void extractContent( throw new RuntimeException("Provide a valid `resourceFile` directory or file."); } - // Read existing properties file, if it exists - Properties existingProperties = new Properties(); - - if (Files.exists(propertiesFilePath)) { + Properties existingProperties = getPropertiesFile(propertiesFilePath.toString()); - try (FileInputStream fileInputStream = new FileInputStream(propertiesFilePath.toFile()); - InputStreamReader input = - new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { - existingProperties.load(input); - } - } // Merge existing properties with new properties existingProperties.putAll(textToHash); writePropertiesFile(existingProperties, translationFile); diff --git a/efsity/src/test/resources/strings_fr.properties b/efsity/src/test/resources/strings_fr.properties index 44a1b58d..9a59bf0d 100644 --- a/efsity/src/test/resources/strings_fr.properties +++ b/efsity/src/test/resources/strings_fr.properties @@ -1,5 +1,5 @@ #Thu Nov 02 09:51:17 EAT 2023 b989b9027fad22ec89690367614e2da6=Raison de l?examen abdominal -f89de0a4cd9317a2987dfc2546e2da5e=Examen abdominal effectué +f89de0a4cd9317a2987dfc2546e2da5e=Examen abdominal effectué bafd7322c6e97d25b6299b5d6fe8920b=Non 93cba07454f06a4a960172bbd6e2a435=Oui From b09cee5aea6866b5e49213fac03fbcc157410627 Mon Sep 17 00:00:00 2001 From: Ager Wasongah Date: Mon, 1 Jul 2024 13:22:41 +0300 Subject: [PATCH 10/34] Update roles.csv (#204) Update missing roles --- importer/csv/setup/roles.csv | 83 +++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/importer/csv/setup/roles.csv b/importer/csv/setup/roles.csv index c230dfef..aee4bc4a 100644 --- a/importer/csv/setup/roles.csv +++ b/importer/csv/setup/roles.csv @@ -1,13 +1,15 @@ role,composite,associated_roles -GET_BINARY,, +ANDROID_CLIENT,, GET_BINARY,, GET_BUNDLE,, GET_CAREPLAN,, GET_CARETEAM,, GET_CONDITION,, +GET_CONSENT,, GET_ENCOUNTER,, GET_FLAG,, GET_GROUP,, +GET_IMMUNIZATION,, GET_LIBRARY,, GET_LIST,, GET_LOCATION,, @@ -20,23 +22,50 @@ GET_PATIENT,, GET_PLANDEFINITION,, GET_PRACTITIONER,, GET_PRACTITIONER-DETAILS,, -GET_PRACTITIONERDETAIL,, GET_PRACTITIONERROLE,, +GET_QUESTIONNAIRE,, GET_QUESTIONNAIRERESPONSE,, GET_RELATEDPERSON,, -GET_TASK,, -GET_BASIC,, -GET_IMMUNIZATION,, +GET_SERVICEREQUEST,, GET_STRUCTUREMAP,, -GET_QUESTIONNAIRE,, +GET_TASK,, +PATCH_BUNDLE,, +PATCH_CAREPLAN,, +PATCH_CARETEAM,, +PATCH_CONDITION,, +PATCH_CONSENT,, +PATCH_ENCOUNTER,, +PATCH_FLAG,, +PATCH_GROUP,, +PATCH_IMMUNIZATION,, +PATCH_LIBRARY,, +PATCH_LIST,, +PATCH_LOCATION,, +PATCH_MEASURE,, +PATCH_MEASUREREPORT,, +PATCH_OBSERVATION,, +PATCH_ORGANIZATION,, +PATCH_ORGANIZATIONAFFILIATION,, +PATCH_PATIENT,, +PATCH_PLANDEFINITION,, +PATCH_PRACTITIONER,, +PATCH_PRACTITIONERROLE,, +PATCH_QUESTIONNAIRE,, +PATCH_QUESTIONNAIRERESPONSE,, +PATCH_RELATEDPERSON,, +PATCH_SERVICEREQUEST,, +PATCH_STRUCTUREMAP,, +PATCH_TASK,, POST_BINARY,, POST_BUNDLE,, POST_CAREPLAN,, POST_CARETEAM,, POST_CONDITION,, +POST_CONSENT,, POST_ENCOUNTER,, POST_FLAG,, POST_GROUP,, +POST_IMMUNIZATION,, POST_LIBRARY,, POST_LIST,, POST_LOCATION,, @@ -49,13 +78,12 @@ POST_PATIENT,, POST_PLANDEFINITION,, POST_PRACTITIONER,, POST_PRACTITIONERROLE,, +POST_QUESTIONNAIRE,, POST_QUESTIONNAIRERESPONSE,, POST_RELATEDPERSON,, -POST_TASK,, -POST_BASIC,, -POST_IMMUNIZATION,, +POST_SERVICEREQUEST,, POST_STRUCTUREMAP,, -POST_QUESTIONNAIRE,, +POST_TASK,, PUT_BUNDLE,, PUT_CAREPLAN,, PUT_CARETEAM,, @@ -63,6 +91,7 @@ PUT_CONDITION,, PUT_ENCOUNTER,, PUT_FLAG,, PUT_GROUP,, +PUT_IMMUNIZATION,, PUT_LIBRARY,, PUT_LIST,, PUT_LOCATION,, @@ -75,38 +104,12 @@ PUT_PATIENT,, PUT_PLANDEFINITION,, PUT_PRACTITIONER,, PUT_PRACTITIONERROLE,, +PUT_QUESTIONNAIRE,, PUT_QUESTIONNAIRERESPONSE,, PUT_RELATEDPERSON,, -PUT_TASK,, -PUT_BASIC,, -PUT_IMMUNIZATION,, +PUT_SERVICEREQUEST,, PUT_STRUCTUREMAP,, -PUT_QUESTIONNAIRE,, -PATCH_BUNDLE,, -PATCH_CAREPLAN,, -PATCH_CARETEAM,, -PATCH_CONDITION,, -PATCH_ENCOUNTER,, -PATCH_FLAG,, -PATCH_GROUP,, -PATCH_LIBRARY,, -PATCH_LIST,, -PATCH_LOCATION,, -PATCH_MEASURE,, -PATCH_MEASUREREPORT,, -PATCH_OBSERVATION,, -PATCH_ORGANIZATION,, -PATCH_ORGANIZATIONAFFILIATION,, -PATCH_PATIENT,, -PATCH_PLANDEFINITION,, -PATCH_PRACTITIONER,, -PATCH_PRACTITIONERROLE,, -PATCH_QUESTIONNAIRERESPONSE,, -PATCH_RELATEDPERSON,, -PATCH_TASK,, -PATCH_BASIC,, -PATCH_IMMUNIZATION,, -PATCH_STRUCTUREMAP,, -PATCH_QUESTIONNAIRE,, +PUT_TASK,, +WEB_CLIENT,, EDIT_KEYCLOAK_USERS,TRUE,manage-users|query-users VIEW_KEYCLOAK_USERS,TRUE,view-users|query-users|query-groups From 535a19fe03aa95b5075b7a0f240fa9d17776871d Mon Sep 17 00:00:00 2001 From: Francis Odhiambo <4540684+f-odhiambo@users.noreply.github.com> Date: Mon, 1 Jul 2024 13:55:38 +0300 Subject: [PATCH 11/34] Update roles.csv (#205) * Update roles.csv Add missing roles on GET,POST,PUT * Update practiotner details role --- importer/csv/setup/roles.csv | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/importer/csv/setup/roles.csv b/importer/csv/setup/roles.csv index aee4bc4a..1432f679 100644 --- a/importer/csv/setup/roles.csv +++ b/importer/csv/setup/roles.csv @@ -29,6 +29,7 @@ GET_RELATEDPERSON,, GET_SERVICEREQUEST,, GET_STRUCTUREMAP,, GET_TASK,, +PATCH_BINARY,, PATCH_BUNDLE,, PATCH_CAREPLAN,, PATCH_CARETEAM,, @@ -84,10 +85,12 @@ POST_RELATEDPERSON,, POST_SERVICEREQUEST,, POST_STRUCTUREMAP,, POST_TASK,, +PUT_BINARY,, PUT_BUNDLE,, PUT_CAREPLAN,, PUT_CARETEAM,, PUT_CONDITION,, +PUT_CONSENT,, PUT_ENCOUNTER,, PUT_FLAG,, PUT_GROUP,, @@ -112,4 +115,4 @@ PUT_STRUCTUREMAP,, PUT_TASK,, WEB_CLIENT,, EDIT_KEYCLOAK_USERS,TRUE,manage-users|query-users -VIEW_KEYCLOAK_USERS,TRUE,view-users|query-users|query-groups +VIEW_KEYCLOAK_USERS,TRUE,view-users|query-users|query-groups \ No newline at end of file From 7b3e310e2c5c619107ae8cff65fd01eefb7d2344 Mon Sep 17 00:00:00 2001 From: Ager Wasongah Date: Mon, 1 Jul 2024 17:02:23 +0300 Subject: [PATCH 12/34] Update roles.csv (#206) --- importer/csv/setup/roles.csv | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/importer/csv/setup/roles.csv b/importer/csv/setup/roles.csv index 1432f679..17747c58 100644 --- a/importer/csv/setup/roles.csv +++ b/importer/csv/setup/roles.csv @@ -13,6 +13,7 @@ GET_IMMUNIZATION,, GET_LIBRARY,, GET_LIST,, GET_LOCATION,, +GET_LOCATIONHIERARCHY,, GET_MEASURE,, GET_MEASUREREPORT,, GET_OBSERVATION,, @@ -21,7 +22,7 @@ GET_ORGANIZATIONAFFILIATION,, GET_PATIENT,, GET_PLANDEFINITION,, GET_PRACTITIONER,, -GET_PRACTITIONER-DETAILS,, +GET_PRACTITIONERDETAIL,, GET_PRACTITIONERROLE,, GET_QUESTIONNAIRE,, GET_QUESTIONNAIRERESPONSE,, @@ -115,4 +116,4 @@ PUT_STRUCTUREMAP,, PUT_TASK,, WEB_CLIENT,, EDIT_KEYCLOAK_USERS,TRUE,manage-users|query-users -VIEW_KEYCLOAK_USERS,TRUE,view-users|query-users|query-groups \ No newline at end of file +VIEW_KEYCLOAK_USERS,TRUE,view-users|query-users|query-groups From 1a28668b9f81c143388bf09464041a708f63fddb Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Fri, 5 Jul 2024 13:12:44 +0300 Subject: [PATCH 13/34] Add i18N support for new config fields --- .../main/java/org/smartregister/util/FCTConstants.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/efsity/src/main/java/org/smartregister/util/FCTConstants.java b/efsity/src/main/java/org/smartregister/util/FCTConstants.java index d687d066..1be5f7f4 100644 --- a/efsity/src/main/java/org/smartregister/util/FCTConstants.java +++ b/efsity/src/main/java/org/smartregister/util/FCTConstants.java @@ -5,5 +5,12 @@ public class FCTConstants { public static final Set questionnaireTranslatables = Set.of("text", "display"); public static final Set configTranslatables = - Set.of("saveButtonText", "title", "display", "actionButtonText", "message"); + Set.of( + "saveButtonText", + "title", + "display", + "actionButtonText", + "message", + "primaryText", + "secondaryText"); } From c13f98c71e94a494d082d242818efce913891037 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Fri, 5 Jul 2024 13:24:05 +0300 Subject: [PATCH 14/34] Update TransformSupportServices class to match FHIRCore --- .../org/smartregister/external/TransformSupportServices.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt b/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt index e1f25356..aef2a102 100644 --- a/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt +++ b/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt @@ -40,7 +40,7 @@ class TransformSupportServices constructor(private val simpleWorkerContext: Simp override fun createType(appInfo: Any, name: String): Base { return when (name) { "RiskAssessment_Prediction" -> RiskAssessmentPredictionComponent() - "Immunization_VaccinationProtocol" -> Immunization.ImmunizationProtocolAppliedComponent() + "Immunization_AppliedProtocol" -> Immunization.ImmunizationProtocolAppliedComponent() "Immunization_Reaction" -> Immunization.ImmunizationReactionComponent() "EpisodeOfCare_Diagnosis" -> EpisodeOfCare.DiagnosisComponent() "Encounter_Diagnosis" -> Encounter.DiagnosisComponent() From 411de34fdb1a396ccd44f3c99fd4343d4faaf2d6 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Fri, 5 Jul 2024 13:25:14 +0300 Subject: [PATCH 15/34] Refactor Versioning, remove Duplication --- .../smartregister/command/PublishFhirResourcesCommand.java | 6 ++++++ .../command/PublishFhirResourcesCommandTest.java | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java index 3cd3363e..71e71e7a 100644 --- a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java +++ b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java @@ -2,6 +2,7 @@ import static org.smartregister.util.authentication.OAuthAuthentication.getAccessToken; +import com.google.common.annotations.VisibleForTesting; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import java.io.BufferedReader; @@ -509,4 +510,9 @@ String getProjectFolder(String projectFolder) { } return parentFolder.toString(); } + + @VisibleForTesting + public static final String getFCTReleaseVersion() { + return BuildConfig.RELEASE_VERSION; + } } diff --git a/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java b/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java index 5695769d..5b4caf0b 100644 --- a/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java +++ b/efsity/src/test/java/org/smartregister/command/PublishFhirResourcesCommandTest.java @@ -269,13 +269,13 @@ void testPublishBinaries() throws IOException { .getString("data") .startsWith("eyJhcHBJZCI6InRlc3Qi")); assertEquals( + publishFhirResourcesCommand.getFCTReleaseVersion(), resources .get(2) .getJSONObject("resource") .getJSONObject("meta") .getJSONArray("tag") .getJSONObject(0) - .getString("code"), - "2.3.5-SNAPSHOT"); + .getString("code")); } } From 27ec4a4908156a3f7c84fec5ab28ebc48e42cb90 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Fri, 5 Jul 2024 13:26:07 +0300 Subject: [PATCH 16/34] =?UTF-8?q?Release=20Version=202.3.6=20=F0=9F=94=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- efsity/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/efsity/build.gradle.kts b/efsity/build.gradle.kts index f1224b04..cc80e204 100644 --- a/efsity/build.gradle.kts +++ b/efsity/build.gradle.kts @@ -18,7 +18,7 @@ repositories { group = "org.smartregister" -version = "2.3.5-SNAPSHOT" +version = "2.3.6-SNAPSHOT" description = "fhircore-tooling (efsity)" From 459843e3605f7489af88839377d0f05cbecba3fa Mon Sep 17 00:00:00 2001 From: Wambere Date: Thu, 11 Jul 2024 01:06:48 +0300 Subject: [PATCH 17/34] Handle deletion of inventory identifiers properly (#219) --- importer/main.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/importer/main.py b/importer/main.py index 0923336c..8c9a0185 100644 --- a/importer/main.py +++ b/importer/main.py @@ -530,6 +530,7 @@ def group_extras(resource, payload_string, group_type): payload_obj = json.loads(payload_string) item_name = resource[0] del_indexes = [] + del_identifier_indexes = [] GROUP_INDEX_MAPPING = { "product_secondary_id_index": 1, @@ -655,27 +656,27 @@ def group_extras(resource, payload_string, group_type): GROUP_INDEX_MAPPING["inventory_official_id_index"] ]["value"] = serial_number else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_official_id_index"] - ] + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["inventory_official_id_index"]) if po_number: payload_obj["resource"]["identifier"][ GROUP_INDEX_MAPPING["inventory_secondary_id_index"] ]["value"] = po_number else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_secondary_id_index"] - ] + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["inventory_secondary_id_index"]) if usual_id: payload_obj["resource"]["identifier"][ GROUP_INDEX_MAPPING["inventory_usual_id_index"] ]["value"] = usual_id else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_usual_id_index"] - ] + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["inventory_usual_id_index"]) + + for x in reversed(del_identifier_indexes): + del payload_obj["resource"]["identifier"][x] if actual: payload_obj["resource"]["actual"] = bool(actual) From 956ce229d42a7c178f43be414d629b97c6c45b55 Mon Sep 17 00:00:00 2001 From: Wambere Date: Thu, 11 Jul 2024 18:06:24 +0300 Subject: [PATCH 18/34] Add product material number indentifier (#220) --- importer/csv/import/product.csv | 6 +++--- .../json_payloads/product_group_payload.json | 2 +- importer/main.py | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/importer/csv/import/product.csv b/importer/csv/import/product.csv index 7f49f22f..47b90a82 100644 --- a/importer/csv/import/product.csv +++ b/importer/csv/import/product.csv @@ -1,3 +1,3 @@ -name,active,method,id,previousId,isAttractiveItem,availability,condition,appropriateUsage,accountabilityPeriod,imageSourceUrl -thermometer,true,create,1d86d0e2-bac8-4424-90ae-e2298900ac3c,10,true,yes,good,ok,12,https://ona.io/home/wp-content//uploads/2022/06/spotlight-fhir.png -sterilizer,true,create,,53209452,true,no,,,, \ No newline at end of file +name,active,method,id,materialNumber,previousId,isAttractiveItem,availability,condition,appropriateUsage,accountabilityPeriod,imageSourceUrl +thermometer,true,create,1d86d0e2-bac8-4424-90ae-e2298900ac3c,56dtdhdh,10,true,yes,good,ok,12,https://ona.io/home/wp-content//uploads/2022/06/spotlight-fhir.png +sterilizer,true,create,,6786kaiw,,true,no,,,, \ No newline at end of file diff --git a/importer/json_payloads/product_group_payload.json b/importer/json_payloads/product_group_payload.json index a41d494e..64208d68 100644 --- a/importer/json_payloads/product_group_payload.json +++ b/importer/json_payloads/product_group_payload.json @@ -17,7 +17,7 @@ } }, "use": "official", - "value": "$unique_uuid" + "value": "$material_number" }, { "use": "secondary", diff --git a/importer/main.py b/importer/main.py index 8c9a0185..32952f82 100644 --- a/importer/main.py +++ b/importer/main.py @@ -533,6 +533,7 @@ def group_extras(resource, payload_string, group_type): del_identifier_indexes = [] GROUP_INDEX_MAPPING = { + "product_official_id_index": 0, "product_secondary_id_index": 1, "product_is_attractive_index": 0, "product_is_available_index": 1, @@ -554,6 +555,7 @@ def group_extras(resource, payload_string, group_type): _, active, *_, + material_number, previous_id, is_attractive_item, availability, @@ -568,14 +570,19 @@ def group_extras(resource, payload_string, group_type): else: del payload_obj["resource"]["active"] + if material_number: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["product_official_id_index"] + ]["value"] = material_number + else: + del_identifier_indexes.append(GROUP_INDEX_MAPPING["product_official_id_index"]) + if previous_id: payload_obj["resource"]["identifier"][ GROUP_INDEX_MAPPING["product_secondary_id_index"] ]["value"] = previous_id else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["product_secondary_id_index"] - ] + del_identifier_indexes.append(GROUP_INDEX_MAPPING["product_secondary_id_index"]) if is_attractive_item: payload_obj["resource"]["characteristic"][ @@ -675,9 +682,6 @@ def group_extras(resource, payload_string, group_type): del_identifier_indexes.append( GROUP_INDEX_MAPPING["inventory_usual_id_index"]) - for x in reversed(del_identifier_indexes): - del payload_obj["resource"]["identifier"][x] - if actual: payload_obj["resource"]["actual"] = bool(actual) else: @@ -736,6 +740,8 @@ def group_extras(resource, payload_string, group_type): for x in reversed(del_indexes): del payload_obj["resource"]["characteristic"][x] + for x in reversed(del_identifier_indexes): + del payload_obj["resource"]["identifier"][x] payload_string = json.dumps(payload_obj, indent=4) return payload_string From 7cff3fea788f9efeceb6162ca232a9b3f0180d0e Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Fri, 12 Jul 2024 17:57:33 +0300 Subject: [PATCH 19/34] =?UTF-8?q?Refactor=20i18N=20fix=20encoding=20bug=20?= =?UTF-8?q?=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- efsity/build.gradle.kts | 2 +- .../command/PublishFhirResourcesCommand.java | 12 +++--------- .../command/TranslateCommand.java | 18 ++---------------- .../command/TranslateCommandTest.java | 14 +++++--------- .../src/test/resources/strings_fr.properties | 2 +- 5 files changed, 12 insertions(+), 36 deletions(-) diff --git a/efsity/build.gradle.kts b/efsity/build.gradle.kts index cc80e204..bb776e83 100644 --- a/efsity/build.gradle.kts +++ b/efsity/build.gradle.kts @@ -18,7 +18,7 @@ repositories { group = "org.smartregister" -version = "2.3.6-SNAPSHOT" +version = "2.3.7-SNAPSHOT" description = "fhircore-tooling (efsity)" diff --git a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java index 71e71e7a..8d5f4bba 100644 --- a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java +++ b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java @@ -7,11 +7,9 @@ import com.google.gson.JsonParser; import java.io.BufferedReader; import java.io.File; -import java.io.FileInputStream; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; -import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -105,13 +103,9 @@ public class PublishFhirResourcesCommand implements Runnable { public void run() { long start = System.currentTimeMillis(); if (propertiesFile != null && !propertiesFile.isBlank()) { - try (InputStream inputProperties = new FileInputStream(propertiesFile)) { - Properties properties = new Properties(); - properties.load(inputProperties); - setProperties(properties); - } catch (IOException e) { - throw new RuntimeException(e); - } + + Properties properties = FctUtils.readPropertiesFile(propertiesFile); + setProperties(properties); } try { if (compositionFilePath != null) { diff --git a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java index 678c5ff6..7f693fac 100644 --- a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java +++ b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java @@ -223,7 +223,7 @@ private static void mergeContent( objectMapper.readTree(Files.newBufferedReader(inputFilePath, StandardCharsets.UTF_8)); // Load the translation properties - Properties translationProperties = getPropertiesFile(translationFile); + Properties translationProperties = FctUtils.readPropertiesFile(translationFile); // Traverse and update the JSON structure JsonNode updatedJson = updateJson(rootNode, translationProperties, locale, targetFields); @@ -235,20 +235,6 @@ private static void mergeContent( String.format("Merged JSON saved to \u001b[36m%s\u001b[0m", inputFilePath.toString())); } - public static Properties getPropertiesFile(String propertiesFilePath) throws IOException { - Properties properties = new Properties(); - // Read existing properties file, if it exists - if (Files.exists(Paths.get(propertiesFilePath))) { - try (FileInputStream fileInputStream = new FileInputStream(propertiesFilePath); - InputStreamReader reader = - new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { - - properties.load(reader); - } - } - return properties; - } - private static JsonNode updateJson( JsonNode node, Properties translationProperties, String locale, Set targetFields) throws NoSuchAlgorithmException { @@ -416,7 +402,7 @@ private static void extractContent( throw new RuntimeException("Provide a valid `resourceFile` directory or file."); } - Properties existingProperties = getPropertiesFile(propertiesFilePath.toString()); + Properties existingProperties = FctUtils.readPropertiesFile(propertiesFilePath.toString()); // Merge existing properties with new properties existingProperties.putAll(textToHash); diff --git a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java index 5a5d2d68..4b1d290a 100644 --- a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java +++ b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java @@ -4,9 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -15,6 +13,7 @@ import java.util.Properties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.smartregister.util.FctUtils; public class TranslateCommandTest { @@ -55,14 +54,11 @@ public void testRunExtract() throws IOException { assertDoesNotThrow(() -> translateCommand.run()); - Properties existingProperties = new Properties(); - InputStream defaultPropertiesInput = new FileInputStream(defaultPropertiesPath.toFile()); - existingProperties.load(defaultPropertiesInput); + Properties existingProperties = + FctUtils.readPropertiesFile(defaultPropertiesPath.toFile().getAbsolutePath()); - Properties newProperties = new Properties(); - InputStream tempDefaultPropertiesInput = - new FileInputStream(tempDefaultPropertiesPath.toFile()); - newProperties.load(tempDefaultPropertiesInput); + Properties newProperties = + FctUtils.readPropertiesFile(tempDefaultPropertiesPath.toFile().getAbsolutePath()); // Compare the contents of the two files assertEquals(existingProperties, newProperties, "File contents are similar."); diff --git a/efsity/src/test/resources/strings_fr.properties b/efsity/src/test/resources/strings_fr.properties index 9a59bf0d..44a1b58d 100644 --- a/efsity/src/test/resources/strings_fr.properties +++ b/efsity/src/test/resources/strings_fr.properties @@ -1,5 +1,5 @@ #Thu Nov 02 09:51:17 EAT 2023 b989b9027fad22ec89690367614e2da6=Raison de l?examen abdominal -f89de0a4cd9317a2987dfc2546e2da5e=Examen abdominal effectué +f89de0a4cd9317a2987dfc2546e2da5e=Examen abdominal effectué bafd7322c6e97d25b6299b5d6fe8920b=Non 93cba07454f06a4a960172bbd6e2a435=Oui From 7664e5405c617c90d8e42be03d99ef4a12ad2dbb Mon Sep 17 00:00:00 2001 From: Wambere Date: Mon, 15 Jul 2024 19:16:40 +0300 Subject: [PATCH 20/34] Create List resource to reference product resources (#221) * Create List resource referencing Group and Binary resources created by product import * Add List resource json payload * Allow auto updating of the List resource --- importer/README.md | 2 +- .../json_payloads/product_list_payload.json | 31 +++++++++ importer/main.py | 69 ++++++++++++++++--- importer/test_main.py | 39 ++++++++++- 4 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 importer/json_payloads/product_list_payload.json diff --git a/importer/README.md b/importer/README.md index c1dc4b4c..325d28c0 100644 --- a/importer/README.md +++ b/importer/README.md @@ -138,7 +138,7 @@ The coverage report `coverage.html` will be at the working directory ### 10. Import products from openSRP 1 - Run `python3 main.py --csv_file csv/import/product.csv --setup products --log_level info` - See example csv [here](/importer/csv/import/product.csv) -- This creates a Group resource for each product imported +- This creates a Group resource for each product imported, a Binary resource for any products with an image, and a List resource with references to all the Group and Binary resources created - The first two columns __name__ and __active__ is the minimum required - The last column __imageSourceUrl__ contains a url to the product image. If this source requires authentication, then you need to provide the `product_access_token` in the config file. The image is added as a binary resource and referenced in the product's Group resource diff --git a/importer/json_payloads/product_list_payload.json b/importer/json_payloads/product_list_payload.json new file mode 100644 index 00000000..5395281c --- /dev/null +++ b/importer/json_payloads/product_list_payload.json @@ -0,0 +1,31 @@ +{ + "request": { + "method": "PUT", + "url": "List/$unique_uuid", + "ifMatch": "$version" + }, + "resource": { + "resourceType": "List", + "id": "$unique_uuid", + "identifier": [ + { + "use": "official", + "value": "$identifier_uuid" + } + ], + "status": "$status", + "mode": "working", + "title": "$name", + "code": { + "coding": [ + { + "system": "http://smartregister.org/", + "code": "22138876", + "display": "Supply Inventory List" + } + ], + "text": "Supply Inventory List" + }, + "entry": [] + } +} \ No newline at end of file diff --git a/importer/main.py b/importer/main.py index 32952f82..4935f822 100644 --- a/importer/main.py +++ b/importer/main.py @@ -526,7 +526,7 @@ def care_team_extras(resource, payload_string, ftype): # custom extras for product import -def group_extras(resource, payload_string, group_type): +def group_extras(resource, payload_string, group_type, created_resources): payload_obj = json.loads(payload_string) item_name = resource[0] del_indexes = [] @@ -627,6 +627,7 @@ def group_extras(resource, payload_string, group_type): payload_obj["resource"]["characteristic"][ GROUP_INDEX_MAPPING["product_image_index"] ]["valueReference"]["reference"] = ("Binary/" + image_binary) + created_resources.append("Binary/" + image_binary) else: logging.error( "Unable to link the image Binary resource for product " + item_name @@ -744,7 +745,7 @@ def group_extras(resource, payload_string, group_type): del payload_obj["resource"]["identifier"][x] payload_string = json.dumps(payload_obj, indent=4) - return payload_string + return payload_string, created_resources def extract_matches(resource_list): @@ -988,7 +989,7 @@ def get_valid_resource_type(resource_type): # This function gets the current resource version from the API def get_resource(resource_id, resource_type): - if resource_type != "Group": + if resource_type not in ["List", "Group"]: resource_type = get_valid_resource_type(resource_type) resource_url = "/".join([config.fhir_base_url, resource_type, resource_id]) response = handle_request("GET", "", resource_url) @@ -1006,10 +1007,11 @@ def check_for_nulls(resource: list) -> list: # This function builds a json payload # which is posted to the api to create resources -def build_payload(resource_type, resources, resource_payload_file): +def build_payload(resource_type, resources, resource_payload_file, created_resources=None): logging.info("Building request payload") initial_string = """{"resourceType": "Bundle","type": "transaction","entry": [ """ - final_string = " " + final_string = group_type = " " + with open(resource_payload_file) as json_file: payload_string = json_file.read() @@ -1077,14 +1079,43 @@ def build_payload(resource_type, resources, resource_payload_file): group_type = "product" else: logging.error("Undefined group type") - ps = group_extras(resource, ps, group_type) + ps, created_resources = group_extras(resource, ps, group_type, created_resources) final_string = final_string + ps + "," final_string = initial_string + final_string[:-1] + " ] } " + if group_type == "product": + return final_string, created_resources return final_string +# This function takes a 'created_resources' array and a response string +# It converts the response string to a json object, then loops through the entry array +# extracting all the referenced resources and adds them to the created_resources array +# then returns it +def extract_resources(created_resources, response_string): + json_response = json.loads(response_string) + entry = json_response["entry"] + for item in entry: + resource = item["response"]["location"] + created_resources.append(resource[0:42]) + return created_resources + + +# This function takes a List resource payload and a list of resources +# It adds the resources into the List resource's entry array +# then returns the full resource payload +def process_resources_list(payload, resources_list): + entry = [] + for resource in resources_list: + item = {"item": {"reference": resource}} + entry.append(item) + + payload = json.loads(payload) + payload["entry"][0]["resource"]["entry"] = entry + return payload + + def link_to_location(resource_list): arr = [] with click.progressbar( @@ -1595,7 +1626,6 @@ def save_image(image_source_url): payload_string = json.dumps(payload, indent=4) response = handle_request("POST", payload_string, get_base_url()) if response.status_code == 200: - logging.info("Binary resource created successfully") logging.info(response.text) return resource_id else: @@ -1786,6 +1816,7 @@ def filter(self, record): @click.option("--bulk_import", required=False, default=False) @click.option("--chunk_size", required=False, default=1000000) @click.option("--resources_count", required=False, default=100) +@click.option("--list_resource_id", required=False) @click.option( "--sync", type=click.Choice(["DIRECT", "SORT"], case_sensitive=False), @@ -1811,6 +1842,7 @@ def main( bulk_import, chunk_size, resources_count, + list_resource_id, sync, ): if log_level == "DEBUG": @@ -1931,10 +1963,27 @@ def main( logging.info("Processing complete!") elif setup == "products": logging.info("Importing products as FHIR Group resources") - json_payload = build_payload( - "Group", resource_list, "json_payloads/product_group_payload.json" + json_payload, created_resources = build_payload( + "Group", resource_list, "json_payloads/product_group_payload.json", [] ) - final_response = handle_request("POST", json_payload, config.fhir_base_url) + product_creation_response = handle_request("POST", json_payload, config.fhir_base_url) + + if product_creation_response.status_code == 200: + full_list_created_resources = extract_resources(created_resources, product_creation_response.text) + if not list_resource_id: + list_resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, csv_file)) + + current_version = get_resource(list_resource_id, "List") + method = "create" if current_version == 0 else "update" + resource = [["Supply Inventory List", "current", method, list_resource_id]] + result_payload = build_payload( + "List", resource, "json_payloads/product_list_payload.json") + + list_payload = process_resources_list(result_payload, full_list_created_resources) + final_response = handle_request("POST", "", config.fhir_base_url, list_payload) + logging.info("Processing complete!") + else: + logging.error(product_creation_response.text) elif setup == "inventories": logging.info("Importing inventories as FHIR Group resources") json_payload = build_payload( diff --git a/importer/test_main.py b/importer/test_main.py index b82dac74..2e61d491 100644 --- a/importer/test_main.py +++ b/importer/test_main.py @@ -17,6 +17,8 @@ check_parent_admin_level, split_chunk, read_file_in_chunks, + extract_resources, + process_resources_list, ) @@ -388,10 +390,11 @@ def test_build_payload_group(self, mock_get_resource, mock_save_image): csv_file = "csv/import/product.csv" resource_list = read_csv(csv_file) - payload = build_payload( - "Group", resource_list, "json_payloads/product_group_payload.json" + payload, list_resource = build_payload( + "Group", resource_list, "json_payloads/product_group_payload.json", [] ) payload_obj = json.loads(payload) + self.assertEqual(list_resource, ['Binary/f374a23a-3c6a-4167-9970-b10c16a91bbd']) self.assertIsInstance(payload_obj, dict) self.assertEqual(payload_obj["resourceType"], "Bundle") @@ -435,6 +438,38 @@ def test_build_payload_group(self, mock_get_resource, mock_save_image): } validate(payload_obj["entry"][0]["request"], request_schema) + def test_build_payload_group_reference_list(self): + binary_resources = ['Binary/df620fe8-eeaa-47c6-809c-84252e22980a'] + response_string = ('{"entry": [{"response": {"location": ' + '"Group/ce64e19d-6d8a-4ef0-8fc6-1da83783aea8/_history/1"}}, {"response": ' + '{"location": "Group/aedd3c1a-5de8-45d5-8b35-5c288ccbb761/_history/1"}}]}') + expected_resource_list = ['Binary/df620fe8-eeaa-47c6-809c-84252e22980a', + 'Group/ce64e19d-6d8a-4ef0-8fc6-1da83783aea8', + 'Group/aedd3c1a-5de8-45d5-8b35-5c288ccbb761'] + + created_resources = extract_resources(binary_resources, response_string) + self.assertEqual(created_resources, expected_resource_list) + + resource = [["Supply Inventory List", "current", "create", "77dae131-fd5d-4585-95db-2dd2b569d7a1"]] + result_payload = build_payload( + "List", resource, "json_payloads/product_list_payload.json") + full_list_payload = process_resources_list(result_payload, created_resources) + + resource_schema = { + "type": "object", + "properties": { + "resourceType": {"const": "List"}, + "id": {"const": "77dae131-fd5d-4585-95db-2dd2b569d7a1"}, + "identifier": {"type": "array", "items": {"type": "object"}}, + "status": {"const": "current"}, + "mode": {"const": "working"}, + "title": {"const": "Supply Inventory List"}, + "entry": {"type": "array", "minItems": 3, "maxItems": 3}, + }, + "required": ["resourceType", "id", "identifier", "status", "mode", "title", "entry"], + } + validate(full_list_payload["entry"][0]["resource"], resource_schema) + def test_extract_matches(self): csv_file = "csv/organizations/organizations_locations.csv" resource_list = read_csv(csv_file) From 3551e6a2adb4d8b537fa170790531f76ec786173 Mon Sep 17 00:00:00 2001 From: Sharon Akinyi <79141719+sharon2719@users.noreply.github.com> Date: Mon, 15 Jul 2024 19:34:12 +0300 Subject: [PATCH 21/34] Add SM Tool readme file (#223) --- sm-gen/README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 sm-gen/README.md diff --git a/sm-gen/README.md b/sm-gen/README.md new file mode 100644 index 00000000..e2ba4836 --- /dev/null +++ b/sm-gen/README.md @@ -0,0 +1,37 @@ +# StructureMap tooling + +The StructureMap Tooling is a utility designed to generate Structure Maps for FHIR (Fast Healthcare Interoperability Resources) transformations. Structure Maps are used to define how one FHIR resource is transformed into another. This tooling leverages the HL7 FHIRPath language to provide support for creating and managing these transformations. + +## Features +**FHIRPath Engine**: Utilizes the FHIRPath engine to evaluate and transform FHIR resources. + +**Transformation Support**: Provides services to support the transformation process, ensuring that resources are accurately converted according to the specified Structure Map. + +**Automation**: Simplifies the process of generating Structure Maps through automated tooling. + +## Files +**FhirPathEngineHostServices.kt**: Contains services for hosting and running the FHIRPath engine. + +**Main.kt**: The entry point of the application. It orchestrates the process of reading input, processing it through the FHIRPath engine, and generating the Structure Map. + +**TransformSupportServices.kt**: Provides additional support services required for the transformation process. + +**Utils.kt**: Contains the main logic for generating the Structure Maps using the FHIRPath engine and transformation support services. + +## Prerequisites +- Questionnaire JSON +- XLS form with the required information based on the questionnaire +### Installation +1. Clone the Repository: + +```console +git clone https://github.com/your-repo/structuremap-tooling.git +cd structuremap-tooling +``` +2. Once the `structuremap-tooling` folder, click on run. +3. A prompt will appear on the CLI `Kindly enter the XLS filepath`: Enter the absolute path of the file's location. Click `Enter` +4. Another prompt will appear on the CLI `Kindly enter the questionnaire filepath`: Enter the absolute path of the file's location. Click `Enter` +5. The structureMap will be generated in the CLI and two complete files in the folder containing the `.json` and `.map` files + +## Contributing +Contributions are welcome! Please open an issue or submit a pull request for any enhancements or bug fixes. \ No newline at end of file From f931d85af47e5b36028b84cd6c7c87fcdc8cf147 Mon Sep 17 00:00:00 2001 From: Francis Odhiambo <4540684+f-odhiambo@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:54:26 +0300 Subject: [PATCH 22/34] Initial Commit (#224) --- .../org/smartregister/external/TransformSupportServices.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt b/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt index aef2a102..bec23f46 100644 --- a/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt +++ b/efsity/src/main/kotlin/org/smartregister/external/TransformSupportServices.kt @@ -57,6 +57,8 @@ class TransformSupportServices constructor(private val simpleWorkerContext: Simp "Task_Output" -> Task.TaskOutputComponent() "Task_Restriction" -> Task.TaskRestrictionComponent() "AdverseEvent_SuspectEntity" -> AdverseEvent.AdverseEventSuspectEntityComponent() + "AdverseEvent_SuspectEntityCausality" -> + AdverseEvent.AdverseEventSuspectEntityCausalityComponent() "Location_Position" -> Location.LocationPositionComponent() "List_Entry" -> ListResource.ListEntryComponent() else -> ResourceFactory.createResourceOrType(name) From 8d9db177569258ee3ccca0047a38b5b7e3d6cbe3 Mon Sep 17 00:00:00 2001 From: Hilary Baraka Egesa Date: Tue, 16 Jul 2024 22:02:13 +0300 Subject: [PATCH 23/34] Maintain current config files on failure (#214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init multifactor authentication setup * Revert "init multifactor authentication setup" This reverts commit 826f31acf6b0f673bd1cf725b3ad08ccef7a1b06. * put translations in the same file if -tf is not given * deep copy config folder * copy over temp configs in happy path * create temp file when config points to single resource file * delete tmp files after translation * fix failing test * add test * test behavior for malformed json * change how properties file is read * put translations in the same file if -tf is not given * deep copy config folder * Add i18N support for new config fields * Update TransformSupportServices class to match FHIRCore * Refactor Versioning, remove Duplication * Release Version 2.3.6 🔖 * copy over temp configs in happy path * create temp file when config points to single resource file * delete tmp files after translation * fix failing test * add test * test behavior for malformed json * Handle deletion of inventory identifiers properly (#219) * Add product material number indentifier (#220) * Refactor i17N fix encoding bug 🛠* Create List resource to reference product resources (#221) * Create List resource referencing Group and Binary resources created by product import * Add List resource json payload * Allow auto updating of the List resource * Add SM Tool readme file (#223) * change how properties file is read * Initial Commit (#224) --------- Co-authored-by: Martin Ndegwa Co-authored-by: Wambere Co-authored-by: Sharon Akinyi <79141719+sharon2719@users.noreply.github.com> Co-authored-by: Francis Odhiambo <4540684+f-odhiambo@users.noreply.github.com> --- .../command/TranslateCommand.java | 171 +++++++++++++----- .../command/TranslateCommandTest.java | 36 ++++ .../clean_configs_folder/profile_config.json | 61 +++++++ .../dirty_configs_folder/profile_config.json | 61 +++++++ 4 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 efsity/src/test/resources/clean_configs_folder/profile_config.json create mode 100644 efsity/src/test/resources/dirty_configs_folder/profile_config.json diff --git a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java index 7f693fac..9a484654 100644 --- a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java +++ b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java @@ -8,6 +8,7 @@ import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.*; @@ -58,6 +59,9 @@ public class TranslateCommand implements Runnable { private static String url = "http://hl7.org/fhir/StructureDefinition/translation"; + Path tempsConfig = null; + Path tempFilePath = null; + @Override public void run() { if (!Arrays.asList(modes).contains(mode)) { @@ -76,27 +80,27 @@ public void run() { FctUtils.printInfo(String.format("Input file \u001b[35m%s\u001b[0m", resourceFile)); try { + Path translationsDirectoryPath = inputFilePath.getParent().resolve("translations"); + + if (Objects.equals(extractionType, "configs")) { + tempsConfig = Files.createTempDirectory("configs"); + } else tempsConfig = null; + if (!Files.exists(translationsDirectoryPath)) + Files.createDirectories(translationsDirectoryPath); + if (translationFile == null) { + translationFile = translationsDirectoryPath + "/strings_default.properties"; + } // Check if the input path is a directory or a JSON file if (Files.isDirectory(inputFilePath)) { - if (Objects.equals(extractionType, "configs") || inputFilePath.endsWith("configs")) { - extractionType = "configs"; Set targetFields = FCTConstants.configTranslatables; - - if (translationFile == null) { - translationFile = - inputFilePath.resolve("translations/strings_config.properties").toString(); - } + copyDirectoryContent(inputFilePath, tempsConfig); extractContent(translationFile, inputFilePath, targetFields, extractionType); } else if (Objects.equals(extractionType, "fhirContent") || inputFilePath.endsWith("fhir_content")) { extractionType = "fhirContent"; Set targetFields = FCTConstants.questionnaireTranslatables; - if (translationFile == null) { - translationFile = - inputFilePath.resolve("translations/strings_default.properties").toString(); - } if (!inputFilePath.endsWith("questionnaires")) { inputFilePath = inputFilePath.resolve("questionnaires"); } @@ -109,13 +113,7 @@ public void run() { if (Files.exists(configsPath) && Files.isDirectory(configsPath)) { extractionType = "configs"; Set targetFields = FCTConstants.configTranslatables; - String configsTranslationFile = null; - configsTranslationFile = - Objects.requireNonNullElseGet( - translationFile, - () -> - configsPath.resolve("translations/strings_config.properties").toString()); - extractContent(configsTranslationFile, configsPath, targetFields, extractionType); + extractContent(translationFile, configsPath, targetFields, extractionType); } else { FctUtils.printWarning("`configs` directory not found in directory"); } @@ -125,16 +123,8 @@ public void run() { && Files.isDirectory(questionnairePath)) { extractionType = "fhirContent"; Set targetFields = FCTConstants.questionnaireTranslatables; - String contentTranslationFile = null; - contentTranslationFile = - Objects.requireNonNullElseGet( - translationFile, - () -> - fhirContentPath - .resolve("translations/strings_default.properties") - .toString()); - extractContent( - contentTranslationFile, questionnairePath, targetFields, extractionType); + + extractContent(translationFile, questionnairePath, targetFields, extractionType); } else { FctUtils.printWarning( "`fhir_content` or `fhir_content/questionnaires` directory not found in directory"); @@ -349,16 +339,28 @@ private static JsonNode createExtensionNode(String locale, String translation) { return extensionArray; } - private static void extractContent( + private void extractContent( String translationFile, Path inputFilePath, Set targetFields, String extractionType) throws IOException, NoSuchAlgorithmException { Map textToHash = new HashMap<>(); Path propertiesFilePath = Paths.get(translationFile); - if (Files.isRegularFile(inputFilePath) && inputFilePath.toString().toLowerCase(Locale.ENGLISH).endsWith(".json")) { - if (Objects.equals(extractionType, "configs")) { + String configFileSubDirectory = + inputFilePath.subpath(2, inputFilePath.getNameCount() - 1).toString(); + try { + Path tempConfigSubDirectory = tempsConfig.resolve(configFileSubDirectory); + if (!Files.exists(tempConfigSubDirectory)) { + Files.createDirectories(tempConfigSubDirectory); + tempFilePath = tempConfigSubDirectory.resolve(inputFilePath.getFileName()); + // copy over content + Files.copy(inputFilePath, tempFilePath, StandardCopyOption.REPLACE_EXISTING); + } + + } catch (IOException e) { + throw new RuntimeException("Error creating temp file " + e); + } // Extract and replace target fields with hashed values ObjectMapper objectMapper = new ObjectMapper(); JsonNode rootNode = @@ -366,7 +368,9 @@ private static void extractContent( FctUtils.printInfo( String.format( "Extracting config file \u001b[35m%s\u001b[0m", inputFilePath.toString())); - replaceTargetFieldsWithHashedValues(rootNode, targetFields, textToHash, inputFilePath); + + replaceTargetFieldsWithHashedValues( + rootNode, targetFields, textToHash, inputFilePath, tempsConfig); } else { // For other types (content/questionnaire), extract as usual processJsonFile(inputFilePath, textToHash, targetFields); @@ -374,7 +378,8 @@ private static void extractContent( } else if (Files.isDirectory(inputFilePath)) { // Handle the case where inputFilePath is a directory (folders may contain multiple JSON // files) - Files.walk(inputFilePath) + + Files.walk(tempsConfig) .filter(Files::isRegularFile) .filter(file -> file.toString().endsWith(".json")) .forEach( @@ -389,25 +394,39 @@ private static void extractContent( FctUtils.printInfo( String.format( "Extracting config file \u001b[35m%s\u001b[0m", file.toString())); - replaceTargetFieldsWithHashedValues(rootNode, targetFields, textToHash, file); + + replaceTargetFieldsWithHashedValues( + rootNode, targetFields, textToHash, file, tempsConfig); } else { // For other types (content/questionnaire), extract as usual processJsonFile(file, textToHash, targetFields); } } catch (IOException | NoSuchAlgorithmException e) { - throw new RuntimeException(e); + deleteDirectoryRecursively(tempsConfig); + throw new RuntimeException( + "Error while reading file " + file.getFileName() + " " + e); } }); } else { throw new RuntimeException("Provide a valid `resourceFile` directory or file."); } + // Copy over translations from temp + if (extractionType.equals("configs")) { + if (Files.isDirectory(inputFilePath)) copyDirectoryContent(tempsConfig, inputFilePath); + else { + assert tempFilePath != null; + Files.copy(tempFilePath, inputFilePath, StandardCopyOption.REPLACE_EXISTING); + } + } + Properties existingProperties = FctUtils.readPropertiesFile(propertiesFilePath.toString()); // Merge existing properties with new properties existingProperties.putAll(textToHash); writePropertiesFile(existingProperties, translationFile); FctUtils.printInfo(String.format("Translation file\u001b[36m %s \u001b[0m", translationFile)); + if (tempsConfig != null) deleteDirectoryRecursively(tempsConfig); } private static void processJsonFile( @@ -421,8 +440,12 @@ private static void processJsonFile( } private static void replaceTargetFieldsWithHashedValues( - JsonNode node, Set targetFields, Map textToHash, Path filePath) - throws NoSuchAlgorithmException { + JsonNode node, + Set targetFields, + Map textToHash, + Path filePath, + Path tempConfigsDir) + throws NoSuchAlgorithmException, IOException { if (node.isObject()) { ObjectNode objectNode = (ObjectNode) node; @@ -444,7 +467,8 @@ private static void replaceTargetFieldsWithHashedValues( JsonNode fieldValue = field.getValue(); if (fieldValue.isObject() || fieldValue.isArray()) { // Recursively update nested objects or arrays - replaceTargetFieldsWithHashedValues(fieldValue, targetFields, textToHash, filePath); + replaceTargetFieldsWithHashedValues( + fieldValue, targetFields, textToHash, filePath, tempConfigsDir); } } } else if (node.isArray()) { @@ -453,18 +477,20 @@ private static void replaceTargetFieldsWithHashedValues( JsonNode arrayElement = arrayNode.get(i); if (arrayElement.isObject() || arrayElement.isArray()) { // Recursively update nested objects or arrays - replaceTargetFieldsWithHashedValues(arrayElement, targetFields, textToHash, filePath); + replaceTargetFieldsWithHashedValues( + arrayElement, targetFields, textToHash, filePath, tempConfigsDir); } } } - // Write the updated JSON back to the file - try (BufferedWriter writer = Files.newBufferedWriter(filePath, StandardCharsets.UTF_8)) { + String configFileSubDirectory = filePath.subpath(2, filePath.getNameCount()).toString(); + + Path tempFilePath = tempConfigsDir.resolve(configFileSubDirectory); + // Write the updated JSON to temp file + try (BufferedWriter writer = Files.newBufferedWriter(tempFilePath, StandardCharsets.UTF_8)) { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.enable(SerializationFeature.INDENT_OUTPUT); objectMapper.writeValue(writer, node); - } catch (IOException e) { - throw new RuntimeException("Failed to write the updated JSON to file.", e); } } @@ -524,4 +550,63 @@ private static void writePropertiesFile(Properties properties, String filePath) properties.store(output, null); } } + + public void copyDirectoryContent(Path sourceDir, Path destinationDir) { + try { + Files.walkFileTree( + sourceDir, + new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + Path targetDir = destinationDir.resolve(sourceDir.relativize(dir)); + if (!Files.exists(targetDir)) { + Files.createDirectory(targetDir); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + + Files.copy( + file, + destinationDir.resolve(sourceDir.relativize(file)), + StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void deleteDirectoryRecursively(Path dirPath) { + + try { + // Delete the directory and its contents recursively + Files.walkFileTree( + dirPath, + new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) + throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + + System.out.println("Directory and its contents deleted successfully: " + dirPath); + } catch (IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java index 4b1d290a..ac155990 100644 --- a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java +++ b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java @@ -13,6 +13,7 @@ import java.util.Properties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.smartregister.util.FctUtils; public class TranslateCommandTest { @@ -67,6 +68,41 @@ public void testRunExtract() throws IOException { tempDefaultPropertiesPath.toFile().delete(); } + @Test + public void testRunExtractConfigWithCleanConfigsFolderRunsSuccessfully() throws IOException { + Path cleanConfigsFolder = Paths.get("src/test/resources/clean_configs_folder"); + TranslateCommand translateCommandForCopyingTemp = new TranslateCommand(); + Path backupFolder = Files.createTempDirectory("temp_back_up_dir"); + translateCommandForCopyingTemp.copyDirectoryContent(cleanConfigsFolder, backupFolder); + TranslateCommand translateCommandSpy = Mockito.spy(translateCommand); + translateCommandSpy.mode = "extract"; + translateCommandSpy.extractionType = "configs"; + translateCommandSpy.resourceFile = cleanConfigsFolder.toString(); + translateCommandSpy.run(); + Mockito.verify(translateCommandSpy, Mockito.atLeast(2)) + .copyDirectoryContent(Mockito.any(), Mockito.any()); + Mockito.verify(translateCommandSpy, Mockito.atLeast(1)) + .deleteDirectoryRecursively(Mockito.any()); + // restore clean_configs_folder + translateCommandForCopyingTemp.copyDirectoryContent(backupFolder, cleanConfigsFolder); + } + + @Test + public void testRunExtractConfigWithDirtyConfigsFolderDeletesTempFileOnFailure() + throws RuntimeException { + Path cleanConfigsFolder = Paths.get("src/test/resources/dirty_configs_folder"); + TranslateCommand translateCommandSpy = Mockito.spy(translateCommand); + translateCommandSpy.mode = "extract"; + translateCommandSpy.extractionType = "configs"; + translateCommandSpy.resourceFile = cleanConfigsFolder.toString(); + + assertThrows(RuntimeException.class, translateCommandSpy::run); + Mockito.verify(translateCommandSpy, Mockito.atLeast(1)) + .copyDirectoryContent(Mockito.any(), Mockito.any()); + Mockito.verify(translateCommandSpy, Mockito.atLeast(1)) + .deleteDirectoryRecursively(Mockito.any()); + } + @Test public void testRunMerge() throws IOException { Path rawQuestionnairePath = Paths.get("src/test/resources/raw_questionnaire.json"); diff --git a/efsity/src/test/resources/clean_configs_folder/profile_config.json b/efsity/src/test/resources/clean_configs_folder/profile_config.json new file mode 100644 index 00000000..c5852900 --- /dev/null +++ b/efsity/src/test/resources/clean_configs_folder/profile_config.json @@ -0,0 +1,61 @@ +{ + "appId": "app_id_name", + "configType": "profile", + "id": "config_id", + "fhirResource": { + "baseResource": { + "resource": "Patient" + }, + "relatedResources": [ + { + "resource": "Condition", + "searchParameter": "subject" + } + ]}, + "rules": [ + { + "name": "patientFirstName", + "condition": "true", + "actions": [ + "data.put('patientFirstName', fhirPath.extractValue(Patient, \"Patient.name[0].select(given)\"))" + ] + }, + { + "name": "patientMiddleName", + "condition": "true", + "actions": [ + "data.put('patientMiddleName', fhirPath.extractValue(Patient, \"Patient.name[0].select(text[0])\"))" + ] + }, + { + "name": "patientLastName", + "condition": "true", + "actions": [ + "data.put('patientLastName', fhirPath.extractValue(Patient, \"Patient.name[0].select(family)\"))" + ] + } + ], + "views": [ + { + "viewType": "COLUMN", + "children": [ + { + "viewType": "CARD", + "padding": 0 + } + ] + } + ], + "overFlowMenuItems": [ + { + "title": "Edit Client Info", + "titleColor": "@{patientTextColor}", + "visible": "true", + "enabled": "@{patientActive}", + "icon": { + "type": "local", + "reference": "ic_user" + } + } + ] +} \ No newline at end of file diff --git a/efsity/src/test/resources/dirty_configs_folder/profile_config.json b/efsity/src/test/resources/dirty_configs_folder/profile_config.json new file mode 100644 index 00000000..51c72c9e --- /dev/null +++ b/efsity/src/test/resources/dirty_configs_folder/profile_config.json @@ -0,0 +1,61 @@ +{ + "appId": "app_id_name", + "configType": "profile", + "id": "config_id", + "fhirResource": { + "baseResource": { + "resource": "Patient" + }, + "relatedResources": [ + { + "resource": "Condition", + "searchParameter": "subject" + } + ]}, + "rules": [ + { + "name": "patientFirstName", + "condition": "true", + "actions": [ + "data.put('patientFirstName', fhirPath.extractValue(Patient, \"Patient.name[0].select(given)\"))" + ] + }, + { + "name": "patientMiddleName", + "condition": "true", + "actions": [ + "data.put('patientMiddleName', fhirPath.extractValue(Patient, \"Patient.name[0].select(text[0])\"))" + ] + }, + { + "name": "patientLastName", + "condition": "true", + "actions": [ + "data.put('patientLastName', fhirPath.extractValue(Patient, \"Patient.name[0].select(family)\"))" + ] + } + ], + "views": [ + { + "viewType": "COLUMN", + "children": [ + { + "viewType": "CARD", + "padding": 0 + } + ] + } + ], + "overFlowMenuItems": [ + { + "title": "Edit Client Info",u + "titleColor": "@{patientTextColor}", + "visible": "true", + "enabled": "@{patientActive}", + "icon": { + "type": "local", + "reference": "ic_user" + } + } + ] +} \ No newline at end of file From 3d299b4af06741491ed68b019919972718d2bd76 Mon Sep 17 00:00:00 2001 From: Wambere Date: Wed, 17 Jul 2024 14:46:25 +0300 Subject: [PATCH 24/34] de-dedupe physical type in location type payload (#225) Co-authored-by: Peter Muriuki --- importer/json_payloads/locations_payload.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/importer/json_payloads/locations_payload.json b/importer/json_payloads/locations_payload.json index d06dbe46..553f5d3e 100644 --- a/importer/json_payloads/locations_payload.json +++ b/importer/json_payloads/locations_payload.json @@ -37,6 +37,15 @@ "display": "Level $adminLevelCode" } ] + }, + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "$pt_code", + "display": "$pt_display" + } + ] } ], "physicalType": { From a4a141675ae09bce9695a080290c5dc52ce14e05 Mon Sep 17 00:00:00 2001 From: Lincoln Simba Date: Thu, 18 Jul 2024 20:00:59 +0300 Subject: [PATCH 25/34] Add support for creating keycloak roles with default groups (#229) --- importer/README.md | 3 ++- importer/csv/setup/roles.csv | 1 + importer/main.py | 14 +++++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/importer/README.md b/importer/README.md index 325d28c0..4b40e77d 100644 --- a/importer/README.md +++ b/importer/README.md @@ -12,13 +12,14 @@ This script is used to setup keycloak roles and groups. It takes in a csv file w - `csv_file` : (Required) The csv file with the list of roles - `group` : (Not required) This is the actual group name. If not passed then the roles will just be created but not assigned to any group - `roles_max` : (Not required) This is the maximum number of roles to pull from the api. The default is set to 500. If the number of roles in your setup is more than this you will need to change this value +- `defaultgroups` : (Not Required) ### To run script 1. Create virtualenv 2. Install requirements.txt - `pip install -r requirements.txt` 3. Create a `config.py` file. The `sample_config.py` is an example of what this should look like. Populate it with the right credentials, you can either provide an access token or client credentials. Ensure that the user whose details you provide in this config file has the necessary permissions/privilleges. -4. Run script - `python3 main.py --setup roles --csv_file csv/setup/roles.csv --group Supervisor` +4. Run script - `python3 main.py --setup roles --csv_file csv/setup/roles.csv --group Supervisor --defaultgroups true` 5. If you are running the script without `https` setup e.g locally or a server without https setup, you will need to set the `OAUTHLIB_INSECURE_TRANSPORT` environment variable to 1. For example `export OAUTHLIB_INSECURE_TRANSPORT=1 && python3 main.py --setup roles --csv_file csv/setup/roles.csv --group OpenSRP_Provider --log_level debug` 6. You can turn on logging by passing a `--log_level` to the command line as `info`, `debug` or `error`. For example `python3 main.py --setup roles --csv_file csv/setup/roles.csv --group Supervisor --log_level debug` diff --git a/importer/csv/setup/roles.csv b/importer/csv/setup/roles.csv index 17747c58..aff12ebd 100644 --- a/importer/csv/setup/roles.csv +++ b/importer/csv/setup/roles.csv @@ -115,5 +115,6 @@ PUT_SERVICEREQUEST,, PUT_STRUCTUREMAP,, PUT_TASK,, WEB_CLIENT,, +ANDROID_CLIENT,, EDIT_KEYCLOAK_USERS,TRUE,manage-users|query-users VIEW_KEYCLOAK_USERS,TRUE,view-users|query-users|query-groups diff --git a/importer/main.py b/importer/main.py index 4935f822..f4c39905 100644 --- a/importer/main.py +++ b/importer/main.py @@ -20,7 +20,10 @@ exit() global_access_token = "" - +DEFAULT_GROUPS = { + "ANDROID_PRACTITIONER" : ["ANDROID_CLIENT"], + "WEB_PRACTITIONER": ["WEB_CLIENT"] +} # This function takes in a csv file # reads it and returns a list of strings/lines @@ -1356,6 +1359,11 @@ def assign_group_roles(role_list, group, roles_max): ) +def assign_default_groups_roles(roles_max): + for group_name, roles in DEFAULT_GROUPS.items(): + assign_group_roles(roles, group_name, roles_max) + + def delete_resource(resource_type, resource_id, cascade): if cascade: cascade = "?_cascade=delete" @@ -1804,6 +1812,7 @@ def filter(self, record): @click.option("--setup", required=False) @click.option("--group", required=False) @click.option("--roles_max", required=False, default=500) +@click.option("--defaultgroups", required=False, default=False) @click.option("--cascade_delete", required=False, default=False) @click.option("--only_response", required=False) @click.option( @@ -1832,6 +1841,7 @@ def main( setup, group, roles_max, + default_groups, cascade_delete, only_response, log_level, @@ -1954,6 +1964,8 @@ def main( if group: assign_group_roles(resource_list, group, roles_max) logging.info("Processing complete") + if default_groups: + assign_default_groups_roles(roles_max) elif setup == "clean_duplicates": logging.info( "You are about to clean/delete Practitioner resources on the HAPI server" From 69e34a18b3ed8e5645bacdb0f5237e45b1cc2625 Mon Sep 17 00:00:00 2001 From: Wambere Date: Tue, 23 Jul 2024 15:00:45 +0300 Subject: [PATCH 26/34] Update how partOf is added to a location (#234) Co-authored-by: Peter Muriuki --- importer/main.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/importer/main.py b/importer/main.py index f4c39905..321cce37 100644 --- a/importer/main.py +++ b/importer/main.py @@ -335,10 +335,16 @@ def location_extras(resource, payload_string): longitude = "longitude" try: - if locationParentName and locationParentName != "parentName": - payload_string = payload_string.replace( - "$parentName", locationParentName - ).replace("$parentID", locationParentId) + if locationParentId and locationParentId != "parentId": + payload_string = payload_string.replace("$parentID", locationParentId) + if not locationParentName or locationParentName == "parentName": + obj = json.loads(payload_string) + del obj["resource"]["partOf"]['display'] + payload_string = json.dumps(obj, indent=4) + else: + payload_string = payload_string.replace( + "$parentName", locationParentName + ) else: obj = json.loads(payload_string) del obj["resource"]["partOf"] From 3845511167ef16c9b4428d57b4571d62db269e41 Mon Sep 17 00:00:00 2001 From: Wambere Date: Tue, 23 Jul 2024 16:02:21 +0300 Subject: [PATCH 27/34] Fix bug in product list resource (#233) * Fix bug: unexpected argument 'defaultgroups' * Fix string-int comparison and update documentation --- importer/README.md | 3 ++- importer/main.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/importer/README.md b/importer/README.md index 4b40e77d..d7fff96d 100644 --- a/importer/README.md +++ b/importer/README.md @@ -137,11 +137,12 @@ The coverage report `coverage.html` will be at the working directory - The csv_file containing the exported resources is labelled using the current time, to know when the resources were exported for example, csv/exports/2024-02-21-12-21-export_Location.csv ### 10. Import products from openSRP 1 -- Run `python3 main.py --csv_file csv/import/product.csv --setup products --log_level info` +- Run `python3 main.py --csv_file csv/import/product.csv --setup products --list_resource_id 123 --log_level info` - See example csv [here](/importer/csv/import/product.csv) - This creates a Group resource for each product imported, a Binary resource for any products with an image, and a List resource with references to all the Group and Binary resources created - The first two columns __name__ and __active__ is the minimum required - The last column __imageSourceUrl__ contains a url to the product image. If this source requires authentication, then you need to provide the `product_access_token` in the config file. The image is added as a binary resource and referenced in the product's Group resource +- You can pass in a `list_resource_id` to be used as the identifier for the List resource, or you can leave it empty and a random uuid will be generated ### 11. Import inventories from openSRP 1 - Run `python3 main.py --csv_file csv/import/inventory.csv --setup inventories --log_level info` diff --git a/importer/main.py b/importer/main.py index 321cce37..703a9359 100644 --- a/importer/main.py +++ b/importer/main.py @@ -1818,7 +1818,7 @@ def filter(self, record): @click.option("--setup", required=False) @click.option("--group", required=False) @click.option("--roles_max", required=False, default=500) -@click.option("--defaultgroups", required=False, default=False) +@click.option("--default_groups", required=False, default=False) @click.option("--cascade_delete", required=False, default=False) @click.option("--only_response", required=False) @click.option( @@ -1992,7 +1992,7 @@ def main( list_resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, csv_file)) current_version = get_resource(list_resource_id, "List") - method = "create" if current_version == 0 else "update" + method = "create" if current_version == str(0) else "update" resource = [["Supply Inventory List", "current", method, list_resource_id]] result_payload = build_payload( "List", resource, "json_payloads/product_list_payload.json") From 5438b7b5b77f952bcdfe814e462e5d38f8892ed4 Mon Sep 17 00:00:00 2001 From: Wambere Date: Wed, 24 Jul 2024 18:11:45 +0300 Subject: [PATCH 28/34] 225 - remove location-physical-type from type[] when not defined (#237) * Also remove location-physical-time from the type[] if missing in file * Check if value exists. If value is None, strip fails --- importer/main.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/importer/main.py b/importer/main.py index 703a9359..567319e2 100644 --- a/importer/main.py +++ b/importer/main.py @@ -355,9 +355,9 @@ def location_extras(resource, payload_string): payload_string = json.dumps(obj, indent=4) try: - if len(locationType.strip()) > 0 and locationType != "type": + if locationType and locationType != "type": payload_string = payload_string.replace("$t_display", locationType) - if len(locationTypeCode.strip()) > 0 and locationTypeCode != "typeCode": + if locationTypeCode and locationTypeCode != "typeCode": payload_string = payload_string.replace("$t_code", locationTypeCode) else: obj = json.loads(payload_string) @@ -377,7 +377,7 @@ def location_extras(resource, payload_string): payload_string = json.dumps(obj, indent=4) try: - if len(locationAdminLevel.strip()) > 0 and locationAdminLevel != "adminLevel": + if locationAdminLevel and locationAdminLevel != "adminLevel": payload_string = payload_string.replace( "$adminLevelCode", locationAdminLevel ) @@ -423,25 +423,37 @@ def location_extras(resource, payload_string): payload_string = json.dumps(obj, indent=4) try: - if ( - len(locationPhysicalType.strip()) > 0 - and locationPhysicalType != "physicalType" - ): + if locationPhysicalType and locationPhysicalType != "physicalType": payload_string = payload_string.replace("$pt_display", locationPhysicalType) - if ( - len(locationPhysicalTypeCode.strip()) > 0 - and locationPhysicalTypeCode != "physicalTypeCode" - ): + if locationPhysicalTypeCode and locationPhysicalTypeCode != "physicalTypeCode": payload_string = payload_string.replace( "$pt_code", locationPhysicalTypeCode ) else: obj = json.loads(payload_string) del obj["resource"]["physicalType"] + # also remove from type[] + payload_type = obj["resource"]["type"] + current_system = "location-physical-type" + index = identify_coding_object_index(payload_type, current_system) + if index >= 0: + del obj["resource"]["type"][index] payload_string = json.dumps(obj, indent=4) except IndexError: obj = json.loads(payload_string) del obj["resource"]["physicalType"] + payload_type = obj["resource"]["type"] + current_system = "location-physical-type" + index = identify_coding_object_index(payload_type, current_system) + if index >= 0: + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + + # check if type is empty + obj = json.loads(payload_string) + _type = obj["resource"]["type"] + if not _type: + del obj["resource"]["type"] payload_string = json.dumps(obj, indent=4) try: From e0864a1a1d0666c6a04afc21affbbd2de3bbbef2 Mon Sep 17 00:00:00 2001 From: Wambere Date: Thu, 25 Jul 2024 10:33:00 +0300 Subject: [PATCH 29/34] Add inventories to List (#244) * update product_list_payload to group_list_payload * Modify List creation to work for inventories too --- importer/README.md | 4 +- ...t_payload.json => group_list_payload.json} | 2 +- importer/main.py | 42 +++++++++++++------ importer/test_main.py | 2 +- 4 files changed, 35 insertions(+), 15 deletions(-) rename importer/json_payloads/{product_list_payload.json => group_list_payload.json} (91%) diff --git a/importer/README.md b/importer/README.md index d7fff96d..bbdae867 100644 --- a/importer/README.md +++ b/importer/README.md @@ -145,11 +145,13 @@ The coverage report `coverage.html` will be at the working directory - You can pass in a `list_resource_id` to be used as the identifier for the List resource, or you can leave it empty and a random uuid will be generated ### 11. Import inventories from openSRP 1 -- Run `python3 main.py --csv_file csv/import/inventory.csv --setup inventories --log_level info` +- Run `python3 main.py --csv_file csv/import/inventory.csv --setup inventories --list_resource_id 123 --log_level info` - See example csv [here](/importer/csv/import/inventory.csv) - This creates a Group resource for each inventory imported - The first two columns __name__ and __active__ is the minimum required - Adding a value to the Location column will create a separate List resource (or update) that links the inventory to the provided location resource +- A separate List resource with references to all the Group and List resources generated is also created +- You can pass in a `list_resource_id` to be used as the identifier for the (reference) List resource, or you can leave it empty and a random uuid will be generated ### 12. Import JSON resources from file - Run `python3 main.py --bulk_import True --json_file tests/fhir_sample.json --chunk_size 500000 --sync sort --resources_count 100 --log_level info` diff --git a/importer/json_payloads/product_list_payload.json b/importer/json_payloads/group_list_payload.json similarity index 91% rename from importer/json_payloads/product_list_payload.json rename to importer/json_payloads/group_list_payload.json index 5395281c..48c6a859 100644 --- a/importer/json_payloads/product_list_payload.json +++ b/importer/json_payloads/group_list_payload.json @@ -19,7 +19,7 @@ "code": { "coding": [ { - "system": "http://smartregister.org/", + "system": "http://smartregister.org/codes", "code": "22138876", "display": "Supply Inventory List" } diff --git a/importer/main.py b/importer/main.py index 567319e2..5450d52f 100644 --- a/importer/main.py +++ b/importer/main.py @@ -1110,6 +1110,17 @@ def build_payload(resource_type, resources, resource_payload_file, created_resou return final_string +def build_group_list_resource(list_resource_id: str, csv_file: str, full_list_created_resources: list, title: str): + if not list_resource_id: + list_resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, csv_file)) + current_version = get_resource(list_resource_id, "List") + method = "create" if current_version == str(0) else "update" + resource = [[title, "current", method, list_resource_id]] + result_payload = build_payload( + "List", resource, "json_payloads/group_list_payload.json") + return process_resources_list(result_payload, full_list_created_resources) + + # This function takes a 'created_resources' array and a response string # It converts the response string to a json object, then loops through the entry array # extracting all the referenced resources and adds them to the created_resources array @@ -1119,7 +1130,8 @@ def extract_resources(created_resources, response_string): entry = json_response["entry"] for item in entry: resource = item["response"]["location"] - created_resources.append(resource[0:42]) + index = resource.find("/", resource.find("/") + 1) + created_resources.append(resource[0:index]) return created_resources @@ -2000,16 +2012,8 @@ def main( if product_creation_response.status_code == 200: full_list_created_resources = extract_resources(created_resources, product_creation_response.text) - if not list_resource_id: - list_resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, csv_file)) - - current_version = get_resource(list_resource_id, "List") - method = "create" if current_version == str(0) else "update" - resource = [["Supply Inventory List", "current", method, list_resource_id]] - result_payload = build_payload( - "List", resource, "json_payloads/product_list_payload.json") - - list_payload = process_resources_list(result_payload, full_list_created_resources) + list_payload = build_group_list_resource( + list_resource_id, csv_file, full_list_created_resources, "Supply Inventory List") final_response = handle_request("POST", "", config.fhir_base_url, list_payload) logging.info("Processing complete!") else: @@ -2019,13 +2023,27 @@ def main( json_payload = build_payload( "Group", resource_list, "json_payloads/inventory_group_payload.json" ) - final_response = handle_request("POST", json_payload, config.fhir_base_url) + inventory_creation_response = handle_request("POST", json_payload, config.fhir_base_url) + groups_created = [] + if inventory_creation_response.status_code == 200: + groups_created = extract_resources(groups_created, inventory_creation_response.text) + + lists_created = [] link_payload = link_to_location(resource_list) if len(link_payload) > 0: link_response = handle_request( "POST", link_payload, config.fhir_base_url ) + if link_response.status_code == 200: + lists_created = extract_resources(lists_created, link_response.text) logging.info(link_response.text) + + full_list_created_resources = groups_created + lists_created + if len(full_list_created_resources) > 0: + list_payload = build_group_list_resource( + list_resource_id, csv_file, full_list_created_resources, "Supply Chain commodities") + final_response = handle_request("POST", "", config.fhir_base_url, list_payload) + logging.info("Processing complete!") else: logging.error("Unsupported request!") else: diff --git a/importer/test_main.py b/importer/test_main.py index 2e61d491..aecfebbe 100644 --- a/importer/test_main.py +++ b/importer/test_main.py @@ -452,7 +452,7 @@ def test_build_payload_group_reference_list(self): resource = [["Supply Inventory List", "current", "create", "77dae131-fd5d-4585-95db-2dd2b569d7a1"]] result_payload = build_payload( - "List", resource, "json_payloads/product_list_payload.json") + "List", resource, "json_payloads/group_list_payload.json") full_list_payload = process_resources_list(result_payload, created_resources) resource_schema = { From c429468939f49185db5a21bd5587bb06c401ae25 Mon Sep 17 00:00:00 2001 From: Wambere Date: Fri, 26 Jul 2024 15:00:47 +0300 Subject: [PATCH 30/34] Add config option to define own location-type coding system url (#239) --- importer/README.md | 1 + importer/json_payloads/locations_payload.json | 2 +- importer/main.py | 19 ++++++++++---- importer/test_main.py | 25 ++++++++++++++++--- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/importer/README.md b/importer/README.md index bbdae867..365f5dd7 100644 --- a/importer/README.md +++ b/importer/README.md @@ -72,6 +72,7 @@ The coverage report `coverage.html` will be at the working directory - The seventh and eighth columns are the location's type and typeCode, respectively - The ninth column is the administrative level, that shows the hierarchical level of the location. Root location would have a `level 0` and all child locations will have a level `parent_admin_level + 1` - The tenth and eleventh columns are the location's physicalType and physicalTypeCode, respectively +- You can pass in `--location_type_coding_system` to define your own location type coding system url (not required) ### 2. Create users in bulk - Run `python3 main.py --csv_file csv/users.csv --resource_type users --log_level info` diff --git a/importer/json_payloads/locations_payload.json b/importer/json_payloads/locations_payload.json index 553f5d3e..5506ca03 100644 --- a/importer/json_payloads/locations_payload.json +++ b/importer/json_payloads/locations_payload.json @@ -23,7 +23,7 @@ { "coding": [ { - "system": "http://terminology.hl7.org/CodeSystem/location-type", + "system": "$t_system", "code": "$t_code", "display": "$t_display" } diff --git a/importer/main.py b/importer/main.py index 5450d52f..deb8fa08 100644 --- a/importer/main.py +++ b/importer/main.py @@ -286,6 +286,8 @@ def identify_coding_object_index(array, current_system): list_of_systems = value["coding"][0]["system"] if current_system in list_of_systems: return index + else: + return -1 def check_parent_admin_level(locationParentId): @@ -309,7 +311,7 @@ def check_parent_admin_level(locationParentId): # custom extras for locations -def location_extras(resource, payload_string): +def location_extras(resource, payload_string, location_coding_system): try: ( locationName, @@ -355,7 +357,8 @@ def location_extras(resource, payload_string): payload_string = json.dumps(obj, indent=4) try: - if locationType and locationType != "type": + payload_string = payload_string.replace("$t_system", location_coding_system) + if len(locationType.strip()) > 0 and locationType != "type": payload_string = payload_string.replace("$t_display", locationType) if locationTypeCode and locationTypeCode != "typeCode": payload_string = payload_string.replace("$t_code", locationTypeCode) @@ -1028,7 +1031,7 @@ def check_for_nulls(resource: list) -> list: # This function builds a json payload # which is posted to the api to create resources -def build_payload(resource_type, resources, resource_payload_file, created_resources=None): +def build_payload(resource_type, resources, resource_payload_file, created_resources=None, location_coding_system=None): logging.info("Building request payload") initial_string = """{"resourceType": "Bundle","type": "transaction","entry": [ """ final_string = group_type = " " @@ -1090,7 +1093,7 @@ def build_payload(resource_type, resources, resource_payload_file, created_resou if resource_type == "organizations": ps = organization_extras(resource, ps) elif resource_type == "locations": - ps = location_extras(resource, ps) + ps = location_extras(resource, ps, location_coding_system) elif resource_type == "careTeams": ps = care_team_extras(resource, ps, "orgs & users") elif resource_type == "Group": @@ -1862,6 +1865,10 @@ def filter(self, record): required=False, default="DIRECT", ) +@click.option( + "--location_type_coding_system", + required=False, + default="http://terminology.hl7.org/CodeSystem/location-type") def main( csv_file, json_file, @@ -1884,6 +1891,7 @@ def main( resources_count, list_resource_id, sync, + location_type_coding_system, ): if log_level == "DEBUG": logging.basicConfig( @@ -1955,7 +1963,8 @@ def main( elif resource_type == "locations": logging.info("Processing locations") json_payload = build_payload( - "locations", resource_list, "json_payloads/locations_payload.json" + "locations", resource_list, "json_payloads/locations_payload.json", None, + location_type_coding_system ) final_response = handle_request("POST", json_payload, config.fhir_base_url) logging.info("Processing complete!") diff --git a/importer/test_main.py b/importer/test_main.py index aecfebbe..c38e0d92 100644 --- a/importer/test_main.py +++ b/importer/test_main.py @@ -136,7 +136,8 @@ def test_build_payload_locations( csv_file = "csv/locations/locations_full.csv" resource_list = read_csv(csv_file) payload = build_payload( - "locations", resource_list, "json_payloads/locations_payload.json" + "locations", resource_list, "json_payloads/locations_payload.json", + None, "http://terminology.hl7.org/CodeSystem/location-type" ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -225,7 +226,8 @@ def test_build_payload_locations( csv_file = "csv/locations/locations_min.csv" resource_list = read_csv(csv_file) payload = build_payload( - "locations", resource_list, "json_payloads/locations_payload.json" + "locations", resource_list, "json_payloads/locations_payload.json", + None, "http://terminology.hl7.org/CodeSystem/location-type" ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -612,7 +614,8 @@ def test_uuid_generated_for_locations_is_unique_and_repeatable(self): ] payload = build_payload( - "locations", resources, "json_payloads/locations_payload.json" + "locations", resources, "json_payloads/locations_payload.json", + None, "http://terminology.hl7.org/CodeSystem/location-type" ) payload_obj = json.loads(payload) location1 = payload_obj["entry"][0]["resource"]["id"] @@ -1521,6 +1524,22 @@ def test_build_resource_type_map(self): self.assertIsInstance(mapping, dict) self.assertEqual(mapping, mapped_resources) + @patch("main.check_parent_admin_level") + @patch("main.get_resource") + def test_define_own_location_type_coding_system_url(self, mock_get_resource, mock_check_parent_admin_level): + mock_get_resource.return_value = "1" + mock_check_parent_admin_level.return_value = "3" + test_system_code = "http://terminology.hl7.org/CodeSystem/test_location-type" + + csv_file = "csv/locations/locations_full.csv" + resource_list = read_csv(csv_file) + payload = build_payload( + "locations", resource_list, "json_payloads/locations_payload.json", + None, test_system_code + ) + payload_obj = json.loads(payload) + self.assertEqual(payload_obj["entry"][0]["resource"]["type"][0]["coding"][0]["system"], test_system_code) + if __name__ == "__main__": unittest.main() From 3edc7dd53bbacc96bb49147517b5469a462dab54 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Fri, 19 Jul 2024 14:31:16 +0300 Subject: [PATCH 31/34] Change input type for Read Properties --- efsity/src/main/java/org/smartregister/util/FctUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/efsity/src/main/java/org/smartregister/util/FctUtils.java b/efsity/src/main/java/org/smartregister/util/FctUtils.java index 02a6b601..0b8295df 100644 --- a/efsity/src/main/java/org/smartregister/util/FctUtils.java +++ b/efsity/src/main/java/org/smartregister/util/FctUtils.java @@ -110,7 +110,7 @@ public static void writeJsonFile(String outputPath, String fhirResourceAsString) public static Properties readPropertiesFile(String filePath) { Properties properties = new Properties(); - try (InputStream input = new FileInputStream(filePath)) { + try (FileInputStream input = new FileInputStream(filePath)) { properties.load(input); return properties; From 828fd2c13e65758f7fa45aaca31911a3e222bb97 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Wed, 24 Jul 2024 13:22:45 +0300 Subject: [PATCH 32/34] Enhance Read Properties --- .../main/java/org/smartregister/util/FctUtils.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/efsity/src/main/java/org/smartregister/util/FctUtils.java b/efsity/src/main/java/org/smartregister/util/FctUtils.java index 0b8295df..332ae886 100644 --- a/efsity/src/main/java/org/smartregister/util/FctUtils.java +++ b/efsity/src/main/java/org/smartregister/util/FctUtils.java @@ -9,7 +9,7 @@ import java.io.FileInputStream; import java.io.FileWriter; import java.io.IOException; -import java.io.InputStream; +import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -108,13 +108,11 @@ public static void writeJsonFile(String outputPath, String fhirResourceAsString) } } - public static Properties readPropertiesFile(String filePath) { + public static Properties readPropertiesFile(String propertiesFilePath) { Properties properties = new Properties(); - try (FileInputStream input = new FileInputStream(filePath)) { - - properties.load(input); - return properties; - + try (FileInputStream fileInputStream = new FileInputStream(propertiesFilePath); + InputStreamReader reader = new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { + properties.load(reader); } catch (IOException ex) { ex.printStackTrace(); } From f2d54045f2ff564e9b428908cbede59bc12f9c3b Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Wed, 24 Jul 2024 17:59:57 +0300 Subject: [PATCH 33/34] Add support for properties file encoding detection Bump up release version --- efsity/build.gradle.kts | 3 ++- efsity/libs.versions.toml | 2 ++ .../command/PublishFhirResourcesCommand.java | 14 ++++++++++--- .../java/org/smartregister/util/FctUtils.java | 21 ++++++++++++------- .../command/TranslateCommandTest.java | 9 ++++---- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/efsity/build.gradle.kts b/efsity/build.gradle.kts index bb776e83..fb954030 100644 --- a/efsity/build.gradle.kts +++ b/efsity/build.gradle.kts @@ -18,7 +18,7 @@ repositories { group = "org.smartregister" -version = "2.3.7-SNAPSHOT" +version = "2.3.8-SNAPSHOT" description = "fhircore-tooling (efsity)" @@ -82,6 +82,7 @@ dependencies { implementation(deps.jsonschemafriend) implementation(deps.picocli) implementation(deps.xstream) + implementation(deps.icu4j) testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") diff --git a/efsity/libs.versions.toml b/efsity/libs.versions.toml index 10ed56ba..43ade7ed 100644 --- a/efsity/libs.versions.toml +++ b/efsity/libs.versions.toml @@ -16,6 +16,7 @@ opencds-cql-version="2.4.0" project-build-sourceEncoding="UTF-8" spotless-version ="6.20.0" xstream="1.4.20" +icu4j-version = "75.1" [libraries] caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine-version" } @@ -44,6 +45,7 @@ jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson-version" } picocli = { module = "info.picocli:picocli", version.ref = "info-picocli-version" } xstream = { module = "com.thoughtworks.xstream:xstream", version.ref = "xstream" } +icu4j = { module="com.ibm.icu:icu4j", version.ref = "icu4j-version" } [bundles] cqf-cql = ["cql-to-elm","elm","elm-jackson","model","model-jackson"] diff --git a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java index 8d5f4bba..2c5cff60 100644 --- a/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java +++ b/efsity/src/main/java/org/smartregister/command/PublishFhirResourcesCommand.java @@ -104,7 +104,12 @@ public void run() { long start = System.currentTimeMillis(); if (propertiesFile != null && !propertiesFile.isBlank()) { - Properties properties = FctUtils.readPropertiesFile(propertiesFile); + Properties properties = null; + try { + properties = FctUtils.readPropertiesFile(propertiesFile); + } catch (IOException e) { + throw new RuntimeException(e); + } setProperties(properties); } try { @@ -121,18 +126,21 @@ public void run() { } void setProperties(Properties properties) { + if (properties == null) + throw new IllegalStateException("Properties file is missing or could not be parsed"); + if (projectFolder == null || projectFolder.isBlank()) { if (properties.getProperty("projectFolder") != null) { projectFolder = properties.getProperty("projectFolder"); } else { - throw new NullPointerException("The projectFolder is missing"); + throw new IllegalStateException("The projectFolder is missing"); } } if (fhirBaseUrl == null || fhirBaseUrl.isBlank()) { if (properties.getProperty("fhirBaseUrl") != null) { fhirBaseUrl = properties.getProperty("fhirBaseUrl"); } else { - throw new NullPointerException("The fhirBaseUrl is missing"); + throw new IllegalStateException("The fhirBaseUrl is missing"); } } if (accessToken == null || accessToken.isBlank()) { diff --git a/efsity/src/main/java/org/smartregister/util/FctUtils.java b/efsity/src/main/java/org/smartregister/util/FctUtils.java index 332ae886..552f7b42 100644 --- a/efsity/src/main/java/org/smartregister/util/FctUtils.java +++ b/efsity/src/main/java/org/smartregister/util/FctUtils.java @@ -4,12 +4,14 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirVersionEnum; import ca.uhn.fhir.parser.IParser; +import com.ibm.icu.text.CharsetDetector; import java.io.BufferedReader; import java.io.BufferedWriter; -import java.io.FileInputStream; +import java.io.ByteArrayInputStream; import java.io.FileWriter; import java.io.IOException; import java.io.InputStreamReader; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -108,14 +110,19 @@ public static void writeJsonFile(String outputPath, String fhirResourceAsString) } } - public static Properties readPropertiesFile(String propertiesFilePath) { + public static Properties readPropertiesFile(String propertiesFilePath) throws IOException { + + CharsetDetector detector = new CharsetDetector(); + byte[] fileBytes = Files.readAllBytes(Path.of(propertiesFilePath)); + detector.setText(fileBytes); + Properties properties = new Properties(); - try (FileInputStream fileInputStream = new FileInputStream(propertiesFilePath); - InputStreamReader reader = new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) { + try (InputStreamReader reader = + new InputStreamReader( + new ByteArrayInputStream(fileBytes), Charset.forName(detector.detect().getName()))) { properties.load(reader); - } catch (IOException ex) { - ex.printStackTrace(); } + return properties; } @@ -136,7 +143,7 @@ public static Map> indexConfigurationFiles( @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { if (!Files.isDirectory(file) - && (fileExtensions.length == 1 && fileExtensions[0] == "*" + && (fileExtensions.length == 1 && "*".equals(fileExtensions[0]) || Arrays.asList(fileExtensions) .contains(FilenameUtils.getExtension(file.getFileName().toString())))) { diff --git a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java index ac155990..60e14d5e 100644 --- a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java +++ b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java @@ -109,7 +109,8 @@ public void testRunMerge() throws IOException { Path tempRawQuestionnaire = Files.createTempFile("temp_raw_questionnaire", ".json"); Files.copy(rawQuestionnairePath, tempRawQuestionnaire, StandardCopyOption.REPLACE_EXISTING); - Path mergedQuestionnairePath = Paths.get("src/test/resources/merged_questionnaire.json"); + Path expectedMergedQuestionnairePath = + Paths.get("src/test/resources/merged_questionnaire.json"); Path frPropertiesPath = Paths.get("src/test/resources/strings_fr.properties"); @@ -121,15 +122,15 @@ public void testRunMerge() throws IOException { assertDoesNotThrow(() -> translateCommand.run()); ObjectMapper objectMapper = new ObjectMapper(); - JsonNode rawQuestionnaire = + JsonNode processedRawQuestionnaire = objectMapper.readTree( Files.newBufferedReader(tempRawQuestionnaire, StandardCharsets.UTF_8)); JsonNode mergedQuestionnaire = objectMapper.readTree( - Files.newBufferedReader(mergedQuestionnairePath, StandardCharsets.UTF_8)); + Files.newBufferedReader(expectedMergedQuestionnairePath, StandardCharsets.UTF_8)); // Compare the contents of the two nodes - assertEquals(rawQuestionnaire, mergedQuestionnaire, "File merged as expected"); + assertEquals(mergedQuestionnaire, processedRawQuestionnaire, "File merged as expected"); tempRawQuestionnaire.toFile().delete(); } } From 313e846d55f2adad73ba5a8b9da8593c88d2df4c Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Fri, 26 Jul 2024 19:58:45 +0300 Subject: [PATCH 34/34] =?UTF-8?q?Refactor=20=E2=99=BB=EF=B8=8F=20=20+=20Fi?= =?UTF-8?q?x=20Build=20=F0=9F=92=9A=20-=20Refactor=20rollback=20implementa?= =?UTF-8?q?tion=20-=20Refactor=20+=20Clean=20up=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Hilary Baraka Egesa --- .../command/TranslateCommand.java | 82 +++---------------- .../java/org/smartregister/util/FctUtils.java | 65 +++++++++++++-- .../command/TranslateCommandTest.java | 37 +++++++-- .../dirty_configs_folder/profile_config.json | 3 +- 4 files changed, 103 insertions(+), 84 deletions(-) diff --git a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java index 9a484654..06ea0623 100644 --- a/efsity/src/main/java/org/smartregister/command/TranslateCommand.java +++ b/efsity/src/main/java/org/smartregister/command/TranslateCommand.java @@ -5,10 +5,10 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.annotations.VisibleForTesting; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; -import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.*; @@ -16,6 +16,7 @@ import java.util.Iterator; import java.util.Map; import java.util.Properties; +import org.apache.commons.lang3.StringUtils; import org.smartregister.util.FCTConstants; import org.smartregister.util.FctUtils; import picocli.CommandLine; @@ -69,7 +70,7 @@ public void run() { } if (extractionType != null && !Arrays.asList(extractionTypes).contains(extractionType)) { throw new RuntimeException( - "extractionTypes should either be `all`, `configs`, `fhir_content`"); + "extractionTypes should either be " + StringUtils.join(extractionTypes, ", ")); } if (Objects.equals(mode, "extract")) { @@ -80,16 +81,11 @@ public void run() { FctUtils.printInfo(String.format("Input file \u001b[35m%s\u001b[0m", resourceFile)); try { - Path translationsDirectoryPath = inputFilePath.getParent().resolve("translations"); if (Objects.equals(extractionType, "configs")) { tempsConfig = Files.createTempDirectory("configs"); } else tempsConfig = null; - if (!Files.exists(translationsDirectoryPath)) - Files.createDirectories(translationsDirectoryPath); - if (translationFile == null) { - translationFile = translationsDirectoryPath + "/strings_default.properties"; - } + // Check if the input path is a directory or a JSON file if (Files.isDirectory(inputFilePath)) { if (Objects.equals(extractionType, "configs") || inputFilePath.endsWith("configs")) { @@ -443,7 +439,7 @@ private static void replaceTargetFieldsWithHashedValues( JsonNode node, Set targetFields, Map textToHash, - Path filePath, + Path tempFilePath, Path tempConfigsDir) throws NoSuchAlgorithmException, IOException { @@ -468,7 +464,7 @@ private static void replaceTargetFieldsWithHashedValues( if (fieldValue.isObject() || fieldValue.isArray()) { // Recursively update nested objects or arrays replaceTargetFieldsWithHashedValues( - fieldValue, targetFields, textToHash, filePath, tempConfigsDir); + fieldValue, targetFields, textToHash, tempFilePath, tempConfigsDir); } } } else if (node.isArray()) { @@ -478,14 +474,11 @@ private static void replaceTargetFieldsWithHashedValues( if (arrayElement.isObject() || arrayElement.isArray()) { // Recursively update nested objects or arrays replaceTargetFieldsWithHashedValues( - arrayElement, targetFields, textToHash, filePath, tempConfigsDir); + arrayElement, targetFields, textToHash, tempFilePath, tempConfigsDir); } } } - String configFileSubDirectory = filePath.subpath(2, filePath.getNameCount()).toString(); - - Path tempFilePath = tempConfigsDir.resolve(configFileSubDirectory); // Write the updated JSON to temp file try (BufferedWriter writer = Files.newBufferedWriter(tempFilePath, StandardCharsets.UTF_8)) { ObjectMapper objectMapper = new ObjectMapper(); @@ -551,62 +544,13 @@ private static void writePropertiesFile(Properties properties, String filePath) } } - public void copyDirectoryContent(Path sourceDir, Path destinationDir) { - try { - Files.walkFileTree( - sourceDir, - new SimpleFileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) - throws IOException { - Path targetDir = destinationDir.resolve(sourceDir.relativize(dir)); - if (!Files.exists(targetDir)) { - Files.createDirectory(targetDir); - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) - throws IOException { - - Files.copy( - file, - destinationDir.resolve(sourceDir.relativize(file)), - StandardCopyOption.REPLACE_EXISTING); - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - throw new RuntimeException(e); - } + @VisibleForTesting + protected void copyDirectoryContent(Path sourceDir, Path destinationDir) { + FctUtils.copyDirectoryContent(sourceDir, destinationDir); } - public void deleteDirectoryRecursively(Path dirPath) { - - try { - // Delete the directory and its contents recursively - Files.walkFileTree( - dirPath, - new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) - throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) - throws IOException { - Files.delete(dir); - return FileVisitResult.CONTINUE; - } - }); - - System.out.println("Directory and its contents deleted successfully: " + dirPath); - } catch (IOException e) { - throw new RuntimeException(e); - } + @VisibleForTesting + protected void deleteDirectoryRecursively(Path dirPath) { + FctUtils.deleteDirectoryRecursively(dirPath); } } diff --git a/efsity/src/main/java/org/smartregister/util/FctUtils.java b/efsity/src/main/java/org/smartregister/util/FctUtils.java index 552f7b42..b5522e6d 100644 --- a/efsity/src/main/java/org/smartregister/util/FctUtils.java +++ b/efsity/src/main/java/org/smartregister/util/FctUtils.java @@ -13,11 +13,7 @@ import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.SimpleFileVisitor; +import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.util.Arrays; import java.util.HashMap; @@ -176,6 +172,65 @@ public static T getFhirResource(Class t, String con return iParser.parseResource(t, contentAsString); } + public static void copyDirectoryContent(Path sourceDir, Path destinationDir) { + try { + Files.walkFileTree( + sourceDir, + new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + Path targetDir = destinationDir.resolve(sourceDir.relativize(dir)); + if (!Files.exists(targetDir)) { + Files.createDirectory(targetDir); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + + Files.copy( + file, + destinationDir.resolve(sourceDir.relativize(file)), + StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void deleteDirectoryRecursively(Path dirPath) { + + try { + // Delete the directory and its contents recursively + Files.walkFileTree( + dirPath, + new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) + throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + + System.out.println("Directory and its contents deleted successfully: " + dirPath); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + public static final class Constants { public static final String HL7_FHIR_PACKAGE = "hl7.fhir.r4.core"; public static final String HL7_FHIR_PACKAGE_VERSION = "4.0.1"; diff --git a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java index 60e14d5e..40b37e1e 100644 --- a/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java +++ b/efsity/src/test/java/org/smartregister/command/TranslateCommandTest.java @@ -11,20 +11,31 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Properties; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.smartregister.util.FctUtils; public class TranslateCommandTest { - + private Path tempCleanConfigsFolder; private TranslateCommand translateCommand; @BeforeEach - public void setUp() { + public void setUp() throws IOException { + + tempCleanConfigsFolder = Files.createTempDirectory("temp_clean_configs_folder"); + Path cleanConfigsFolder = Paths.get("src/test/resources/clean_configs_folder"); + FctUtils.copyDirectoryContent(cleanConfigsFolder, tempCleanConfigsFolder); + translateCommand = new TranslateCommand(); } + @AfterEach + public void tearDown() { + FctUtils.deleteDirectoryRecursively(tempCleanConfigsFolder); + } + @Test public void testRunInvalidMode() { translateCommand.mode = "invalid_mode"; @@ -70,33 +81,41 @@ public void testRunExtract() throws IOException { @Test public void testRunExtractConfigWithCleanConfigsFolderRunsSuccessfully() throws IOException { - Path cleanConfigsFolder = Paths.get("src/test/resources/clean_configs_folder"); - TranslateCommand translateCommandForCopyingTemp = new TranslateCommand(); + + Path cleanConfigsFolder = tempCleanConfigsFolder; Path backupFolder = Files.createTempDirectory("temp_back_up_dir"); - translateCommandForCopyingTemp.copyDirectoryContent(cleanConfigsFolder, backupFolder); + FctUtils.copyDirectoryContent(cleanConfigsFolder, backupFolder); TranslateCommand translateCommandSpy = Mockito.spy(translateCommand); translateCommandSpy.mode = "extract"; translateCommandSpy.extractionType = "configs"; translateCommandSpy.resourceFile = cleanConfigsFolder.toString(); + + Path frPropertiesPathOriginal = Paths.get("src/test/resources/strings_fr.properties"); + Path frPropertiesPathTest = + tempCleanConfigsFolder.resolve(frPropertiesPathOriginal.getFileName()); + Files.copy(frPropertiesPathOriginal, frPropertiesPathTest, StandardCopyOption.REPLACE_EXISTING); + translateCommandSpy.translationFile = frPropertiesPathTest.toString(); translateCommandSpy.run(); Mockito.verify(translateCommandSpy, Mockito.atLeast(2)) .copyDirectoryContent(Mockito.any(), Mockito.any()); Mockito.verify(translateCommandSpy, Mockito.atLeast(1)) .deleteDirectoryRecursively(Mockito.any()); - // restore clean_configs_folder - translateCommandForCopyingTemp.copyDirectoryContent(backupFolder, cleanConfigsFolder); } @Test public void testRunExtractConfigWithDirtyConfigsFolderDeletesTempFileOnFailure() throws RuntimeException { - Path cleanConfigsFolder = Paths.get("src/test/resources/dirty_configs_folder"); + Path dirtyConfigsFolder = Paths.get("src/test/resources/dirty_configs_folder"); TranslateCommand translateCommandSpy = Mockito.spy(translateCommand); translateCommandSpy.mode = "extract"; translateCommandSpy.extractionType = "configs"; - translateCommandSpy.resourceFile = cleanConfigsFolder.toString(); + translateCommandSpy.resourceFile = dirtyConfigsFolder.toString(); + + Path frPropertiesPath = Paths.get("src/test/resources/strings_fr.properties"); + translateCommandSpy.translationFile = frPropertiesPath.toString(); assertThrows(RuntimeException.class, translateCommandSpy::run); + Mockito.verify(translateCommandSpy, Mockito.atLeast(1)) .copyDirectoryContent(Mockito.any(), Mockito.any()); Mockito.verify(translateCommandSpy, Mockito.atLeast(1)) diff --git a/efsity/src/test/resources/dirty_configs_folder/profile_config.json b/efsity/src/test/resources/dirty_configs_folder/profile_config.json index 51c72c9e..be59aaea 100644 --- a/efsity/src/test/resources/dirty_configs_folder/profile_config.json +++ b/efsity/src/test/resources/dirty_configs_folder/profile_config.json @@ -1,4 +1,5 @@ { + "DO NOT REMOVE _ MALFORMED JSON CONTENT FOR TESTING", "appId": "app_id_name", "configType": "profile", "id": "config_id", @@ -48,7 +49,7 @@ ], "overFlowMenuItems": [ { - "title": "Edit Client Info",u + "title": "Edit Client Info", "titleColor": "@{patientTextColor}", "visible": "true", "enabled": "@{patientActive}",