Added Fhir Sample validation (alpha)

This commit is contained in:
Joshua Hare 2025-04-13 00:29:52 +10:00
parent 9b7b8344de
commit afafcd0a22
25 changed files with 1763 additions and 677 deletions

View File

@ -10,6 +10,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY services.py .
COPY forms.py .
COPY routes.py .
COPY templates/ templates/
COPY static/ static/
COPY tests/ tests/

265
app.py
View File

@ -1,9 +1,11 @@
import sys
import os
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
from flask import Flask, render_template, render_template_string, request, redirect, url_for, flash, jsonify, Response
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, SelectField
from wtforms.validators import DataRequired, Regexp
import os
from flask_wtf.csrf import CSRFProtect
import tarfile
import json
from datetime import datetime
@ -11,6 +13,7 @@ import services
import logging
import requests
import re
from forms import IgImportForm, ValidationForm
# Set up logging
logging.basicConfig(level=logging.DEBUG)
@ -20,14 +23,14 @@ app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////app/instance/fhir_ig.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['FHIR_PACKAGES_DIR'] = os.path.join(app.instance_path, 'fhir_packages')
app.config['FHIR_PACKAGES_DIR'] = '/app/instance/fhir_packages'
app.config['API_KEY'] = 'your-api-key-here'
app.config['VALIDATE_IMPOSED_PROFILES'] = True # Enable/disable imposed profile validation
app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = True # Enable/disable UI display of relationships
app.config['VALIDATE_IMPOSED_PROFILES'] = True
app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = True
# Ensure directories exist and are writable
instance_path = '/app/instance'
db_path = os.path.join(instance_path, 'fhir_ig.db')
db_path = '/app/instance/fhir_ig.db'
packages_path = app.config['FHIR_PACKAGES_DIR']
logger.debug(f"Instance path: {instance_path}")
@ -37,9 +40,9 @@ logger.debug(f"Packages path: {packages_path}")
try:
os.makedirs(instance_path, exist_ok=True)
os.makedirs(packages_path, exist_ok=True)
os.chmod(instance_path, 0o777)
os.chmod(packages_path, 0o777)
logger.debug(f"Directories created: {os.listdir('/app')}")
os.chmod(instance_path, 0o755)
os.chmod(packages_path, 0o755)
logger.debug(f"Directories created: {os.listdir(os.path.dirname(__file__))}")
logger.debug(f"Instance contents: {os.listdir(instance_path)}")
except Exception as e:
logger.error(f"Failed to create directories: {e}")
@ -47,22 +50,7 @@ except Exception as e:
logger.warning("Falling back to /tmp/fhir_ig.db")
db = SQLAlchemy(app)
class IgImportForm(FlaskForm):
package_name = StringField('Package Name (e.g., hl7.fhir.us.core)', validators=[
DataRequired(),
Regexp(r'^[a-zAZ0-9]+(\.[a-zA-Z0-9]+)+$', message='Invalid package name format.')
])
package_version = StringField('Package Version (e.g., 1.0.0 or current)', validators=[
DataRequired(),
Regexp(r'^[a-zA-Z0-9\.\-]+$', message='Invalid version format.')
])
dependency_mode = SelectField('Dependency Pulling Mode', choices=[
('recursive', 'Current Recursive'),
('patch-canonical', 'Patch Canonical Versions'),
('tree-shaking', 'Tree Shaking (Only Used Dependencies)')
], default='recursive')
submit = SubmitField('Fetch & Download IG')
csrf = CSRFProtect(app)
class ProcessedIg(db.Model):
id = db.Column(db.Integer, primary_key=True)
@ -107,10 +95,11 @@ def import_ig():
return redirect(url_for('view_igs'))
except Exception as e:
flash(f"Error downloading IG: {str(e)}", "error")
return render_template('import_ig.html', form=form, site_name='FLARE FHIR IG Toolkit', now=datetime.now())
return render_template('import_ig.html', form=form, site_name='FHIRFLARE IG Toolkit', now=datetime.now())
@app.route('/view-igs')
def view_igs():
form = FlaskForm()
igs = ProcessedIg.query.all()
processed_ids = {(ig.package_name, ig.version) for ig in igs}
@ -161,14 +150,15 @@ def view_igs():
for i, name in enumerate(duplicate_groups.keys()):
group_colors[name] = colors[i % len(colors)]
return render_template('cp_downloaded_igs.html', packages=packages, processed_list=igs,
return render_template('cp_downloaded_igs.html', form=form, packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names,
duplicate_groups=duplicate_groups, group_colors=group_colors,
site_name='FLARE FHIR IG Toolkit', now=datetime.now(),
site_name='FHIRFLARE IG Toolkit', now=datetime.now(),
config=app.config)
@app.route('/push-igs', methods=['GET', 'POST'])
def push_igs():
form = FlaskForm()
igs = ProcessedIg.query.all()
processed_ids = {(ig.package_name, ig.version) for ig in igs}
@ -219,90 +209,102 @@ def push_igs():
for i, name in enumerate(duplicate_groups.keys()):
group_colors[name] = colors[i % len(colors)]
return render_template('cp_push_igs.html', packages=packages, processed_list=igs,
return render_template('cp_push_igs.html', form=form, packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names,
duplicate_groups=duplicate_groups, group_colors=group_colors,
site_name='FLARE FHIR IG Toolkit', now=datetime.now(),
site_name='FHIRFLARE IG Toolkit', now=datetime.now(),
api_key=app.config['API_KEY'],
config=app.config)
@app.route('/process-igs', methods=['POST'])
def process_ig():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error")
return redirect(url_for('view_igs'))
try:
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4]
name = name.replace('_', '.')
else:
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename} during processing")
package_info = services.process_package_file(tgz_path)
processed_ig = ProcessedIg(
package_name=name,
version=version,
processed_date=datetime.now(),
resource_types_info=package_info['resource_types_info'],
must_support_elements=package_info.get('must_support_elements'),
examples=package_info.get('examples')
)
db.session.add(processed_ig)
db.session.commit()
flash(f"Successfully processed {name}#{version}!", "success")
except Exception as e:
flash(f"Error processing IG: {str(e)}", "error")
form = FlaskForm()
if form.validate_on_submit():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error")
return redirect(url_for('view_igs'))
try:
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4]
name = name.replace('_', '.')
else:
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename} during processing")
package_info = services.process_package_file(tgz_path)
processed_ig = ProcessedIg(
package_name=name,
version=version,
processed_date=datetime.now(),
resource_types_info=package_info['resource_types_info'],
must_support_elements=package_info.get('must_support_elements'),
examples=package_info.get('examples')
)
db.session.add(processed_ig)
db.session.commit()
flash(f"Successfully processed {name}#{version}!", "success")
except Exception as e:
flash(f"Error processing IG: {str(e)}", "error")
else:
flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs'))
@app.route('/delete-ig', methods=['POST'])
def delete_ig():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
metadata_path = tgz_path.replace('.tgz', '.metadata.json')
if os.path.exists(tgz_path):
try:
os.remove(tgz_path)
if os.path.exists(metadata_path):
os.remove(metadata_path)
logger.debug(f"Deleted metadata file: {metadata_path}")
flash(f"Deleted {filename}", "success")
except Exception as e:
flash(f"Error deleting {filename}: {str(e)}", "error")
form = FlaskForm()
if form.validate_on_submit():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
metadata_path = tgz_path.replace('.tgz', '.metadata.json')
if os.path.exists(tgz_path):
try:
os.remove(tgz_path)
if os.path.exists(metadata_path):
os.remove(metadata_path)
logger.debug(f"Deleted metadata file: {metadata_path}")
flash(f"Deleted {filename}", "success")
except Exception as e:
flash(f"Error deleting {filename}: {str(e)}", "error")
else:
flash(f"File not found: {filename}", "error")
else:
flash(f"File not found: {filename}", "error")
flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs'))
@app.route('/unload-ig', methods=['POST'])
def unload_ig():
ig_id = request.form.get('ig_id')
if not ig_id:
flash("Invalid package ID.", "error")
return redirect(url_for('view_igs'))
processed_ig = db.session.get(ProcessedIg, ig_id)
if processed_ig:
try:
db.session.delete(processed_ig)
db.session.commit()
flash(f"Unloaded {processed_ig.package_name}#{processed_ig.version}", "success")
except Exception as e:
flash(f"Error unloading package: {str(e)}", "error")
form = FlaskForm()
if form.validate_on_submit():
ig_id = request.form.get('ig_id')
if not ig_id:
flash("Invalid package ID.", "error")
return redirect(url_for('view_igs'))
processed_ig = db.session.get(ProcessedIg, ig_id)
if processed_ig:
try:
db.session.delete(processed_ig)
db.session.commit()
flash(f"Unloaded {processed_ig.package_name}#{processed_ig.version}", "success")
except Exception as e:
flash(f"Error unloading package: {str(e)}", "error")
else:
flash(f"Package not found with ID: {ig_id}", "error")
else:
flash(f"Package not found with ID: {ig_id}", "error")
flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs'))
@app.route('/view-ig/<int:processed_ig_id>')
@ -327,7 +329,7 @@ def view_ig(processed_ig_id):
return render_template('cp_view_processed_ig.html', title=f"View {processed_ig.package_name}#{processed_ig.version}",
processed_ig=processed_ig, profile_list=profile_list, base_list=base_list,
examples_by_type=examples_by_type, site_name='FLARE FHIR IG Toolkit', now=datetime.now(),
examples_by_type=examples_by_type, site_name='FHIRFLARE IG Toolkit', now=datetime.now(),
complies_with_profiles=complies_with_profiles, imposed_profiles=imposed_profiles,
config=app.config)
@ -418,7 +420,6 @@ def get_package_metadata():
return jsonify({'dependency_mode': metadata['dependency_mode']})
return jsonify({'error': 'Metadata not found'}), 404
# API Endpoint: Import IG Package
@app.route('/api/import-ig', methods=['POST'])
def api_import_ig():
auth_error = check_api_key()
@ -522,7 +523,6 @@ def api_import_ig():
logger.error(f"Error in api_import_ig: {str(e)}")
return jsonify({"status": "error", "message": f"Error importing package: {str(e)}"}), 500
# API Endpoint: Push IG to FHIR Server with Streaming
@app.route('/api/push-ig', methods=['POST'])
def api_push_ig():
auth_error = check_api_key()
@ -597,7 +597,7 @@ def api_push_ig():
continue
# Validate against the profile and imposed profiles
validation_result = services.validate_resource_against_profile(resource, pkg_name, pkg_version, resource_type)
validation_result = services.validate_resource_against_profile(pkg_name, pkg_version, resource, include_dependencies=False)
if not validation_result['valid']:
yield json.dumps({"type": "error", "message": f"Validation failed for {resource_type}/{resource_id} in {pkg_name}#{pkg_version}: {', '.join(validation_result['errors'])}"}) + "\n"
failure_count += 1
@ -643,10 +643,71 @@ def api_push_ig():
return Response(generate_stream(), mimetype='application/x-ndjson')
@app.route('/validate-sample', methods=['GET', 'POST'])
def validate_sample():
form = ValidationForm()
validation_report = None
packages = []
# Load available packages
packages_dir = app.config['FHIR_PACKAGES_DIR']
if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'):
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4]
name = name.replace('_', '.')
try:
with tarfile.open(os.path.join(packages_dir, filename), 'r:gz') as tar:
package_json = tar.extractfile('package/package.json')
pkg_info = json.load(package_json)
packages.append({'name': pkg_info['name'], 'version': pkg_info['version']})
except Exception as e:
logger.warning(f"Error reading package {filename}: {e}")
continue
if form.validate_on_submit():
package_name = form.package_name.data
version = form.version.data
include_dependencies = form.include_dependencies.data
mode = form.mode.data
try:
sample_input = json.loads(form.sample_input.data)
if mode == 'single':
validation_report = services.validate_resource_against_profile(
package_name, version, sample_input, include_dependencies
)
else: # mode == 'bundle'
validation_report = services.validate_bundle_against_profile(
package_name, version, sample_input, include_dependencies
)
flash("Validation completed.", 'success')
except json.JSONDecodeError:
flash("Invalid JSON format in sample input.", 'error')
except Exception as e:
logger.error(f"Error validating sample: {e}")
flash(f"Error validating sample: {str(e)}", 'error')
validation_report = {'valid': False, 'errors': [str(e)], 'warnings': [], 'results': {}}
return render_template(
'validate_sample.html',
form=form,
packages=packages,
validation_report=validation_report,
site_name='FHIRFLARE IG Toolkit',
now=datetime.now()
)
with app.app_context():
logger.debug(f"Creating database at: {app.config['SQLALCHEMY_DATABASE_URI']}")
db.create_all()
logger.debug("Database initialization complete")
try:
db.create_all()
logger.debug("Database initialization complete")
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
raise
if __name__ == '__main__':
app.run(debug=True)

View File

@ -1,19 +1,44 @@
# app/modules/fhir_ig_importer/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Regexp
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Regexp, Optional
class IgImportForm(FlaskForm):
"""Form for specifying an IG package to import."""
# Basic validation for FHIR package names (e.g., hl7.fhir.r4.core)
package_name = StringField('Package Name (e.g., hl7.fhir.au.base)', validators=[
package_name = StringField('Package Name', validators=[
DataRequired(),
Regexp(r'^[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+$', message='Invalid package name format.')
Regexp(r'^[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9]$', message="Invalid package name format.")
])
# Basic validation for version (e.g., 4.1.0, current)
package_version = StringField('Package Version (e.g., 4.1.0 or current)', validators=[
package_version = StringField('Package Version', validators=[
DataRequired(),
Regexp(r'^[a-zA-Z0-9\.\-]+$', message='Invalid version format.')
Regexp(r'^[a-zA-Z0-9\.\-]+$', message="Invalid version format. Use alphanumeric characters, dots, or hyphens (e.g., 1.2.3, 1.1.0-preview, current).")
])
submit = SubmitField('Fetch & Download IG')
dependency_mode = SelectField('Dependency Mode', choices=[
('recursive', 'Current Recursive'),
('patch-canonical', 'Patch Canonical Versions'),
('tree-shaking', 'Tree Shaking (Only Used Dependencies)')
], default='recursive')
submit = SubmitField('Import')
class ValidationForm(FlaskForm):
package_name = StringField('Package Name', validators=[DataRequired()])
version = StringField('Package Version', validators=[DataRequired()])
include_dependencies = BooleanField('Include Dependencies', default=True)
mode = SelectField('Validation Mode', choices=[
('single', 'Single Resource'),
('bundle', 'Bundle')
], default='single')
sample_input = TextAreaField('Sample Input', validators=[DataRequired()])
submit = SubmitField('Validate')
def validate_json(field, mode):
"""Custom validator to ensure input is valid JSON and matches the selected mode."""
import json
try:
data = json.loads(field)
if mode == 'single' and not isinstance(data, dict):
raise ValueError("Single resource mode requires a JSON object.")
if mode == 'bundle' and (not isinstance(data, dict) or data.get('resourceType') != 'Bundle'):
raise ValueError("Bundle mode requires a JSON object with resourceType 'Bundle'.")
except json.JSONDecodeError:
raise ValueError("Invalid JSON format.")
except ValueError as e:
raise ValueError(str(e))

Binary file not shown.

View File

@ -0,0 +1,21 @@
{
"package_name": "hl7.fhir.au.base",
"version": "5.1.0-preview",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
},
{
"name": "hl7.terminology.r4",
"version": "6.2.0"
},
{
"name": "hl7.fhir.uv.extensions.r4",
"version": "5.2.0"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -1,7 +1,7 @@
{
"package_name": "hl7.fhir.au.core",
"version": "1.1.0-preview",
"dependency_mode": "tree-shaking",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",

View File

@ -0,0 +1,8 @@
{
"package_name": "hl7.fhir.r4.core",
"version": "4.0.1",
"dependency_mode": "recursive",
"imported_dependencies": [],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.fhir.uv.extensions.r4",
"version": "5.2.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -0,0 +1,21 @@
{
"package_name": "hl7.fhir.uv.ipa",
"version": "1.0.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
},
{
"name": "hl7.terminology.r4",
"version": "5.0.0"
},
{
"name": "hl7.fhir.uv.smart-app-launch",
"version": "2.0.0"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

Binary file not shown.

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.fhir.uv.smart-app-launch",
"version": "2.0.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -0,0 +1,17 @@
{
"package_name": "hl7.fhir.uv.smart-app-launch",
"version": "2.1.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
},
{
"name": "hl7.terminology.r4",
"version": "5.0.0"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.terminology.r4",
"version": "5.0.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

Binary file not shown.

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.terminology.r4",
"version": "6.2.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,9 @@
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'push_igs' else '' }}" href="{{ url_for('push_igs') }}"><i class="bi bi-upload me-1"></i> Push IGs</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'validate_sample' else '' }}" href="{{ url_for('validate_sample') }}"><i class="bi bi-check-circle me-1"></i> Validate FHIR Sample</a>
</li>
</ul>
</div>
</div>

View File

@ -31,7 +31,6 @@
.badge.bg-danger {
background-color: #dc3545 !important;
}
/* Ensure table rows with custom background colors are not overridden by Bootstrap */
.table tr.bg-warning {
background-color: #ffc107 !important;
}
@ -44,7 +43,6 @@
.table tr.bg-danger {
background-color: #dc3545 !important;
}
/* Override Bootstrap's table background to allow custom row colors to show */
.table-custom-bg {
--bs-table-bg: transparent !important;
}
@ -64,9 +62,7 @@
<div class="card-body">
{% if packages %}
<div class="table-responsive">
<!-- remove this warning and move it down the bottom-----------------------------------------------------------------------------------------
<p class="mb-2"><small><span class="badge bg-danger text-light border me-1">Risk:</span> Duplicate Dependency with Different Versions</small></p>
--------------------------------------------------------------------------------------------------------------------------------------------->
<p class="mb-2"><small><span class="badge bg-danger text-light border me-1">Risk:</span> = Duplicate Dependencies</small></p>
<table class="table table-sm table-hover table-custom-bg">
<thead>
<tr><th>Package Name</th><th>Version</th><th>Actions</th></tr>
@ -90,11 +86,13 @@
<span class="btn btn-success disabled"><i class="bi bi-check-lg"></i> Processed</span>
{% else %}
<form method="POST" action="{{ url_for('process_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="filename" value="{{ pkg.filename }}">
<button type="submit" class="btn btn-outline-primary"><i class="bi bi-gear"></i> Process</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('delete_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="filename" value="{{ pkg.filename }}">
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Are you sure you want to delete {{ pkg.name }}#{{ pkg.version }}?');"><i class="bi bi-trash"></i> Delete</button>
</form>
@ -106,7 +104,6 @@
</table>
</div>
{% if duplicate_groups %}
<p class="mb-2"><small><span class="badge bg-danger text-light border me-1">Risk:</span> = Duplicate Dependencies</small></p>
<p class="mt-2 small text-muted">Duplicate dependencies detected:
{% for name, versions in duplicate_groups.items() %}
{% set group_color = group_colors[name] if name in group_colors else 'bg-warning' %}
@ -158,6 +155,7 @@
<div class="btn-group-vertical btn-group-sm w-100">
<a href="{{ url_for('view_ig', processed_ig_id=processed_ig.id) }}" class="btn btn-outline-info w-100" title="View Details"><i class="bi bi-search"></i> View</a>
<form method="POST" action="{{ url_for('unload_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="ig_id" value="{{ processed_ig.id }}">
<button type="submit" class="btn btn-outline-warning w-100 mt-1" onclick="return confirm('Are you sure you want to unload {{ processed_ig.package_name }}#{{ processed_ig.version }}?');"><i class="bi bi-x-lg"></i> Unload</button>
</form>

View File

@ -12,9 +12,6 @@
<tr>
<th>Package Name</th>
<th>Version</th>
<!--------------------------------------------------------------------------------------------------------------if you unhide buttons unhide this--------------------------------------------
<th>Actions</th>
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->
</tr>
</thead>
<tbody>
@ -27,20 +24,6 @@
<tr {% if duplicate_group %}class="{{ group_colors[name] }}"{% endif %}>
<td>{{ name }}</td>
<td>{{ version }}</td>
<!--------------------------------------------------------------------------------------------------------------Dont need the buttons here--------------------------------------------------
<td>
{% if not processed %}
<form method="POST" action="{{ url_for('process_ig') }}" style="display:inline;">
<input type="hidden" name="filename" value="{{ filename }}">
<button type="submit" class="btn btn-primary btn-sm">Process</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('delete_ig') }}" style="display:inline;" onsubmit="return confirm('Are you sure you want to delete {{ name }}#{{ version }}?');">
<input type="hidden" name="filename" value="{{ filename }}">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->
</tr>
{% endfor %}
</tbody>
@ -65,6 +48,7 @@
<div class="col-md-6">
<h2>Push IGs to FHIR Server</h2>
<form id="pushIgForm" method="POST">
{{ form.csrf_token }}
<div class="mb-3">
<label for="packageSelect" class="form-label">Select Package to Push</label>
<select class="form-select" id="packageSelect" name="package_id" required>
@ -115,6 +99,7 @@
document.addEventListener('DOMContentLoaded', function() {
const packageSelect = document.getElementById('packageSelect');
const dependencyModeField = document.getElementById('dependencyMode');
const csrfToken = "{{ form.csrf_token._value() }}";
// Update dependency mode when package selection changes
packageSelect.addEventListener('change', function() {
@ -143,70 +128,135 @@ document.addEventListener('DOMContentLoaded', function() {
if (packageSelect.value) {
packageSelect.dispatchEvent(new Event('change'));
}
});
document.getElementById('pushIgForm').addEventListener('submit', async function(event) {
event.preventDefault();
document.getElementById('pushIgForm').addEventListener('submit', async function(event) {
event.preventDefault();
const form = event.target;
const pushButton = document.getElementById('pushButton');
const formData = new FormData(form);
const packageId = formData.get('package_id');
const [packageName, version] = packageId.split('#');
const fhirServerUrl = formData.get('fhir_server_url');
const includeDependencies = formData.get('include_dependencies') === 'on';
const form = event.target;
const pushButton = document.getElementById('pushButton');
const formData = new FormData(form);
const packageId = formData.get('package_id');
const [packageName, version] = packageId.split('#');
const fhirServerUrl = formData.get('fhir_server_url');
const includeDependencies = formData.get('include_dependencies') === 'on';
// Disable the button to prevent multiple submissions
pushButton.disabled = true;
pushButton.textContent = 'Pushing...';
// Disable the button to prevent multiple submissions
pushButton.disabled = true;
pushButton.textContent = 'Pushing...';
// Clear the console and response area
const liveConsole = document.getElementById('liveConsole');
const responseDiv = document.getElementById('pushResponse');
liveConsole.innerHTML = '<div>Starting push operation...</div>';
responseDiv.innerHTML = '';
// Clear the console and response area
const liveConsole = document.getElementById('liveConsole');
const responseDiv = document.getElementById('pushResponse');
liveConsole.innerHTML = '<div>Starting push operation...</div>';
responseDiv.innerHTML = '';
try {
const response = await fetch('/api/push-ig', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/x-ndjson'
},
body: JSON.stringify({
package_name: packageName,
version: version,
fhir_server_url: fhirServerUrl,
include_dependencies: includeDependencies,
api_key: '{{ api_key | safe }}' // Use the API key passed from the server
})
});
try {
const response = await fetch('/api/push-ig', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/x-ndjson',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
package_name: packageName,
version: version,
fhir_server_url: fhirServerUrl,
include_dependencies: includeDependencies,
api_key: '{{ api_key | safe }}'
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to push package');
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to push package');
}
// Stream the response
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// Stream the response
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode the chunk and append to buffer
buffer += decoder.decode(value, { stream: true });
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
// Process each line (newline-separated JSON)
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep the last (possibly incomplete) line in the buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const timestamp = new Date().toLocaleTimeString();
let messageClass = '';
let prefix = '';
for (const line of lines) {
if (!line.trim()) continue; // Skip empty lines
switch (data.type) {
case 'start':
case 'progress':
messageClass = 'text-info';
prefix = '[INFO]';
break;
case 'success':
messageClass = 'text-success';
prefix = '[SUCCESS]';
break;
case 'error':
messageClass = 'text-danger';
prefix = '[ERROR]';
break;
case 'warning':
messageClass = 'text-warning';
prefix = '[WARNING]';
break;
case 'complete':
if (data.data.status === 'success') {
responseDiv.innerHTML = `
<div class="alert alert-success">
<strong>Success:</strong> ${data.data.message}<br>
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br>
<strong>Server Response:</strong> ${data.data.server_response}
</div>
`;
} else if (data.data.status === 'partial') {
responseDiv.innerHTML = `
<div class="alert alert-warning">
<strong>Partial Success:</strong> ${data.data.message}<br>
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br>
<strong>Server Response:</strong> ${data.data.server_response}
</div>
`;
} else {
responseDiv.innerHTML = `
<div class="alert alert-danger">
<strong>Error:</strong> ${data.data.message}
</div>
`;
}
messageClass = data.data.status === 'success' ? 'text-success' : (data.data.status === 'partial' ? 'text-warning' : 'text-danger');
prefix = data.data.status === 'success' ? '[SUCCESS]' : (data.data.status === 'partial' ? '[PARTIAL]' : '[ERROR]');
break;
default:
messageClass = 'text-light';
prefix = '[INFO]';
}
const messageDiv = document.createElement('div');
messageDiv.className = messageClass;
messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown message'}`;
liveConsole.appendChild(messageDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
} catch (parseError) {
console.error('Error parsing streamed message:', parseError, 'Line:', line);
}
}
}
if (buffer.trim()) {
try {
const data = JSON.parse(line);
const data = JSON.parse(buffer);
const timestamp = new Date().toLocaleTimeString();
let messageClass = '';
let prefix = '';
@ -230,7 +280,6 @@ document.getElementById('pushIgForm').addEventListener('submit', async function(
prefix = '[WARNING]';
break;
case 'complete':
// Display the final summary in the response area
if (data.data.status === 'success') {
responseDiv.innerHTML = `
<div class="alert alert-success">
@ -254,7 +303,6 @@ document.getElementById('pushIgForm').addEventListener('submit', async function(
</div>
`;
}
// Also log the completion in the console
messageClass = data.data.status === 'success' ? 'text-success' : (data.data.status === 'partial' ? 'text-warning' : 'text-danger');
prefix = data.data.status === 'success' ? '[SUCCESS]' : (data.data.status === 'partial' ? '[PARTIAL]' : '[ERROR]');
break;
@ -267,103 +315,30 @@ document.getElementById('pushIgForm').addEventListener('submit', async function(
messageDiv.className = messageClass;
messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown message'}`;
liveConsole.appendChild(messageDiv);
// Auto-scroll to the bottom
liveConsole.scrollTop = liveConsole.scrollHeight;
} catch (parseError) {
console.error('Error parsing streamed message:', parseError, 'Line:', line);
console.error('Error parsing final streamed message:', parseError, 'Buffer:', buffer);
}
}
} catch (error) {
const timestamp = new Date().toLocaleTimeString();
const errorDiv = document.createElement('div');
errorDiv.className = 'text-danger';
errorDiv.textContent = `${timestamp} [ERROR] Failed to push package: ${error.message}`;
liveConsole.appendChild(errorDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
responseDiv.innerHTML = `
<div class="alert alert-danger">
<strong>Error:</strong> Failed to push package: ${error.message}
</div>
`;
} finally {
pushButton.disabled = false;
pushButton.textContent = 'Push to FHIR Server';
}
// Process any remaining buffer
if (buffer.trim()) {
try {
const data = JSON.parse(buffer);
const timestamp = new Date().toLocaleTimeString();
let messageClass = '';
let prefix = '';
switch (data.type) {
case 'start':
case 'progress':
messageClass = 'text-info';
prefix = '[INFO]';
break;
case 'success':
messageClass = 'text-success';
prefix = '[SUCCESS]';
break;
case 'error':
messageClass = 'text-danger';
prefix = '[ERROR]';
break;
case 'warning':
messageClass = 'text-warning';
prefix = '[WARNING]';
break;
case 'complete':
if (data.data.status === 'success') {
responseDiv.innerHTML = `
<div class="alert alert-success">
<strong>Success:</strong> ${data.data.message}<br>
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br>
<strong>Server Response:</strong> ${data.data.server_response}
</div>
`;
} else if (data.data.status === 'partial') {
responseDiv.innerHTML = `
<div class="alert alert-warning">
<strong>Partial Success:</strong> ${data.data.message}<br>
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br>
<strong>Server Response:</strong> ${data.data.server_response}
</div>
`;
} else {
responseDiv.innerHTML = `
<div class="alert alert-danger">
<strong>Error:</strong> ${data.data.message}
</div>
`;
}
messageClass = data.data.status === 'success' ? 'text-success' : (data.data.status === 'partial' ? 'text-warning' : 'text-danger');
prefix = data.data.status === 'success' ? '[SUCCESS]' : (data.data.status === 'partial' ? '[PARTIAL]' : '[ERROR]');
break;
default:
messageClass = 'text-light';
prefix = '[INFO]';
}
const messageDiv = document.createElement('div');
messageDiv.className = messageClass;
messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown message'}`;
liveConsole.appendChild(messageDiv);
// Auto-scroll to the bottom
liveConsole.scrollTop = liveConsole.scrollHeight;
} catch (parseError) {
console.error('Error parsing final streamed message:', parseError, 'Buffer:', buffer);
}
}
} catch (error) {
const timestamp = new Date().toLocaleTimeString();
const errorDiv = document.createElement('div');
errorDiv.className = 'text-danger';
errorDiv.textContent = `${timestamp} [ERROR] Failed to push package: ${error.message}`;
liveConsole.appendChild(errorDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
responseDiv.innerHTML = `
<div class="alert alert-danger">
<strong>Error:</strong> Failed to push package: ${error.message}
</div>
`;
} finally {
// Re-enable the button
pushButton.disabled = false;
pushButton.textContent = 'Push to FHIR Server';
}
});
});
</script>
{% endblock %}

View File

@ -6,13 +6,13 @@
<h1 class="display-5 fw-bold text-body-emphasis">Welcome to {{ site_name }}</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">
Simple tool for importing and viewing FHIR Implementation Guides.
Simple tool for importing, viewing, and validating FHIR Implementation Guides.
</p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('import_ig') }}" class="btn btn-primary btn-lg px-4 gap-3">Import FHIR IG</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4">View Downloaded IGs</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IGs</a>
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary btn-lg px-4">Validate FHIR Sample</a>
</div>
</div>
</div>

View File

@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block content %}
<div class="px-4 py-5 my-5 text-center">
<img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE IG Toolkit" width="192" height="192">
<h1 class="display-5 fw-bold text-body-emphasis">Validate FHIR Sample</h1>
<div class="col-lg-6 mx-auto">
<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)
</p>
<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>
</div>
<div class="container mt-4">
<div class="card">
<div class="card-header">Validation Form</div>
<div class="card-body">
<form method="POST" id="validationForm">
{{ form.hidden_tag() }}
<div class="mb-3">
<small class="form-text text-muted">
Select a package from the list below or enter a new one (e.g., <code>hl7.fhir.us.core</code>).
</small>
<div class="mt-2">
<select class="form-select" id="packageSelect" onchange="if(this.value) { let parts = this.value.split('#'); document.getElementById('{{ form.package_name.id }}').value = parts[0]; document.getElementById('{{ form.version.id }}').value = parts[1]; }">
<option value="">-- Select a Package --</option>
{% for pkg in packages %}
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
{% endfor %}
</select>
</div>
<label for="{{ form.package_name.id }}" class="form-label">Package Name</label>
{{ form.package_name(class="form-control") }}
{% for error in form.package_name.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="{{ form.version.id }}" class="form-label">Package Version</label>
{{ form.version(class="form-control") }}
{% for error in form.version.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="{{ form.include_dependencies.id }}" class="form-label">Include Dependencies</label>
<div class="form-check">
{{ form.include_dependencies(class="form-check-input") }}
{{ form.include_dependencies.label(class="form-check-label") }}
</div>
</div>
<div class="mb-3">
<label for="{{ form.mode.id }}" class="form-label">Validation Mode</label>
{{ form.mode(class="form-select") }}
</div>
<div class="mb-3">
<label for="{{ form.sample_input.id }}" class="form-label">Sample FHIR Resource/Bundle (JSON)</label>
{{ form.sample_input(class="form-control", rows=10, placeholder="Paste your FHIR JSON here...") }}
{% for error in form.sample_input.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
</div>
{% if validation_report %}
<div class="card mt-4">
<div class="card-header">Validation Report</div>
<div class="card-body">
<h5>Summary</h5>
<p><strong>Valid:</strong> {{ 'Yes' if validation_report.valid else 'No' }}</p>
{% if validation_report.errors %}
<h6>Errors</h6>
<ul class="text-danger">
{% for error in validation_report.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{% if validation_report.warnings %}
<h6>Warnings</h6>
<ul class="text-warning">
{% for warning in validation_report.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
{% endif %}
{% if validation_report.results %}
<h5>Detailed Results</h5>
{% for resource_id, result in validation_report.results.items() %}
<h6>{{ resource_id }}</h6>
<p><strong>Valid:</strong> {{ 'Yes' if result.valid else 'No' }}</p>
{% if result.errors %}
<ul class="text-danger">
{% for error in result.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{% if result.warnings %}
<ul class="text-warning">
{% for warning in result.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('validationForm');
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
// Client-side JSON validation preview
sampleInput.addEventListener('input', function() {
try {
JSON.parse(this.value);
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} catch (e) {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
}
});
});
</script>
{% endblock %}