diff --git a/app.py b/app.py index 6ffac30..ab74d3f 100644 --- a/app.py +++ b/app.py @@ -614,7 +614,9 @@ def api_push_ig(): package_name = data.get('package_name') version = data.get('version') fhir_server_url = data.get('fhir_server_url') - include_dependencies = data.get('include_dependencies', True) + include_dependencies = data.get('include_dependencies', True) # Default to True if not provided + + # --- Input Validation --- if not all([package_name, version, fhir_server_url]): return jsonify({"status": "error", "message": "Missing package_name, version, or fhir_server_url"}), 400 if not (isinstance(fhir_server_url, str) and fhir_server_url.startswith(('http://', 'https://'))): @@ -623,26 +625,35 @@ def api_push_ig(): re.match(r'^[a-zA-Z0-9\-\.]+$', package_name) and re.match(r'^[a-zA-Z0-9\.\-\+]+$', version)): return jsonify({"status": "error", "message": "Invalid characters in package name or version"}), 400 + + # --- File Path Setup --- packages_dir = current_app.config.get('FHIR_PACKAGES_DIR') if not packages_dir: logger.error("[API Push] FHIR_PACKAGES_DIR not configured.") return jsonify({"status": "error", "message": "Server configuration error: Package directory not set."}), 500 + tgz_filename = services.construct_tgz_filename(package_name, version) tgz_path = os.path.join(packages_dir, tgz_filename) if not os.path.exists(tgz_path): logger.error(f"[API Push] Main package not found: {tgz_path}") return jsonify({"status": "error", "message": f"Package not found locally: {package_name}#{version}"}), 404 + + # --- Streaming Response --- def generate_stream(): pushed_packages_info = [] success_count = 0 failure_count = 0 - validation_failure_count = 0 + validation_failure_count = 0 # Note: This count is not currently incremented anywhere. total_resources_attempted = 0 processed_resources = set() + failed_uploads_details = [] # <<<<< Initialize list for failure details + try: yield json.dumps({"type": "start", "message": f"Starting push for {package_name}#{version} to {fhir_server_url}"}) + "\n" packages_to_push = [(package_name, version, tgz_path)] dependencies_to_include = [] + + # --- Dependency Handling --- if include_dependencies: yield json.dumps({"type": "progress", "message": "Checking dependencies..."}) + "\n" metadata = services.get_package_metadata(package_name, version) @@ -657,14 +668,18 @@ def api_push_ig(): dep_tgz_filename = services.construct_tgz_filename(dep_name, dep_version) dep_tgz_path = os.path.join(packages_dir, dep_tgz_filename) if os.path.exists(dep_tgz_path): - packages_to_push.append((dep_name, dep_version, dep_tgz_path)) - yield json.dumps({"type": "progress", "message": f"Queued dependency: {dep_name}#{dep_version}"}) + "\n" + # Add dependency package to the list of packages to process + if (dep_name, dep_version, dep_tgz_path) not in packages_to_push: # Avoid adding duplicates if listed multiple times + packages_to_push.append((dep_name, dep_version, dep_tgz_path)) + yield json.dumps({"type": "progress", "message": f"Queued dependency: {dep_name}#{dep_version}"}) + "\n" else: yield json.dumps({"type": "warning", "message": f"Dependency package file not found, skipping: {dep_name}#{dep_version} ({dep_tgz_filename})"}) + "\n" else: yield json.dumps({"type": "info", "message": "No dependency metadata found or no dependencies listed."}) + "\n" + + # --- Resource Extraction --- resources_to_upload = [] - seen_resource_files = set() + seen_resource_files = set() # Track processed filenames to avoid duplicates across packages for pkg_name, pkg_version, pkg_path in packages_to_push: yield json.dumps({"type": "progress", "message": f"Extracting resources from {pkg_name}#{pkg_version}..."}) + "\n" try: @@ -673,13 +688,16 @@ def api_push_ig(): if (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json') and - os.path.basename(member.name).lower() not in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']): - if member.name in seen_resource_files: + os.path.basename(member.name).lower() not in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']): # Skip common metadata files + + if member.name in seen_resource_files: # Skip if already seen from another package (e.g., core resource in multiple IGs) continue seen_resource_files.add(member.name) + try: with tar.extractfile(member) as f: - resource_data = json.load(f) + resource_data = json.load(f) # Read and parse JSON content + # Basic check for a valid FHIR resource structure if isinstance(resource_data, dict) and 'resourceType' in resource_data and 'id' in resource_data: resources_to_upload.append({ "data": resource_data, @@ -690,34 +708,49 @@ def api_push_ig(): yield json.dumps({"type": "warning", "message": f"Skipping invalid/incomplete resource in {member.name} from {pkg_name}#{pkg_version}"}) + "\n" except (json.JSONDecodeError, UnicodeDecodeError) as json_e: yield json.dumps({"type": "warning", "message": f"Skipping non-JSON or corrupt file {member.name} from {pkg_name}#{pkg_version}: {json_e}"}) + "\n" - except Exception as extract_e: + except Exception as extract_e: # Catch potential errors during file extraction within the tar yield json.dumps({"type": "warning", "message": f"Error extracting file {member.name} from {pkg_name}#{pkg_version}: {extract_e}"}) + "\n" except (tarfile.TarError, FileNotFoundError) as tar_e: - yield json.dumps({"type": "error", "message": f"Error reading package {pkg_name}#{pkg_version}: {tar_e}. Skipping its resources."}) + "\n" - failure_count += 1 - continue + # Error reading the package itself + error_msg = f"Error reading package {pkg_name}#{pkg_version}: {tar_e}. Skipping its resources." + yield json.dumps({"type": "error", "message": error_msg}) + "\n" + failure_count += 1 # Increment general failure count + failed_uploads_details.append({'resource': f"Package: {pkg_name}#{pkg_version}", 'error': f"TarError/FileNotFound: {tar_e}"}) # <<<<< ADDED package level error + continue # Skip to the next package if one fails to open + total_resources_attempted = len(resources_to_upload) - yield json.dumps({"type": "info", "message": f"Found {total_resources_attempted} potential resources to upload."}) + "\n" - session = requests.Session() + yield json.dumps({"type": "info", "message": f"Found {total_resources_attempted} potential resources to upload across all packages."}) + "\n" + + # --- Resource Upload Loop --- + session = requests.Session() # Use a session for potential connection reuse headers = {'Content-Type': 'application/fhir+json', 'Accept': 'application/fhir+json'} + for i, resource_info in enumerate(resources_to_upload, 1): resource = resource_info["data"] source_pkg = resource_info["source_package"] - source_file = resource_info["source_filename"] + # source_file = resource_info["source_filename"] # Available if needed resource_type = resource.get('resourceType') resource_id = resource.get('id') resource_log_id = f"{resource_type}/{resource_id}" + + # Skip if already processed (e.g., if same resource ID appeared in multiple packages) if resource_log_id in processed_resources: - yield json.dumps({"type": "info", "message": f"Skipping duplicate resource: {resource_log_id} (already uploaded or queued)"}) + "\n" + yield json.dumps({"type": "info", "message": f"Skipping duplicate resource ID: {resource_log_id} (already attempted/uploaded)"}) + "\n" continue processed_resources.add(resource_log_id) - resource_url = f"{fhir_server_url.rstrip('/')}/{resource_type}/{resource_id}" + + resource_url = f"{fhir_server_url.rstrip('/')}/{resource_type}/{resource_id}" # Construct PUT URL yield json.dumps({"type": "progress", "message": f"Uploading {resource_log_id} ({i}/{total_resources_attempted}) from {source_pkg}..."}) + "\n" + try: + # Attempt to PUT the resource response = session.put(resource_url, json=resource, headers=headers, timeout=30) - response.raise_for_status() + response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx) + yield json.dumps({"type": "success", "message": f"Uploaded {resource_log_id} successfully (Status: {response.status_code})"}) + "\n" success_count += 1 + + # Track which packages contributed successful uploads if source_pkg not in [p["id"] for p in pushed_packages_info]: pushed_packages_info.append({"id": source_pkg, "resource_count": 1}) else: @@ -725,68 +758,88 @@ def api_push_ig(): if p["id"] == source_pkg: p["resource_count"] += 1 break + # --- Error Handling for Upload --- except requests.exceptions.Timeout: error_msg = f"Timeout uploading {resource_log_id} to {resource_url}" yield json.dumps({"type": "error", "message": error_msg}) + "\n" failure_count += 1 + failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED except requests.exceptions.ConnectionError as e: error_msg = f"Connection error uploading {resource_log_id}: {e}" yield json.dumps({"type": "error", "message": error_msg}) + "\n" failure_count += 1 + failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED except requests.exceptions.HTTPError as e: outcome_text = "" + # Try to parse OperationOutcome from response try: outcome = e.response.json() if outcome and outcome.get('resourceType') == 'OperationOutcome' and outcome.get('issue'): - outcome_text = "; ".join([f"{issue.get('severity')}: {issue.get('diagnostics')}" for issue in outcome['issue']]) - except: - outcome_text = e.response.text[:200] + outcome_text = "; ".join([f"{issue.get('severity', 'info')}: {issue.get('diagnostics') or issue.get('details', {}).get('text', 'No details')}" for issue in outcome['issue']]) + except ValueError: # Handle cases where response is not JSON + outcome_text = e.response.text[:200] # Fallback to raw text error_msg = f"Failed to upload {resource_log_id} (Status: {e.response.status_code}): {outcome_text or str(e)}" yield json.dumps({"type": "error", "message": error_msg}) + "\n" failure_count += 1 - except requests.exceptions.RequestException as e: - error_msg = f"Failed to upload {resource_log_id}: {str(e)}" + failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED + except requests.exceptions.RequestException as e: # Catch other potential request errors + error_msg = f"Request error uploading {resource_log_id}: {str(e)}" yield json.dumps({"type": "error", "message": error_msg}) + "\n" failure_count += 1 - except Exception as e: + failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED + except Exception as e: # Catch unexpected errors during the PUT request error_msg = f"Unexpected error uploading {resource_log_id}: {str(e)}" yield json.dumps({"type": "error", "message": error_msg}) + "\n" failure_count += 1 - logger.error(f"[API Push] Unexpected upload error: {e}", exc_info=True) + failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED + logger.error(f"[API Push] Unexpected upload error for {resource_log_id}: {e}", exc_info=True) + + # --- Final Summary --- + # Determine overall status based on counts final_status = "success" if failure_count == 0 and total_resources_attempted > 0 else \ "partial" if success_count > 0 else \ "failure" summary_message = f"Push finished: {success_count} succeeded, {failure_count} failed out of {total_resources_attempted} resources attempted." - if validation_failure_count > 0: - summary_message += f" ({validation_failure_count} failed validation)." + if validation_failure_count > 0: # This count isn't used currently, but keeping structure + summary_message += f" ({validation_failure_count} failed validation)." + + # Create final summary payload summary = { "status": final_status, "message": summary_message, "target_server": fhir_server_url, - "package_name": package_name, - "version": version, + "package_name": package_name, # Initial requested package + "version": version, # Initial requested version "included_dependencies": include_dependencies, "resources_attempted": total_resources_attempted, "success_count": success_count, "failure_count": failure_count, "validation_failure_count": validation_failure_count, - "pushed_packages_summary": pushed_packages_info + "failed_details": failed_uploads_details, # <<<<< ADDED Failure details list + "pushed_packages_summary": pushed_packages_info # Summary of packages contributing uploads } yield json.dumps({"type": "complete", "data": summary}) + "\n" logger.info(f"[API Push] Completed for {package_name}#{version}. Status: {final_status}. {summary_message}") - except Exception as e: - logger.error(f"[API Push] Critical error during push stream generation: {str(e)}", exc_info=True) + + except Exception as e: # Catch critical errors during the entire stream generation + logger.error(f"[API Push] Critical error during push stream generation for {package_name}#{version}: {str(e)}", exc_info=True) + # Attempt to yield a final error message to the stream error_response = { "status": "error", "message": f"Server error during push operation: {str(e)}" + # Optionally add failed_details collected so far if needed + # "failed_details": failed_uploads_details } try: + # Send error info both as a stream message and in the complete data yield json.dumps({"type": "error", "message": error_response["message"]}) + "\n" yield json.dumps({"type": "complete", "data": error_response}) + "\n" - except GeneratorExit: + except GeneratorExit: # If the client disconnects logger.warning("[API Push] Stream closed before final error could be sent.") - except Exception as yield_e: + except Exception as yield_e: # Error during yielding the error itself logger.error(f"[API Push] Error yielding final error message: {yield_e}") + + # Return the streaming response return Response(generate_stream(), mimetype='application/x-ndjson') @app.route('/validate-sample', methods=['GET']) @@ -818,6 +871,13 @@ def validate_sample(): now=datetime.datetime.now() ) +# Exempt specific API views defined directly on 'app' +csrf.exempt(api_import_ig) # Add this line +csrf.exempt(api_push_ig) # Add this line + +# Exempt the entire API blueprint (for routes defined IN services.py, like /api/validate-sample) +csrf.exempt(services_bp) # Keep this line for routes defined in the blueprint + def create_db(): logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}") try: diff --git a/instance/fhir_ig.db b/instance/fhir_ig.db index 654bd6c..1c996e7 100644 Binary files a/instance/fhir_ig.db and b/instance/fhir_ig.db differ diff --git a/instance/fhir_packages/hl7.fhir.au.base-5.1.0-preview.metadata.json b/instance/fhir_packages/hl7.fhir.au.base-5.1.0-preview.metadata.json index 54b0001..6c69c1f 100644 --- a/instance/fhir_packages/hl7.fhir.au.base-5.1.0-preview.metadata.json +++ b/instance/fhir_packages/hl7.fhir.au.base-5.1.0-preview.metadata.json @@ -17,5 +17,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:11.760501+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.fhir.au.core-1.1.0-preview.metadata.json b/instance/fhir_packages/hl7.fhir.au.core-1.1.0-preview.metadata.json index 0b89e9e..bbc13da 100644 --- a/instance/fhir_packages/hl7.fhir.au.core-1.1.0-preview.metadata.json +++ b/instance/fhir_packages/hl7.fhir.au.core-1.1.0-preview.metadata.json @@ -29,5 +29,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:09.766234+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.fhir.r4.core-4.0.1.metadata.json b/instance/fhir_packages/hl7.fhir.r4.core-4.0.1.metadata.json index d1f669e..0cd94c0 100644 --- a/instance/fhir_packages/hl7.fhir.r4.core-4.0.1.metadata.json +++ b/instance/fhir_packages/hl7.fhir.r4.core-4.0.1.metadata.json @@ -4,5 +4,6 @@ "dependency_mode": "recursive", "imported_dependencies": [], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:11.077657+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.fhir.uv.extensions.r4-5.2.0.metadata.json b/instance/fhir_packages/hl7.fhir.uv.extensions.r4-5.2.0.metadata.json index 3544d6d..98574f3 100644 --- a/instance/fhir_packages/hl7.fhir.uv.extensions.r4-5.2.0.metadata.json +++ b/instance/fhir_packages/hl7.fhir.uv.extensions.r4-5.2.0.metadata.json @@ -9,5 +9,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:11.693803+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.fhir.uv.ipa-1.0.0.metadata.json b/instance/fhir_packages/hl7.fhir.uv.ipa-1.0.0.metadata.json index cff87e4..e22581b 100644 --- a/instance/fhir_packages/hl7.fhir.uv.ipa-1.0.0.metadata.json +++ b/instance/fhir_packages/hl7.fhir.uv.ipa-1.0.0.metadata.json @@ -17,5 +17,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:11.802489+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.0.0.metadata.json b/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.0.0.metadata.json index e2ffb4e..19a6c21 100644 --- a/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.0.0.metadata.json +++ b/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.0.0.metadata.json @@ -9,5 +9,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:12.239784+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.1.0.metadata.json b/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.1.0.metadata.json index d5b71d3..540135f 100644 --- a/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.1.0.metadata.json +++ b/instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.1.0.metadata.json @@ -13,5 +13,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:11.776036+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.terminology.r4-5.0.0.metadata.json b/instance/fhir_packages/hl7.terminology.r4-5.0.0.metadata.json index 4d7bc33..4aa45bf 100644 --- a/instance/fhir_packages/hl7.terminology.r4-5.0.0.metadata.json +++ b/instance/fhir_packages/hl7.terminology.r4-5.0.0.metadata.json @@ -9,5 +9,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:12.227176+00:00" } \ No newline at end of file diff --git a/instance/fhir_packages/hl7.terminology.r4-6.2.0.metadata.json b/instance/fhir_packages/hl7.terminology.r4-6.2.0.metadata.json index fd28a4e..c4c9374 100644 --- a/instance/fhir_packages/hl7.terminology.r4-6.2.0.metadata.json +++ b/instance/fhir_packages/hl7.terminology.r4-6.2.0.metadata.json @@ -9,5 +9,6 @@ } ], "complies_with_profiles": [], - "imposed_profiles": [] + "imposed_profiles": [], + "timestamp": "2025-04-14T10:53:11.555556+00:00" } \ No newline at end of file diff --git a/services.py b/services.py index e48b324..8fb5c3e 100644 --- a/services.py +++ b/services.py @@ -143,7 +143,7 @@ def find_and_extract_sd(tgz_path, resource_identifier, profile_url=None): return None, None try: with tarfile.open(tgz_path, "r:gz") as tar: - logger.debug(f"Searching for SD matching '{resource_identifier}' in {os.path.basename(tgz_path)}") + logger.debug(f"Searching for SD matching '{resource_identifier}' with profile '{profile_url}' in {os.path.basename(tgz_path)}") for member in tar: if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')): continue @@ -161,23 +161,28 @@ def find_and_extract_sd(tgz_path, resource_identifier, profile_url=None): sd_name = data.get('name') sd_type = data.get('type') sd_url = data.get('url') + # Log SD details for debugging + logger.debug(f"Found SD: id={sd_id}, name={sd_name}, type={sd_type}, url={sd_url}, path={member.name}") # Prioritize match with profile_url if provided if profile_url and sd_url == profile_url: sd_data = data found_path = member.name logger.info(f"Found SD matching profile '{profile_url}' at path: {found_path}") break - # Fallback to resource type or identifier match + # Broader matching for resource_identifier elif resource_identifier and ( (sd_id and resource_identifier.lower() == sd_id.lower()) or (sd_name and resource_identifier.lower() == sd_name.lower()) or (sd_type and resource_identifier.lower() == sd_type.lower()) or - (os.path.splitext(os.path.basename(member.name))[0].lower() == resource_identifier.lower()) + # Add fallback for partial filename match + (resource_identifier.lower() in os.path.splitext(os.path.basename(member.name))[0].lower()) or + # Handle AU Core naming conventions + (sd_url and resource_identifier.lower() in sd_url.lower()) ): sd_data = data found_path = member.name logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}") - # Continue searching in case a profile match is found + # Continue searching for a profile match except json.JSONDecodeError as e: logger.debug(f"Could not parse JSON in {member.name}, skipping: {e}") except UnicodeDecodeError as e: @@ -643,12 +648,25 @@ def navigate_fhir_path(resource, path, extension_url=None): def validate_resource_against_profile(package_name, version, resource, include_dependencies=True): """Validates a FHIR resource against a StructureDefinition in the specified package.""" - logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}") - result = {'valid': True, 'errors': [], 'warnings': []} + logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}, include_dependencies={include_dependencies}") + result = { + 'valid': True, + 'errors': [], + 'warnings': [], + 'details': [], # Enhanced info for future use + 'resource_type': resource.get('resourceType'), + 'resource_id': resource.get('id', 'unknown'), + 'profile': resource.get('meta', {}).get('profile', [None])[0] + } download_dir = _get_download_dir() if not download_dir: result['valid'] = False result['errors'].append("Could not access download directory") + result['details'].append({ + 'issue': "Could not access download directory", + 'severity': 'error', + 'description': "The server could not locate the directory where FHIR packages are stored." + }) logger.error("Validation failed: Could not access download directory") return result @@ -657,6 +675,11 @@ def validate_resource_against_profile(package_name, version, resource, include_d if not os.path.exists(tgz_path): result['valid'] = False result['errors'].append(f"Package file not found: {package_name}#{version}") + result['details'].append({ + 'issue': f"Package file not found: {package_name}#{version}", + 'severity': 'error', + 'description': f"The package {package_name}#{version} is not available in the download directory." + }) logger.error(f"Validation failed: Package file not found at {tgz_path}") return result @@ -665,41 +688,85 @@ def validate_resource_against_profile(package_name, version, resource, include_d meta = resource.get('meta', {}) profiles = meta.get('profile', []) if profiles: - profile_url = profiles[0] # Use first profile + profile_url = profiles[0] logger.debug(f"Using profile from meta.profile: {profile_url}") + # Find StructureDefinition sd_data, sd_path = find_and_extract_sd(tgz_path, resource.get('resourceType'), profile_url) + if not sd_data and include_dependencies: + logger.debug(f"SD not found in {package_name}#{version}. Checking dependencies.") + try: + with tarfile.open(tgz_path, "r:gz") as tar: + package_json_member = None + for member in tar: + if member.name == 'package/package.json': + package_json_member = member + break + if package_json_member: + fileobj = tar.extractfile(package_json_member) + pkg_data = json.load(fileobj) + fileobj.close() + dependencies = pkg_data.get('dependencies', {}) + logger.debug(f"Found dependencies: {dependencies}") + for dep_name, dep_version in dependencies.items(): + dep_tgz = os.path.join(download_dir, construct_tgz_filename(dep_name, dep_version)) + if os.path.exists(dep_tgz): + logger.debug(f"Searching SD in dependency {dep_name}#{dep_version}") + sd_data, sd_path = find_and_extract_sd(dep_tgz, resource.get('resourceType'), profile_url) + if sd_data: + logger.info(f"Found SD in dependency {dep_name}#{dep_version} at {sd_path}") + break + else: + logger.warning(f"Dependency package {dep_name}#{dep_version} not found at {dep_tgz}") + else: + logger.warning(f"No package.json found in {tgz_path}") + except json.JSONDecodeError as e: + logger.error(f"Failed to parse package.json in {tgz_path}: {e}") + except tarfile.TarError as e: + logger.error(f"Failed to read {tgz_path} while checking dependencies: {e}") + except Exception as e: + logger.error(f"Unexpected error while checking dependencies in {tgz_path}: {e}") + if not sd_data: result['valid'] = False - result['errors'].append(f"No StructureDefinition found for {resource.get('resourceType')} with profile {profile_url or 'any'} in {package_name}#{version}") + result['errors'].append(f"No StructureDefinition found for {resource.get('resourceType')} with profile {profile_url or 'any'}") + result['details'].append({ + 'issue': f"No StructureDefinition found for {resource.get('resourceType')} with profile {profile_url or 'any'}", + 'severity': 'error', + 'description': f"The package {package_name}#{version} (and dependencies, if checked) does not contain a matching StructureDefinition." + }) logger.error(f"Validation failed: No SD for {resource.get('resourceType')} in {tgz_path}") return result logger.debug(f"Found SD at {sd_path}") # Validate required elements (min=1) errors = [] + warnings = set() # Deduplicate warnings elements = sd_data.get('snapshot', {}).get('element', []) for element in elements: path = element.get('path') min_val = element.get('min', 0) - # Only check top-level required elements + must_support = element.get('mustSupport', False) + definition = element.get('definition', 'No definition provided in StructureDefinition.') + + # Check required elements if min_val > 0 and not '.' in path[1 + path.find('.'):]: value = navigate_fhir_path(resource, path) if value is None or (isinstance(value, list) and not any(value)): - errors.append(f"Required element {path} missing") + error_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Required element {path} missing" + errors.append(error_msg) + result['details'].append({ + 'issue': error_msg, + 'severity': 'error', + 'description': f"{definition} This element is mandatory (min={min_val}) per the profile {profile_url or 'unknown'}." + }) logger.info(f"Validation error: Required element {path} missing") - # Validate must-support elements - warnings = [] - for element in elements: - path = element.get('path') - # Only check top-level must-support elements - if element.get('mustSupport', False) and not '.' in path[1 + path.find('.'):]: - # Handle choice types (e.g., value[x], effective[x]) + # Check must-support elements + if must_support and not '.' in path[1 + path.find('.'):]: if '[x]' in path: base_path = path.replace('[x]', '') found = False - # Try common choice type suffixes for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']: test_path = f"{base_path}{suffix}" value = navigate_fhir_path(resource, test_path) @@ -707,17 +774,29 @@ def validate_resource_against_profile(package_name, version, resource, include_d found = True break if not found: - warnings.append(f"Must Support element {path} missing or empty") + warning_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Must Support element {path} missing or empty" + warnings.add(warning_msg) + result['details'].append({ + 'issue': warning_msg, + 'severity': 'warning', + 'description': f"{definition} This element is marked as Must Support in AU Core, meaning it should be populated if the data is available (e.g., phone or email for Patient.telecom)." + }) logger.info(f"Validation warning: Must Support element {path} missing or empty") else: value = navigate_fhir_path(resource, path) if value is None or (isinstance(value, list) and not any(value)): - # Only warn for non-required must-support elements if element.get('min', 0) == 0: - warnings.append(f"Must Support element {path} missing or empty") + warning_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Must Support element {path} missing or empty" + warnings.add(warning_msg) + result['details'].append({ + 'issue': warning_msg, + 'severity': 'warning', + 'description': f"{definition} This element is marked as Must Support in AU Core, meaning it should be populated if the data is available (e.g., phone or email for Patient.telecom)." + }) logger.info(f"Validation warning: Must Support element {path} missing or empty") - # Handle dataAbsentReason only if value[x] is absent - if path.endswith('dataAbsentReason') and element.get('mustSupport', False): + + # Handle dataAbsentReason for must-support elements + if path.endswith('dataAbsentReason') and must_support: value_x_path = path.replace('dataAbsentReason', 'value[x]') value_found = False for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']: @@ -729,44 +808,106 @@ def validate_resource_against_profile(package_name, version, resource, include_d if not value_found: value = navigate_fhir_path(resource, path) if value is None or (isinstance(value, list) and not any(value)): - warnings.append(f"Must Support element {path} missing or empty") + warning_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Must Support element {path} missing or empty" + warnings.add(warning_msg) + result['details'].append({ + 'issue': warning_msg, + 'severity': 'warning', + 'description': f"{definition} This element is marked as Must Support and should be used to indicate why the associated value is absent." + }) logger.info(f"Validation warning: Must Support element {path} missing or empty") - result['valid'] = len(errors) == 0 result['errors'] = errors - result['warnings'] = warnings - logger.debug(f"Validation result: valid={result['valid']}, errors={result['errors']}, warnings={result['warnings']}") + result['warnings'] = list(warnings) + result['valid'] = len(errors) == 0 + result['summary'] = { + 'error_count': len(errors), + 'warning_count': len(warnings) + } + logger.debug(f"Validation result: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}") return result def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True): """Validates a FHIR Bundle against profiles in the specified package.""" - logger.debug(f"Validating bundle against {package_name}#{version}") + logger.debug(f"Validating bundle against {package_name}#{version}, include_dependencies={include_dependencies}") result = { 'valid': True, 'errors': [], 'warnings': [], - 'results': {} + 'details': [], + 'results': {}, + 'summary': { + 'resource_count': 0, + 'failed_resources': 0, + 'profiles_validated': set() + } } if not bundle.get('resourceType') == 'Bundle': result['valid'] = False result['errors'].append("Resource is not a Bundle") + result['details'].append({ + 'issue': "Resource is not a Bundle", + 'severity': 'error', + 'description': "The provided resource must have resourceType 'Bundle' to be validated as a bundle." + }) logger.error("Validation failed: Resource is not a Bundle") return result + # Track references to validate resolvability + references = set() + resolved_references = set() + for entry in bundle.get('entry', []): resource = entry.get('resource') if not resource: continue resource_type = resource.get('resourceType') resource_id = resource.get('id', 'unknown') + result['summary']['resource_count'] += 1 + + # Collect references + for key, value in resource.items(): + if isinstance(value, dict) and 'reference' in value: + references.add(value['reference']) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict) and 'reference' in item: + references.add(item['reference']) + + # Validate resource validation_result = validate_resource_against_profile(package_name, version, resource, include_dependencies) result['results'][f"{resource_type}/{resource_id}"] = validation_result + result['summary']['profiles_validated'].add(validation_result['profile'] or 'unknown') + + # Aggregate errors and warnings if not validation_result['valid']: result['valid'] = False + result['summary']['failed_resources'] += 1 result['errors'].extend(validation_result['errors']) result['warnings'].extend(validation_result['warnings']) + result['details'].extend(validation_result['details']) - logger.debug(f"Bundle validation result: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}") + # Mark resource as resolved if it has an ID + if resource_id != 'unknown': + resolved_references.add(f"{resource_type}/{resource_id}") + + # Check for unresolved references + unresolved = references - resolved_references + for ref in unresolved: + warning_msg = f"Unresolved reference: {ref}" + result['warnings'].append(warning_msg) + result['details'].append({ + 'issue': warning_msg, + 'severity': 'warning', + 'description': f"The reference {ref} points to a resource not included in the bundle. Ensure the referenced resource is present or resolvable." + }) + logger.info(f"Validation warning: Unresolved reference {ref}") + + # Finalize summary + result['summary']['profiles_validated'] = list(result['summary']['profiles_validated']) + result['summary']['error_count'] = len(result['errors']) + result['summary']['warning_count'] = len(result['warnings']) + logger.debug(f"Bundle validation result: valid={result['valid']}, errors={result['summary']['error_count']}, warnings={result['summary']['warning_count']}, resources={result['summary']['resource_count']}") return result # --- Structure Definition Retrieval --- diff --git a/templates/cp_push_igs.html b/templates/cp_push_igs.html index f5138aa..7bc29cc 100644 --- a/templates/cp_push_igs.html +++ b/templates/cp_push_igs.html @@ -3,7 +3,6 @@ {% block content %}