Remove Hapi validaotr
replace with Hl7 Validator
restructure validation report
provide Json and Html validation reports
remove hardcoded local references and couple to ENV allowing custom env Fhir Server URl to be set for local ""instance:
This commit is contained in:
Joshua Hare 2025-08-26 21:15:24 +10:00
parent 5725f8a22f
commit 236390e57b
10 changed files with 986 additions and 671 deletions

View File

@ -12,6 +12,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# REMOVED pip install fhirpath from this line # REMOVED pip install fhirpath from this line
RUN npm install -g gofsh fsh-sushi RUN npm install -g gofsh fsh-sushi
# ADDED: Download the latest HL7 FHIR Validator CLI
RUN mkdir -p /app/validator_cli
WORKDIR /app/validator_cli
# Download the validator JAR and a separate checksum file for verification
RUN curl -L -o validator_cli.jar "https://github.com/hapifhir/org.hl7.fhir.core/releases/latest/download/validator_cli.jar"
# Set permissions for the downloaded file
RUN chmod 755 validator_cli.jar
# Change back to the main app directory for the next steps
WORKDIR /app
# Set up Python environment # Set up Python environment
WORKDIR /app WORKDIR /app
RUN python3 -m venv /app/venv RUN python3 -m venv /app/venv

66
app.py
View File

@ -29,6 +29,7 @@ import re
import yaml import yaml
import threading import threading
import time # Add time import import time # Add time import
import io
import services import services
from services import ( from services import (
services_bp, services_bp,
@ -44,9 +45,12 @@ from services import (
pkg_version, pkg_version,
get_package_description, get_package_description,
safe_parse_version, safe_parse_version,
import_manual_package_and_dependencies import_manual_package_and_dependencies,
parse_and_list_igs,
generate_ig_yaml,
apply_and_validate
) )
from forms import IgImportForm, ManualIgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm, RetrieveSplitDataForm from forms import IgImportForm, ManualIgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm, RetrieveSplitDataForm, IgYamlForm
from wtforms import SubmitField from wtforms import SubmitField
from package import package_bp from package import package_bp
from flasgger import Swagger, swag_from # Import Flasgger from flasgger import Swagger, swag_from # Import Flasgger
@ -3133,7 +3137,65 @@ def package_details_view(name):
package_name=actual_package_name, package_name=actual_package_name,
latest_official_version=latest_official_version_str) latest_official_version=latest_official_version_str)
@app.route('/ig-configurator', methods=['GET', 'POST'])
def ig_configurator():
form = IgYamlForm()
packages_dir = current_app.config['FHIR_PACKAGES_DIR']
loaded_igs = services.parse_and_list_igs(packages_dir)
form.igs.choices = [(name, f"{name} ({info['version']})") for name, info in loaded_igs.items()]
yaml_output = None
if form.validate_on_submit():
selected_ig_names = form.igs.data
try:
yaml_output = generate_ig_yaml(selected_ig_names, loaded_igs)
flash("YAML configuration generated successfully!", "success")
except Exception as e:
flash(f"Error generating YAML: {str(e)}", "error")
logger.error(f"Error generating YAML: {str(e)}", exc_info=True)
return render_template('ig_configurator.html', form=form, yaml_output=yaml_output)
@app.route('/download/<path:filename>')
def download_file(filename):
"""
Serves a file from the temporary directory.
This route is necessary to allow the user to download the validator reports.
"""
# The temporary directory path is hardcoded for security
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, filename)
if not os.path.exists(file_path):
return jsonify({"error": "File not found."}), 404
try:
# Use send_file with as_attachment=True to trigger a download
return send_file(file_path, as_attachment=True, download_name=filename)
except Exception as e:
logger.error(f"Error serving file {file_path}: {str(e)}")
return jsonify({"error": "Error serving file.", "details": str(e)}), 500
@app.route('/api/clear-validation-results', methods=['POST'])
@csrf.exempt
def clear_validation_results():
"""
Clears the temporary directory containing the last validation run's results.
"""
temp_dir = current_app.config.get('VALIDATION_TEMP_DIR')
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
current_app.config['VALIDATION_TEMP_DIR'] = None
return jsonify({"status": "success", "message": f"Results cleared successfully."})
except Exception as e:
logger.error(f"Failed to clear validation results at {temp_dir}: {e}")
return jsonify({"status": "error", "message": f"Failed to clear results: {e}"}), 500
else:
return jsonify({"status": "success", "message": "No results to clear."})
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():

View File

@ -18,5 +18,5 @@ services:
- NODE_PATH=/usr/lib/node_modules - NODE_PATH=/usr/lib/node_modules
- APP_MODE=standalone - APP_MODE=standalone
- APP_BASE_URL=http://localhost:5000 - APP_BASE_URL=http://localhost:5000
- HAPI_FHIR_URL=http://localhost:8080/fhir - HAPI_FHIR_URL=https://fhir.hl7.org.au/aucore/fhir/DEFAULT/
command: supervisord -c /etc/supervisord.conf command: supervisord -c /etc/supervisord.conf

View File

@ -1,6 +1,6 @@
# forms.py # forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField, PasswordField from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField, PasswordField, SelectMultipleField
from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired
from flask import request from flask import request
import json import json
@ -307,3 +307,8 @@ class FhirRequestForm(FlaskForm):
self.basic_auth_password.errors.append('Password is required for Basic Authentication with a custom URL.') self.basic_auth_password.errors.append('Password is required for Basic Authentication with a custom URL.')
return False return False
return True return True
class IgYamlForm(FlaskForm):
"""Form to select IGs for YAML generation."""
igs = SelectMultipleField('Select IGs to include', validators=[DataRequired()])
generate_yaml = SubmitField('Generate YAML')

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -796,6 +796,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a> <a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'ig_configurator' else '' }}" href="{{ url_for('ig_configurator') }}"><i class="fas fa-wrench me-1"></i> IG Valid-Conf Gen</a>
</li>
{% if app_mode != 'lite' %} {% if app_mode != 'lite' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a> <a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a>

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>IG Configuration Generator</h2>
<p>Select the Implementation Guides you want to load into HAPI for validation. This will generate the YAML configuration required for the <code>application.yaml</code> file.</p>
<div class="card mb-4">
<div class="card-header">Select IGs</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
<label class="form-label">IGs and their dependencies</label>
{% for choice in form.igs.choices %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="igs" value="{{ choice[0] }}" id="ig-{{ choice[0] }}">
<label class="form-check-label" for="ig-{{ choice[0] }}">{{ choice[1] }}</label>
</div>
{% endfor %}
</div>
{{ form.generate_yaml(class="btn btn-primary") }}
</form>
</div>
</div>
{% if yaml_output %}
<div class="card mt-4">
<div class="card-header">Generated <code>application.yaml</code> Excerpt</div>
<div class="card-body">
<p>Copy the following configuration block and paste it into the `hapi.fhir` section of your HAPI server's <code>application.yaml</code> file.</p>
<pre><code class="language-yaml">{{ yaml_output }}</code></pre>
<div class="alert alert-warning mt-3">
<strong>Important:</strong> After updating the <code>application.yaml</code> file, you must **restart your HAPI server** for the changes to take effect.
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -1,4 +1,3 @@
<!-- templates/validate_sample.html -->
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
@ -7,14 +6,15 @@
<h1 class="display-5 fw-bold text-body-emphasis">Validate FHIR Sample</h1> <h1 class="display-5 fw-bold text-body-emphasis">Validate FHIR Sample</h1>
<div class="col-lg-6 mx-auto"> <div class="col-lg-6 mx-auto">
<p class="lead mb-4"> <p class="lead mb-4">
Validate a FHIR resource or bundle against a selected Implementation Guide. (ALPHA - TEST Fhir pathing is complex and the logic is WIP, please report anomalies you find in Github issues alongside your sample json REMOVE PHI) Validate a FHIR resource or bundle against a selected Implementation Guide.
</p> </p>
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Manage FHIR Packages</a>
</div> </div>
----------------------------------------------------------------remove the buttons-----------------------------------------------------> </div>
<div id="spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50 d-flex align-items-center justify-content-center" style="z-index: 1050;">
<div class="text-center text-white">
<div id="spinner-animation" style="width: 200px; height: 200px;"></div>
<p class="mt-3" id="spinner-message">Waiting, don't leave this page...</p>
</div> </div>
</div> </div>
@ -71,7 +71,10 @@
<div class="text-danger">{{ error }}</div> <div class="text-danger">{{ error }}</div>
{% endfor %} {% endfor %}
</div> </div>
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-primary" id="validateButton">Validate</button> <button type="button" class="btn btn-primary" id="validateButton">Validate</button>
<button type="button" class="btn btn-secondary d-none" id="clearResultsButton">Clear Results</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@ -79,72 +82,103 @@
<div class="card mt-4" id="validationResult" style="display: none;"> <div class="card mt-4" id="validationResult" style="display: none;">
<div class="card-header">Validation Report</div> <div class="card-header">Validation Report</div>
<div class="card-body" id="validationContent"> <div class="card-body" id="validationContent">
<!-- Results will be populated dynamically -->
</div> </div>
</div> </div>
</div> </div>
<script src="{{ url_for('static', filename='js/lottie.min.js') }}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { // --- Global Variables and Helper Functions ---
let currentLottieAnimation = null;
let pollInterval = null;
const spinnerOverlay = document.getElementById('spinner-overlay');
const spinnerMessage = document.getElementById('spinner-message');
const spinnerContainer = document.getElementById('spinner-animation');
// Function to load the Lottie animation based on theme
function loadLottieAnimation(theme) {
if (currentLottieAnimation) {
currentLottieAnimation.destroy();
currentLottieAnimation = null;
}
const animationPath = (theme === 'dark')
? '{{ url_for('static', filename='animations/Validation abstract.json') }}'
: '{{ url_for('static', filename='animations/Validation abstract.json') }}';
try {
currentLottieAnimation = lottie.loadAnimation({
container: spinnerContainer,
renderer: 'svg',
loop: true,
autoplay: true,
path: animationPath
});
} catch(lottieError) {
console.error("Error loading Lottie animation:", lottieError);
}
}
// Function to show/hide the spinner with a custom message
function toggleSpinner(show, message = "Running validator...") {
if (show) {
spinnerMessage.textContent = message;
spinnerOverlay.classList.remove('d-none');
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
loadLottieAnimation(currentTheme);
} else {
spinnerOverlay.classList.add('d-none');
if (currentLottieAnimation) {
currentLottieAnimation.destroy();
currentLottieAnimation = null;
}
}
}
// --- Frontend logic starts here
document.addEventListener('DOMContentLoaded', function() {
const packageSelect = document.getElementById('packageSelect'); const packageSelect = document.getElementById('packageSelect');
const packageNameInput = document.getElementById('{{ form.package_name.id }}'); const packageNameInput = document.getElementById('{{ form.package_name.id }}');
const versionInput = document.getElementById('{{ form.version.id }}'); const versionInput = document.getElementById('{{ form.version.id }}');
const validateButton = document.getElementById('validateButton'); const validateButton = document.getElementById('validateButton');
const clearResultsButton = document.getElementById('clearResultsButton');
const sampleInput = document.getElementById('{{ form.sample_input.id }}'); const sampleInput = document.getElementById('{{ form.sample_input.id }}');
const validationResult = document.getElementById('validationResult'); const validationResult = document.getElementById('validationResult');
const validationContent = document.getElementById('validationContent'); const validationContent = document.getElementById('validationContent');
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
const perPage = 10;
// Debug DOM elements
console.log('Package Select:', packageSelect ? 'Found' : 'Missing');
console.log('Package Name Input:', packageNameInput ? 'Found' : 'Missing');
console.log('Version Input:', versionInput ? 'Found' : 'Missing');
// Update package_name and version fields from dropdown
function updatePackageFields(select) { function updatePackageFields(select) {
const value = select.value; const value = select.value;
console.log('Selected package:', value); if (!packageNameInput || !versionInput) return;
if (!packageNameInput || !versionInput) {
console.error('Input fields not found');
return;
}
if (value) { if (value) {
const [name, version] = value.split('#'); const [name, version] = value.split('#');
if (name && version) { if (name && version) {
packageNameInput.value = name; packageNameInput.value = name;
versionInput.value = version; versionInput.value = version;
console.log('Updated fields:', { packageName: name, version });
} else { } else {
console.warn('Invalid package format:', value);
packageNameInput.value = ''; packageNameInput.value = '';
versionInput.value = ''; versionInput.value = '';
} }
} else { } else {
packageNameInput.value = ''; packageNameInput.value = '';
versionInput.value = ''; versionInput.value = '';
console.log('Cleared fields: no package selected');
} }
} }
// Initialize dropdown based on form values if (packageNameInput.value && versionInput.value) {
if (packageNameInput && versionInput && packageNameInput.value && versionInput.value) {
const currentValue = `${packageNameInput.value}#${versionInput.value}`; const currentValue = `${packageNameInput.value}#${versionInput.value}`;
if (packageSelect.querySelector(`option[value="${currentValue}"]`)) { const optionExists = Array.from(packageSelect.options).some(opt => opt.value === currentValue);
if (optionExists) {
packageSelect.value = currentValue; packageSelect.value = currentValue;
console.log('Initialized dropdown with:', currentValue);
} }
} }
// Debug dropdown options
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => opt.value));
// Attach event listener for package selection
if (packageSelect) { if (packageSelect) {
packageSelect.addEventListener('change', function() { packageSelect.addEventListener('change', function() {
updatePackageFields(this); updatePackageFields(this);
}); });
} }
// Client-side JSON validation preview
if (sampleInput) { if (sampleInput) {
sampleInput.addEventListener('input', function() { sampleInput.addEventListener('input', function() {
try { try {
@ -162,149 +196,114 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Check HAPI server status
function checkHapiStatus() {
return fetch('/fhir/metadata', { method: 'GET' })
.then(response => {
if (!response.ok) {
throw new Error('HAPI server unavailable');
}
return true;
})
.catch(error => {
console.warn('HAPI status check failed:', error.message);
validationContent.innerHTML = '<div class="alert alert-warning">HAPI server unavailable; using local validation.</div>';
return false;
});
}
// Sanitize text to prevent XSS
function sanitizeText(text) { function sanitizeText(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
// Validate button handler // Tidy up the display by paginating results
if (validateButton) { function renderResultList(container, items, title, isWarnings = false) {
validateButton.addEventListener('click', function() { container.innerHTML = '';
const packageName = packageNameInput.value; if (items.length === 0) {
const version = versionInput.value;
const sampleData = sampleInput.value.trim();
const includeDependencies = document.getElementById('{{ form.include_dependencies.id }}').checked;
if (!packageName || !version) {
validationResult.style.display = 'block';
validationContent.innerHTML = '<div class="alert alert-danger">Please select a package and version.</div>';
return; return;
} }
if (!sampleData) { const wrapper = document.createElement('div');
validationResult.style.display = 'block'; const listId = `list-${title.toLowerCase()}`;
validationContent.innerHTML = '<div class="alert alert-danger">Please provide FHIR sample JSON.</div>';
return;
}
try { let initialItems = items.slice(0, perPage);
JSON.parse(sampleData); let remainingItems = items.slice(perPage);
} catch (e) {
validationResult.style.display = 'block';
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + sanitizeText(e.message) + '</div>';
return;
}
validationResult.style.display = 'block'; const titleElement = document.createElement('h5');
validationContent.innerHTML = '<div class="text-center py-3"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Validating...</span></div></div>'; titleElement.textContent = title;
wrapper.appendChild(titleElement);
// Check HAPI status before validation const ul = document.createElement('ul');
checkHapiStatus().then(() => { ul.className = 'list-unstyled';
console.log('Sending request to /api/validate-sample with:', { initialItems.forEach(item => {
package_name: packageName, const li = document.createElement('li');
version: version, li.innerHTML = `
include_dependencies: includeDependencies, <div class="alert alert-${isWarnings ? 'warning' : 'danger'}">
sample_data_length: sampleData.length ${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)}
${isWarnings && item.includes('Must Support') ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}
</div>
`;
ul.appendChild(li);
});
wrapper.appendChild(ul);
if (remainingItems.length > 0) {
const toggleButton = document.createElement('a');
toggleButton.href = '#';
toggleButton.className = 'btn btn-sm btn-link text-decoration-none';
toggleButton.textContent = `Show More (${remainingItems.length} more)`;
const remainingUl = document.createElement('ul');
remainingUl.className = 'list-unstyled d-none';
remainingItems.forEach(item => {
const li = document.createElement('li');
li.innerHTML = `
<div class="alert alert-${isWarnings ? 'warning' : 'danger'}">
${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)}
${isWarnings && item.includes('Must Support') ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}
</div>
`;
remainingUl.appendChild(li);
}); });
fetch('/api/validate-sample', { toggleButton.addEventListener('click', (e) => {
method: 'POST', e.preventDefault();
headers: { if (remainingUl.classList.contains('d-none')) {
'Content-Type': 'application/json', remainingUl.classList.remove('d-none');
'X-CSRFToken': '{{ form.csrf_token._value() }}' toggleButton.textContent = 'Show Less';
}, } else {
body: JSON.stringify({ remainingUl.classList.add('d-none');
package_name: packageName, toggleButton.textContent = `Show More (${remainingItems.length} more)`;
version: version,
include_dependencies: includeDependencies,
sample_data: sampleData
})
})
.then(response => {
console.log('Server response status:', response.status, response.statusText);
if (!response.ok) {
return response.text().then(text => {
console.error('Server response text:', text.substring(0, 200) + (text.length > 200 ? '...' : ''));
throw new Error(`HTTP error ${response.status}: ${text.substring(0, 100)}...`);
});
} }
return response.json(); });
}) wrapper.appendChild(toggleButton);
.then(data => { wrapper.appendChild(remainingUl);
console.log('Validation response:', data); }
container.appendChild(wrapper);
}
function displayFinalReport(data) {
validationContent.innerHTML = ''; validationContent.innerHTML = '';
let hasContent = false; let hasContent = false;
// Display profile information clearResultsButton.classList.remove('d-none');
if (data.results) {
const profileDiv = document.createElement('div'); if (data.file_paths) {
profileDiv.className = 'mb-3'; const jsonFileName = data.file_paths.json.split('/').pop();
const profiles = new Set(); const htmlFileName = data.file_paths.html.split('/').pop();
Object.values(data.results).forEach(result => { const downloadDiv = document.createElement('div');
if (result.profile) profiles.add(result.profile); downloadDiv.className = 'alert alert-info d-flex justify-content-between align-items-center';
}); downloadDiv.innerHTML = `
if (profiles.size > 0) { <span>Download Raw Reports:</span>
profileDiv.innerHTML = ` <div>
<h5>Validated Against Profile${profiles.size > 1 ? 's' : ''}</h5> <a href="/download/${jsonFileName}" class="btn btn-sm btn-secondary me-2" download>Download JSON</a>
<ul> <a href="/download/${htmlFileName}" class="btn btn-sm btn-secondary" download>Download HTML</a>
${Array.from(profiles).map(p => `<li><a href="${sanitizeText(p)}" target="_blank">${sanitizeText(p.split('/').pop())}</a></li>`).join('')} </div>
</ul>
`; `;
validationContent.appendChild(profileDiv); validationContent.appendChild(downloadDiv);
hasContent = true; hasContent = true;
} }
}
// Display errors
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
hasContent = true; hasContent = true;
const errorDiv = document.createElement('div'); const errorContainer = document.createElement('div');
errorDiv.className = 'alert alert-danger'; renderResultList(errorContainer, data.errors, 'Errors');
errorDiv.innerHTML = ` validationContent.appendChild(errorContainer);
<h5>Errors</h5>
<ul>
${data.errors.map(e => `<li>${sanitizeText(typeof e === 'string' ? e : e.message)}</li>`).join('')}
</ul>
`;
validationContent.appendChild(errorDiv);
} }
// Display warnings
if (data.warnings && data.warnings.length > 0) { if (data.warnings && data.warnings.length > 0) {
hasContent = true; hasContent = true;
const warningDiv = document.createElement('div'); const warningContainer = document.createElement('div');
warningDiv.className = 'alert alert-warning'; renderResultList(warningContainer, data.warnings, 'Warnings', true);
warningDiv.innerHTML = ` validationContent.appendChild(warningContainer);
<h5>Warnings</h5>
<ul>
${data.warnings.map(w => {
const isMustSupport = w.includes('Must Support');
return `<li>${sanitizeText(typeof w === 'string' ? w : w.message)}${isMustSupport ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}</li>`;
}).join('')}
</ul>
`;
validationContent.appendChild(warningDiv);
} }
// Detailed results with collapsible sections
if (data.results) { if (data.results) {
hasContent = true; hasContent = true;
const resultsDiv = document.createElement('div'); const resultsDiv = document.createElement('div');
@ -338,7 +337,7 @@ document.addEventListener('DOMContentLoaded', function() {
validationContent.appendChild(resultsDiv); validationContent.appendChild(resultsDiv);
} }
// Summary if (data.summary) {
const summaryDiv = document.createElement('div'); const summaryDiv = document.createElement('div');
summaryDiv.className = 'alert alert-info'; summaryDiv.className = 'alert alert-info';
summaryDiv.innerHTML = ` summaryDiv.innerHTML = `
@ -348,12 +347,12 @@ document.addEventListener('DOMContentLoaded', function() {
`; `;
validationContent.appendChild(summaryDiv); validationContent.appendChild(summaryDiv);
hasContent = true; hasContent = true;
}
if (!hasContent && data.valid) { if (!hasContent && data.valid) {
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>'; validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
} }
// Initialize Bootstrap collapse and tooltips
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => { document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
const target = document.querySelector(el.getAttribute('data-bs-target')); const target = document.querySelector(el.getAttribute('data-bs-target'));
@ -363,14 +362,135 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el); new bootstrap.Tooltip(el);
}); });
}) }
.catch(error => {
console.error('Validation error:', error); function checkValidationStatus(taskId) {
validationContent.innerHTML = `<div class="alert alert-danger">Error during validation: ${sanitizeText(error.message)}</div>`; fetch(`/api/check-validation-status/${taskId}`)
}); .then(response => {
}); if (response.status === 202) {
console.log('Validation still in progress...');
return null;
} else if (response.status === 200) {
clearInterval(pollInterval);
toggleSpinner(false);
return response.json();
} else {
clearInterval(pollInterval);
toggleSpinner(false);
return response.json().then(errorData => {
throw new Error(errorData.message);
}); });
} }
}); })
.then(data => {
if (data && data.status === 'complete') {
validationResult.style.display = 'block';
displayFinalReport(data.result);
}
})
.catch(error => {
clearInterval(pollInterval);
toggleSpinner(false);
console.error('Validation failed during polling:', error);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">An unexpected error occurred during validation: ${sanitizeText(error.message)}</div>`;
});
}
function runBlockingValidation() {
const packageName = packageNameInput.value;
const version = versionInput.value;
const sampleData = sampleInput.value.trim();
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
if (!packageName || !version) {
validationResult.style.display = 'block';
validationContent.innerHTML = '<div class="alert alert-danger">Please select a package and version.</div>';
return;
}
if (!sampleData) {
validationResult.style.display = 'block';
validationContent.innerHTML = '<div class="alert alert-danger">Please provide FHIR sample JSON.</div>';
return;
}
try {
JSON.parse(sampleData);
} catch (e) {
validationResult.style.display = 'block';
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + sanitizeText(e.message) + '</div>';
return;
}
toggleSpinner(true, "Running FHIR Validator (This may take up to 5 minutes on the first run).");
fetch('/api/validate-sample', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
package_name: packageName,
version: version,
sample_data: sampleData
})
})
.then(response => {
if (response.status === 202) {
return response.json();
} else {
toggleSpinner(false);
return response.json().then(errorData => {
throw new Error(errorData.message);
});
}
})
.then(data => {
if (data.status === 'accepted' && data.task_id) {
pollInterval = setInterval(() => checkValidationStatus(data.task_id), 3000);
} else {
toggleSpinner(false);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">Server response format unexpected.</div>`;
}
})
.catch(error => {
clearInterval(pollInterval);
toggleSpinner(false);
console.error('Validation failed:', error);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">An unexpected error occurred: ${sanitizeText(error.message)}</div>`;
});
}
clearResultsButton.addEventListener('click', function() {
fetch('/api/clear-validation-results', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
validationResult.style.display = 'none';
clearResultsButton.classList.add('d-none');
} else {
console.error('Failed to clear results:', data.message);
}
})
.catch(error => {
console.error('Network error while clearing results:', error);
});
});
if (validateButton) {
validateButton.addEventListener('click', function() {
runBlockingValidation();
});
}
});
</script> </script>
{% endblock %} {% endblock %}