initial port of the fork
This commit is contained in:
Joshua Hare 2025-04-10 17:02:45 +10:00
parent cf055a67a8
commit 86a2718dea
29 changed files with 3280 additions and 114 deletions

View File

@ -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
View 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
View 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)

View 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 %}

View 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, '&quot;').replace(/'/g, '&apos;');
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
View 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
View 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
View 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
View 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
View File

@ -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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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
View 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
View 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

View 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 %}

View 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>

View 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, '&quot;').replace(/'/g, '&apos;');
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 #}

View 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, '&quot;').replace(/'/g, '&apos;');
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>

View 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 %}