mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-15 00:40:00 +00:00
0.01b
initial port of the fork
This commit is contained in:
parent
cf055a67a8
commit
86a2718dea
17
Dockerfile
17
Dockerfile
@ -1,30 +1,21 @@
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (if any needed by services.py, e.g., for FHIR processing)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements file and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY app.py .
|
||||
COPY services.py . # Assuming you have this; replace with actual file if different
|
||||
COPY instance/ instance/ # Pre-create instance dir if needed for SQLite/packages
|
||||
COPY services.py .
|
||||
COPY templates/ templates/
|
||||
|
||||
# Ensure instance directory exists for SQLite DB and FHIR packages
|
||||
RUN mkdir -p /app/instance/fhir_packages
|
||||
# Ensure /tmp is writable as a fallback
|
||||
RUN mkdir -p /tmp && chmod 777 /tmp
|
||||
|
||||
# Expose Flask port
|
||||
EXPOSE 5000
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.py
|
||||
ENV FLASK_ENV=development
|
||||
|
||||
# Run the app
|
||||
CMD ["flask", "run", "--host=0.0.0.0"]
|
28
Dump/__init__.txt
Normal file
28
Dump/__init__.txt
Normal file
@ -0,0 +1,28 @@
|
||||
# app/modules/fhir_ig_importer/__init__.py
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
# --- Module Metadata ---
|
||||
metadata = {
|
||||
'module_id': 'fhir_ig_importer', # Matches folder name
|
||||
'display_name': 'FHIR IG Importer',
|
||||
'description': 'Imports FHIR Implementation Guide packages from a registry.',
|
||||
'version': '0.1.0',
|
||||
# No main nav items, will be accessed via Control Panel
|
||||
'nav_items': []
|
||||
}
|
||||
# --- End Module Metadata ---
|
||||
|
||||
# Define Blueprint
|
||||
# We'll mount this under the control panel later
|
||||
bp = Blueprint(
|
||||
metadata['module_id'],
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
# Define a URL prefix if mounting standalone, but we'll likely register
|
||||
# it under /control-panel via app/__init__.py later
|
||||
# url_prefix='/fhir-importer'
|
||||
)
|
||||
|
||||
# Import routes after creating blueprint
|
||||
from . import routes, forms # Import forms too
|
162
Dump/app.txt
Normal file
162
Dump/app.txt
Normal file
@ -0,0 +1,162 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
import os
|
||||
import services # Assuming your existing services module for FHIR IG handling
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///instance/fhir_ig.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['FHIR_PACKAGES_DIR'] = os.path.join(app.instance_path, 'fhir_packages')
|
||||
os.makedirs(app.config['FHIR_PACKAGES_DIR'], exist_ok=True)
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# Simplified ProcessedIg model (no user-related fields)
|
||||
class ProcessedIg(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
package_name = db.Column(db.String(128), nullable=False)
|
||||
version = db.Column(db.String(32), nullable=False)
|
||||
processed_date = db.Column(db.DateTime, nullable=False)
|
||||
resource_types_info = db.Column(db.JSON, nullable=False) # List of resource type metadata
|
||||
must_support_elements = db.Column(db.JSON, nullable=True) # Dict of MS elements
|
||||
examples = db.Column(db.JSON, nullable=True) # Dict of example filepaths
|
||||
|
||||
# Landing page with two buttons
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FHIR IG Toolkit</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.button { padding: 15px 30px; margin: 10px; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>FHIR IG Toolkit</h1>
|
||||
<p>Simple tool for importing and viewing FHIR Implementation Guides.</p>
|
||||
<a href="{{ url_for('import_ig') }}"><button class="button">Import FHIR IG</button></a>
|
||||
<a href="{{ url_for('view_igs') }}"><button class="button">View Downloaded IGs</button></a>
|
||||
</body>
|
||||
</html>
|
||||
''')
|
||||
|
||||
# Import IG route
|
||||
@app.route('/import-ig', methods=['GET', 'POST'])
|
||||
def import_ig():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
version = request.form.get('version', 'latest')
|
||||
try:
|
||||
# Call your existing service to download package and dependencies
|
||||
result = services.import_package_and_dependencies(name, version, app.config['FHIR_PACKAGES_DIR'])
|
||||
downloaded_files = result.get('downloaded', [])
|
||||
for file_path in downloaded_files:
|
||||
# Process each downloaded package
|
||||
package_info = services.process_package_file(file_path)
|
||||
processed_ig = ProcessedIg(
|
||||
package_name=package_info['name'],
|
||||
version=package_info['version'],
|
||||
processed_date=package_info['processed_date'],
|
||||
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 imported {name} {version} and dependencies!", "success")
|
||||
return redirect(url_for('view_igs'))
|
||||
except Exception as e:
|
||||
flash(f"Error importing IG: {str(e)}", "error")
|
||||
return redirect(url_for('import_ig'))
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Import FHIR IG</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
.form { max-width: 400px; margin: 0 auto; }
|
||||
.field { margin: 10px 0; }
|
||||
input[type="text"] { width: 100%; padding: 5px; }
|
||||
.button { padding: 10px 20px; }
|
||||
.message { color: {% if category == "success" %}green{% else %}red{% endif %}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Import FHIR IG</h1>
|
||||
<form class="form" method="POST">
|
||||
<div class="field">
|
||||
<label>Package Name:</label>
|
||||
<input type="text" name="name" placeholder="e.g., hl7.fhir.us.core" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Version (optional):</label>
|
||||
<input type="text" name="version" placeholder="e.g., 1.0.0 or latest">
|
||||
</div>
|
||||
<button class="button" type="submit">Import</button>
|
||||
<a href="{{ url_for('index') }}"><button class="button" type="button">Back</button></a>
|
||||
</form>
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="message">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</body>
|
||||
</html>
|
||||
''')
|
||||
|
||||
# View Downloaded IGs route
|
||||
@app.route('/view-igs')
|
||||
def view_igs():
|
||||
igs = ProcessedIg.query.all()
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>View Downloaded IGs</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
table { width: 80%; margin: 20px auto; border-collapse: collapse; }
|
||||
th, td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
.button { padding: 10px 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Downloaded FHIR IGs</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Package Name</th>
|
||||
<th>Version</th>
|
||||
<th>Processed Date</th>
|
||||
<th>Resource Types</th>
|
||||
</tr>
|
||||
{% for ig in igs %}
|
||||
<tr>
|
||||
<td>{{ ig.package_name }}</td>
|
||||
<td>{{ ig.version }}</td>
|
||||
<td>{{ ig.processed_date }}</td>
|
||||
<td>{{ ig.resource_types_info | length }} types</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<a href="{{ url_for('index') }}"><button class="button">Back</button></a>
|
||||
</body>
|
||||
</html>
|
||||
''', igs=igs)
|
||||
|
||||
# Initialize DB
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
135
Dump/cp_downloaded_igs.html.txt
Normal file
135
Dump/cp_downloaded_igs.html.txt
Normal file
@ -0,0 +1,135 @@
|
||||
{# app/control_panel/templates/cp_downloaded_igs.html #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-journal-arrow-down me-2"></i>Manage FHIR Packages</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('fhir_ig_importer.import_ig') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a>
|
||||
<a href="{{ url_for('control_panel.index') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to CP Index</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h5 class="alert-heading">Error</h5>
|
||||
{{ error_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# NOTE: The block calculating processed_ids set using {% set %} was REMOVED from here #}
|
||||
|
||||
{# --- Start Two Column Layout --- #}
|
||||
<div class="row g-4">
|
||||
|
||||
{# --- Left Column: Downloaded Packages (Horizontal Buttons) --- #}
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-folder-symlink me-1"></i> Downloaded Packages ({{ packages|length }})</div>
|
||||
<div class="card-body">
|
||||
{% if packages %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<p class="mb-2"><small><span class="badge bg-danger text-dark border me-1">Risk:</span>= Duplicate Dependancy with different versions</small></p>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pkg in packages %}
|
||||
{% set is_processed = (pkg.name, pkg.version) in processed_ids %}
|
||||
{# --- ADDED: Check for duplicate name --- #}
|
||||
{% set is_duplicate = pkg.name in duplicate_names %}
|
||||
{# --- ADDED: Assign row class based on duplicate group --- #}
|
||||
<tr class="{{ duplicate_groups.get(pkg.name, '') if is_duplicate else '' }}">
|
||||
<td>
|
||||
{# --- ADDED: Risk Badge for duplicates --- #}
|
||||
{% if is_duplicate %}
|
||||
<span class="badge bg-danger mb-1 d-block">Duplicate</span>
|
||||
{% endif %}
|
||||
{# --- End Add --- #}
|
||||
<code>{{ pkg.name }}</code>
|
||||
</td>
|
||||
<td>{{ pkg.version }}</td>
|
||||
<td> {# Actions #}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if is_processed %}
|
||||
<span class="btn btn-success disabled"><i class="bi bi-check-lg"></i> Processed</span>
|
||||
{% else %}
|
||||
<form action="{{ url_for('control_panel.process_ig') }}" method="POST" style="display: inline-block;">
|
||||
<input type="hidden" name="package_name" value="{{ pkg.name }}">
|
||||
<input type="hidden" name="package_version" value="{{ pkg.version }}">
|
||||
<button type="submit" class="btn btn-outline-primary" title="Mark as processed"><i class="bi bi-gear"></i> Process</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('control_panel.delete_ig_file') }}" method="POST" style="display: inline-block;" onsubmit="return confirm('Delete file \'{{ pkg.filename }}\'?');">
|
||||
<input type="hidden" name="filename" value="{{ pkg.filename }}">
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete File"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif not error_message %}<p class="text-muted">No downloaded FHIR packages found.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>{# --- End Left Column --- #}
|
||||
|
||||
|
||||
{# --- Right Column: Processed Packages (Vertical Buttons) --- #}
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-check-circle me-1"></i> Processed Packages ({{ processed_list|length }})</div>
|
||||
<div class="card-body">
|
||||
{% if processed_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Resource Types</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for processed_ig in processed_list %}
|
||||
<tr>
|
||||
<td>{# Tooltip for Processed At / Status #}
|
||||
{% set tooltip_title_parts = [] %}
|
||||
{% if processed_ig.processed_at %}{% set _ = tooltip_title_parts.append("Processed: " + processed_ig.processed_at.strftime('%Y-%m-%d %H:%M')) %}{% endif %}
|
||||
{% if processed_ig.status %}{% set _ = tooltip_title_parts.append("Status: " + processed_ig.status) %}{% endif %}
|
||||
{% set tooltip_text = tooltip_title_parts | join('\n') %}
|
||||
<code data-bs-toggle="tooltip" data-bs-placement="top" title="{{ tooltip_text }}">{{ processed_ig.package_name }}</code>
|
||||
</td>
|
||||
<td>{{ processed_ig.package_version }}</td>
|
||||
<td> {# Resource Types Cell w/ Badges #}
|
||||
{% set types_info = processed_ig.resource_types_info %}
|
||||
{% if types_info %}<div class="d-flex flex-wrap gap-1">{% for type_info in types_info %}{% if type_info.must_support %}<span class="badge bg-warning text-dark border" title="Has Must Support">{{ type_info.name }}</span>{% else %}<span class="badge bg-light text-dark border">{{ type_info.name }}</span>{% endif %}{% endfor %}</div>{% else %}<small class="text-muted">N/A</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{# Vertical Button Group #}
|
||||
<div class="btn-group-vertical btn-group-sm w-100" role="group" aria-label="Processed Package Actions for {{ processed_ig.package_name }}">
|
||||
<a href="{{ url_for('control_panel.view_processed_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 action="{{ url_for('control_panel.unload_ig') }}" method="POST" onsubmit="return confirm('Unload record for \'{{ processed_ig.package_name }}#{{ processed_ig.package_version }}\'?');">
|
||||
<input type="hidden" name="processed_ig_id" value="{{ processed_ig.id }}">
|
||||
<button type="submit" class="btn btn-outline-warning w-100" title="Unload/Remove Processed Record"><i class="bi bi-x-lg"></i> Unload</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif not error_message %}<p class="text-muted">No packages recorded as processed yet.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>{# --- End Right Column --- #}
|
||||
|
||||
</div>{# --- End Row --- #}
|
||||
|
||||
</div>{# End container #}
|
||||
{% endblock %}
|
||||
|
||||
{# Tooltip JS Initializer should be in base.html #}
|
||||
{% block scripts %}{{ super() }}{% endblock %}
|
405
Dump/cp_view_processed_ig.html.txt
Normal file
405
Dump/cp_view_processed_ig.html.txt
Normal file
@ -0,0 +1,405 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="bi bi-info-circle me-2"></i>{{ title }}</h2>
|
||||
<a href="{{ url_for('control_panel.list_downloaded_igs') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to Package List</a>
|
||||
</div>
|
||||
|
||||
{% if processed_ig %}
|
||||
<div class="card">
|
||||
<div class="card-header">Package Details</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Package Name</dt>
|
||||
<dd class="col-sm-9"><code>{{ processed_ig.package_name }}</code></dd>
|
||||
<dt class="col-sm-3">Package Version</dt>
|
||||
<dd class="col-sm-9">{{ processed_ig.package_version }}</dd>
|
||||
<dt class="col-sm-3">Processed At</dt>
|
||||
<dd class="col-sm-9">{{ processed_ig.processed_at.strftime('%Y-%m-%d %H:%M:%S UTC') if processed_ig.processed_at else 'N/A' }}</dd>
|
||||
<dt class="col-sm-3">Processing Status</dt>
|
||||
<dd class="col-sm-9">
|
||||
<span class="badge rounded-pill text-bg-{{ 'success' if processed_ig.status == 'processed' else ('warning' if processed_ig.status == 'processed_with_errors' else 'secondary') }}">{{ processed_ig.status or 'N/A' }}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">Resource Types Found / Defined </div>
|
||||
<div class="card-body">
|
||||
{% if profile_list or base_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
|
||||
{% if profile_list %}
|
||||
<h6>Profiles Defined ({{ profile_list|length }}):</h6>
|
||||
<div class="d-flex flex-wrap gap-1 mb-3 resource-type-list">
|
||||
{% for type_info in profile_list %}
|
||||
<a href="#" class="resource-type-link text-decoration-none"
|
||||
data-package-name="{{ processed_ig.package_name }}"
|
||||
data-package-version="{{ processed_ig.package_version }}"
|
||||
data-resource-type="{{ type_info.name }}"
|
||||
aria-label="View structure for {{ type_info.name }}">
|
||||
{% if type_info.must_support %}
|
||||
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if base_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-primary text-light border me-1">Examples</span> = - Examples will be displayed when selecting Base Types if contained in the IG</small></p>
|
||||
<h6>Base Resource Types Referenced ({{ base_list|length }}):</h6>
|
||||
<div class="d-flex flex-wrap gap-1 resource-type-list">
|
||||
{% for type_info in base_list %}
|
||||
<a href="#" class="resource-type-link text-decoration-none"
|
||||
data-package-name="{{ processed_ig.package_name }}"
|
||||
data-package-version="{{ processed_ig.package_version }}"
|
||||
data-resource-type="{{ type_info.name }}"
|
||||
aria-label="View structure for {{ type_info.name }}">
|
||||
{% if type_info.must_support %}
|
||||
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted"><em>No resource type information extracted or stored.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="structure-display-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Structure Definition for: <code id="structure-title"></code></span>
|
||||
<button type="button" class="btn-close" aria-label="Close Details View" onclick="document.getElementById('structure-display-wrapper').style.display='none'; document.getElementById('example-display-wrapper').style.display='none'; document.getElementById('raw-structure-wrapper').style.display='none';"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="structure-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<div id="structure-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="raw-structure-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Raw Structure Definition for <code id="raw-structure-title"></code></div>
|
||||
<div class="card-body">
|
||||
<div id="raw-structure-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<pre><code id="raw-structure-content" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="example-display-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Examples for <code id="example-resource-type-title"></code></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3" id="example-selector-wrapper" style="display: none;">
|
||||
<label for="example-select" class="form-label">Select Example:</label>
|
||||
<select class="form-select form-select-sm" id="example-select"><option selected value="">-- Select --</option></select>
|
||||
</div>
|
||||
<div id="example-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<div id="example-content-wrapper" style="display: none;">
|
||||
<h6 id="example-filename" class="mt-2 small text-muted"></h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Raw Content</h6>
|
||||
<pre><code id="example-content-raw" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Pretty-Printed JSON</h6>
|
||||
<pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-warning" role="alert">Processed IG details not available.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const structureDisplayWrapper = document.getElementById('structure-display-wrapper');
|
||||
const structureDisplay = document.getElementById('structure-content');
|
||||
const structureTitle = document.getElementById('structure-title');
|
||||
const structureLoading = document.getElementById('structure-loading');
|
||||
const exampleDisplayWrapper = document.getElementById('example-display-wrapper');
|
||||
const exampleSelectorWrapper = document.getElementById('example-selector-wrapper');
|
||||
const exampleSelect = document.getElementById('example-select');
|
||||
const exampleResourceTypeTitle = document.getElementById('example-resource-type-title');
|
||||
const exampleLoading = document.getElementById('example-loading');
|
||||
const exampleContentWrapper = document.getElementById('example-content-wrapper');
|
||||
const exampleFilename = document.getElementById('example-filename');
|
||||
const exampleContentRaw = document.getElementById('example-content-raw');
|
||||
const exampleContentJson = document.getElementById('example-content-json');
|
||||
const rawStructureWrapper = document.getElementById('raw-structure-wrapper');
|
||||
const rawStructureTitle = document.getElementById('raw-structure-title');
|
||||
const rawStructureContent = document.getElementById('raw-structure-content');
|
||||
const rawStructureLoading = document.getElementById('raw-structure-loading');
|
||||
|
||||
const structureBaseUrl = "/control-panel/fhir/get-structure";
|
||||
const exampleBaseUrl = "/control-panel/fhir/get-example";
|
||||
|
||||
let currentPkgName = null;
|
||||
let currentPkgVersion = null;
|
||||
let currentRawStructureData = null;
|
||||
|
||||
document.body.addEventListener('click', function(event) {
|
||||
const link = event.target.closest('.resource-type-link');
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
|
||||
currentPkgName = link.dataset.packageName;
|
||||
currentPkgVersion = link.dataset.packageVersion;
|
||||
const resourceType = link.dataset.resourceType;
|
||||
if (!currentPkgName || !currentPkgVersion || !resourceType) return;
|
||||
|
||||
const structureParams = new URLSearchParams({
|
||||
package_name: currentPkgName,
|
||||
package_version: currentPkgVersion,
|
||||
resource_type: resourceType
|
||||
});
|
||||
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
|
||||
|
||||
structureTitle.textContent = `${resourceType} (${currentPkgName}#${currentPkgVersion})`;
|
||||
rawStructureTitle.textContent = `${resourceType} (${currentPkgName}#${currentPkgVersion})`;
|
||||
structureDisplay.innerHTML = '';
|
||||
rawStructureContent.textContent = '';
|
||||
structureLoading.style.display = 'block';
|
||||
rawStructureLoading.style.display = 'block';
|
||||
structureDisplayWrapper.style.display = 'block';
|
||||
rawStructureWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
|
||||
fetch(structureFetchUrl)
|
||||
.then(response => response.json().then(data => ({ ok: response.ok, status: response.status, data })))
|
||||
.then(result => {
|
||||
if (!result.ok) throw new Error(result.data.error || `HTTP error ${result.status}`);
|
||||
currentRawStructureData = result.data;
|
||||
renderStructureTree(result.data.elements, result.data.must_support_paths || []);
|
||||
rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
|
||||
populateExampleSelector(resourceType);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching structure:', error);
|
||||
structureDisplay.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
|
||||
rawStructureContent.textContent = `Error: ${error.message}`;
|
||||
})
|
||||
.finally(() => {
|
||||
structureLoading.style.display = 'none';
|
||||
rawStructureLoading.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
function populateExampleSelector(resourceOrProfileIdentifier) {
|
||||
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
|
||||
const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
|
||||
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
|
||||
exampleContentWrapper.style.display = 'none';
|
||||
exampleFilename.textContent = '';
|
||||
exampleContentRaw.textContent = '';
|
||||
exampleContentJson.textContent = '';
|
||||
if (availableExamples.length > 0) {
|
||||
availableExamples.forEach(filePath => {
|
||||
const filename = filePath.split('/').pop();
|
||||
const option = document.createElement('option');
|
||||
option.value = filePath;
|
||||
option.textContent = filename;
|
||||
exampleSelect.appendChild(option);
|
||||
});
|
||||
exampleSelectorWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'block';
|
||||
} else {
|
||||
exampleSelectorWrapper.style.display = 'none';
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if(exampleSelect) {
|
||||
exampleSelect.addEventListener('change', function(event) {
|
||||
const selectedFilePath = this.value;
|
||||
exampleContentRaw.textContent = '';
|
||||
exampleContentJson.textContent = '';
|
||||
exampleFilename.textContent = '';
|
||||
exampleContentWrapper.style.display = 'none';
|
||||
if (!selectedFilePath || !currentPkgName || !currentPkgVersion) return;
|
||||
|
||||
const exampleParams = new URLSearchParams({
|
||||
package_name: currentPkgName,
|
||||
package_version: currentPkgVersion,
|
||||
filename: selectedFilePath
|
||||
});
|
||||
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
|
||||
console.log("Fetching example from:", exampleFetchUrl);
|
||||
exampleLoading.style.display = 'block';
|
||||
fetch(exampleFetchUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(content => {
|
||||
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
|
||||
exampleContentRaw.textContent = content;
|
||||
try {
|
||||
const jsonContent = JSON.parse(content);
|
||||
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
|
||||
} catch (e) {
|
||||
exampleContentJson.textContent = 'Not valid JSON';
|
||||
}
|
||||
exampleContentWrapper.style.display = 'block';
|
||||
})
|
||||
.catch(error => {
|
||||
exampleFilename.textContent = 'Error';
|
||||
exampleContentRaw.textContent = `Error: ${error.message}`;
|
||||
exampleContentJson.textContent = `Error: ${error.message}`;
|
||||
exampleContentWrapper.style.display = 'block';
|
||||
})
|
||||
.finally(() => {
|
||||
exampleLoading.style.display = 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildTreeData(elements) {
|
||||
const treeRoot = { children: {}, element: null, name: 'Root' };
|
||||
const nodeMap = {'Root': treeRoot};
|
||||
elements.forEach(el => {
|
||||
const path = el.path;
|
||||
if (!path) return;
|
||||
const parts = path.split('.');
|
||||
let currentPath = '';
|
||||
let parentNode = treeRoot;
|
||||
for(let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const currentPartName = part.includes('[') ? part.substring(0, part.indexOf('[')) : part;
|
||||
currentPath = i === 0 ? currentPartName : `${currentPath}.${part}`;
|
||||
if (!nodeMap[currentPath]) {
|
||||
const newNode = { children: {}, element: null, name: part, path: currentPath };
|
||||
parentNode.children[part] = newNode;
|
||||
nodeMap[currentPath] = newNode;
|
||||
}
|
||||
if (i === parts.length - 1) {
|
||||
nodeMap[currentPath].element = el;
|
||||
}
|
||||
parentNode = nodeMap[currentPath];
|
||||
}
|
||||
});
|
||||
return Object.values(treeRoot.children);
|
||||
}
|
||||
|
||||
function renderNodeAsLi(node, mustSupportPathsSet, level = 0) {
|
||||
if (!node || !node.element) return '';
|
||||
const el = node.element;
|
||||
const path = el.path || 'N/A';
|
||||
const min = el.min !== undefined ? el.min : '';
|
||||
const max = el.max || '';
|
||||
const short = el.short || '';
|
||||
const definition = el.definition || '';
|
||||
const isMustSupport = mustSupportPathsSet.has(path);
|
||||
const mustSupportDisplay = isMustSupport ? '<i class="bi bi-check-circle-fill text-warning"></i>' : '';
|
||||
const hasChildren = Object.keys(node.children).length > 0;
|
||||
const collapseId = `collapse-${path.replace(/[\.\:]/g, '-')}`;
|
||||
const padding = level * 20;
|
||||
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
|
||||
let typeString = 'N/A';
|
||||
if (el.type && el.type.length > 0) {
|
||||
typeString = el.type.map(t => {
|
||||
let s = t.code || '';
|
||||
if (t.targetProfile && t.targetProfile.length > 0) {
|
||||
const targetTypes = t.targetProfile.map(p => p.split('/').pop());
|
||||
s += `(<span class="text-muted">${targetTypes.join('|')}</span>)`;
|
||||
}
|
||||
return s;
|
||||
}).join(' | ');
|
||||
}
|
||||
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
|
||||
let childrenHtml = '';
|
||||
if (hasChildren) {
|
||||
childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`;
|
||||
Object.values(node.children).sort((a,b) => a.name.localeCompare(b.name)).forEach(childNode => {
|
||||
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, level + 1);
|
||||
});
|
||||
childrenHtml += `</ul>`;
|
||||
}
|
||||
let itemHtml = `<li class="${liClass}">`;
|
||||
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-bs-toggle="collapse" href="#${collapseId}" role="button" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
|
||||
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}"><span style="display: inline-block; width: 1.2em; text-align: center;">`;
|
||||
if (hasChildren) {
|
||||
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
|
||||
}
|
||||
itemHtml += `</span><code class="fw-bold ms-1">${node.name}</code></div>`;
|
||||
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
|
||||
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
|
||||
itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
|
||||
let descriptionTooltipAttrs = '';
|
||||
if (definition) {
|
||||
const escapedDefinition = definition.replace(/"/g, '"').replace(/'/g, ''');
|
||||
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
|
||||
}
|
||||
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short}</div>`;
|
||||
itemHtml += `</div>`;
|
||||
itemHtml += childrenHtml;
|
||||
itemHtml += `</li>`;
|
||||
return itemHtml;
|
||||
}
|
||||
|
||||
function renderStructureTree(elements, mustSupportPaths) {
|
||||
if (!elements || elements.length === 0) {
|
||||
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found.</em></p>';
|
||||
return;
|
||||
}
|
||||
const mustSupportPathsSet = new Set(mustSupportPaths || []);
|
||||
console.log("Rendering tree. MS Set:", mustSupportPathsSet);
|
||||
const treeData = buildTreeData(elements);
|
||||
let html = '<p><small><span class="badge bg-warning text-dark border me-1">MS</span> = Must Support (Row Highlighted)</small></p>';
|
||||
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">MS</div><div class="col-lg-3 col-md-4">Description</div></div>`;
|
||||
html += '<ul class="list-group list-group-flush">';
|
||||
treeData.sort((a,b) => a.name.localeCompare(b.name)).forEach(rootNode => {
|
||||
html += renderNodeAsLi(rootNode, mustSupportPathsSet, 0);
|
||||
});
|
||||
html += '</ul>';
|
||||
structureDisplay.innerHTML = html;
|
||||
var tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
||||
new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
structureDisplay.querySelectorAll('.collapse').forEach(collapseEl => {
|
||||
collapseEl.addEventListener('show.bs.collapse', event => {
|
||||
event.target.previousElementSibling.querySelector('.toggle-icon')?.classList.replace('bi-chevron-right', 'bi-chevron-down');
|
||||
});
|
||||
collapseEl.addEventListener('hide.bs.collapse', event => {
|
||||
event.target.previousElementSibling.querySelector('.toggle-icon')?.classList.replace('bi-chevron-down', 'bi-chevron-right');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) });
|
||||
});
|
||||
</script>
|
||||
{% endblock scripts %}
|
||||
{% endblock content %} {# Main content block END #}
|
19
Dump/forms.txt
Normal file
19
Dump/forms.txt
Normal file
@ -0,0 +1,19 @@
|
||||
# app/modules/fhir_ig_importer/forms.py
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import DataRequired, Regexp
|
||||
|
||||
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=[
|
||||
DataRequired(),
|
||||
Regexp(r'^[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=[
|
||||
DataRequired(),
|
||||
Regexp(r'^[a-zA-Z0-9\.\-]+$', message='Invalid version format.')
|
||||
])
|
||||
submit = SubmitField('Fetch & Download IG')
|
162
Dump/routes.txt
Normal file
162
Dump/routes.txt
Normal file
@ -0,0 +1,162 @@
|
||||
# app/modules/fhir_ig_importer/routes.py
|
||||
|
||||
import requests
|
||||
import os
|
||||
import tarfile # Needed for find_and_extract_sd
|
||||
import gzip
|
||||
import json
|
||||
import io
|
||||
import re
|
||||
from flask import (render_template, redirect, url_for, flash, request,
|
||||
current_app, jsonify, send_file)
|
||||
from flask_login import login_required
|
||||
from app.decorators import admin_required
|
||||
from werkzeug.utils import secure_filename
|
||||
from . import bp
|
||||
from .forms import IgImportForm
|
||||
# Import the services module
|
||||
from . import services
|
||||
# Import ProcessedIg model for get_structure_definition
|
||||
from app.models import ProcessedIg
|
||||
from app import db
|
||||
|
||||
|
||||
# --- Helper: Find/Extract SD ---
|
||||
# Moved from services.py to be local to routes that use it, or keep in services and call services.find_and_extract_sd
|
||||
def find_and_extract_sd(tgz_path, resource_identifier):
|
||||
"""Helper to find and extract SD json from a given tgz path by ID, Name, or Type."""
|
||||
sd_data = None; found_path = None; logger = current_app.logger # Use current_app logger
|
||||
if not tgz_path or not os.path.exists(tgz_path): logger.error(f"File not found in find_and_extract_sd: {tgz_path}"); 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)}")
|
||||
for member in tar:
|
||||
if member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json'):
|
||||
if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']: continue
|
||||
fileobj = None
|
||||
try:
|
||||
fileobj = tar.extractfile(member)
|
||||
if fileobj:
|
||||
content_bytes = fileobj.read(); content_string = content_bytes.decode('utf-8-sig'); data = json.loads(content_string)
|
||||
if isinstance(data, dict) and data.get('resourceType') == 'StructureDefinition':
|
||||
sd_id = data.get('id'); sd_name = data.get('name'); sd_type = data.get('type')
|
||||
if resource_identifier == sd_type or resource_identifier == sd_id or resource_identifier == sd_name:
|
||||
sd_data = data; found_path = member.name; logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}"); break
|
||||
except Exception as e: logger.warning(f"Could not read/parse potential SD {member.name}: {e}")
|
||||
finally:
|
||||
if fileobj: fileobj.close()
|
||||
if sd_data is None: logger.warning(f"SD matching '{resource_identifier}' not found within archive {os.path.basename(tgz_path)}")
|
||||
except Exception as e: logger.error(f"Error reading archive {tgz_path} in find_and_extract_sd: {e}", exc_info=True); raise
|
||||
return sd_data, found_path
|
||||
# --- End Helper ---
|
||||
|
||||
|
||||
# --- Route for the main import page ---
|
||||
@bp.route('/import-ig', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def import_ig():
|
||||
"""Handles FHIR IG recursive download using services."""
|
||||
form = IgImportForm()
|
||||
template_context = {"title": "Import FHIR IG", "form": form, "results": None }
|
||||
if form.validate_on_submit():
|
||||
package_name = form.package_name.data; package_version = form.package_version.data
|
||||
template_context.update(package_name=package_name, package_version=package_version)
|
||||
flash(f"Starting full import for {package_name}#{package_version}...", "info"); current_app.logger.info(f"Calling import service for: {package_name}#{package_version}")
|
||||
try:
|
||||
# Call the CORRECT orchestrator service function
|
||||
import_results = services.import_package_and_dependencies(package_name, package_version)
|
||||
template_context["results"] = import_results
|
||||
# Flash summary messages
|
||||
dl_count = len(import_results.get('downloaded', {})); proc_count = len(import_results.get('processed', set())); error_count = len(import_results.get('errors', []))
|
||||
if dl_count > 0: flash(f"Downloaded/verified {dl_count} package file(s).", "success")
|
||||
if proc_count < dl_count and dl_count > 0 : flash(f"Dependency data extraction failed for {dl_count - proc_count} package(s).", "warning")
|
||||
if error_count > 0: flash(f"{error_count} total error(s) occurred.", "danger")
|
||||
elif dl_count == 0 and error_count == 0: flash("No packages needed downloading or initial package failed.", "info")
|
||||
elif error_count == 0: flash("Import process completed successfully.", "success")
|
||||
except Exception as e:
|
||||
fatal_error = f"Critical unexpected error during import: {e}"; template_context["fatal_error"] = fatal_error; current_app.logger.error(f"Critical import error: {e}", exc_info=True); flash(fatal_error, "danger")
|
||||
return render_template('fhir_ig_importer/import_ig_page.html', **template_context)
|
||||
return render_template('fhir_ig_importer/import_ig_page.html', **template_context)
|
||||
|
||||
|
||||
# --- Route to get StructureDefinition elements ---
|
||||
@bp.route('/get-structure')
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_structure_definition():
|
||||
"""API endpoint to fetch SD elements and pre-calculated Must Support paths."""
|
||||
package_name = request.args.get('package_name'); package_version = request.args.get('package_version'); resource_identifier = request.args.get('resource_type')
|
||||
error_response_data = {"elements": [], "must_support_paths": []}
|
||||
if not all([package_name, package_version, resource_identifier]): error_response_data["error"] = "Missing query parameters"; return jsonify(error_response_data), 400
|
||||
current_app.logger.info(f"Request for structure: {package_name}#{package_version} / {resource_identifier}")
|
||||
|
||||
# Find the primary package file
|
||||
package_dir_name = 'fhir_packages'; download_dir = os.path.join(current_app.instance_path, package_dir_name)
|
||||
# Use service helper for consistency
|
||||
filename = services._construct_tgz_filename(package_name, package_version)
|
||||
tgz_path = os.path.join(download_dir, filename)
|
||||
if not os.path.exists(tgz_path): error_response_data["error"] = f"Package file not found: {filename}"; return jsonify(error_response_data), 404
|
||||
|
||||
sd_data = None; found_path = None; error_msg = None
|
||||
try:
|
||||
# Call the local helper function correctly
|
||||
sd_data, found_path = find_and_extract_sd(tgz_path, resource_identifier)
|
||||
# Fallback check
|
||||
if sd_data is None:
|
||||
core_pkg_name = "hl7.fhir.r4.core"; core_pkg_version = "4.0.1" # TODO: Make dynamic
|
||||
core_filename = services._construct_tgz_filename(core_pkg_name, core_pkg_version)
|
||||
core_tgz_path = os.path.join(download_dir, core_filename)
|
||||
if os.path.exists(core_tgz_path):
|
||||
current_app.logger.info(f"Trying fallback search in {core_pkg_name}...")
|
||||
sd_data, found_path = find_and_extract_sd(core_tgz_path, resource_identifier) # Call local helper
|
||||
else: current_app.logger.warning(f"Core package {core_tgz_path} not found.")
|
||||
except Exception as e:
|
||||
error_msg = f"Error searching package(s): {e}"; current_app.logger.error(error_msg, exc_info=True); error_response_data["error"] = error_msg; return jsonify(error_response_data), 500
|
||||
|
||||
if sd_data is None: error_msg = f"SD for '{resource_identifier}' not found."; error_response_data["error"] = error_msg; return jsonify(error_response_data), 404
|
||||
|
||||
# Extract elements
|
||||
elements = sd_data.get('snapshot', {}).get('element', [])
|
||||
if not elements: elements = sd_data.get('differential', {}).get('element', [])
|
||||
|
||||
# Fetch pre-calculated Must Support paths from DB
|
||||
must_support_paths = [];
|
||||
try:
|
||||
stmt = db.select(ProcessedIg).filter_by(package_name=package_name, package_version=package_version); processed_ig_record = db.session.scalar(stmt)
|
||||
if processed_ig_record: all_ms_paths_dict = processed_ig_record.must_support_elements; must_support_paths = all_ms_paths_dict.get(resource_identifier, [])
|
||||
else: current_app.logger.warning(f"No ProcessedIg record found for {package_name}#{package_version}")
|
||||
except Exception as e: current_app.logger.error(f"Error fetching MS paths from DB: {e}", exc_info=True)
|
||||
|
||||
current_app.logger.info(f"Returning {len(elements)} elements for {resource_identifier} from {found_path or 'Unknown File'}")
|
||||
return jsonify({"elements": elements, "must_support_paths": must_support_paths})
|
||||
|
||||
|
||||
# --- Route to get raw example file content ---
|
||||
@bp.route('/get-example')
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_example_content():
|
||||
# ... (Function remains the same as response #147) ...
|
||||
package_name = request.args.get('package_name'); package_version = request.args.get('package_version'); example_member_path = request.args.get('filename')
|
||||
if not all([package_name, package_version, example_member_path]): return jsonify({"error": "Missing query parameters"}), 400
|
||||
current_app.logger.info(f"Request for example: {package_name}#{package_version} / {example_member_path}")
|
||||
package_dir_name = 'fhir_packages'; download_dir = os.path.join(current_app.instance_path, package_dir_name)
|
||||
pkg_filename = services._construct_tgz_filename(package_name, package_version) # Use service helper
|
||||
tgz_path = os.path.join(download_dir, pkg_filename)
|
||||
if not os.path.exists(tgz_path): return jsonify({"error": f"Package file not found: {pkg_filename}"}), 404
|
||||
# Basic security check on member path
|
||||
safe_member_path = secure_filename(example_member_path.replace("package/","")) # Allow paths within package/
|
||||
if not example_member_path.startswith('package/') or '..' in example_member_path: return jsonify({"error": "Invalid example file path."}), 400
|
||||
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
try: example_member = tar.getmember(example_member_path) # Use original path here
|
||||
except KeyError: return jsonify({"error": f"Example file '{example_member_path}' not found."}), 404
|
||||
example_fileobj = tar.extractfile(example_member)
|
||||
if not example_fileobj: return jsonify({"error": "Could not extract example file."}), 500
|
||||
try: content_bytes = example_fileobj.read()
|
||||
finally: example_fileobj.close()
|
||||
return content_bytes # Return raw bytes
|
||||
except tarfile.TarError as e: err_msg = f"Error reading {tgz_path}: {e}"; current_app.logger.error(err_msg); return jsonify({"error": err_msg}), 500
|
||||
except Exception as e: err_msg = f"Unexpected error getting example {example_member_path}: {e}"; current_app.logger.error(err_msg, exc_info=True); return jsonify({"error": err_msg}), 500
|
345
Dump/services.txt
Normal file
345
Dump/services.txt
Normal file
@ -0,0 +1,345 @@
|
||||
# app/modules/fhir_ig_importer/services.py
|
||||
|
||||
import requests
|
||||
import os
|
||||
import tarfile
|
||||
import gzip
|
||||
import json
|
||||
import io
|
||||
import re
|
||||
import logging
|
||||
from flask import current_app
|
||||
from collections import defaultdict
|
||||
|
||||
# Constants
|
||||
FHIR_REGISTRY_BASE_URL = "https://packages.fhir.org"
|
||||
DOWNLOAD_DIR_NAME = "fhir_packages"
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def _get_download_dir():
|
||||
"""Gets the absolute path to the download directory, creating it if needed."""
|
||||
logger = logging.getLogger(__name__)
|
||||
instance_path = None # Initialize
|
||||
try:
|
||||
# --- FIX: Indent code inside try block ---
|
||||
instance_path = current_app.instance_path
|
||||
logger.debug(f"Using instance path from current_app: {instance_path}")
|
||||
except RuntimeError:
|
||||
# --- FIX: Indent code inside except block ---
|
||||
logger.warning("No app context for instance_path, constructing relative path.")
|
||||
instance_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'instance'))
|
||||
logger.debug(f"Constructed instance path: {instance_path}")
|
||||
|
||||
# This part depends on instance_path being set above
|
||||
if not instance_path:
|
||||
logger.error("Fatal Error: Could not determine instance path.")
|
||||
return None
|
||||
|
||||
download_dir = os.path.join(instance_path, DOWNLOAD_DIR_NAME)
|
||||
try:
|
||||
# --- FIX: Indent code inside try block ---
|
||||
os.makedirs(download_dir, exist_ok=True)
|
||||
return download_dir
|
||||
except OSError as e:
|
||||
# --- FIX: Indent code inside except block ---
|
||||
logger.error(f"Fatal Error creating dir {download_dir}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def sanitize_filename_part(text): # Public version
|
||||
"""Basic sanitization for name/version parts of filename."""
|
||||
# --- FIX: Indent function body ---
|
||||
safe_text = "".join(c if c.isalnum() or c in ['.', '-'] else '_' for c in text)
|
||||
safe_text = re.sub(r'_+', '_', safe_text) # Uses re
|
||||
safe_text = safe_text.strip('_-.')
|
||||
return safe_text if safe_text else "invalid_name"
|
||||
|
||||
def _construct_tgz_filename(name, version):
|
||||
"""Constructs the standard filename using the sanitized parts."""
|
||||
# --- FIX: Indent function body ---
|
||||
return f"{sanitize_filename_part(name)}-{sanitize_filename_part(version)}.tgz"
|
||||
|
||||
def find_and_extract_sd(tgz_path, resource_identifier): # Public version
|
||||
"""Helper to find and extract SD json from a given tgz path by ID, Name, or Type."""
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
sd_data = None
|
||||
found_path = None
|
||||
logger = logging.getLogger(__name__)
|
||||
if not tgz_path or not os.path.exists(tgz_path):
|
||||
logger.error(f"File not found in find_and_extract_sd: {tgz_path}")
|
||||
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)}")
|
||||
for member in tar:
|
||||
if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')):
|
||||
continue
|
||||
if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']:
|
||||
continue
|
||||
|
||||
fileobj = None
|
||||
try:
|
||||
fileobj = tar.extractfile(member)
|
||||
if fileobj:
|
||||
content_bytes = fileobj.read()
|
||||
content_string = content_bytes.decode('utf-8-sig')
|
||||
data = json.loads(content_string)
|
||||
if isinstance(data, dict) and data.get('resourceType') == 'StructureDefinition':
|
||||
sd_id = data.get('id')
|
||||
sd_name = data.get('name')
|
||||
sd_type = data.get('type')
|
||||
# Match if requested identifier matches ID, Name, or Base Type
|
||||
if resource_identifier == sd_type or resource_identifier == sd_id or resource_identifier == sd_name:
|
||||
sd_data = data
|
||||
found_path = member.name
|
||||
logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}")
|
||||
break # Stop searching once found
|
||||
except Exception as e:
|
||||
# Log issues reading/parsing individual files but continue search
|
||||
logger.warning(f"Could not read/parse potential SD {member.name}: {e}")
|
||||
finally:
|
||||
if fileobj: fileobj.close() # Ensure resource cleanup
|
||||
|
||||
if sd_data is None:
|
||||
logger.warning(f"SD matching '{resource_identifier}' not found within archive {os.path.basename(tgz_path)}")
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f"TarError reading {tgz_path} in find_and_extract_sd: {e}")
|
||||
raise tarfile.TarError(f"Error reading package archive: {e}") from e
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"FileNotFoundError reading {tgz_path} in find_and_extract_sd: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in find_and_extract_sd for {tgz_path}: {e}", exc_info=True)
|
||||
raise
|
||||
return sd_data, found_path
|
||||
|
||||
# --- Core Service Functions ---
|
||||
|
||||
def download_package(name, version):
|
||||
""" Downloads a single FHIR package. Returns (save_path, error_message) """
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
logger = logging.getLogger(__name__)
|
||||
download_dir = _get_download_dir()
|
||||
if not download_dir:
|
||||
return None, "Could not get/create download directory."
|
||||
|
||||
package_id = f"{name}#{version}"
|
||||
package_url = f"{FHIR_REGISTRY_BASE_URL}/{name}/{version}"
|
||||
filename = _construct_tgz_filename(name, version) # Uses public sanitize via helper
|
||||
save_path = os.path.join(download_dir, filename)
|
||||
|
||||
if os.path.exists(save_path):
|
||||
logger.info(f"Exists: {filename}")
|
||||
return save_path, None
|
||||
|
||||
logger.info(f"Downloading: {package_id} -> {filename}")
|
||||
try:
|
||||
with requests.get(package_url, stream=True, timeout=90) as r:
|
||||
r.raise_for_status()
|
||||
with open(save_path, 'wb') as f:
|
||||
logger.debug(f"Opened {save_path} for writing.")
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
logger.info(f"Success: Downloaded {filename}")
|
||||
return save_path, None
|
||||
except requests.exceptions.RequestException as e:
|
||||
err_msg = f"Download error for {package_id}: {e}"; logger.error(err_msg); return None, err_msg
|
||||
except OSError as e:
|
||||
err_msg = f"File save error for {filename}: {e}"; logger.error(err_msg); return None, err_msg
|
||||
except Exception as e:
|
||||
err_msg = f"Unexpected download error for {package_id}: {e}"; logger.error(err_msg, exc_info=True); return None, err_msg
|
||||
|
||||
def extract_dependencies(tgz_path):
|
||||
""" Extracts dependencies dict from package.json. Returns (dep_dict or None on error, error_message) """
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
logger = logging.getLogger(__name__)
|
||||
package_json_path = "package/package.json"
|
||||
dependencies = {}
|
||||
error_message = None
|
||||
if not tgz_path or not os.path.exists(tgz_path):
|
||||
return None, f"File not found at {tgz_path}"
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
package_json_member = tar.getmember(package_json_path)
|
||||
package_json_fileobj = tar.extractfile(package_json_member)
|
||||
if package_json_fileobj:
|
||||
try:
|
||||
package_data = json.loads(package_json_fileobj.read().decode('utf-8-sig'))
|
||||
dependencies = package_data.get('dependencies', {})
|
||||
finally:
|
||||
package_json_fileobj.close()
|
||||
else:
|
||||
raise FileNotFoundError(f"Could not extract {package_json_path}")
|
||||
except KeyError:
|
||||
error_message = f"'{package_json_path}' not found in {os.path.basename(tgz_path)}.";
|
||||
logger.warning(error_message) # OK if missing
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
error_message = f"Parse error in {package_json_path}: {e}"; logger.error(error_message); dependencies = None # Parsing failed
|
||||
except (tarfile.TarError, FileNotFoundError) as e:
|
||||
error_message = f"Archive error {os.path.basename(tgz_path)}: {e}"; logger.error(error_message); dependencies = None # Archive read failed
|
||||
except Exception as e:
|
||||
error_message = f"Unexpected error extracting deps: {e}"; logger.error(error_message, exc_info=True); dependencies = None
|
||||
return dependencies, error_message
|
||||
|
||||
|
||||
# --- Recursive Import Orchestrator ---
|
||||
def import_package_and_dependencies(initial_name, initial_version):
|
||||
"""Orchestrates recursive download and dependency extraction."""
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Starting recursive import for {initial_name}#{initial_version}")
|
||||
results = {'requested': (initial_name, initial_version), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'errors': [] }
|
||||
pending_queue = [(initial_name, initial_version)]
|
||||
processed_lookup = set()
|
||||
|
||||
while pending_queue:
|
||||
name, version = pending_queue.pop(0)
|
||||
package_id_tuple = (name, version)
|
||||
|
||||
if package_id_tuple in processed_lookup:
|
||||
continue
|
||||
|
||||
logger.info(f"Processing: {name}#{version}")
|
||||
processed_lookup.add(package_id_tuple)
|
||||
|
||||
save_path, dl_error = download_package(name, version)
|
||||
|
||||
if dl_error:
|
||||
error_msg = f"Download failed for {name}#{version}: {dl_error}"
|
||||
results['errors'].append(error_msg)
|
||||
if package_id_tuple == results['requested']:
|
||||
logger.error("Aborting import: Initial package download failed.")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
else: # Download OK
|
||||
results['downloaded'][package_id_tuple] = save_path
|
||||
# --- Correctly indented block ---
|
||||
dependencies, dep_error = extract_dependencies(save_path)
|
||||
if dep_error:
|
||||
results['errors'].append(f"Dependency extraction failed for {name}#{version}: {dep_error}")
|
||||
elif dependencies is not None:
|
||||
results['all_dependencies'][package_id_tuple] = dependencies
|
||||
results['processed'].add(package_id_tuple)
|
||||
logger.debug(f"Dependencies for {name}#{version}: {list(dependencies.keys())}")
|
||||
for dep_name, dep_version in dependencies.items():
|
||||
if isinstance(dep_name, str) and isinstance(dep_version, str) and dep_name and dep_version:
|
||||
dep_tuple = (dep_name, dep_version)
|
||||
if dep_tuple not in processed_lookup:
|
||||
if dep_tuple not in pending_queue:
|
||||
pending_queue.append(dep_tuple)
|
||||
logger.debug(f"Added to queue: {dep_name}#{dep_version}")
|
||||
else:
|
||||
logger.warning(f"Skipping invalid dependency '{dep_name}': '{dep_version}' in {name}#{version}")
|
||||
# --- End Correctly indented block ---
|
||||
|
||||
proc_count=len(results['processed']); dl_count=len(results['downloaded']); err_count=len(results['errors'])
|
||||
logger.info(f"Import finished. Processed: {proc_count}, Downloaded/Verified: {dl_count}, Errors: {err_count}")
|
||||
return results
|
||||
|
||||
|
||||
# --- Package File Content Processor (V6.2 - Fixed MS path handling) ---
|
||||
def process_package_file(tgz_path):
|
||||
""" Extracts types, profile status, MS elements, and examples from a downloaded .tgz package (Single Pass). """
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Processing package file details (V6.2 Logic): {tgz_path}")
|
||||
|
||||
results = {'resource_types_info': [], 'must_support_elements': {}, 'examples': {}, 'errors': [] }
|
||||
resource_info = defaultdict(lambda: {'name': None, 'type': None, 'is_profile': False, 'ms_flag': False, 'ms_paths': set(), 'examples': set()})
|
||||
|
||||
if not tgz_path or not os.path.exists(tgz_path):
|
||||
results['errors'].append(f"Package file not found: {tgz_path}"); return results
|
||||
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile() or not member.name.startswith('package/') or not member.name.lower().endswith(('.json', '.xml', '.html')): continue
|
||||
member_name_lower = member.name.lower(); base_filename_lower = os.path.basename(member_name_lower); fileobj = None
|
||||
if base_filename_lower in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']: continue
|
||||
|
||||
is_example = member.name.startswith('package/example/') or 'example' in base_filename_lower
|
||||
is_json = member_name_lower.endswith('.json')
|
||||
|
||||
try: # Process individual member
|
||||
if is_json:
|
||||
fileobj = tar.extractfile(member);
|
||||
if not fileobj: continue
|
||||
content_bytes = fileobj.read(); content_string = content_bytes.decode('utf-8-sig'); data = json.loads(content_string)
|
||||
if not isinstance(data, dict) or 'resourceType' not in data: continue
|
||||
|
||||
resource_type = data['resourceType']; entry_key = resource_type; is_sd = False
|
||||
|
||||
if resource_type == 'StructureDefinition':
|
||||
is_sd = True; profile_id = data.get('id') or data.get('name'); sd_type = data.get('type'); sd_base = data.get('baseDefinition'); is_profile_sd = bool(sd_base);
|
||||
if not profile_id or not sd_type: logger.warning(f"SD missing ID or Type: {member.name}"); continue
|
||||
entry_key = profile_id
|
||||
|
||||
entry = resource_info[entry_key]; entry.setdefault('type', resource_type) # Ensure type exists
|
||||
|
||||
if is_sd:
|
||||
entry['name'] = entry_key; entry['type'] = sd_type; entry['is_profile'] = is_profile_sd;
|
||||
if not entry.get('sd_processed'):
|
||||
has_ms = False; ms_paths_for_sd = set()
|
||||
for element_list in [data.get('snapshot', {}).get('element', []), data.get('differential', {}).get('element', [])]:
|
||||
for element in element_list:
|
||||
if isinstance(element, dict) and element.get('mustSupport') is True:
|
||||
# --- FIX: Check path safely ---
|
||||
element_path = element.get('path')
|
||||
if element_path: # Only add if path exists
|
||||
ms_paths_for_sd.add(element_path)
|
||||
has_ms = True # Mark MS found if we added a path
|
||||
else:
|
||||
logger.warning(f"Found mustSupport=true without path in element of {entry_key}")
|
||||
# --- End FIX ---
|
||||
if ms_paths_for_sd: entry['ms_paths'] = ms_paths_for_sd # Store the set of paths
|
||||
if has_ms: entry['ms_flag'] = True; logger.debug(f" Found MS elements in {entry_key}") # Use boolean flag
|
||||
entry['sd_processed'] = True # Mark MS check done
|
||||
|
||||
elif is_example: # JSON Example
|
||||
key_to_use = None; profile_meta = data.get('meta', {}).get('profile', [])
|
||||
if profile_meta and isinstance(profile_meta, list):
|
||||
for profile_url in profile_meta: profile_id_from_meta = profile_url.split('/')[-1];
|
||||
if profile_id_from_meta in resource_info: key_to_use = profile_id_from_meta; break
|
||||
if not key_to_use: key_to_use = resource_type
|
||||
if key_to_use not in resource_info: resource_info[key_to_use].update({'name': key_to_use, 'type': resource_type})
|
||||
resource_info[key_to_use]['examples'].add(member.name)
|
||||
|
||||
elif is_example: # XML/HTML examples
|
||||
# ... (XML/HTML example association logic) ...
|
||||
guessed_type = base_filename_lower.split('-')[0].capitalize(); guessed_profile_id = base_filename_lower.split('-')[0]; key_to_use = None
|
||||
if guessed_profile_id in resource_info: key_to_use = guessed_profile_id
|
||||
elif guessed_type in resource_info: key_to_use = guessed_type
|
||||
if key_to_use: resource_info[key_to_use]['examples'].add(member.name)
|
||||
else: logger.warning(f"Could not associate non-JSON example {member.name}")
|
||||
|
||||
except Exception as e: logger.warning(f"Could not process member {member.name}: {e}", exc_info=False)
|
||||
finally:
|
||||
if fileobj: fileobj.close()
|
||||
# -- End Member Loop --
|
||||
|
||||
# --- Final formatting moved INSIDE the main try block ---
|
||||
final_list = []; final_ms_elements = {}; final_examples = {}
|
||||
logger.debug(f"Formatting results from resource_info keys: {list(resource_info.keys())}")
|
||||
for key, info in resource_info.items():
|
||||
display_name = info.get('name') or key; base_type = info.get('type')
|
||||
if display_name or base_type:
|
||||
logger.debug(f" Formatting item '{display_name}': type='{base_type}', profile='{info.get('is_profile', False)}', ms_flag='{info.get('ms_flag', False)}'")
|
||||
final_list.append({'name': display_name, 'type': base_type, 'is_profile': info.get('is_profile', False), 'must_support': info.get('ms_flag', False)}) # Ensure 'must_support' key uses 'ms_flag'
|
||||
if info['ms_paths']: final_ms_elements[display_name] = sorted(list(info['ms_paths']))
|
||||
if info['examples']: final_examples[display_name] = sorted(list(info['examples']))
|
||||
else: logger.warning(f"Skipping formatting for key: {key}")
|
||||
|
||||
results['resource_types_info'] = sorted(final_list, key=lambda x: (not x.get('is_profile', False), x.get('name', '')))
|
||||
results['must_support_elements'] = final_ms_elements
|
||||
results['examples'] = final_examples
|
||||
# --- End formatting moved inside ---
|
||||
|
||||
except Exception as e:
|
||||
err_msg = f"Error processing package file {tgz_path}: {e}"; logger.error(err_msg, exc_info=True); results['errors'].append(err_msg)
|
||||
|
||||
# Logging counts
|
||||
final_types_count = len(results['resource_types_info']); ms_count = sum(1 for r in results['resource_types_info'] if r['must_support']); total_ms_paths = sum(len(v) for v in results['must_support_elements'].values()); total_examples = sum(len(v) for v in results['examples'].values())
|
||||
logger.info(f"V6.2 Extraction: {final_types_count} items ({ms_count} MS; {total_ms_paths} MS paths; {total_examples} examples) from {os.path.basename(tgz_path)}")
|
||||
|
||||
return results
|
28
__init__.py
Normal file
28
__init__.py
Normal file
@ -0,0 +1,28 @@
|
||||
# app/modules/fhir_ig_importer/__init__.py
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
# --- Module Metadata ---
|
||||
metadata = {
|
||||
'module_id': 'fhir_ig_importer', # Matches folder name
|
||||
'display_name': 'FHIR IG Importer',
|
||||
'description': 'Imports FHIR Implementation Guide packages from a registry.',
|
||||
'version': '0.1.0',
|
||||
# No main nav items, will be accessed via Control Panel
|
||||
'nav_items': []
|
||||
}
|
||||
# --- End Module Metadata ---
|
||||
|
||||
# Define Blueprint
|
||||
# We'll mount this under the control panel later
|
||||
bp = Blueprint(
|
||||
metadata['module_id'],
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
# Define a URL prefix if mounting standalone, but we'll likely register
|
||||
# it under /control-panel via app/__init__.py later
|
||||
# url_prefix='/fhir-importer'
|
||||
)
|
||||
|
||||
# Import routes after creating blueprint
|
||||
from . import routes, forms # Import forms too
|
362
app.py
362
app.py
@ -1,28 +1,68 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash
|
||||
from flask import Flask, render_template, render_template_string, request, redirect, url_for, flash, jsonify
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import DataRequired, Regexp
|
||||
import os
|
||||
import services # Assuming your existing services module for FHIR IG handling
|
||||
import tarfile
|
||||
import json
|
||||
from datetime import datetime
|
||||
import services
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///instance/fhir_ig.db'
|
||||
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')
|
||||
os.makedirs(app.config['FHIR_PACKAGES_DIR'], exist_ok=True)
|
||||
|
||||
# Ensure directories exist and are writable
|
||||
instance_path = '/app/instance'
|
||||
db_path = os.path.join(instance_path, 'fhir_ig.db')
|
||||
packages_path = app.config['FHIR_PACKAGES_DIR']
|
||||
|
||||
logger.debug(f"Instance path: {instance_path}")
|
||||
logger.debug(f"Database path: {db_path}")
|
||||
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')}")
|
||||
logger.debug(f"Instance contents: {os.listdir(instance_path)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directories: {e}")
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/fhir_ig.db'
|
||||
logger.warning("Falling back to /tmp/fhir_ig.db")
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# Simplified ProcessedIg model (no user-related fields)
|
||||
class IgImportForm(FlaskForm):
|
||||
package_name = StringField('Package Name (e.g., hl7.fhir.us.core)', validators=[
|
||||
DataRequired(),
|
||||
Regexp(r'^[a-zA-Z0-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.')
|
||||
])
|
||||
submit = SubmitField('Fetch & Download IG')
|
||||
|
||||
class ProcessedIg(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
package_name = db.Column(db.String(128), nullable=False)
|
||||
version = db.Column(db.String(32), nullable=False)
|
||||
processed_date = db.Column(db.DateTime, nullable=False)
|
||||
resource_types_info = db.Column(db.JSON, nullable=False) # List of resource type metadata
|
||||
must_support_elements = db.Column(db.JSON, nullable=True) # Dict of MS elements
|
||||
examples = db.Column(db.JSON, nullable=True) # Dict of example filepaths
|
||||
resource_types_info = db.Column(db.JSON, nullable=False)
|
||||
must_support_elements = db.Column(db.JSON, nullable=True)
|
||||
examples = db.Column(db.JSON, nullable=True)
|
||||
|
||||
# Landing page with two buttons
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template_string('''
|
||||
@ -31,132 +71,254 @@ def index():
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FHIR IG Toolkit</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
body { text-align: center; padding: 50px; }
|
||||
.button { padding: 15px 30px; margin: 10px; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>FHIR IG Toolkit</h1>
|
||||
<p>Simple tool for importing and viewing FHIR Implementation Guides.</p>
|
||||
<a href="{{ url_for('import_ig') }}"><button class="button">Import FHIR IG</button></a>
|
||||
<a href="{{ url_for('view_igs') }}"><button class="button">View Downloaded IGs</button></a>
|
||||
<a href="{{ url_for('import_ig') }}"><button class="button btn btn-primary">Import FHIR IG</button></a>
|
||||
<a href="{{ url_for('view_igs') }}"><button class="button btn btn-primary">View Downloaded IGs</button></a>
|
||||
</body>
|
||||
</html>
|
||||
''')
|
||||
|
||||
# Import IG route
|
||||
@app.route('/import-ig', methods=['GET', 'POST'])
|
||||
def import_ig():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
version = request.form.get('version', 'latest')
|
||||
form = IgImportForm()
|
||||
if form.validate_on_submit():
|
||||
name = form.package_name.data
|
||||
version = form.package_version.data
|
||||
try:
|
||||
# Call your existing service to download package and dependencies
|
||||
result = services.import_package_and_dependencies(name, version, app.config['FHIR_PACKAGES_DIR'])
|
||||
downloaded_files = result.get('downloaded', [])
|
||||
for file_path in downloaded_files:
|
||||
# Process each downloaded package
|
||||
package_info = services.process_package_file(file_path)
|
||||
processed_ig = ProcessedIg(
|
||||
package_name=package_info['name'],
|
||||
version=package_info['version'],
|
||||
processed_date=package_info['processed_date'],
|
||||
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 imported {name} {version} and dependencies!", "success")
|
||||
result = services.import_package_and_dependencies(name, version)
|
||||
if result['errors'] and not result['downloaded']:
|
||||
flash(f"Failed to import {name}#{version}: {result['errors'][0]}", "error")
|
||||
return redirect(url_for('import_ig'))
|
||||
flash(f"Successfully downloaded {name}#{version} and dependencies!", "success")
|
||||
return redirect(url_for('view_igs'))
|
||||
except Exception as e:
|
||||
flash(f"Error importing IG: {str(e)}", "error")
|
||||
return redirect(url_for('import_ig'))
|
||||
flash(f"Error downloading IG: {str(e)}", "error")
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Import FHIR IG</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
.form { max-width: 400px; margin: 0 auto; }
|
||||
.field { margin: 10px 0; }
|
||||
input[type="text"] { width: 100%; padding: 5px; }
|
||||
.button { padding: 10px 20px; }
|
||||
.message { color: {% if category == "success" %}green{% else %}red{% endif %}; }
|
||||
.form { max-width: 400px; margin: 20px auto; }
|
||||
.message { margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Import FHIR IG</h1>
|
||||
<form class="form" method="POST">
|
||||
<div class="field">
|
||||
<label>Package Name:</label>
|
||||
<input type="text" name="name" placeholder="e.g., hl7.fhir.us.core" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Version (optional):</label>
|
||||
<input type="text" name="version" placeholder="e.g., 1.0.0 or latest">
|
||||
</div>
|
||||
<button class="button" type="submit">Import</button>
|
||||
<a href="{{ url_for('index') }}"><button class="button" type="button">Back</button></a>
|
||||
</form>
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="message">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="container">
|
||||
<h1 class="mt-4">Import FHIR IG</h1>
|
||||
<form class="form" method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.package_name.label(class="form-label") }}
|
||||
{{ form.package_name(class="form-control") }}
|
||||
{% for error in form.package_name.errors %}<p class="text-danger message">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.package_version.label(class="form-label") }}
|
||||
{{ form.package_version(class="form-control") }}
|
||||
{% for error in form.package_version.errors %}<p class="text-danger message">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a>
|
||||
</form>
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="message text-{{ 'success' if category == 'success' else 'danger' }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
''')
|
||||
''', form=form)
|
||||
|
||||
# View Downloaded IGs route
|
||||
@app.route('/view-igs')
|
||||
def view_igs():
|
||||
igs = ProcessedIg.query.all()
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>View Downloaded IGs</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
table { width: 80%; margin: 20px auto; border-collapse: collapse; }
|
||||
th, td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
.button { padding: 10px 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Downloaded FHIR IGs</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Package Name</th>
|
||||
<th>Version</th>
|
||||
<th>Processed Date</th>
|
||||
<th>Resource Types</th>
|
||||
</tr>
|
||||
{% for ig in igs %}
|
||||
<tr>
|
||||
<td>{{ ig.package_name }}</td>
|
||||
<td>{{ ig.version }}</td>
|
||||
<td>{{ ig.processed_date }}</td>
|
||||
<td>{{ ig.resource_types_info | length }} types</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<a href="{{ url_for('index') }}"><button class="button">Back</button></a>
|
||||
</body>
|
||||
</html>
|
||||
''', igs=igs)
|
||||
processed_ids = {(ig.package_name, ig.version) for ig in igs}
|
||||
|
||||
packages = []
|
||||
packages_dir = app.config['FHIR_PACKAGES_DIR']
|
||||
logger.debug(f"Scanning packages directory: {packages_dir}")
|
||||
if os.path.exists(packages_dir):
|
||||
for filename in os.listdir(packages_dir):
|
||||
if filename.endswith('.tgz'):
|
||||
# Remove .tgz and split on the last segment that looks like a version
|
||||
name_version = filename[:-4] # Remove .tgz
|
||||
parts = name_version.split('-')
|
||||
# Assume version starts with a digit or common version keywords
|
||||
version_idx = -1
|
||||
for i, part in enumerate(parts[::-1]):
|
||||
if part[0].isdigit() or part in ('preview', 'current', 'latest'):
|
||||
version_idx = len(parts) - i - 1
|
||||
break
|
||||
if version_idx >= 0:
|
||||
name = '-'.join(parts[:version_idx + 1])
|
||||
version = '-'.join(parts[version_idx + 1:])
|
||||
packages.append({'name': name, 'version': version, 'filename': filename})
|
||||
else:
|
||||
# Fallback: treat last part as version
|
||||
name = '-'.join(parts[:-1])
|
||||
version = parts[-1]
|
||||
packages.append({'name': name, 'version': version, 'filename': filename})
|
||||
logger.debug(f"Found packages: {packages}")
|
||||
else:
|
||||
logger.warning(f"Packages directory not found: {packages_dir}")
|
||||
|
||||
duplicate_names = {}
|
||||
duplicate_groups = {}
|
||||
for pkg in packages:
|
||||
name = pkg['name']
|
||||
if name in duplicate_names:
|
||||
duplicate_names[name].append(pkg)
|
||||
duplicate_groups.setdefault(name, []).append(pkg['version'])
|
||||
else:
|
||||
duplicate_names[name] = [pkg]
|
||||
|
||||
return render_template('cp_downloaded_igs.html', packages=packages, processed_list=igs,
|
||||
processed_ids=processed_ids, duplicate_names=duplicate_names,
|
||||
duplicate_groups=duplicate_groups)
|
||||
|
||||
@app.route('/process-ig', 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:
|
||||
parts = filename[:-4].split('-')
|
||||
version_idx = -1
|
||||
for i, part in enumerate(parts[::-1]):
|
||||
if part[0].isdigit() or part in ('preview', 'current', 'latest'):
|
||||
version_idx = len(parts) - i - 1
|
||||
break
|
||||
name = '-'.join(parts[:version_idx + 1]) if version_idx >= 0 else '-'.join(parts[:-1])
|
||||
version = '-'.join(parts[version_idx + 1:]) if version_idx >= 0 else parts[-1]
|
||||
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")
|
||||
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)
|
||||
if os.path.exists(tgz_path):
|
||||
try:
|
||||
os.remove(tgz_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")
|
||||
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 = ProcessedIg.query.get(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")
|
||||
return redirect(url_for('view_igs'))
|
||||
|
||||
@app.route('/view-ig/<int:processed_ig_id>')
|
||||
def view_ig(processed_ig_id):
|
||||
processed_ig = ProcessedIg.query.get_or_404(processed_ig_id)
|
||||
profile_list = [t for t in processed_ig.resource_types_info if t.get('is_profile')]
|
||||
base_list = [t for t in processed_ig.resource_types_info if not t.get('is_profile')]
|
||||
examples_by_type = processed_ig.examples or {}
|
||||
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)
|
||||
|
||||
@app.route('/get-structure')
|
||||
def get_structure_definition():
|
||||
package_name = request.args.get('package_name')
|
||||
package_version = request.args.get('package_version')
|
||||
resource_identifier = request.args.get('resource_type')
|
||||
if not all([package_name, package_version, resource_identifier]):
|
||||
return jsonify({"error": "Missing query parameters"}), 400
|
||||
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], services._construct_tgz_filename(package_name, package_version))
|
||||
if not os.path.exists(tgz_path):
|
||||
return jsonify({"error": f"Package file not found: {tgz_path}"}), 404
|
||||
sd_data, _ = services.find_and_extract_sd(tgz_path, resource_identifier)
|
||||
if sd_data is None:
|
||||
return jsonify({"error": f"SD for '{resource_identifier}' not found."}), 404
|
||||
elements = sd_data.get('snapshot', {}).get('element', []) or sd_data.get('differential', {}).get('element', [])
|
||||
processed_ig = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first()
|
||||
must_support_paths = processed_ig.must_support_elements.get(resource_identifier, []) if processed_ig else []
|
||||
return jsonify({"elements": elements, "must_support_paths": must_support_paths})
|
||||
|
||||
@app.route('/get-example')
|
||||
def get_example_content():
|
||||
package_name = request.args.get('package_name')
|
||||
package_version = request.args.get('package_version')
|
||||
example_member_path = request.args.get('filename')
|
||||
if not all([package_name, package_version, example_member_path]):
|
||||
return jsonify({"error": "Missing query parameters"}), 400
|
||||
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], services._construct_tgz_filename(package_name, package_version))
|
||||
if not os.path.exists(tgz_path):
|
||||
return jsonify({"error": f"Package file not found: {tgz_path}"}), 404
|
||||
if not example_member_path.startswith('package/') or '..' in example_member_path:
|
||||
return jsonify({"error": "Invalid example file path."}), 400
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
try:
|
||||
example_member = tar.getmember(example_member_path)
|
||||
except KeyError:
|
||||
return jsonify({"error": f"Example file '{example_member_path}' not found."}), 404
|
||||
with tar.extractfile(example_member) as example_fileobj:
|
||||
content_bytes = example_fileobj.read()
|
||||
return content_bytes.decode('utf-8-sig')
|
||||
except tarfile.TarError as e:
|
||||
return jsonify({"error": f"Error reading {tgz_path}: {e}"}), 500
|
||||
|
||||
# Initialize DB
|
||||
with app.app_context():
|
||||
logger.debug(f"Creating database at: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||
db.create_all()
|
||||
logger.debug("Database initialization complete")
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
19
forms.py
Normal file
19
forms.py
Normal file
@ -0,0 +1,19 @@
|
||||
# app/modules/fhir_ig_importer/forms.py
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import DataRequired, Regexp
|
||||
|
||||
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=[
|
||||
DataRequired(),
|
||||
Regexp(r'^[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=[
|
||||
DataRequired(),
|
||||
Regexp(r'^[a-zA-Z0-9\.\-]+$', message='Invalid version format.')
|
||||
])
|
||||
submit = SubmitField('Fetch & Download IG')
|
BIN
instance/fhir_ig.db
Normal file
BIN
instance/fhir_ig.db
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.fhir.au.base-5.1.0-preview.tgz
Normal file
BIN
instance/fhir_packages/hl7.fhir.au.base-5.1.0-preview.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.fhir.au.core-1.1.0-preview.tgz
Normal file
BIN
instance/fhir_packages/hl7.fhir.au.core-1.1.0-preview.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.fhir.r4.core-4.0.1.tgz
Normal file
BIN
instance/fhir_packages/hl7.fhir.r4.core-4.0.1.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.fhir.uv.extensions.r4-5.2.0.tgz
Normal file
BIN
instance/fhir_packages/hl7.fhir.uv.extensions.r4-5.2.0.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.fhir.uv.ipa-1.0.0.tgz
Normal file
BIN
instance/fhir_packages/hl7.fhir.uv.ipa-1.0.0.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.0.0.tgz
Normal file
BIN
instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.0.0.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.1.0.tgz
Normal file
BIN
instance/fhir_packages/hl7.fhir.uv.smart-app-launch-2.1.0.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.terminology.r4-5.0.0.tgz
Normal file
BIN
instance/fhir_packages/hl7.terminology.r4-5.0.0.tgz
Normal file
Binary file not shown.
BIN
instance/fhir_packages/hl7.terminology.r4-6.2.0.tgz
Normal file
BIN
instance/fhir_packages/hl7.terminology.r4-6.2.0.tgz
Normal file
Binary file not shown.
@ -1,3 +1,6 @@
|
||||
Flask==2.3.3
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
Werkzeug==2.3.7
|
||||
Werkzeug==2.3.7
|
||||
requests==2.31.0
|
||||
Flask-WTF==1.2.1
|
||||
WTForms==3.1.2
|
162
routes.py
Normal file
162
routes.py
Normal file
@ -0,0 +1,162 @@
|
||||
# app/modules/fhir_ig_importer/routes.py
|
||||
|
||||
import requests
|
||||
import os
|
||||
import tarfile # Needed for find_and_extract_sd
|
||||
import gzip
|
||||
import json
|
||||
import io
|
||||
import re
|
||||
from flask import (render_template, redirect, url_for, flash, request,
|
||||
current_app, jsonify, send_file)
|
||||
from flask_login import login_required
|
||||
from app.decorators import admin_required
|
||||
from werkzeug.utils import secure_filename
|
||||
from . import bp
|
||||
from .forms import IgImportForm
|
||||
# Import the services module
|
||||
from . import services
|
||||
# Import ProcessedIg model for get_structure_definition
|
||||
from app.models import ProcessedIg
|
||||
from app import db
|
||||
|
||||
|
||||
# --- Helper: Find/Extract SD ---
|
||||
# Moved from services.py to be local to routes that use it, or keep in services and call services.find_and_extract_sd
|
||||
def find_and_extract_sd(tgz_path, resource_identifier):
|
||||
"""Helper to find and extract SD json from a given tgz path by ID, Name, or Type."""
|
||||
sd_data = None; found_path = None; logger = current_app.logger # Use current_app logger
|
||||
if not tgz_path or not os.path.exists(tgz_path): logger.error(f"File not found in find_and_extract_sd: {tgz_path}"); 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)}")
|
||||
for member in tar:
|
||||
if member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json'):
|
||||
if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']: continue
|
||||
fileobj = None
|
||||
try:
|
||||
fileobj = tar.extractfile(member)
|
||||
if fileobj:
|
||||
content_bytes = fileobj.read(); content_string = content_bytes.decode('utf-8-sig'); data = json.loads(content_string)
|
||||
if isinstance(data, dict) and data.get('resourceType') == 'StructureDefinition':
|
||||
sd_id = data.get('id'); sd_name = data.get('name'); sd_type = data.get('type')
|
||||
if resource_identifier == sd_type or resource_identifier == sd_id or resource_identifier == sd_name:
|
||||
sd_data = data; found_path = member.name; logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}"); break
|
||||
except Exception as e: logger.warning(f"Could not read/parse potential SD {member.name}: {e}")
|
||||
finally:
|
||||
if fileobj: fileobj.close()
|
||||
if sd_data is None: logger.warning(f"SD matching '{resource_identifier}' not found within archive {os.path.basename(tgz_path)}")
|
||||
except Exception as e: logger.error(f"Error reading archive {tgz_path} in find_and_extract_sd: {e}", exc_info=True); raise
|
||||
return sd_data, found_path
|
||||
# --- End Helper ---
|
||||
|
||||
|
||||
# --- Route for the main import page ---
|
||||
@bp.route('/import-ig', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def import_ig():
|
||||
"""Handles FHIR IG recursive download using services."""
|
||||
form = IgImportForm()
|
||||
template_context = {"title": "Import FHIR IG", "form": form, "results": None }
|
||||
if form.validate_on_submit():
|
||||
package_name = form.package_name.data; package_version = form.package_version.data
|
||||
template_context.update(package_name=package_name, package_version=package_version)
|
||||
flash(f"Starting full import for {package_name}#{package_version}...", "info"); current_app.logger.info(f"Calling import service for: {package_name}#{package_version}")
|
||||
try:
|
||||
# Call the CORRECT orchestrator service function
|
||||
import_results = services.import_package_and_dependencies(package_name, package_version)
|
||||
template_context["results"] = import_results
|
||||
# Flash summary messages
|
||||
dl_count = len(import_results.get('downloaded', {})); proc_count = len(import_results.get('processed', set())); error_count = len(import_results.get('errors', []))
|
||||
if dl_count > 0: flash(f"Downloaded/verified {dl_count} package file(s).", "success")
|
||||
if proc_count < dl_count and dl_count > 0 : flash(f"Dependency data extraction failed for {dl_count - proc_count} package(s).", "warning")
|
||||
if error_count > 0: flash(f"{error_count} total error(s) occurred.", "danger")
|
||||
elif dl_count == 0 and error_count == 0: flash("No packages needed downloading or initial package failed.", "info")
|
||||
elif error_count == 0: flash("Import process completed successfully.", "success")
|
||||
except Exception as e:
|
||||
fatal_error = f"Critical unexpected error during import: {e}"; template_context["fatal_error"] = fatal_error; current_app.logger.error(f"Critical import error: {e}", exc_info=True); flash(fatal_error, "danger")
|
||||
return render_template('fhir_ig_importer/import_ig_page.html', **template_context)
|
||||
return render_template('fhir_ig_importer/import_ig_page.html', **template_context)
|
||||
|
||||
|
||||
# --- Route to get StructureDefinition elements ---
|
||||
@bp.route('/get-structure')
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_structure_definition():
|
||||
"""API endpoint to fetch SD elements and pre-calculated Must Support paths."""
|
||||
package_name = request.args.get('package_name'); package_version = request.args.get('package_version'); resource_identifier = request.args.get('resource_type')
|
||||
error_response_data = {"elements": [], "must_support_paths": []}
|
||||
if not all([package_name, package_version, resource_identifier]): error_response_data["error"] = "Missing query parameters"; return jsonify(error_response_data), 400
|
||||
current_app.logger.info(f"Request for structure: {package_name}#{package_version} / {resource_identifier}")
|
||||
|
||||
# Find the primary package file
|
||||
package_dir_name = 'fhir_packages'; download_dir = os.path.join(current_app.instance_path, package_dir_name)
|
||||
# Use service helper for consistency
|
||||
filename = services._construct_tgz_filename(package_name, package_version)
|
||||
tgz_path = os.path.join(download_dir, filename)
|
||||
if not os.path.exists(tgz_path): error_response_data["error"] = f"Package file not found: {filename}"; return jsonify(error_response_data), 404
|
||||
|
||||
sd_data = None; found_path = None; error_msg = None
|
||||
try:
|
||||
# Call the local helper function correctly
|
||||
sd_data, found_path = find_and_extract_sd(tgz_path, resource_identifier)
|
||||
# Fallback check
|
||||
if sd_data is None:
|
||||
core_pkg_name = "hl7.fhir.r4.core"; core_pkg_version = "4.0.1" # TODO: Make dynamic
|
||||
core_filename = services._construct_tgz_filename(core_pkg_name, core_pkg_version)
|
||||
core_tgz_path = os.path.join(download_dir, core_filename)
|
||||
if os.path.exists(core_tgz_path):
|
||||
current_app.logger.info(f"Trying fallback search in {core_pkg_name}...")
|
||||
sd_data, found_path = find_and_extract_sd(core_tgz_path, resource_identifier) # Call local helper
|
||||
else: current_app.logger.warning(f"Core package {core_tgz_path} not found.")
|
||||
except Exception as e:
|
||||
error_msg = f"Error searching package(s): {e}"; current_app.logger.error(error_msg, exc_info=True); error_response_data["error"] = error_msg; return jsonify(error_response_data), 500
|
||||
|
||||
if sd_data is None: error_msg = f"SD for '{resource_identifier}' not found."; error_response_data["error"] = error_msg; return jsonify(error_response_data), 404
|
||||
|
||||
# Extract elements
|
||||
elements = sd_data.get('snapshot', {}).get('element', [])
|
||||
if not elements: elements = sd_data.get('differential', {}).get('element', [])
|
||||
|
||||
# Fetch pre-calculated Must Support paths from DB
|
||||
must_support_paths = [];
|
||||
try:
|
||||
stmt = db.select(ProcessedIg).filter_by(package_name=package_name, package_version=package_version); processed_ig_record = db.session.scalar(stmt)
|
||||
if processed_ig_record: all_ms_paths_dict = processed_ig_record.must_support_elements; must_support_paths = all_ms_paths_dict.get(resource_identifier, [])
|
||||
else: current_app.logger.warning(f"No ProcessedIg record found for {package_name}#{package_version}")
|
||||
except Exception as e: current_app.logger.error(f"Error fetching MS paths from DB: {e}", exc_info=True)
|
||||
|
||||
current_app.logger.info(f"Returning {len(elements)} elements for {resource_identifier} from {found_path or 'Unknown File'}")
|
||||
return jsonify({"elements": elements, "must_support_paths": must_support_paths})
|
||||
|
||||
|
||||
# --- Route to get raw example file content ---
|
||||
@bp.route('/get-example')
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_example_content():
|
||||
# ... (Function remains the same as response #147) ...
|
||||
package_name = request.args.get('package_name'); package_version = request.args.get('package_version'); example_member_path = request.args.get('filename')
|
||||
if not all([package_name, package_version, example_member_path]): return jsonify({"error": "Missing query parameters"}), 400
|
||||
current_app.logger.info(f"Request for example: {package_name}#{package_version} / {example_member_path}")
|
||||
package_dir_name = 'fhir_packages'; download_dir = os.path.join(current_app.instance_path, package_dir_name)
|
||||
pkg_filename = services._construct_tgz_filename(package_name, package_version) # Use service helper
|
||||
tgz_path = os.path.join(download_dir, pkg_filename)
|
||||
if not os.path.exists(tgz_path): return jsonify({"error": f"Package file not found: {pkg_filename}"}), 404
|
||||
# Basic security check on member path
|
||||
safe_member_path = secure_filename(example_member_path.replace("package/","")) # Allow paths within package/
|
||||
if not example_member_path.startswith('package/') or '..' in example_member_path: return jsonify({"error": "Invalid example file path."}), 400
|
||||
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
try: example_member = tar.getmember(example_member_path) # Use original path here
|
||||
except KeyError: return jsonify({"error": f"Example file '{example_member_path}' not found."}), 404
|
||||
example_fileobj = tar.extractfile(example_member)
|
||||
if not example_fileobj: return jsonify({"error": "Could not extract example file."}), 500
|
||||
try: content_bytes = example_fileobj.read()
|
||||
finally: example_fileobj.close()
|
||||
return content_bytes # Return raw bytes
|
||||
except tarfile.TarError as e: err_msg = f"Error reading {tgz_path}: {e}"; current_app.logger.error(err_msg); return jsonify({"error": err_msg}), 500
|
||||
except Exception as e: err_msg = f"Unexpected error getting example {example_member_path}: {e}"; current_app.logger.error(err_msg, exc_info=True); return jsonify({"error": err_msg}), 500
|
345
services.py
Normal file
345
services.py
Normal file
@ -0,0 +1,345 @@
|
||||
# app/modules/fhir_ig_importer/services.py
|
||||
|
||||
import requests
|
||||
import os
|
||||
import tarfile
|
||||
import gzip
|
||||
import json
|
||||
import io
|
||||
import re
|
||||
import logging
|
||||
from flask import current_app
|
||||
from collections import defaultdict
|
||||
|
||||
# Constants
|
||||
FHIR_REGISTRY_BASE_URL = "https://packages.fhir.org"
|
||||
DOWNLOAD_DIR_NAME = "fhir_packages"
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def _get_download_dir():
|
||||
"""Gets the absolute path to the download directory, creating it if needed."""
|
||||
logger = logging.getLogger(__name__)
|
||||
instance_path = None # Initialize
|
||||
try:
|
||||
# --- FIX: Indent code inside try block ---
|
||||
instance_path = current_app.instance_path
|
||||
logger.debug(f"Using instance path from current_app: {instance_path}")
|
||||
except RuntimeError:
|
||||
# --- FIX: Indent code inside except block ---
|
||||
logger.warning("No app context for instance_path, constructing relative path.")
|
||||
instance_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'instance'))
|
||||
logger.debug(f"Constructed instance path: {instance_path}")
|
||||
|
||||
# This part depends on instance_path being set above
|
||||
if not instance_path:
|
||||
logger.error("Fatal Error: Could not determine instance path.")
|
||||
return None
|
||||
|
||||
download_dir = os.path.join(instance_path, DOWNLOAD_DIR_NAME)
|
||||
try:
|
||||
# --- FIX: Indent code inside try block ---
|
||||
os.makedirs(download_dir, exist_ok=True)
|
||||
return download_dir
|
||||
except OSError as e:
|
||||
# --- FIX: Indent code inside except block ---
|
||||
logger.error(f"Fatal Error creating dir {download_dir}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def sanitize_filename_part(text): # Public version
|
||||
"""Basic sanitization for name/version parts of filename."""
|
||||
# --- FIX: Indent function body ---
|
||||
safe_text = "".join(c if c.isalnum() or c in ['.', '-'] else '_' for c in text)
|
||||
safe_text = re.sub(r'_+', '_', safe_text) # Uses re
|
||||
safe_text = safe_text.strip('_-.')
|
||||
return safe_text if safe_text else "invalid_name"
|
||||
|
||||
def _construct_tgz_filename(name, version):
|
||||
"""Constructs the standard filename using the sanitized parts."""
|
||||
# --- FIX: Indent function body ---
|
||||
return f"{sanitize_filename_part(name)}-{sanitize_filename_part(version)}.tgz"
|
||||
|
||||
def find_and_extract_sd(tgz_path, resource_identifier): # Public version
|
||||
"""Helper to find and extract SD json from a given tgz path by ID, Name, or Type."""
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
sd_data = None
|
||||
found_path = None
|
||||
logger = logging.getLogger(__name__)
|
||||
if not tgz_path or not os.path.exists(tgz_path):
|
||||
logger.error(f"File not found in find_and_extract_sd: {tgz_path}")
|
||||
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)}")
|
||||
for member in tar:
|
||||
if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')):
|
||||
continue
|
||||
if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']:
|
||||
continue
|
||||
|
||||
fileobj = None
|
||||
try:
|
||||
fileobj = tar.extractfile(member)
|
||||
if fileobj:
|
||||
content_bytes = fileobj.read()
|
||||
content_string = content_bytes.decode('utf-8-sig')
|
||||
data = json.loads(content_string)
|
||||
if isinstance(data, dict) and data.get('resourceType') == 'StructureDefinition':
|
||||
sd_id = data.get('id')
|
||||
sd_name = data.get('name')
|
||||
sd_type = data.get('type')
|
||||
# Match if requested identifier matches ID, Name, or Base Type
|
||||
if resource_identifier == sd_type or resource_identifier == sd_id or resource_identifier == sd_name:
|
||||
sd_data = data
|
||||
found_path = member.name
|
||||
logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}")
|
||||
break # Stop searching once found
|
||||
except Exception as e:
|
||||
# Log issues reading/parsing individual files but continue search
|
||||
logger.warning(f"Could not read/parse potential SD {member.name}: {e}")
|
||||
finally:
|
||||
if fileobj: fileobj.close() # Ensure resource cleanup
|
||||
|
||||
if sd_data is None:
|
||||
logger.warning(f"SD matching '{resource_identifier}' not found within archive {os.path.basename(tgz_path)}")
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f"TarError reading {tgz_path} in find_and_extract_sd: {e}")
|
||||
raise tarfile.TarError(f"Error reading package archive: {e}") from e
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"FileNotFoundError reading {tgz_path} in find_and_extract_sd: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in find_and_extract_sd for {tgz_path}: {e}", exc_info=True)
|
||||
raise
|
||||
return sd_data, found_path
|
||||
|
||||
# --- Core Service Functions ---
|
||||
|
||||
def download_package(name, version):
|
||||
""" Downloads a single FHIR package. Returns (save_path, error_message) """
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
logger = logging.getLogger(__name__)
|
||||
download_dir = _get_download_dir()
|
||||
if not download_dir:
|
||||
return None, "Could not get/create download directory."
|
||||
|
||||
package_id = f"{name}#{version}"
|
||||
package_url = f"{FHIR_REGISTRY_BASE_URL}/{name}/{version}"
|
||||
filename = _construct_tgz_filename(name, version) # Uses public sanitize via helper
|
||||
save_path = os.path.join(download_dir, filename)
|
||||
|
||||
if os.path.exists(save_path):
|
||||
logger.info(f"Exists: {filename}")
|
||||
return save_path, None
|
||||
|
||||
logger.info(f"Downloading: {package_id} -> {filename}")
|
||||
try:
|
||||
with requests.get(package_url, stream=True, timeout=90) as r:
|
||||
r.raise_for_status()
|
||||
with open(save_path, 'wb') as f:
|
||||
logger.debug(f"Opened {save_path} for writing.")
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
logger.info(f"Success: Downloaded {filename}")
|
||||
return save_path, None
|
||||
except requests.exceptions.RequestException as e:
|
||||
err_msg = f"Download error for {package_id}: {e}"; logger.error(err_msg); return None, err_msg
|
||||
except OSError as e:
|
||||
err_msg = f"File save error for {filename}: {e}"; logger.error(err_msg); return None, err_msg
|
||||
except Exception as e:
|
||||
err_msg = f"Unexpected download error for {package_id}: {e}"; logger.error(err_msg, exc_info=True); return None, err_msg
|
||||
|
||||
def extract_dependencies(tgz_path):
|
||||
""" Extracts dependencies dict from package.json. Returns (dep_dict or None on error, error_message) """
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
logger = logging.getLogger(__name__)
|
||||
package_json_path = "package/package.json"
|
||||
dependencies = {}
|
||||
error_message = None
|
||||
if not tgz_path or not os.path.exists(tgz_path):
|
||||
return None, f"File not found at {tgz_path}"
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
package_json_member = tar.getmember(package_json_path)
|
||||
package_json_fileobj = tar.extractfile(package_json_member)
|
||||
if package_json_fileobj:
|
||||
try:
|
||||
package_data = json.loads(package_json_fileobj.read().decode('utf-8-sig'))
|
||||
dependencies = package_data.get('dependencies', {})
|
||||
finally:
|
||||
package_json_fileobj.close()
|
||||
else:
|
||||
raise FileNotFoundError(f"Could not extract {package_json_path}")
|
||||
except KeyError:
|
||||
error_message = f"'{package_json_path}' not found in {os.path.basename(tgz_path)}.";
|
||||
logger.warning(error_message) # OK if missing
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
error_message = f"Parse error in {package_json_path}: {e}"; logger.error(error_message); dependencies = None # Parsing failed
|
||||
except (tarfile.TarError, FileNotFoundError) as e:
|
||||
error_message = f"Archive error {os.path.basename(tgz_path)}: {e}"; logger.error(error_message); dependencies = None # Archive read failed
|
||||
except Exception as e:
|
||||
error_message = f"Unexpected error extracting deps: {e}"; logger.error(error_message, exc_info=True); dependencies = None
|
||||
return dependencies, error_message
|
||||
|
||||
|
||||
# --- Recursive Import Orchestrator ---
|
||||
def import_package_and_dependencies(initial_name, initial_version):
|
||||
"""Orchestrates recursive download and dependency extraction."""
|
||||
# --- FIX: Ensure consistent indentation ---
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Starting recursive import for {initial_name}#{initial_version}")
|
||||
results = {'requested': (initial_name, initial_version), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'errors': [] }
|
||||
pending_queue = [(initial_name, initial_version)]
|
||||
processed_lookup = set()
|
||||
|
||||
while pending_queue:
|
||||
name, version = pending_queue.pop(0)
|
||||
package_id_tuple = (name, version)
|
||||
|
||||
if package_id_tuple in processed_lookup:
|
||||
continue
|
||||
|
||||
logger.info(f"Processing: {name}#{version}")
|
||||
processed_lookup.add(package_id_tuple)
|
||||
|
||||
save_path, dl_error = download_package(name, version)
|
||||
|
||||
if dl_error:
|
||||
error_msg = f"Download failed for {name}#{version}: {dl_error}"
|
||||
results['errors'].append(error_msg)
|
||||
if package_id_tuple == results['requested']:
|
||||
logger.error("Aborting import: Initial package download failed.")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
else: # Download OK
|
||||
results['downloaded'][package_id_tuple] = save_path
|
||||
# --- Correctly indented block ---
|
||||
dependencies, dep_error = extract_dependencies(save_path)
|
||||
if dep_error:
|
||||
results['errors'].append(f"Dependency extraction failed for {name}#{version}: {dep_error}")
|
||||
elif dependencies is not None:
|
||||
results['all_dependencies'][package_id_tuple] = dependencies
|
||||
results['processed'].add(package_id_tuple)
|
||||
logger.debug(f"Dependencies for {name}#{version}: {list(dependencies.keys())}")
|
||||
for dep_name, dep_version in dependencies.items():
|
||||
if isinstance(dep_name, str) and isinstance(dep_version, str) and dep_name and dep_version:
|
||||
dep_tuple = (dep_name, dep_version)
|
||||
if dep_tuple not in processed_lookup:
|
||||
if dep_tuple not in pending_queue:
|
||||
pending_queue.append(dep_tuple)
|
||||
logger.debug(f"Added to queue: {dep_name}#{dep_version}")
|
||||
else:
|
||||
logger.warning(f"Skipping invalid dependency '{dep_name}': '{dep_version}' in {name}#{version}")
|
||||
# --- End Correctly indented block ---
|
||||
|
||||
proc_count=len(results['processed']); dl_count=len(results['downloaded']); err_count=len(results['errors'])
|
||||
logger.info(f"Import finished. Processed: {proc_count}, Downloaded/Verified: {dl_count}, Errors: {err_count}")
|
||||
return results
|
||||
|
||||
|
||||
# --- Package File Content Processor (V6.2 - Fixed MS path handling) ---
|
||||
def process_package_file(tgz_path):
|
||||
""" Extracts types, profile status, MS elements, and examples from a downloaded .tgz package (Single Pass). """
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Processing package file details (V6.2 Logic): {tgz_path}")
|
||||
|
||||
results = {'resource_types_info': [], 'must_support_elements': {}, 'examples': {}, 'errors': [] }
|
||||
resource_info = defaultdict(lambda: {'name': None, 'type': None, 'is_profile': False, 'ms_flag': False, 'ms_paths': set(), 'examples': set()})
|
||||
|
||||
if not tgz_path or not os.path.exists(tgz_path):
|
||||
results['errors'].append(f"Package file not found: {tgz_path}"); return results
|
||||
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile() or not member.name.startswith('package/') or not member.name.lower().endswith(('.json', '.xml', '.html')): continue
|
||||
member_name_lower = member.name.lower(); base_filename_lower = os.path.basename(member_name_lower); fileobj = None
|
||||
if base_filename_lower in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']: continue
|
||||
|
||||
is_example = member.name.startswith('package/example/') or 'example' in base_filename_lower
|
||||
is_json = member_name_lower.endswith('.json')
|
||||
|
||||
try: # Process individual member
|
||||
if is_json:
|
||||
fileobj = tar.extractfile(member);
|
||||
if not fileobj: continue
|
||||
content_bytes = fileobj.read(); content_string = content_bytes.decode('utf-8-sig'); data = json.loads(content_string)
|
||||
if not isinstance(data, dict) or 'resourceType' not in data: continue
|
||||
|
||||
resource_type = data['resourceType']; entry_key = resource_type; is_sd = False
|
||||
|
||||
if resource_type == 'StructureDefinition':
|
||||
is_sd = True; profile_id = data.get('id') or data.get('name'); sd_type = data.get('type'); sd_base = data.get('baseDefinition'); is_profile_sd = bool(sd_base);
|
||||
if not profile_id or not sd_type: logger.warning(f"SD missing ID or Type: {member.name}"); continue
|
||||
entry_key = profile_id
|
||||
|
||||
entry = resource_info[entry_key]; entry.setdefault('type', resource_type) # Ensure type exists
|
||||
|
||||
if is_sd:
|
||||
entry['name'] = entry_key; entry['type'] = sd_type; entry['is_profile'] = is_profile_sd;
|
||||
if not entry.get('sd_processed'):
|
||||
has_ms = False; ms_paths_for_sd = set()
|
||||
for element_list in [data.get('snapshot', {}).get('element', []), data.get('differential', {}).get('element', [])]:
|
||||
for element in element_list:
|
||||
if isinstance(element, dict) and element.get('mustSupport') is True:
|
||||
# --- FIX: Check path safely ---
|
||||
element_path = element.get('path')
|
||||
if element_path: # Only add if path exists
|
||||
ms_paths_for_sd.add(element_path)
|
||||
has_ms = True # Mark MS found if we added a path
|
||||
else:
|
||||
logger.warning(f"Found mustSupport=true without path in element of {entry_key}")
|
||||
# --- End FIX ---
|
||||
if ms_paths_for_sd: entry['ms_paths'] = ms_paths_for_sd # Store the set of paths
|
||||
if has_ms: entry['ms_flag'] = True; logger.debug(f" Found MS elements in {entry_key}") # Use boolean flag
|
||||
entry['sd_processed'] = True # Mark MS check done
|
||||
|
||||
elif is_example: # JSON Example
|
||||
key_to_use = None; profile_meta = data.get('meta', {}).get('profile', [])
|
||||
if profile_meta and isinstance(profile_meta, list):
|
||||
for profile_url in profile_meta: profile_id_from_meta = profile_url.split('/')[-1];
|
||||
if profile_id_from_meta in resource_info: key_to_use = profile_id_from_meta; break
|
||||
if not key_to_use: key_to_use = resource_type
|
||||
if key_to_use not in resource_info: resource_info[key_to_use].update({'name': key_to_use, 'type': resource_type})
|
||||
resource_info[key_to_use]['examples'].add(member.name)
|
||||
|
||||
elif is_example: # XML/HTML examples
|
||||
# ... (XML/HTML example association logic) ...
|
||||
guessed_type = base_filename_lower.split('-')[0].capitalize(); guessed_profile_id = base_filename_lower.split('-')[0]; key_to_use = None
|
||||
if guessed_profile_id in resource_info: key_to_use = guessed_profile_id
|
||||
elif guessed_type in resource_info: key_to_use = guessed_type
|
||||
if key_to_use: resource_info[key_to_use]['examples'].add(member.name)
|
||||
else: logger.warning(f"Could not associate non-JSON example {member.name}")
|
||||
|
||||
except Exception as e: logger.warning(f"Could not process member {member.name}: {e}", exc_info=False)
|
||||
finally:
|
||||
if fileobj: fileobj.close()
|
||||
# -- End Member Loop --
|
||||
|
||||
# --- Final formatting moved INSIDE the main try block ---
|
||||
final_list = []; final_ms_elements = {}; final_examples = {}
|
||||
logger.debug(f"Formatting results from resource_info keys: {list(resource_info.keys())}")
|
||||
for key, info in resource_info.items():
|
||||
display_name = info.get('name') or key; base_type = info.get('type')
|
||||
if display_name or base_type:
|
||||
logger.debug(f" Formatting item '{display_name}': type='{base_type}', profile='{info.get('is_profile', False)}', ms_flag='{info.get('ms_flag', False)}'")
|
||||
final_list.append({'name': display_name, 'type': base_type, 'is_profile': info.get('is_profile', False), 'must_support': info.get('ms_flag', False)}) # Ensure 'must_support' key uses 'ms_flag'
|
||||
if info['ms_paths']: final_ms_elements[display_name] = sorted(list(info['ms_paths']))
|
||||
if info['examples']: final_examples[display_name] = sorted(list(info['examples']))
|
||||
else: logger.warning(f"Skipping formatting for key: {key}")
|
||||
|
||||
results['resource_types_info'] = sorted(final_list, key=lambda x: (not x.get('is_profile', False), x.get('name', '')))
|
||||
results['must_support_elements'] = final_ms_elements
|
||||
results['examples'] = final_examples
|
||||
# --- End formatting moved inside ---
|
||||
|
||||
except Exception as e:
|
||||
err_msg = f"Error processing package file {tgz_path}: {e}"; logger.error(err_msg, exc_info=True); results['errors'].append(err_msg)
|
||||
|
||||
# Logging counts
|
||||
final_types_count = len(results['resource_types_info']); ms_count = sum(1 for r in results['resource_types_info'] if r['must_support']); total_ms_paths = sum(len(v) for v in results['must_support_elements'].values()); total_examples = sum(len(v) for v in results['examples'].values())
|
||||
logger.info(f"V6.2 Extraction: {final_types_count} items ({ms_count} MS; {total_ms_paths} MS paths; {total_examples} examples) from {os.path.basename(tgz_path)}")
|
||||
|
||||
return results
|
135
templates/cp_downloaded_igs - Copy.html
Normal file
135
templates/cp_downloaded_igs - Copy.html
Normal file
@ -0,0 +1,135 @@
|
||||
{# app/control_panel/templates/cp_downloaded_igs.html #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-journal-arrow-down me-2"></i>Manage FHIR Packages</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('fhir_ig_importer.import_ig') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a>
|
||||
<a href="{{ url_for('control_panel.index') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to CP Index</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h5 class="alert-heading">Error</h5>
|
||||
{{ error_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# NOTE: The block calculating processed_ids set using {% set %} was REMOVED from here #}
|
||||
|
||||
{# --- Start Two Column Layout --- #}
|
||||
<div class="row g-4">
|
||||
|
||||
{# --- Left Column: Downloaded Packages (Horizontal Buttons) --- #}
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-folder-symlink me-1"></i> Downloaded Packages ({{ packages|length }})</div>
|
||||
<div class="card-body">
|
||||
{% if packages %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<p class="mb-2"><small><span class="badge bg-danger text-dark border me-1">Risk:</span>= Duplicate Dependancy with different versions</small></p>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pkg in packages %}
|
||||
{% set is_processed = (pkg.name, pkg.version) in processed_ids %}
|
||||
{# --- ADDED: Check for duplicate name --- #}
|
||||
{% set is_duplicate = pkg.name in duplicate_names %}
|
||||
{# --- ADDED: Assign row class based on duplicate group --- #}
|
||||
<tr class="{{ duplicate_groups.get(pkg.name, '') if is_duplicate else '' }}">
|
||||
<td>
|
||||
{# --- ADDED: Risk Badge for duplicates --- #}
|
||||
{% if is_duplicate %}
|
||||
<span class="badge bg-danger mb-1 d-block">Duplicate</span>
|
||||
{% endif %}
|
||||
{# --- End Add --- #}
|
||||
<code>{{ pkg.name }}</code>
|
||||
</td>
|
||||
<td>{{ pkg.version }}</td>
|
||||
<td> {# Actions #}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if is_processed %}
|
||||
<span class="btn btn-success disabled"><i class="bi bi-check-lg"></i> Processed</span>
|
||||
{% else %}
|
||||
<form action="{{ url_for('control_panel.process_ig') }}" method="POST" style="display: inline-block;">
|
||||
<input type="hidden" name="package_name" value="{{ pkg.name }}">
|
||||
<input type="hidden" name="package_version" value="{{ pkg.version }}">
|
||||
<button type="submit" class="btn btn-outline-primary" title="Mark as processed"><i class="bi bi-gear"></i> Process</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('control_panel.delete_ig_file') }}" method="POST" style="display: inline-block;" onsubmit="return confirm('Delete file \'{{ pkg.filename }}\'?');">
|
||||
<input type="hidden" name="filename" value="{{ pkg.filename }}">
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete File"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif not error_message %}<p class="text-muted">No downloaded FHIR packages found.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>{# --- End Left Column --- #}
|
||||
|
||||
|
||||
{# --- Right Column: Processed Packages (Vertical Buttons) --- #}
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-check-circle me-1"></i> Processed Packages ({{ processed_list|length }})</div>
|
||||
<div class="card-body">
|
||||
{% if processed_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Resource Types</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for processed_ig in processed_list %}
|
||||
<tr>
|
||||
<td>{# Tooltip for Processed At / Status #}
|
||||
{% set tooltip_title_parts = [] %}
|
||||
{% if processed_ig.processed_at %}{% set _ = tooltip_title_parts.append("Processed: " + processed_ig.processed_at.strftime('%Y-%m-%d %H:%M')) %}{% endif %}
|
||||
{% if processed_ig.status %}{% set _ = tooltip_title_parts.append("Status: " + processed_ig.status) %}{% endif %}
|
||||
{% set tooltip_text = tooltip_title_parts | join('\n') %}
|
||||
<code data-bs-toggle="tooltip" data-bs-placement="top" title="{{ tooltip_text }}">{{ processed_ig.package_name }}</code>
|
||||
</td>
|
||||
<td>{{ processed_ig.package_version }}</td>
|
||||
<td> {# Resource Types Cell w/ Badges #}
|
||||
{% set types_info = processed_ig.resource_types_info %}
|
||||
{% if types_info %}<div class="d-flex flex-wrap gap-1">{% for type_info in types_info %}{% if type_info.must_support %}<span class="badge bg-warning text-dark border" title="Has Must Support">{{ type_info.name }}</span>{% else %}<span class="badge bg-light text-dark border">{{ type_info.name }}</span>{% endif %}{% endfor %}</div>{% else %}<small class="text-muted">N/A</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{# Vertical Button Group #}
|
||||
<div class="btn-group-vertical btn-group-sm w-100" role="group" aria-label="Processed Package Actions for {{ processed_ig.package_name }}">
|
||||
<a href="{{ url_for('control_panel.view_processed_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 action="{{ url_for('control_panel.unload_ig') }}" method="POST" onsubmit="return confirm('Unload record for \'{{ processed_ig.package_name }}#{{ processed_ig.package_version }}\'?');">
|
||||
<input type="hidden" name="processed_ig_id" value="{{ processed_ig.id }}">
|
||||
<button type="submit" class="btn btn-outline-warning w-100" title="Unload/Remove Processed Record"><i class="bi bi-x-lg"></i> Unload</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif not error_message %}<p class="text-muted">No packages recorded as processed yet.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>{# --- End Right Column --- #}
|
||||
|
||||
</div>{# --- End Row --- #}
|
||||
|
||||
</div>{# End container #}
|
||||
{% endblock %}
|
||||
|
||||
{# Tooltip JS Initializer should be in base.html #}
|
||||
{% block scripts %}{{ super() }}{% endblock %}
|
150
templates/cp_downloaded_igs.html
Normal file
150
templates/cp_downloaded_igs.html
Normal file
@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Manage FHIR Packages</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-journal-arrow-down me-2"></i>Manage FHIR Packages</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('import_ig') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'success' if category == 'success' else 'danger' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-folder-symlink me-1"></i> Downloaded Packages ({{ packages|length }})</div>
|
||||
<div class="card-body">
|
||||
{% if packages %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set colors = ['bg-warning', 'bg-info', 'bg-success', 'bg-danger'] %}
|
||||
{% set group_idx = {} %}
|
||||
{% for name in duplicate_groups %}
|
||||
{% if duplicate_groups[name]|length > 1 %}
|
||||
{% set group_idx[name] = loop.index0 % colors|length %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for pkg in packages %}
|
||||
{% set is_processed = (pkg.name, pkg.version) in processed_ids %}
|
||||
{% set is_duplicate = pkg.name in duplicate_names and duplicate_names[pkg.name]|length > 1 %}
|
||||
{% set group_color = colors[group_idx[pkg.name]] if is_duplicate and duplicate_groups|length > 1 else 'bg-warning' %}
|
||||
<tr {% if is_duplicate %}class="{{ group_color }} text-dark"{% endif %}>
|
||||
<td>
|
||||
<code>{{ pkg.name }}</code>
|
||||
{% if is_duplicate %}
|
||||
<span class="badge {{ group_color }} text-dark ms-1">Duplicate</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ pkg.version }}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if is_processed %}
|
||||
<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;">
|
||||
<input type="hidden" name="filename" value="{{ pkg.filename }}">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-gear"></i> Process</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('delete_ig') }}" style="display:inline;">
|
||||
<input type="hidden" name="filename" value="{{ pkg.filename }}">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete {{ pkg.name }}#{{ pkg.version }}?');"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if duplicate_groups %}
|
||||
<p class="mt-2 small text-muted">Duplicates detected:
|
||||
{% for name, versions in duplicate_groups.items() %}
|
||||
{% if versions|length > 1 %}
|
||||
<span class="badge {{ colors[group_idx[name] if duplicate_groups|length > 1 else 0] }} text-dark">{{ name }} ({{ versions|join(', ') }})</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted">No downloaded FHIR packages found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-check-circle me-1"></i> Processed Packages ({{ processed_list|length }})</div>
|
||||
<div class="card-body">
|
||||
{% if processed_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Resource Types</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for processed_ig in processed_list %}
|
||||
<tr>
|
||||
<td><code>{{ processed_ig.package_name }}</code></td>
|
||||
<td>{{ processed_ig.version }}</td>
|
||||
<td>
|
||||
{% set types_info = processed_ig.resource_types_info %}
|
||||
{% if types_info %}<div class="d-flex flex-wrap gap-1">
|
||||
{% for type_info in types_info %}
|
||||
{% if type_info.must_support %}
|
||||
<span class="badge bg-warning text-dark border" title="Has Must Support">{{ type_info.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>{% else %}<small class="text-muted">N/A</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<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;">
|
||||
<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-arrow-down-circle"></i> Unload</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No packages recorded as processed yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
405
templates/cp_view_processed_ig - Copy.html
Normal file
405
templates/cp_view_processed_ig - Copy.html
Normal file
@ -0,0 +1,405 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="bi bi-info-circle me-2"></i>{{ title }}</h2>
|
||||
<a href="{{ url_for('control_panel.list_downloaded_igs') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to Package List</a>
|
||||
</div>
|
||||
|
||||
{% if processed_ig %}
|
||||
<div class="card">
|
||||
<div class="card-header">Package Details</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Package Name</dt>
|
||||
<dd class="col-sm-9"><code>{{ processed_ig.package_name }}</code></dd>
|
||||
<dt class="col-sm-3">Package Version</dt>
|
||||
<dd class="col-sm-9">{{ processed_ig.package_version }}</dd>
|
||||
<dt class="col-sm-3">Processed At</dt>
|
||||
<dd class="col-sm-9">{{ processed_ig.processed_at.strftime('%Y-%m-%d %H:%M:%S UTC') if processed_ig.processed_at else 'N/A' }}</dd>
|
||||
<dt class="col-sm-3">Processing Status</dt>
|
||||
<dd class="col-sm-9">
|
||||
<span class="badge rounded-pill text-bg-{{ 'success' if processed_ig.status == 'processed' else ('warning' if processed_ig.status == 'processed_with_errors' else 'secondary') }}">{{ processed_ig.status or 'N/A' }}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">Resource Types Found / Defined </div>
|
||||
<div class="card-body">
|
||||
{% if profile_list or base_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
|
||||
{% if profile_list %}
|
||||
<h6>Profiles Defined ({{ profile_list|length }}):</h6>
|
||||
<div class="d-flex flex-wrap gap-1 mb-3 resource-type-list">
|
||||
{% for type_info in profile_list %}
|
||||
<a href="#" class="resource-type-link text-decoration-none"
|
||||
data-package-name="{{ processed_ig.package_name }}"
|
||||
data-package-version="{{ processed_ig.package_version }}"
|
||||
data-resource-type="{{ type_info.name }}"
|
||||
aria-label="View structure for {{ type_info.name }}">
|
||||
{% if type_info.must_support %}
|
||||
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if base_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-primary text-light border me-1">Examples</span> = - Examples will be displayed when selecting Base Types if contained in the IG</small></p>
|
||||
<h6>Base Resource Types Referenced ({{ base_list|length }}):</h6>
|
||||
<div class="d-flex flex-wrap gap-1 resource-type-list">
|
||||
{% for type_info in base_list %}
|
||||
<a href="#" class="resource-type-link text-decoration-none"
|
||||
data-package-name="{{ processed_ig.package_name }}"
|
||||
data-package-version="{{ processed_ig.package_version }}"
|
||||
data-resource-type="{{ type_info.name }}"
|
||||
aria-label="View structure for {{ type_info.name }}">
|
||||
{% if type_info.must_support %}
|
||||
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted"><em>No resource type information extracted or stored.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="structure-display-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Structure Definition for: <code id="structure-title"></code></span>
|
||||
<button type="button" class="btn-close" aria-label="Close Details View" onclick="document.getElementById('structure-display-wrapper').style.display='none'; document.getElementById('example-display-wrapper').style.display='none'; document.getElementById('raw-structure-wrapper').style.display='none';"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="structure-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<div id="structure-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="raw-structure-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Raw Structure Definition for <code id="raw-structure-title"></code></div>
|
||||
<div class="card-body">
|
||||
<div id="raw-structure-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<pre><code id="raw-structure-content" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="example-display-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Examples for <code id="example-resource-type-title"></code></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3" id="example-selector-wrapper" style="display: none;">
|
||||
<label for="example-select" class="form-label">Select Example:</label>
|
||||
<select class="form-select form-select-sm" id="example-select"><option selected value="">-- Select --</option></select>
|
||||
</div>
|
||||
<div id="example-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<div id="example-content-wrapper" style="display: none;">
|
||||
<h6 id="example-filename" class="mt-2 small text-muted"></h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Raw Content</h6>
|
||||
<pre><code id="example-content-raw" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Pretty-Printed JSON</h6>
|
||||
<pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-warning" role="alert">Processed IG details not available.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const structureDisplayWrapper = document.getElementById('structure-display-wrapper');
|
||||
const structureDisplay = document.getElementById('structure-content');
|
||||
const structureTitle = document.getElementById('structure-title');
|
||||
const structureLoading = document.getElementById('structure-loading');
|
||||
const exampleDisplayWrapper = document.getElementById('example-display-wrapper');
|
||||
const exampleSelectorWrapper = document.getElementById('example-selector-wrapper');
|
||||
const exampleSelect = document.getElementById('example-select');
|
||||
const exampleResourceTypeTitle = document.getElementById('example-resource-type-title');
|
||||
const exampleLoading = document.getElementById('example-loading');
|
||||
const exampleContentWrapper = document.getElementById('example-content-wrapper');
|
||||
const exampleFilename = document.getElementById('example-filename');
|
||||
const exampleContentRaw = document.getElementById('example-content-raw');
|
||||
const exampleContentJson = document.getElementById('example-content-json');
|
||||
const rawStructureWrapper = document.getElementById('raw-structure-wrapper');
|
||||
const rawStructureTitle = document.getElementById('raw-structure-title');
|
||||
const rawStructureContent = document.getElementById('raw-structure-content');
|
||||
const rawStructureLoading = document.getElementById('raw-structure-loading');
|
||||
|
||||
const structureBaseUrl = "/control-panel/fhir/get-structure";
|
||||
const exampleBaseUrl = "/control-panel/fhir/get-example";
|
||||
|
||||
let currentPkgName = null;
|
||||
let currentPkgVersion = null;
|
||||
let currentRawStructureData = null;
|
||||
|
||||
document.body.addEventListener('click', function(event) {
|
||||
const link = event.target.closest('.resource-type-link');
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
|
||||
currentPkgName = link.dataset.packageName;
|
||||
currentPkgVersion = link.dataset.packageVersion;
|
||||
const resourceType = link.dataset.resourceType;
|
||||
if (!currentPkgName || !currentPkgVersion || !resourceType) return;
|
||||
|
||||
const structureParams = new URLSearchParams({
|
||||
package_name: currentPkgName,
|
||||
package_version: currentPkgVersion,
|
||||
resource_type: resourceType
|
||||
});
|
||||
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
|
||||
|
||||
structureTitle.textContent = `${resourceType} (${currentPkgName}#${currentPkgVersion})`;
|
||||
rawStructureTitle.textContent = `${resourceType} (${currentPkgName}#${currentPkgVersion})`;
|
||||
structureDisplay.innerHTML = '';
|
||||
rawStructureContent.textContent = '';
|
||||
structureLoading.style.display = 'block';
|
||||
rawStructureLoading.style.display = 'block';
|
||||
structureDisplayWrapper.style.display = 'block';
|
||||
rawStructureWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
|
||||
fetch(structureFetchUrl)
|
||||
.then(response => response.json().then(data => ({ ok: response.ok, status: response.status, data })))
|
||||
.then(result => {
|
||||
if (!result.ok) throw new Error(result.data.error || `HTTP error ${result.status}`);
|
||||
currentRawStructureData = result.data;
|
||||
renderStructureTree(result.data.elements, result.data.must_support_paths || []);
|
||||
rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
|
||||
populateExampleSelector(resourceType);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching structure:', error);
|
||||
structureDisplay.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
|
||||
rawStructureContent.textContent = `Error: ${error.message}`;
|
||||
})
|
||||
.finally(() => {
|
||||
structureLoading.style.display = 'none';
|
||||
rawStructureLoading.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
function populateExampleSelector(resourceOrProfileIdentifier) {
|
||||
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
|
||||
const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
|
||||
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
|
||||
exampleContentWrapper.style.display = 'none';
|
||||
exampleFilename.textContent = '';
|
||||
exampleContentRaw.textContent = '';
|
||||
exampleContentJson.textContent = '';
|
||||
if (availableExamples.length > 0) {
|
||||
availableExamples.forEach(filePath => {
|
||||
const filename = filePath.split('/').pop();
|
||||
const option = document.createElement('option');
|
||||
option.value = filePath;
|
||||
option.textContent = filename;
|
||||
exampleSelect.appendChild(option);
|
||||
});
|
||||
exampleSelectorWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'block';
|
||||
} else {
|
||||
exampleSelectorWrapper.style.display = 'none';
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if(exampleSelect) {
|
||||
exampleSelect.addEventListener('change', function(event) {
|
||||
const selectedFilePath = this.value;
|
||||
exampleContentRaw.textContent = '';
|
||||
exampleContentJson.textContent = '';
|
||||
exampleFilename.textContent = '';
|
||||
exampleContentWrapper.style.display = 'none';
|
||||
if (!selectedFilePath || !currentPkgName || !currentPkgVersion) return;
|
||||
|
||||
const exampleParams = new URLSearchParams({
|
||||
package_name: currentPkgName,
|
||||
package_version: currentPkgVersion,
|
||||
filename: selectedFilePath
|
||||
});
|
||||
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
|
||||
console.log("Fetching example from:", exampleFetchUrl);
|
||||
exampleLoading.style.display = 'block';
|
||||
fetch(exampleFetchUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(content => {
|
||||
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
|
||||
exampleContentRaw.textContent = content;
|
||||
try {
|
||||
const jsonContent = JSON.parse(content);
|
||||
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
|
||||
} catch (e) {
|
||||
exampleContentJson.textContent = 'Not valid JSON';
|
||||
}
|
||||
exampleContentWrapper.style.display = 'block';
|
||||
})
|
||||
.catch(error => {
|
||||
exampleFilename.textContent = 'Error';
|
||||
exampleContentRaw.textContent = `Error: ${error.message}`;
|
||||
exampleContentJson.textContent = `Error: ${error.message}`;
|
||||
exampleContentWrapper.style.display = 'block';
|
||||
})
|
||||
.finally(() => {
|
||||
exampleLoading.style.display = 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildTreeData(elements) {
|
||||
const treeRoot = { children: {}, element: null, name: 'Root' };
|
||||
const nodeMap = {'Root': treeRoot};
|
||||
elements.forEach(el => {
|
||||
const path = el.path;
|
||||
if (!path) return;
|
||||
const parts = path.split('.');
|
||||
let currentPath = '';
|
||||
let parentNode = treeRoot;
|
||||
for(let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const currentPartName = part.includes('[') ? part.substring(0, part.indexOf('[')) : part;
|
||||
currentPath = i === 0 ? currentPartName : `${currentPath}.${part}`;
|
||||
if (!nodeMap[currentPath]) {
|
||||
const newNode = { children: {}, element: null, name: part, path: currentPath };
|
||||
parentNode.children[part] = newNode;
|
||||
nodeMap[currentPath] = newNode;
|
||||
}
|
||||
if (i === parts.length - 1) {
|
||||
nodeMap[currentPath].element = el;
|
||||
}
|
||||
parentNode = nodeMap[currentPath];
|
||||
}
|
||||
});
|
||||
return Object.values(treeRoot.children);
|
||||
}
|
||||
|
||||
function renderNodeAsLi(node, mustSupportPathsSet, level = 0) {
|
||||
if (!node || !node.element) return '';
|
||||
const el = node.element;
|
||||
const path = el.path || 'N/A';
|
||||
const min = el.min !== undefined ? el.min : '';
|
||||
const max = el.max || '';
|
||||
const short = el.short || '';
|
||||
const definition = el.definition || '';
|
||||
const isMustSupport = mustSupportPathsSet.has(path);
|
||||
const mustSupportDisplay = isMustSupport ? '<i class="bi bi-check-circle-fill text-warning"></i>' : '';
|
||||
const hasChildren = Object.keys(node.children).length > 0;
|
||||
const collapseId = `collapse-${path.replace(/[\.\:]/g, '-')}`;
|
||||
const padding = level * 20;
|
||||
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
|
||||
let typeString = 'N/A';
|
||||
if (el.type && el.type.length > 0) {
|
||||
typeString = el.type.map(t => {
|
||||
let s = t.code || '';
|
||||
if (t.targetProfile && t.targetProfile.length > 0) {
|
||||
const targetTypes = t.targetProfile.map(p => p.split('/').pop());
|
||||
s += `(<span class="text-muted">${targetTypes.join('|')}</span>)`;
|
||||
}
|
||||
return s;
|
||||
}).join(' | ');
|
||||
}
|
||||
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
|
||||
let childrenHtml = '';
|
||||
if (hasChildren) {
|
||||
childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`;
|
||||
Object.values(node.children).sort((a,b) => a.name.localeCompare(b.name)).forEach(childNode => {
|
||||
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, level + 1);
|
||||
});
|
||||
childrenHtml += `</ul>`;
|
||||
}
|
||||
let itemHtml = `<li class="${liClass}">`;
|
||||
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-bs-toggle="collapse" href="#${collapseId}" role="button" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
|
||||
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}"><span style="display: inline-block; width: 1.2em; text-align: center;">`;
|
||||
if (hasChildren) {
|
||||
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
|
||||
}
|
||||
itemHtml += `</span><code class="fw-bold ms-1">${node.name}</code></div>`;
|
||||
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
|
||||
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
|
||||
itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
|
||||
let descriptionTooltipAttrs = '';
|
||||
if (definition) {
|
||||
const escapedDefinition = definition.replace(/"/g, '"').replace(/'/g, ''');
|
||||
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
|
||||
}
|
||||
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short}</div>`;
|
||||
itemHtml += `</div>`;
|
||||
itemHtml += childrenHtml;
|
||||
itemHtml += `</li>`;
|
||||
return itemHtml;
|
||||
}
|
||||
|
||||
function renderStructureTree(elements, mustSupportPaths) {
|
||||
if (!elements || elements.length === 0) {
|
||||
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found.</em></p>';
|
||||
return;
|
||||
}
|
||||
const mustSupportPathsSet = new Set(mustSupportPaths || []);
|
||||
console.log("Rendering tree. MS Set:", mustSupportPathsSet);
|
||||
const treeData = buildTreeData(elements);
|
||||
let html = '<p><small><span class="badge bg-warning text-dark border me-1">MS</span> = Must Support (Row Highlighted)</small></p>';
|
||||
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">MS</div><div class="col-lg-3 col-md-4">Description</div></div>`;
|
||||
html += '<ul class="list-group list-group-flush">';
|
||||
treeData.sort((a,b) => a.name.localeCompare(b.name)).forEach(rootNode => {
|
||||
html += renderNodeAsLi(rootNode, mustSupportPathsSet, 0);
|
||||
});
|
||||
html += '</ul>';
|
||||
structureDisplay.innerHTML = html;
|
||||
var tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
||||
new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
structureDisplay.querySelectorAll('.collapse').forEach(collapseEl => {
|
||||
collapseEl.addEventListener('show.bs.collapse', event => {
|
||||
event.target.previousElementSibling.querySelector('.toggle-icon')?.classList.replace('bi-chevron-right', 'bi-chevron-down');
|
||||
});
|
||||
collapseEl.addEventListener('hide.bs.collapse', event => {
|
||||
event.target.previousElementSibling.querySelector('.toggle-icon')?.classList.replace('bi-chevron-down', 'bi-chevron-right');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) });
|
||||
});
|
||||
</script>
|
||||
{% endblock scripts %}
|
||||
{% endblock content %} {# Main content block END #}
|
386
templates/cp_view_processed_ig.html
Normal file
386
templates/cp_view_processed_ig.html
Normal file
@ -0,0 +1,386 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ title }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="bi bi-info-circle me-2"></i>{{ title }}</h2>
|
||||
<a href="{{ url_for('view_igs') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to Package List</a>
|
||||
</div>
|
||||
|
||||
{% if processed_ig %}
|
||||
<div class="card">
|
||||
<div class="card-header">Package Details</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Package Name</dt>
|
||||
<dd class="col-sm-9"><code>{{ processed_ig.package_name }}</code></dd>
|
||||
<dt class="col-sm-3">Package Version</dt>
|
||||
<dd class="col-sm-9">{{ processed_ig.version }}</dd>
|
||||
<dt class="col-sm-3">Processed At</dt>
|
||||
<dd class="col-sm-9">{{ processed_ig.processed_date.strftime('%Y-%m-%d %H:%M:%S UTC') }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">Resource Types Found / Defined</div>
|
||||
<div class="card-body">
|
||||
{% if profile_list or base_list %}
|
||||
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
|
||||
{% if profile_list %}
|
||||
<h6>Profiles Defined ({{ profile_list|length }}):</h6>
|
||||
<div class="d-flex flex-wrap gap-1 mb-3 resource-type-list">
|
||||
{% for type_info in profile_list %}
|
||||
<a href="#" class="resource-type-link text-decoration-none"
|
||||
data-package-name="{{ processed_ig.package_name }}"
|
||||
data-package-version="{{ processed_ig.version }}"
|
||||
data-resource-type="{{ type_info.name }}"
|
||||
aria-label="View structure for {{ type_info.name }}">
|
||||
{% if type_info.must_support %}
|
||||
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if base_list %}
|
||||
<h6>Base Resource Types Referenced ({{ base_list|length }}):</h6>
|
||||
<div class="d-flex flex-wrap gap-1 resource-type-list">
|
||||
{% for type_info in base_list %}
|
||||
<a href="#" class="resource-type-link text-decoration-none"
|
||||
data-package-name="{{ processed_ig.package_name }}"
|
||||
data-package-version="{{ processed_ig.version }}"
|
||||
data-resource-type="{{ type_info.name }}"
|
||||
aria-label="View structure for {{ type_info.name }}">
|
||||
{% if type_info.must_support %}
|
||||
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted"><em>No resource type information extracted or stored.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="structure-display-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Structure Definition for: <code id="structure-title"></code></span>
|
||||
<button type="button" class="btn-close" aria-label="Close" onclick="document.getElementById('structure-display-wrapper').style.display='none'; document.getElementById('example-display-wrapper').style.display='none'; document.getElementById('raw-structure-wrapper').style.display='none';"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="structure-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<div id="structure-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="raw-structure-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Raw Structure Definition for <code id="raw-structure-title"></code></div>
|
||||
<div class="card-body">
|
||||
<div id="raw-structure-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<pre><code id="raw-structure-content" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="example-display-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Examples for <code id="example-resource-type-title"></code></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3" id="example-selector-wrapper" style="display: none;">
|
||||
<label for="example-select" class="form-label">Select Example:</label>
|
||||
<select class="form-select form-select-sm" id="example-select"><option selected value="">-- Select --</option></select>
|
||||
</div>
|
||||
<div id="example-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<div id="example-content-wrapper" style="display: none;">
|
||||
<h6 id="example-filename" class="mt-2 small text-muted"></h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Raw Content</h6>
|
||||
<pre><code id="example-content-raw" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Pretty-Printed JSON</h6>
|
||||
<pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning" role="alert">Processed IG details not available.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const structureDisplayWrapper = document.getElementById('structure-display-wrapper');
|
||||
const structureDisplay = document.getElementById('structure-content');
|
||||
const structureTitle = document.getElementById('structure-title');
|
||||
const structureLoading = document.getElementById('structure-loading');
|
||||
const exampleDisplayWrapper = document.getElementById('example-display-wrapper');
|
||||
const exampleSelectorWrapper = document.getElementById('example-selector-wrapper');
|
||||
const exampleSelect = document.getElementById('example-select');
|
||||
const exampleResourceTypeTitle = document.getElementById('example-resource-type-title');
|
||||
const exampleLoading = document.getElementById('example-loading');
|
||||
const exampleContentWrapper = document.getElementById('example-content-wrapper');
|
||||
const exampleFilename = document.getElementById('example-filename');
|
||||
const exampleContentRaw = document.getElementById('example-content-raw');
|
||||
const exampleContentJson = document.getElementById('example-content-json');
|
||||
const rawStructureWrapper = document.getElementById('raw-structure-wrapper');
|
||||
const rawStructureTitle = document.getElementById('raw-structure-title');
|
||||
const rawStructureContent = document.getElementById('raw-structure-content');
|
||||
const rawStructureLoading = document.getElementById('raw-structure-loading');
|
||||
|
||||
const structureBaseUrl = "{{ url_for('get_structure_definition') }}";
|
||||
const exampleBaseUrl = "{{ url_for('get_example_content') }}";
|
||||
|
||||
document.body.addEventListener('click', function(event) {
|
||||
const link = event.target.closest('.resource-type-link');
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
|
||||
const pkgName = link.dataset.packageName;
|
||||
const pkgVersion = link.dataset.packageVersion;
|
||||
const resourceType = link.dataset.resourceType;
|
||||
if (!pkgName || !pkgVersion || !resourceType) return;
|
||||
|
||||
const structureParams = new URLSearchParams({ package_name: pkgName, package_version: pkgVersion, resource_type: resourceType });
|
||||
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
|
||||
|
||||
structureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
|
||||
rawStructureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
|
||||
structureDisplay.innerHTML = '';
|
||||
rawStructureContent.textContent = '';
|
||||
structureLoading.style.display = 'block';
|
||||
rawStructureLoading.style.display = 'block';
|
||||
structureDisplayWrapper.style.display = 'block';
|
||||
rawStructureWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
|
||||
fetch(structureFetchUrl)
|
||||
.then(response => response.json().then(data => ({ ok: response.ok, status: response.status, data })))
|
||||
.then(result => {
|
||||
if (!result.ok) throw new Error(result.data.error || `HTTP error ${result.status}`);
|
||||
renderStructureTree(result.data.elements, result.data.must_support_paths || []);
|
||||
rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
|
||||
populateExampleSelector(resourceType);
|
||||
})
|
||||
.catch(error => {
|
||||
structureDisplay.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
|
||||
rawStructureContent.textContent = `Error: ${error.message}`;
|
||||
})
|
||||
.finally(() => {
|
||||
structureLoading.style.display = 'none';
|
||||
rawStructureLoading.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
function populateExampleSelector(resourceOrProfileIdentifier) {
|
||||
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
|
||||
const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
|
||||
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
|
||||
exampleContentWrapper.style.display = 'none';
|
||||
exampleFilename.textContent = '';
|
||||
exampleContentRaw.textContent = '';
|
||||
exampleContentJson.textContent = '';
|
||||
if (availableExamples.length > 0) {
|
||||
availableExamples.forEach(filePath => {
|
||||
const filename = filePath.split('/').pop();
|
||||
const option = document.createElement('option');
|
||||
option.value = filePath;
|
||||
option.textContent = filename;
|
||||
exampleSelect.appendChild(option);
|
||||
});
|
||||
exampleSelectorWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'block';
|
||||
} else {
|
||||
exampleSelectorWrapper.style.display = 'none';
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if(exampleSelect) {
|
||||
exampleSelect.addEventListener('change', function(event) {
|
||||
const selectedFilePath = this.value;
|
||||
exampleContentRaw.textContent = '';
|
||||
exampleContentJson.textContent = '';
|
||||
exampleFilename.textContent = '';
|
||||
exampleContentWrapper.style.display = 'none';
|
||||
if (!selectedFilePath) return;
|
||||
|
||||
const exampleParams = new URLSearchParams({
|
||||
package_name: "{{ processed_ig.package_name }}",
|
||||
package_version: "{{ processed_ig.version }}",
|
||||
filename: selectedFilePath
|
||||
});
|
||||
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
|
||||
exampleLoading.style.display = 'block';
|
||||
fetch(exampleFetchUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(content => {
|
||||
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
|
||||
exampleContentRaw.textContent = content;
|
||||
try {
|
||||
const jsonContent = JSON.parse(content);
|
||||
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
|
||||
} catch (e) {
|
||||
exampleContentJson.textContent = 'Not valid JSON';
|
||||
}
|
||||
exampleContentWrapper.style.display = 'block';
|
||||
})
|
||||
.catch(error => {
|
||||
exampleFilename.textContent = 'Error';
|
||||
exampleContentRaw.textContent = `Error: ${error.message}`;
|
||||
exampleContentJson.textContent = `Error: ${error.message}`;
|
||||
exampleContentWrapper.style.display = 'block';
|
||||
})
|
||||
.finally(() => {
|
||||
exampleLoading.style.display = 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildTreeData(elements) {
|
||||
const treeRoot = { children: {}, element: null, name: 'Root' };
|
||||
const nodeMap = {'Root': treeRoot};
|
||||
elements.forEach(el => {
|
||||
const path = el.path;
|
||||
if (!path) return;
|
||||
const parts = path.split('.');
|
||||
let currentPath = '';
|
||||
let parentNode = treeRoot;
|
||||
for(let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const currentPartName = part.includes('[') ? part.substring(0, part.indexOf('[')) : part;
|
||||
currentPath = i === 0 ? currentPartName : `${currentPath}.${part}`;
|
||||
if (!nodeMap[currentPath]) {
|
||||
const newNode = { children: {}, element: null, name: part, path: currentPath };
|
||||
parentNode.children[part] = newNode;
|
||||
nodeMap[currentPath] = newNode;
|
||||
}
|
||||
if (i === parts.length - 1) {
|
||||
nodeMap[currentPath].element = el;
|
||||
}
|
||||
parentNode = nodeMap[currentPath];
|
||||
}
|
||||
});
|
||||
return Object.values(treeRoot.children);
|
||||
}
|
||||
|
||||
function renderNodeAsLi(node, mustSupportPathsSet, level = 0) {
|
||||
if (!node || !node.element) return '';
|
||||
const el = node.element;
|
||||
const path = el.path || 'N/A';
|
||||
const min = el.min !== undefined ? el.min : '';
|
||||
const max = el.max || '';
|
||||
const short = el.short || '';
|
||||
const definition = el.definition || '';
|
||||
const isMustSupport = mustSupportPathsSet.has(path);
|
||||
const mustSupportDisplay = isMustSupport ? '<i class="bi bi-check-circle-fill text-warning"></i>' : '';
|
||||
const hasChildren = Object.keys(node.children).length > 0;
|
||||
const collapseId = `collapse-${path.replace(/[\.\:]/g, '-')}`;
|
||||
const padding = level * 20;
|
||||
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
|
||||
let typeString = 'N/A';
|
||||
if (el.type && el.type.length > 0) {
|
||||
typeString = el.type.map(t => {
|
||||
let s = t.code || '';
|
||||
if (t.targetProfile && t.targetProfile.length > 0) {
|
||||
const targetTypes = t.targetProfile.map(p => p.split('/').pop());
|
||||
s += `(<span class="text-muted">${targetTypes.join('|')}</span>)`;
|
||||
}
|
||||
return s;
|
||||
}).join(' | ');
|
||||
}
|
||||
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
|
||||
let childrenHtml = '';
|
||||
if (hasChildren) {
|
||||
childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`;
|
||||
Object.values(node.children).sort((a,b) => a.name.localeCompare(b.name)).forEach(childNode => {
|
||||
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, level + 1);
|
||||
});
|
||||
childrenHtml += `</ul>`;
|
||||
}
|
||||
let itemHtml = `<li class="${liClass}">`;
|
||||
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-bs-toggle="collapse" href="#${collapseId}" role="button" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
|
||||
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}"><span style="display: inline-block; width: 1.2em; text-align: center;">`;
|
||||
if (hasChildren) {
|
||||
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
|
||||
}
|
||||
itemHtml += `</span><code class="fw-bold ms-1">${node.name}</code></div>`;
|
||||
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
|
||||
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
|
||||
itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
|
||||
let descriptionTooltipAttrs = '';
|
||||
if (definition) {
|
||||
const escapedDefinition = definition.replace(/"/g, '"').replace(/'/g, ''');
|
||||
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
|
||||
}
|
||||
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short}</div>`;
|
||||
itemHtml += `</div>`;
|
||||
itemHtml += childrenHtml;
|
||||
itemHtml += `</li>`;
|
||||
return itemHtml;
|
||||
}
|
||||
|
||||
function renderStructureTree(elements, mustSupportPaths) {
|
||||
if (!elements || elements.length === 0) {
|
||||
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found.</em></p>';
|
||||
return;
|
||||
}
|
||||
const mustSupportPathsSet = new Set(mustSupportPaths || []);
|
||||
let html = '<p><small><span class="badge bg-warning text-dark border me-1">MS</span> = Must Support (Row Highlighted)</small></p>';
|
||||
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">MS</div><div class="col-lg-3 col-md-4">Description</div></div>`;
|
||||
html += '<ul class="list-group list-group-flush">';
|
||||
buildTreeData(elements).sort((a,b) => a.name.localeCompare(b.name)).forEach(rootNode => {
|
||||
html += renderNodeAsLi(rootNode, mustSupportPathsSet, 0);
|
||||
});
|
||||
html += '</ul>';
|
||||
structureDisplay.innerHTML = html;
|
||||
var tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
||||
new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
structureDisplay.querySelectorAll('.collapse').forEach(collapseEl => {
|
||||
collapseEl.addEventListener('show.bs.collapse', event => {
|
||||
event.target.previousElementSibling.querySelector('.toggle-icon')?.classList.replace('bi-chevron-right', 'bi-chevron-down');
|
||||
});
|
||||
collapseEl.addEventListener('hide.bs.collapse', event => {
|
||||
event.target.previousElementSibling.querySelector('.toggle-icon')?.classList.replace('bi-chevron-down', 'bi-chevron-right');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
124
templates/fhir_ig_importer/import_ig_page.html
Normal file
124
templates/fhir_ig_importer/import_ig_page.html
Normal file
@ -0,0 +1,124 @@
|
||||
{# app/modules/fhir_ig_importer/templates/fhir_ig_importer/import_ig_page.html #}
|
||||
{% extends "base.html" %} {# Or your control panel base template "cp_base.html" #}
|
||||
{% from "_form_helpers.html" import render_field %} {# Assumes you have this macro #}
|
||||
|
||||
{% block content %} {# Or your specific CP content block #}
|
||||
<div class="container mt-4">
|
||||
<h2 class="mb-4"><i class="bi bi-bootstrap-reboot me-2"></i>Import FHIR Implementation Guide</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
{# --- Import Form --- #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Enter Package Details</h5>
|
||||
<form method="POST" action="{{ url_for('.import_ig') }}" novalidate> {# Use relative endpoint for blueprint #}
|
||||
{{ form.hidden_tag() }} {# CSRF token #}
|
||||
<div class="mb-3">
|
||||
{{ render_field(form.package_name, class="form-control" + (" is-invalid" if form.package_name.errors else ""), placeholder="e.g., hl7.fhir.au.base") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ render_field(form.package_version, class="form-control" + (" is-invalid" if form.package_version.errors else ""), placeholder="e.g., 4.1.0 or latest") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.submit(class="btn btn-primary", value="Fetch & Download IG") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- Results Section --- #}
|
||||
<div id="results">
|
||||
{# Display Fatal Error if passed #}
|
||||
{% if fatal_error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h5 class="alert-heading">Critical Error during Import</h5>
|
||||
{{ fatal_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Display results if the results dictionary exists (meaning POST happened) #}
|
||||
{% if results %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{# Use results.requested for package info #}
|
||||
<h5 class="card-title">Import Results for: <code>{{ results.requested[0] }}#{{ results.requested[1] }}</code></h5>
|
||||
<hr>
|
||||
|
||||
{# --- Process Errors --- #}
|
||||
{% if results.errors %}
|
||||
<h6 class="mt-4 text-danger">Errors Encountered ({{ results.errors|length }}):</h6>
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
{% for error in results.errors %}
|
||||
<li class="list-group-item list-group-item-danger py-1">{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# --- Downloaded Files --- #}
|
||||
<h6 class="mt-4">Downloaded Packages ({{ results.downloaded|length }} / {{ results.processed|length }} processed):</h6>
|
||||
{# Check results.downloaded dictionary #}
|
||||
{% if results.downloaded %}
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
{# Iterate through results.downloaded items #}
|
||||
{% for (name, version), path in results.downloaded.items()|sort %}
|
||||
<li class="list-group-item py-1 d-flex justify-content-between align-items-center">
|
||||
<code>{{ name }}#{{ version }}</code>
|
||||
{# Display relative path cleanly #}
|
||||
<small class="text-muted ms-2">{{ path | replace('/app/', '') | replace('\\', '/') }}</small>
|
||||
<span class="badge bg-success rounded-pill ms-auto">Downloaded</span> {# Moved badge to end #}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p><small>Files saved in server's `instance/fhir_packages` directory.</small></p>
|
||||
{% else %}
|
||||
<p><small>No packages were successfully downloaded.</small></p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{# --- All Dependencies Found --- #}
|
||||
<h6 class="mt-4">Consolidated Dependencies Found:</h6>
|
||||
{# Calculate unique_deps based on results.all_dependencies #}
|
||||
{% set unique_deps = {} %}
|
||||
{% for pkg_id, deps_dict in results.all_dependencies.items() %}
|
||||
{% for dep_name, dep_version in deps_dict.items() %}
|
||||
{% set _ = unique_deps.update({(dep_name, dep_version): true}) %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% if unique_deps %}
|
||||
<p><small>Unique direct dependencies found across all processed packages:</small></p>
|
||||
<dl class="row">
|
||||
{% for (name, version), _ in unique_deps.items()|sort %}
|
||||
<dt class="col-sm-4 text-muted"><code>{{ name }}</code></dt>
|
||||
<dd class="col-sm-8">{{ version }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary" role="alert" style="padding: 0.5rem 1rem;">
|
||||
No explicit dependencies found in any processed package metadata.
|
||||
</div>
|
||||
{% endif %}
|
||||
{# --- End Dependency Result --- #}
|
||||
|
||||
</div> {# End card-body #}
|
||||
</div> {# End card #}
|
||||
{% endif %} {# End if results #}
|
||||
</div> {# End results #}
|
||||
{# --- End Results Section --- #}
|
||||
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{# --- Instructions Panel --- #}
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Instructions</h5>
|
||||
<p class="card-text">Enter the official FHIR package name (e.g., <code>hl7.fhir.au.base</code>) and the desired version (e.g., <code>4.1.0</code>, <code>latest</code>).</p>
|
||||
<p class="card-text">The system will download the package and attempt to list its direct dependencies.</p>
|
||||
<p class="card-text"><small>Downloads are saved to the server's `instance/fhir_packages` folder.</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> {# End row #}
|
||||
</div> {# End container #}
|
||||
{% endblock %}
|
Loading…
x
Reference in New Issue
Block a user