Merge pull request #1 from Sudo-JHare/Complies-Imposes

Complies imposes /  sample validation
This commit is contained in:
Joshua Hare 2025-04-13 00:31:54 +10:00 committed by GitHub
commit 9b904d980f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1955 additions and 638 deletions

View File

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

View File

@ -8,10 +8,12 @@ The FHIRFLARE IG Toolkit is a Flask-based web application designed to simplify t
- **Import IGs**: Download FHIR IG packages and their dependencies from a package registry.
- **Manage IGs**: View, process, and delete downloaded IGs, with duplicate detection.
- **Process IGs**: Extract resource types, profiles, must-support elements, and examples from IGs.
- **Push IGs**: Upload IG resources to a FHIR server with real-time console output.
- **API Support**: Provides RESTful API endpoints for importing and pushing IGs.
- **Process IGs**: Extract resource types, profiles, must-support elements, examples, and profile relationships (`compliesWithProfile` and `imposeProfile`) from IGs.
- **Push IGs**: Upload IG resources to a FHIR server with real-time console output, including validation against imposed profiles.
- **Profile Relationships**: Support for `structuredefinition-compliesWithProfile` and `structuredefinition-imposeProfile` extensions, with validation and UI display.
- **API Support**: Provides RESTful API endpoints for importing and pushing IGs, including profile relationship metadata.
- **Live Console**: Displays real-time logs during push operations.
- **Configurable Behavior**: Options to enable/disable imposed profile validation and UI display of profile relationships.
## Technology Stack
@ -85,18 +87,23 @@ The FHIRFLARE IG Toolkit is built using the following technologies:
- Go to the "Manage FHIR Packages" tab to view downloaded IGs.
- Process, delete, or view details of IGs. Duplicates are highlighted for resolution.
3. **Push IGs to a FHIR Server**:
3. **View Processed IGs**:
- After processing an IG, view its details, including resource types, profiles, must-support elements, examples, and profile relationships (`compliesWithProfile` and `imposeProfile`).
- Profile relationships are displayed if enabled via the `DISPLAY_PROFILE_RELATIONSHIPS` configuration.
4. **Push IGs to a FHIR Server**:
- Navigate to the "Push IGs" tab.
- Select a package, enter a FHIR server URL (e.g., `http://hapi.fhir.org/baseR4`), and choose whether to include dependencies.
- Click "Push to FHIR Server" to upload resources, with progress shown in the live console.
- Click "Push to FHIR Server" to upload resources, with validation against imposed profiles (if enabled via `VALIDATE_IMPOSED_PROFILES`) and progress shown in the live console.
4. **API Usage**:
5. **API Usage**:
- **Import IG**: `POST /api/import-ig`
```bash
curl -X POST http://localhost:5000/api/import-ig \
-H "Content-Type: application/json" \
-d '{"package_name": "hl7.fhir.us.core", "version": "3.1.1", "api_key": "your-api-key"}'
```
Response includes `complies_with_profiles` and `imposed_profiles` if present.
- **Push IG**: `POST /api/push-ig`
```bash
curl -X POST http://localhost:5000/api/push-ig \
@ -104,6 +111,18 @@ The FHIRFLARE IG Toolkit is built using the following technologies:
-H "Accept: application/x-ndjson" \
-d '{"package_name": "hl7.fhir.us.core", "version": "3.1.1", "fhir_server_url": "http://hapi.fhir.org/baseR4", "include_dependencies": true, "api_key": "your-api-key"}'
```
Resources are validated against imposed profiles before pushing.
## Configuration Options
- **`VALIDATE_IMPOSED_PROFILES`**: Set to `True` (default) to validate resources against imposed profiles during the push operation. Set to `False` to skip this validation.
```python
app.config['VALIDATE_IMPOSED_PROFILES'] = False
```
- **`DISPLAY_PROFILE_RELATIONSHIPS`**: Set to `True` (default) to display `compliesWithProfile` and `imposeProfile` relationships in the UI. Set to `False` to hide them.
```python
app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = False
```
## Testing
@ -147,11 +166,11 @@ The test suite includes 27 test cases covering the following areas:
- Import IG page (`/import-ig`): Form rendering and submission (success, failure, invalid input).
- Manage IGs page (`/view-igs`): Rendering with and without packages.
- Push IGs page (`/push-igs`): Rendering and live console.
- View Processed IG page (`/view-ig/<id>`): Rendering processed IG details.
- View Processed IG page (`/view-ig/<id>`): Rendering processed IG details, including profile relationships.
- **API Endpoints**:
- `POST /api/import-ig`: Success, invalid API key, missing parameters.
- `POST /api/push-ig`: Success, invalid API key, package not found.
- `POST /api/import-ig`: Success, invalid API key, missing parameters, profile relationships in response.
- `POST /api/push-ig`: Success, invalid API key, package not found, imposed profile validation.
- `GET /get-structure`: Fetching structure definitions (success, not found).
- `GET /get-example`: Fetching example content (success, invalid path).
@ -161,7 +180,7 @@ The test suite includes 27 test cases covering the following areas:
- Viewing processed IGs: Retrieving and displaying processed IG data.
- **File Operations**:
- Processing IG packages: Extracting data from `.tgz` files.
- Processing IG packages: Extracting data from `.tgz` files, including profile relationships.
- Deleting IG packages: Removing `.tgz` files from the filesystem.
- **Security**:
@ -219,7 +238,7 @@ test_app.py::TestFHIRFlareIGToolkit::test_get_example_content_invalid_path PASSE
### Background
The FHIRFLARE IG Toolkit was developed to address the need for a user-friendly tool to manage FHIR Implementation Guides. The project focuses on providing a seamless experience for importing, processing, and analyzing FHIR packages, with a particular emphasis on handling duplicate dependencies—a common challenge in FHIR development.
The FHIRFLARE IG Toolkit was developed to address the need for a user-friendly tool to manage FHIR Implementation Guides. The project focuses on providing a seamless experience for importing, processing, and analyzing FHIR packages, with a particular emphasis on handling duplicate dependencies and profile relationships—a common challenge in FHIR development.
### Technical Decisions
@ -227,18 +246,21 @@ The FHIRFLARE IG Toolkit was developed to address the need for a user-friendly t
- **SQLite**: Used as the database for simplicity and ease of setup. For production use, consider switching to a more robust database like PostgreSQL.
- **Bootstrap**: Integrated for a responsive and professional UI, with custom CSS to handle duplicate package highlighting.
- **Docker Support**: Added to simplify deployment and ensure consistency across development and production environments.
- **Profile Validation**: Added support for `structuredefinition-compliesWithProfile` and `structuredefinition-imposeProfile` to ensure resources comply with required profiles during push operations.
### Known Issues and Workarounds
- **Bootstrap CSS Conflicts**: Early versions of the application had issues with Bootstraps table background styles (`--bs-table-bg`) overriding custom row colors for duplicate packages. This was resolved by setting `--bs-table-bg` to `transparent` for the affected table (see `templates/cp_downloaded_igs.html`).
- **Database Permissions**: The `instance` directory must be writable by the application. If you encounter permission errors, ensure the directory has the correct permissions (`chmod -R 777 instance`).
- **Package Parsing**: Some FHIR package filenames may not follow the expected `name-version.tgz` format, leading to parsing issues. The application includes a fallback to treat such files as name-only packages, but this may need further refinement.
- **Profile Validation Overhead**: Validating against imposed profiles can increase processing time during push operations. This can be disabled via the `VALIDATE_IMPOSED_PROFILES` configuration if performance is a concern.
### Future Improvements
- [ ] **Sorting Versions**: Add sorting for package versions in the "Manage FHIR Packages" view to display them in a consistent order (e.g., ascending or descending).
- [ ] **Advanced Duplicate Handling**: Implement options to resolve duplicates (e.g., keep the latest version, merge resources).
- [ ] **Production Database**: Support for PostgreSQL or MySQL for better scalability in production environments.
- [ ] **Profile Validation Enhancements**: Add more detailed validation reports for imposed profiles, including specific element mismatches.
**Completed Items** (Removed from the list as they are done):
- ~~Testing: Add unit tests using pytest to cover core functionality, especially package processing and database operations.~~ (Implemented in `tests/test_app.py` with 27 test cases covering UI, API, database, and file operations.)
@ -267,11 +289,12 @@ Please ensure your code follows the projects coding style and includes approp
- **Database Issues**: If the SQLite database (`instance/fhir_ig.db`) cannot be created, ensure the `instance` directory is writable. You may need to adjust permissions (`chmod -R 777 instance`).
- **Package Download Fails**: Verify your internet connection and ensure the package name and version are correct.
- **Colors Not Displaying**: If table row colors for duplicates are not showing, inspect the page with browser developer tools (F12) to check for CSS conflicts with Bootstrap.
- **Profile Relationships Not Displaying**: Ensure `DISPLAY_PROFILE_RELATIONSHIPS` is set to `True` in the application configuration.
## Directory Structure
- `app.py`: Main Flask application file.
- `services.py`: Business logic for importing, processing, and pushing IGs.
- `services.py`: Business logic for importing, processing, and pushing IGs, including profile relationship handling.
- `templates/`: HTML templates for the UI.
- `instance/`: Directory for SQLite database and downloaded packages.
- `tests/`: Directory for test files.

394
app.py
View File

@ -1,16 +1,19 @@
import sys
import os
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
from flask import Flask, render_template, render_template_string, request, redirect, url_for, flash, jsonify, Response
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, SelectField
from wtforms.validators import DataRequired, Regexp
import os
from flask_wtf.csrf import CSRFProtect
import tarfile
import json
from datetime import datetime
import services
import logging
import requests
import re # Added for regex validation
import re
from forms import IgImportForm, ValidationForm
# Set up logging
logging.basicConfig(level=logging.DEBUG)
@ -20,12 +23,14 @@ app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////app/instance/fhir_ig.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['FHIR_PACKAGES_DIR'] = os.path.join(app.instance_path, 'fhir_packages')
app.config['API_KEY'] = 'your-api-key-here' # Hardcoded API key for now; replace with a secure solution
app.config['FHIR_PACKAGES_DIR'] = '/app/instance/fhir_packages'
app.config['API_KEY'] = 'your-api-key-here'
app.config['VALIDATE_IMPOSED_PROFILES'] = True
app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = True
# Ensure directories exist and are writable
instance_path = '/app/instance'
db_path = os.path.join(instance_path, 'fhir_ig.db')
db_path = '/app/instance/fhir_ig.db'
packages_path = app.config['FHIR_PACKAGES_DIR']
logger.debug(f"Instance path: {instance_path}")
@ -35,9 +40,9 @@ logger.debug(f"Packages path: {packages_path}")
try:
os.makedirs(instance_path, exist_ok=True)
os.makedirs(packages_path, exist_ok=True)
os.chmod(instance_path, 0o777)
os.chmod(packages_path, 0o777)
logger.debug(f"Directories created: {os.listdir('/app')}")
os.chmod(instance_path, 0o755)
os.chmod(packages_path, 0o755)
logger.debug(f"Directories created: {os.listdir(os.path.dirname(__file__))}")
logger.debug(f"Instance contents: {os.listdir(instance_path)}")
except Exception as e:
logger.error(f"Failed to create directories: {e}")
@ -45,22 +50,7 @@ except Exception as e:
logger.warning("Falling back to /tmp/fhir_ig.db")
db = SQLAlchemy(app)
class IgImportForm(FlaskForm):
package_name = StringField('Package Name (e.g., hl7.fhir.us.core)', validators=[
DataRequired(),
Regexp(r'^[a-zAZ0-9]+(\.[a-zA-Z0-9]+)+$', message='Invalid package name format.')
])
package_version = StringField('Package Version (e.g., 1.0.0 or current)', validators=[
DataRequired(),
Regexp(r'^[a-zA-Z0-9\.\-]+$', message='Invalid version format.')
])
dependency_mode = SelectField('Dependency Pulling Mode', choices=[
('recursive', 'Current Recursive'),
('patch-canonical', 'Patch Canonical Versions'),
('tree-shaking', 'Tree Shaking (Only Used Dependencies)')
], default='recursive')
submit = SubmitField('Fetch & Download IG')
csrf = CSRFProtect(app)
class ProcessedIg(db.Model):
id = db.Column(db.Integer, primary_key=True)
@ -98,7 +88,6 @@ def import_ig():
result = services.import_package_and_dependencies(name, version, dependency_mode=dependency_mode)
if result['errors'] and not result['downloaded']:
error_msg = result['errors'][0]
# Simplify the error message by taking the last part after the last colon
simplified_msg = error_msg.split(": ")[-1] if ": " in error_msg else error_msg
flash(f"Failed to import {name}#{version}: {simplified_msg}", "error - check the name and version!")
return redirect(url_for('import_ig'))
@ -106,10 +95,11 @@ def import_ig():
return redirect(url_for('view_igs'))
except Exception as e:
flash(f"Error downloading IG: {str(e)}", "error")
return render_template('import_ig.html', form=form, site_name='FLARE FHIR IG Toolkit', now=datetime.now())
return render_template('import_ig.html', form=form, site_name='FHIRFLARE IG Toolkit', now=datetime.now())
@app.route('/view-igs')
def view_igs():
form = FlaskForm()
igs = ProcessedIg.query.all()
processed_ids = {(ig.package_name, ig.version) for ig in igs}
@ -119,24 +109,19 @@ def view_igs():
if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'):
# Split on the last hyphen to separate name and version
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4] # Remove .tgz
# Validate that the version looks reasonable (e.g., starts with a digit or is a known keyword)
version = filename[last_hyphen_index + 1:-4]
if version[0].isdigit() or version in ('preview', 'current', 'latest'):
# Replace underscores with dots to match FHIR package naming convention
name = name.replace('_', '.')
packages.append({'name': name, 'version': version, 'filename': filename})
else:
# Fallback: treat as name only, log warning
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only")
packages.append({'name': name, 'version': version, 'filename': filename})
else:
# Fallback: treat as name only, log warning
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only")
@ -156,7 +141,7 @@ def view_igs():
# Calculate duplicate_groups
duplicate_groups = {}
for name, pkgs in duplicate_names.items():
if len(pkgs) > 1: # Only include packages with multiple versions
if len(pkgs) > 1:
duplicate_groups[name] = [pkg['version'] for pkg in pkgs]
# Precompute group colors
@ -165,13 +150,15 @@ def view_igs():
for i, name in enumerate(duplicate_groups.keys()):
group_colors[name] = colors[i % len(colors)]
return render_template('cp_downloaded_igs.html', packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names,
return render_template('cp_downloaded_igs.html', form=form, packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names,
duplicate_groups=duplicate_groups, group_colors=group_colors,
site_name='FLARE FHIR IG Toolkit', now=datetime.now())
site_name='FHIRFLARE IG Toolkit', now=datetime.now(),
config=app.config)
@app.route('/push-igs', methods=['GET', 'POST'])
def push_igs():
form = FlaskForm()
igs = ProcessedIg.query.all()
processed_ids = {(ig.package_name, ig.version) for ig in igs}
@ -181,24 +168,19 @@ def push_igs():
if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'):
# Split on the last hyphen to separate name and version
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4] # Remove .tgz
# Validate that the version looks reasonable (e.g., starts with a digit or is a known keyword)
version = filename[last_hyphen_index + 1:-4]
if version[0].isdigit() or version in ('preview', 'current', 'latest'):
# Replace underscores with dots to match FHIR package naming convention
name = name.replace('_', '.')
packages.append({'name': name, 'version': version, 'filename': filename})
else:
# Fallback: treat as name only, log warning
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only")
packages.append({'name': name, 'version': version, 'filename': filename})
else:
# Fallback: treat as name only, log warning
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only")
@ -218,7 +200,7 @@ def push_igs():
# Calculate duplicate_groups
duplicate_groups = {}
for name, pkgs in duplicate_names.items():
if len(pkgs) > 1: # Only include packages with multiple versions
if len(pkgs) > 1:
duplicate_groups[name] = [pkg['version'] for pkg in pkgs]
# Precompute group colors
@ -227,91 +209,102 @@ def push_igs():
for i, name in enumerate(duplicate_groups.keys()):
group_colors[name] = colors[i % len(colors)]
return render_template('cp_push_igs.html', packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names,
return render_template('cp_push_igs.html', form=form, packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names,
duplicate_groups=duplicate_groups, group_colors=group_colors,
site_name='FLARE FHIR IG Toolkit', now=datetime.now(),
api_key=app.config['API_KEY']) # Pass the API key to the template
site_name='FHIRFLARE IG Toolkit', now=datetime.now(),
api_key=app.config['API_KEY'],
config=app.config)
@app.route('/process-igs', methods=['POST'])
def process_ig():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error")
return redirect(url_for('view_igs'))
try:
# Parse name and version from filename
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4]
# Replace underscores with dots to match FHIR package naming convention
name = name.replace('_', '.')
else:
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename} during processing")
package_info = services.process_package_file(tgz_path)
processed_ig = ProcessedIg(
package_name=name,
version=version,
processed_date=datetime.now(),
resource_types_info=package_info['resource_types_info'],
must_support_elements=package_info.get('must_support_elements'),
examples=package_info.get('examples')
)
db.session.add(processed_ig)
db.session.commit()
flash(f"Successfully processed {name}#{version}!", "success")
except Exception as e:
flash(f"Error processing IG: {str(e)}", "error")
form = FlaskForm()
if form.validate_on_submit():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error")
return redirect(url_for('view_igs'))
try:
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4]
name = name.replace('_', '.')
else:
name = filename[:-4]
version = ''
logger.warning(f"Could not parse version from {filename} during processing")
package_info = services.process_package_file(tgz_path)
processed_ig = ProcessedIg(
package_name=name,
version=version,
processed_date=datetime.now(),
resource_types_info=package_info['resource_types_info'],
must_support_elements=package_info.get('must_support_elements'),
examples=package_info.get('examples')
)
db.session.add(processed_ig)
db.session.commit()
flash(f"Successfully processed {name}#{version}!", "success")
except Exception as e:
flash(f"Error processing IG: {str(e)}", "error")
else:
flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs'))
@app.route('/delete-ig', methods=['POST'])
def delete_ig():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
metadata_path = tgz_path.replace('.tgz', '.metadata.json')
if os.path.exists(tgz_path):
try:
os.remove(tgz_path)
if os.path.exists(metadata_path):
os.remove(metadata_path)
logger.debug(f"Deleted metadata file: {metadata_path}")
flash(f"Deleted {filename}", "success")
except Exception as e:
flash(f"Error deleting {filename}: {str(e)}", "error")
form = FlaskForm()
if form.validate_on_submit():
filename = request.form.get('filename')
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
return redirect(url_for('view_igs'))
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
metadata_path = tgz_path.replace('.tgz', '.metadata.json')
if os.path.exists(tgz_path):
try:
os.remove(tgz_path)
if os.path.exists(metadata_path):
os.remove(metadata_path)
logger.debug(f"Deleted metadata file: {metadata_path}")
flash(f"Deleted {filename}", "success")
except Exception as e:
flash(f"Error deleting {filename}: {str(e)}", "error")
else:
flash(f"File not found: {filename}", "error")
else:
flash(f"File not found: {filename}", "error")
flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs'))
@app.route('/unload-ig', methods=['POST'])
def unload_ig():
ig_id = request.form.get('ig_id')
if not ig_id:
flash("Invalid package ID.", "error")
return redirect(url_for('view_igs'))
processed_ig = db.session.get(ProcessedIg, ig_id)
if processed_ig:
try:
db.session.delete(processed_ig)
db.session.commit()
flash(f"Unloaded {processed_ig.package_name}#{processed_ig.version}", "success")
except Exception as e:
flash(f"Error unloading package: {str(e)}", "error")
form = FlaskForm()
if form.validate_on_submit():
ig_id = request.form.get('ig_id')
if not ig_id:
flash("Invalid package ID.", "error")
return redirect(url_for('view_igs'))
processed_ig = db.session.get(ProcessedIg, ig_id)
if processed_ig:
try:
db.session.delete(processed_ig)
db.session.commit()
flash(f"Unloaded {processed_ig.package_name}#{processed_ig.version}", "success")
except Exception as e:
flash(f"Error unloading package: {str(e)}", "error")
else:
flash(f"Package not found with ID: {ig_id}", "error")
else:
flash(f"Package not found with ID: {ig_id}", "error")
flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs'))
@app.route('/view-ig/<int:processed_ig_id>')
@ -320,9 +313,25 @@ def view_ig(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 {}
# Load metadata to get profile relationships
package_name = processed_ig.package_name
version = processed_ig.version
metadata_filename = f"{services.sanitize_filename_part(package_name)}-{services.sanitize_filename_part(version)}.metadata.json"
metadata_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], metadata_filename)
complies_with_profiles = []
imposed_profiles = []
if os.path.exists(metadata_path):
with open(metadata_path, 'r') as f:
metadata = json.load(f)
complies_with_profiles = metadata.get('complies_with_profiles', [])
imposed_profiles = metadata.get('imposed_profiles', [])
return render_template('cp_view_processed_ig.html', title=f"View {processed_ig.package_name}#{processed_ig.version}",
processed_ig=processed_ig, profile_list=profile_list, base_list=base_list,
examples_by_type=examples_by_type, site_name='FLARE FHIR IG Toolkit', now=datetime.now())
examples_by_type=examples_by_type, site_name='FHIRFLARE IG Toolkit', now=datetime.now(),
complies_with_profiles=complies_with_profiles, imposed_profiles=imposed_profiles,
config=app.config)
@app.route('/get-structure')
def get_structure_definition():
@ -332,7 +341,6 @@ def get_structure_definition():
if not all([package_name, package_version, resource_identifier]):
return jsonify({"error": "Missing query parameters"}), 400
# First, try to get the structure definition from the specified package
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], services._construct_tgz_filename(package_name, package_version))
sd_data = None
fallback_used = False
@ -340,13 +348,11 @@ def get_structure_definition():
if os.path.exists(tgz_path):
sd_data, _ = services.find_and_extract_sd(tgz_path, resource_identifier)
# If not found, fall back to the core FHIR package (hl7.fhir.r4.core#4.0.1)
if sd_data is None:
logger.debug(f"Structure definition for '{resource_identifier}' not found in {package_name}#{package_version}, attempting fallback to hl7.fhir.r4.core#4.0.1")
core_package_name = "hl7.fhir.r4.core"
core_package_version = "4.0.1"
# Ensure the core package is downloaded
core_tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], services._construct_tgz_filename(core_package_name, core_package_version))
if not os.path.exists(core_tgz_path):
logger.debug(f"Core package {core_package_name}#{core_package_version} not found, attempting to download")
@ -359,7 +365,6 @@ def get_structure_definition():
logger.error(f"Error downloading core package: {str(e)}")
return jsonify({"error": f"SD for '{resource_identifier}' not found in {package_name}#{package_version}, and error downloading core package: {str(e)}"}), 500
# Try to extract the structure definition from the core package
if os.path.exists(core_tgz_path):
sd_data, _ = services.find_and_extract_sd(core_tgz_path, resource_identifier)
if sd_data is None:
@ -415,55 +420,59 @@ def get_package_metadata():
return jsonify({'dependency_mode': metadata['dependency_mode']})
return jsonify({'error': 'Metadata not found'}), 404
# API Endpoint: Import IG Package
@app.route('/api/import-ig', methods=['POST'])
def api_import_ig():
# Check API key
auth_error = check_api_key()
if auth_error:
return auth_error
# Validate request
if not request.is_json:
return jsonify({"status": "error", "message": "Request must be JSON"}), 400
data = request.get_json()
package_name = data.get('package_name')
version = data.get('version')
dependency_mode = data.get('dependency_mode', 'recursive') # Default to recursive
dependency_mode = data.get('dependency_mode', 'recursive')
if not package_name or not version:
return jsonify({"status": "error", "message": "Missing package_name or version"}), 400
# Validate package name and version format using re
if not (isinstance(package_name, str) and isinstance(version, str) and
re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', package_name) and
re.match(r'^[a-zA-Z0-9\.\-]+$', version)):
return jsonify({"status": "error", "message": "Invalid package name or version format"}), 400
# Validate dependency mode
valid_modes = ['recursive', 'patch-canonical', 'tree-shaking']
if dependency_mode not in valid_modes:
return jsonify({"status": "error", "message": f"Invalid dependency mode: {dependency_mode}. Must be one of {valid_modes}"}), 400
try:
# Import package and dependencies
result = services.import_package_and_dependencies(package_name, version, dependency_mode=dependency_mode)
if result['errors'] and not result['downloaded']:
return jsonify({"status": "error", "message": f"Failed to import {package_name}#{version}: {result['errors'][0]}"}), 500
# Process the package to get compliesWithProfile and imposeProfile
package_filename = f"{services.sanitize_filename_part(package_name)}-{services.sanitize_filename_part(version)}.tgz"
package_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], package_filename)
complies_with_profiles = []
imposed_profiles = []
if os.path.exists(package_path):
process_result = services.process_package_file(package_path)
complies_with_profiles = process_result.get('complies_with_profiles', [])
imposed_profiles = process_result.get('imposed_profiles', [])
else:
logger.warning(f"Package file not found after import: {package_path}")
# Check for duplicates
packages = []
packages_dir = app.config['FHIR_PACKAGES_DIR']
if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'):
# Split on the last hyphen to separate name and version
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4]
# Replace underscores with dots to match FHIR package naming convention
name = name.replace('_', '.')
if version[0].isdigit() or version in ('preview', 'current', 'latest'):
packages.append({'name': name, 'version': version, 'filename': filename})
@ -476,7 +485,6 @@ def api_import_ig():
version = ''
packages.append({'name': name, 'version': version, 'filename': filename})
# Calculate duplicates
duplicate_names = {}
for pkg in packages:
name = pkg['name']
@ -490,7 +498,6 @@ def api_import_ig():
versions = [pkg['version'] for pkg in pkgs]
duplicates.append(f"{name} (exists as {', '.join(versions)})")
# Deduplicate dependencies
seen = set()
unique_dependencies = []
for dep in result.get('dependencies', []):
@ -499,7 +506,6 @@ def api_import_ig():
seen.add(dep_str)
unique_dependencies.append(dep_str)
# Prepare response
response = {
"status": "success",
"message": "Package imported successfully",
@ -507,6 +513,8 @@ def api_import_ig():
"version": version,
"dependency_mode": dependency_mode,
"dependencies": unique_dependencies,
"complies_with_profiles": complies_with_profiles,
"imposed_profiles": imposed_profiles,
"duplicates": duplicates
}
return jsonify(response), 200
@ -515,15 +523,12 @@ def api_import_ig():
logger.error(f"Error in api_import_ig: {str(e)}")
return jsonify({"status": "error", "message": f"Error importing package: {str(e)}"}), 500
# API Endpoint: Push IG to FHIR Server with Streaming
@app.route('/api/push-ig', methods=['POST'])
def api_push_ig():
# Check API key
auth_error = check_api_key()
if auth_error:
return auth_error
# Validate request
if not request.is_json:
return jsonify({"status": "error", "message": "Request must be JSON"}), 400
@ -536,13 +541,11 @@ def api_push_ig():
if not all([package_name, version, fhir_server_url]):
return jsonify({"status": "error", "message": "Missing package_name, version, or fhir_server_url"}), 400
# Validate package name and version format using re
if not (isinstance(package_name, str) and isinstance(version, str) and
re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', package_name) and
re.match(r'^[a-zA-Z0-9\.\-]+$', version)):
return jsonify({"status": "error", "message": "Invalid package name or version format"}), 400
# Check if package exists
tgz_filename = services._construct_tgz_filename(package_name, version)
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], tgz_filename)
if not os.path.exists(tgz_path):
@ -550,21 +553,10 @@ def api_push_ig():
def generate_stream():
try:
# Start message
yield json.dumps({"type": "start", "message": f"Starting push for {package_name}#{version}..."}) + "\n"
# Extract resources from the main package
resources = []
with tarfile.open(tgz_path, "r:gz") as tar:
for member in tar.getmembers():
if member.name.startswith('package/') and member.name.endswith('.json') and not member.name.endswith('package.json'):
with tar.extractfile(member) as f:
resource_data = json.load(f)
if 'resourceType' in resource_data:
resources.append(resource_data)
# If include_dependencies is True, fetch dependencies from metadata
pushed_packages = [f"{package_name}#{version}"]
packages_to_push = [(package_name, version, tgz_path)]
if include_dependencies:
yield json.dumps({"type": "progress", "message": "Processing dependencies..."}) + "\n"
metadata = services.get_package_metadata(package_name, version)
@ -575,36 +567,44 @@ def api_push_ig():
dep_tgz_filename = services._construct_tgz_filename(dep_name, dep_version)
dep_tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], dep_tgz_filename)
if os.path.exists(dep_tgz_path):
with tarfile.open(dep_tgz_path, "r:gz") as tar:
for member in tar.getmembers():
if member.name.startswith('package/') and member.name.endswith('.json') and not member.name.endswith('package.json'):
with tar.extractfile(member) as f:
resource_data = json.load(f)
if 'resourceType' in resource_data:
resources.append(resource_data)
pushed_packages.append(f"{dep_name}#{dep_version}")
packages_to_push.append((dep_name, dep_version, dep_tgz_path))
yield json.dumps({"type": "progress", "message": f"Added dependency {dep_name}#{dep_version}"}) + "\n"
else:
yield json.dumps({"type": "warning", "message": f"Dependency {dep_name}#{dep_version} not found, skipping"}) + "\n"
# Push resources to FHIR server
for pkg_name, pkg_version, pkg_path in packages_to_push:
with tarfile.open(pkg_path, "r:gz") as tar:
for member in tar.getmembers():
if member.name.startswith('package/') and member.name.endswith('.json') and not member.name.endswith('package.json'):
with tar.extractfile(member) as f:
resource_data = json.load(f)
if 'resourceType' in resource_data:
resources.append((resource_data, pkg_name, pkg_version))
server_response = []
success_count = 0
failure_count = 0
total_resources = len(resources)
yield json.dumps({"type": "progress", "message": f"Found {total_resources} resources to upload"}) + "\n"
for i, resource in enumerate(resources, 1):
pushed_packages = []
for i, (resource, pkg_name, pkg_version) in enumerate(resources, 1):
resource_type = resource.get('resourceType')
resource_id = resource.get('id')
if not resource_type or not resource_id:
yield json.dumps({"type": "warning", "message": f"Skipping invalid resource at index {i}"}) + "\n"
yield json.dumps({"type": "warning", "message": f"Skipping invalid resource at index {i} from {pkg_name}#{pkg_version}"}) + "\n"
failure_count += 1
continue
# Validate against the profile and imposed profiles
validation_result = services.validate_resource_against_profile(pkg_name, pkg_version, resource, include_dependencies=False)
if not validation_result['valid']:
yield json.dumps({"type": "error", "message": f"Validation failed for {resource_type}/{resource_id} in {pkg_name}#{pkg_version}: {', '.join(validation_result['errors'])}"}) + "\n"
failure_count += 1
continue
# Construct the FHIR server URL for the resource
resource_url = f"{fhir_server_url.rstrip('/')}/{resource_type}/{resource_id}"
yield json.dumps({"type": "progress", "message": f"Uploading {resource_type}/{resource_id} ({i}/{total_resources})..."}) + "\n"
yield json.dumps({"type": "progress", "message": f"Uploading {resource_type}/{resource_id} ({i}/{total_resources}) from {pkg_name}#{pkg_version}..."}) + "\n"
try:
response = requests.put(resource_url, json=resource, headers={'Content-Type': 'application/fhir+json'})
@ -612,13 +612,14 @@ def api_push_ig():
server_response.append(f"Uploaded {resource_type}/{resource_id} successfully")
yield json.dumps({"type": "success", "message": f"Uploaded {resource_type}/{resource_id} successfully"}) + "\n"
success_count += 1
if f"{pkg_name}#{pkg_version}" not in pushed_packages:
pushed_packages.append(f"{pkg_name}#{pkg_version}")
except requests.exceptions.RequestException as e:
error_msg = f"Failed to upload {resource_type}/{resource_id}: {str(e)}"
server_response.append(error_msg)
yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1
# Final summary
summary = {
"status": "success" if failure_count == 0 else "partial",
"message": f"Push completed: {success_count} resources uploaded, {failure_count} failed",
@ -642,10 +643,71 @@ def api_push_ig():
return Response(generate_stream(), mimetype='application/x-ndjson')
@app.route('/validate-sample', methods=['GET', 'POST'])
def validate_sample():
form = ValidationForm()
validation_report = None
packages = []
# Load available packages
packages_dir = app.config['FHIR_PACKAGES_DIR']
if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'):
last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4]
name = name.replace('_', '.')
try:
with tarfile.open(os.path.join(packages_dir, filename), 'r:gz') as tar:
package_json = tar.extractfile('package/package.json')
pkg_info = json.load(package_json)
packages.append({'name': pkg_info['name'], 'version': pkg_info['version']})
except Exception as e:
logger.warning(f"Error reading package {filename}: {e}")
continue
if form.validate_on_submit():
package_name = form.package_name.data
version = form.version.data
include_dependencies = form.include_dependencies.data
mode = form.mode.data
try:
sample_input = json.loads(form.sample_input.data)
if mode == 'single':
validation_report = services.validate_resource_against_profile(
package_name, version, sample_input, include_dependencies
)
else: # mode == 'bundle'
validation_report = services.validate_bundle_against_profile(
package_name, version, sample_input, include_dependencies
)
flash("Validation completed.", 'success')
except json.JSONDecodeError:
flash("Invalid JSON format in sample input.", 'error')
except Exception as e:
logger.error(f"Error validating sample: {e}")
flash(f"Error validating sample: {str(e)}", 'error')
validation_report = {'valid': False, 'errors': [str(e)], 'warnings': [], 'results': {}}
return render_template(
'validate_sample.html',
form=form,
packages=packages,
validation_report=validation_report,
site_name='FHIRFLARE IG Toolkit',
now=datetime.now()
)
with app.app_context():
logger.debug(f"Creating database at: {app.config['SQLALCHEMY_DATABASE_URI']}")
db.create_all()
logger.debug("Database initialization complete")
try:
db.create_all()
logger.debug("Database initialization complete")
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
raise
if __name__ == '__main__':
app.run(debug=True)

View File

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

Binary file not shown.

View File

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

View File

@ -1,7 +1,7 @@
{
"package_name": "hl7.fhir.au.core",
"version": "1.1.0-preview",
"dependency_mode": "tree-shaking",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
@ -27,5 +27,7 @@
"name": "hl7.fhir.uv.ipa",
"version": "1.0.0"
}
]
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -2,5 +2,7 @@
"package_name": "hl7.fhir.r4.core",
"version": "4.0.1",
"dependency_mode": "recursive",
"imported_dependencies": []
"imported_dependencies": [],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -37,6 +37,35 @@
</div>
</div>
{% if config.DISPLAY_PROFILE_RELATIONSHIPS %}
<div class="card mt-4">
<div class="card-header">Profile Relationships</div>
<div class="card-body">
<h6>Complies With</h6>
{% if complies_with_profiles %}
<ul>
{% for profile in complies_with_profiles %}
<li>{{ profile }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted"><em>No profiles declared as compatible.</em></p>
{% endif %}
<h6>Required Dependent Profiles (Must Also Validate Against)</h6>
{% if imposed_profiles %}
<ul>
{% for profile in imposed_profiles %}
<li>{{ profile }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted"><em>No imposed profiles.</em></p>
{% endif %}
</div>
</div>
{% endif %}
<div class="card mt-4">
<div class="card-header">Resource Types Found / Defined</div>
<div class="card-body">

View File

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

View File

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