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 app.py .
COPY services.py . COPY services.py .
COPY forms.py .
COPY routes.py .
COPY templates/ templates/ COPY templates/ templates/
COPY static/ static/ COPY static/ static/
COPY tests/ tests/ 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. - **Import IGs**: Download FHIR IG packages and their dependencies from a package registry.
- **Manage IGs**: View, process, and delete downloaded IGs, with duplicate detection. - **Manage IGs**: View, process, and delete downloaded IGs, with duplicate detection.
- **Process IGs**: Extract resource types, profiles, must-support elements, and examples from 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. - **Push IGs**: Upload IG resources to a FHIR server with real-time console output, including validation against imposed profiles.
- **API Support**: Provides RESTful API endpoints for importing and pushing IGs. - **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. - **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 ## 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. - Go to the "Manage FHIR Packages" tab to view downloaded IGs.
- Process, delete, or view details of IGs. Duplicates are highlighted for resolution. - 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. - 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. - 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` - **Import IG**: `POST /api/import-ig`
```bash ```bash
curl -X POST http://localhost:5000/api/import-ig \ curl -X POST http://localhost:5000/api/import-ig \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"package_name": "hl7.fhir.us.core", "version": "3.1.1", "api_key": "your-api-key"}' -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` - **Push IG**: `POST /api/push-ig`
```bash ```bash
curl -X POST http://localhost:5000/api/push-ig \ 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" \ -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"}' -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 ## 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). - Import IG page (`/import-ig`): Form rendering and submission (success, failure, invalid input).
- Manage IGs page (`/view-igs`): Rendering with and without packages. - Manage IGs page (`/view-igs`): Rendering with and without packages.
- Push IGs page (`/push-igs`): Rendering and live console. - 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**: - **API Endpoints**:
- `POST /api/import-ig`: Success, invalid API key, missing parameters. - `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. - `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-structure`: Fetching structure definitions (success, not found).
- `GET /get-example`: Fetching example content (success, invalid path). - `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. - Viewing processed IGs: Retrieving and displaying processed IG data.
- **File Operations**: - **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. - Deleting IG packages: Removing `.tgz` files from the filesystem.
- **Security**: - **Security**:
@ -219,7 +238,7 @@ test_app.py::TestFHIRFlareIGToolkit::test_get_example_content_invalid_path PASSE
### Background ### 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 ### 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. - **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. - **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. - **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 ### 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`). - **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`). - **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. - **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 ### 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). - [ ] **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). - [ ] **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. - [ ] **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): **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.) - ~~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`). - **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. - **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. - **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 ## Directory Structure
- `app.py`: Main Flask application file. - `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. - `templates/`: HTML templates for the UI.
- `instance/`: Directory for SQLite database and downloaded packages. - `instance/`: Directory for SQLite database and downloaded packages.
- `tests/`: Directory for test files. - `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 import Flask, render_template, render_template_string, request, redirect, url_for, flash, jsonify, Response
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, SelectField from flask_wtf.csrf import CSRFProtect
from wtforms.validators import DataRequired, Regexp
import os
import tarfile import tarfile
import json import json
from datetime import datetime from datetime import datetime
import services import services
import logging import logging
import requests import requests
import re # Added for regex validation import re
from forms import IgImportForm, ValidationForm
# Set up logging # Set up logging
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@ -20,12 +23,14 @@ app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here' app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////app/instance/fhir_ig.db' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////app/instance/fhir_ig.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['FHIR_PACKAGES_DIR'] = os.path.join(app.instance_path, 'fhir_packages') app.config['FHIR_PACKAGES_DIR'] = '/app/instance/fhir_packages'
app.config['API_KEY'] = 'your-api-key-here' # Hardcoded API key for now; replace with a secure solution 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 # Ensure directories exist and are writable
instance_path = '/app/instance' 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'] packages_path = app.config['FHIR_PACKAGES_DIR']
logger.debug(f"Instance path: {instance_path}") logger.debug(f"Instance path: {instance_path}")
@ -35,9 +40,9 @@ logger.debug(f"Packages path: {packages_path}")
try: try:
os.makedirs(instance_path, exist_ok=True) os.makedirs(instance_path, exist_ok=True)
os.makedirs(packages_path, exist_ok=True) os.makedirs(packages_path, exist_ok=True)
os.chmod(instance_path, 0o777) os.chmod(instance_path, 0o755)
os.chmod(packages_path, 0o777) os.chmod(packages_path, 0o755)
logger.debug(f"Directories created: {os.listdir('/app')}") logger.debug(f"Directories created: {os.listdir(os.path.dirname(__file__))}")
logger.debug(f"Instance contents: {os.listdir(instance_path)}") logger.debug(f"Instance contents: {os.listdir(instance_path)}")
except Exception as e: except Exception as e:
logger.error(f"Failed to create directories: {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") logger.warning("Falling back to /tmp/fhir_ig.db")
db = SQLAlchemy(app) db = SQLAlchemy(app)
csrf = CSRFProtect(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')
class ProcessedIg(db.Model): class ProcessedIg(db.Model):
id = db.Column(db.Integer, primary_key=True) 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) result = services.import_package_and_dependencies(name, version, dependency_mode=dependency_mode)
if result['errors'] and not result['downloaded']: if result['errors'] and not result['downloaded']:
error_msg = result['errors'][0] 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 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!") flash(f"Failed to import {name}#{version}: {simplified_msg}", "error - check the name and version!")
return redirect(url_for('import_ig')) return redirect(url_for('import_ig'))
@ -106,10 +95,11 @@ def import_ig():
return redirect(url_for('view_igs')) return redirect(url_for('view_igs'))
except Exception as e: except Exception as e:
flash(f"Error downloading IG: {str(e)}", "error") 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') @app.route('/view-igs')
def view_igs(): def view_igs():
form = FlaskForm()
igs = ProcessedIg.query.all() igs = ProcessedIg.query.all()
processed_ids = {(ig.package_name, ig.version) for ig in igs} processed_ids = {(ig.package_name, ig.version) for ig in igs}
@ -119,24 +109,19 @@ def view_igs():
if os.path.exists(packages_dir): if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir): for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'): if filename.endswith('.tgz'):
# Split on the last hyphen to separate name and version
last_hyphen_index = filename.rfind('-') last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'): if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index] name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4] # Remove .tgz version = filename[last_hyphen_index + 1:-4]
# Validate that the version looks reasonable (e.g., starts with a digit or is a known keyword)
if version[0].isdigit() or version in ('preview', 'current', 'latest'): if version[0].isdigit() or version in ('preview', 'current', 'latest'):
# Replace underscores with dots to match FHIR package naming convention
name = name.replace('_', '.') name = name.replace('_', '.')
packages.append({'name': name, 'version': version, 'filename': filename}) packages.append({'name': name, 'version': version, 'filename': filename})
else: else:
# Fallback: treat as name only, log warning
name = filename[:-4] name = filename[:-4]
version = '' version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only") logger.warning(f"Could not parse version from {filename}, treating as name only")
packages.append({'name': name, 'version': version, 'filename': filename}) packages.append({'name': name, 'version': version, 'filename': filename})
else: else:
# Fallback: treat as name only, log warning
name = filename[:-4] name = filename[:-4]
version = '' version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only") logger.warning(f"Could not parse version from {filename}, treating as name only")
@ -156,7 +141,7 @@ def view_igs():
# Calculate duplicate_groups # Calculate duplicate_groups
duplicate_groups = {} duplicate_groups = {}
for name, pkgs in duplicate_names.items(): 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] duplicate_groups[name] = [pkg['version'] for pkg in pkgs]
# Precompute group colors # Precompute group colors
@ -165,13 +150,15 @@ def view_igs():
for i, name in enumerate(duplicate_groups.keys()): for i, name in enumerate(duplicate_groups.keys()):
group_colors[name] = colors[i % len(colors)] group_colors[name] = colors[i % len(colors)]
return render_template('cp_downloaded_igs.html', packages=packages, processed_list=igs, return render_template('cp_downloaded_igs.html', form=form, packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names, processed_ids=processed_ids, duplicate_names=duplicate_names,
duplicate_groups=duplicate_groups, group_colors=group_colors, 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']) @app.route('/push-igs', methods=['GET', 'POST'])
def push_igs(): def push_igs():
form = FlaskForm()
igs = ProcessedIg.query.all() igs = ProcessedIg.query.all()
processed_ids = {(ig.package_name, ig.version) for ig in igs} processed_ids = {(ig.package_name, ig.version) for ig in igs}
@ -181,24 +168,19 @@ def push_igs():
if os.path.exists(packages_dir): if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir): for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'): if filename.endswith('.tgz'):
# Split on the last hyphen to separate name and version
last_hyphen_index = filename.rfind('-') last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'): if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index] name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4] # Remove .tgz version = filename[last_hyphen_index + 1:-4]
# Validate that the version looks reasonable (e.g., starts with a digit or is a known keyword)
if version[0].isdigit() or version in ('preview', 'current', 'latest'): if version[0].isdigit() or version in ('preview', 'current', 'latest'):
# Replace underscores with dots to match FHIR package naming convention
name = name.replace('_', '.') name = name.replace('_', '.')
packages.append({'name': name, 'version': version, 'filename': filename}) packages.append({'name': name, 'version': version, 'filename': filename})
else: else:
# Fallback: treat as name only, log warning
name = filename[:-4] name = filename[:-4]
version = '' version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only") logger.warning(f"Could not parse version from {filename}, treating as name only")
packages.append({'name': name, 'version': version, 'filename': filename}) packages.append({'name': name, 'version': version, 'filename': filename})
else: else:
# Fallback: treat as name only, log warning
name = filename[:-4] name = filename[:-4]
version = '' version = ''
logger.warning(f"Could not parse version from {filename}, treating as name only") logger.warning(f"Could not parse version from {filename}, treating as name only")
@ -218,7 +200,7 @@ def push_igs():
# Calculate duplicate_groups # Calculate duplicate_groups
duplicate_groups = {} duplicate_groups = {}
for name, pkgs in duplicate_names.items(): 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] duplicate_groups[name] = [pkg['version'] for pkg in pkgs]
# Precompute group colors # Precompute group colors
@ -227,91 +209,102 @@ def push_igs():
for i, name in enumerate(duplicate_groups.keys()): for i, name in enumerate(duplicate_groups.keys()):
group_colors[name] = colors[i % len(colors)] group_colors[name] = colors[i % len(colors)]
return render_template('cp_push_igs.html', packages=packages, processed_list=igs, return render_template('cp_push_igs.html', form=form, packages=packages, processed_list=igs,
processed_ids=processed_ids, duplicate_names=duplicate_names, processed_ids=processed_ids, duplicate_names=duplicate_names,
duplicate_groups=duplicate_groups, group_colors=group_colors, duplicate_groups=duplicate_groups, group_colors=group_colors,
site_name='FLARE FHIR IG Toolkit', now=datetime.now(), site_name='FHIRFLARE IG Toolkit', now=datetime.now(),
api_key=app.config['API_KEY']) # Pass the API key to the template api_key=app.config['API_KEY'],
config=app.config)
@app.route('/process-igs', methods=['POST']) @app.route('/process-igs', methods=['POST'])
def process_ig(): def process_ig():
filename = request.form.get('filename') form = FlaskForm()
if not filename or not filename.endswith('.tgz'): if form.validate_on_submit():
flash("Invalid package file.", "error") filename = request.form.get('filename')
return redirect(url_for('view_igs')) if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename) return redirect(url_for('view_igs'))
if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error") tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
return redirect(url_for('view_igs')) if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error")
try: return redirect(url_for('view_igs'))
# Parse name and version from filename
last_hyphen_index = filename.rfind('-') try:
if last_hyphen_index != -1 and filename.endswith('.tgz'): last_hyphen_index = filename.rfind('-')
name = filename[:last_hyphen_index] if last_hyphen_index != -1 and filename.endswith('.tgz'):
version = filename[last_hyphen_index + 1:-4] name = filename[:last_hyphen_index]
# Replace underscores with dots to match FHIR package naming convention version = filename[last_hyphen_index + 1:-4]
name = name.replace('_', '.') name = name.replace('_', '.')
else: else:
name = filename[:-4] name = filename[:-4]
version = '' version = ''
logger.warning(f"Could not parse version from {filename} during processing") logger.warning(f"Could not parse version from {filename} during processing")
package_info = services.process_package_file(tgz_path) package_info = services.process_package_file(tgz_path)
processed_ig = ProcessedIg( processed_ig = ProcessedIg(
package_name=name, package_name=name,
version=version, version=version,
processed_date=datetime.now(), processed_date=datetime.now(),
resource_types_info=package_info['resource_types_info'], resource_types_info=package_info['resource_types_info'],
must_support_elements=package_info.get('must_support_elements'), must_support_elements=package_info.get('must_support_elements'),
examples=package_info.get('examples') examples=package_info.get('examples')
) )
db.session.add(processed_ig) db.session.add(processed_ig)
db.session.commit() db.session.commit()
flash(f"Successfully processed {name}#{version}!", "success") flash(f"Successfully processed {name}#{version}!", "success")
except Exception as e: except Exception as e:
flash(f"Error processing IG: {str(e)}", "error") flash(f"Error processing IG: {str(e)}", "error")
else:
flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs')) return redirect(url_for('view_igs'))
@app.route('/delete-ig', methods=['POST']) @app.route('/delete-ig', methods=['POST'])
def delete_ig(): def delete_ig():
filename = request.form.get('filename') form = FlaskForm()
if not filename or not filename.endswith('.tgz'): if form.validate_on_submit():
flash("Invalid package file.", "error") filename = request.form.get('filename')
return redirect(url_for('view_igs')) if not filename or not filename.endswith('.tgz'):
flash("Invalid package file.", "error")
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename) return redirect(url_for('view_igs'))
metadata_path = tgz_path.replace('.tgz', '.metadata.json')
if os.path.exists(tgz_path): tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
try: metadata_path = tgz_path.replace('.tgz', '.metadata.json')
os.remove(tgz_path) if os.path.exists(tgz_path):
if os.path.exists(metadata_path): try:
os.remove(metadata_path) os.remove(tgz_path)
logger.debug(f"Deleted metadata file: {metadata_path}") if os.path.exists(metadata_path):
flash(f"Deleted {filename}", "success") os.remove(metadata_path)
except Exception as e: logger.debug(f"Deleted metadata file: {metadata_path}")
flash(f"Error deleting {filename}: {str(e)}", "error") 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: else:
flash(f"File not found: {filename}", "error") flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs')) return redirect(url_for('view_igs'))
@app.route('/unload-ig', methods=['POST']) @app.route('/unload-ig', methods=['POST'])
def unload_ig(): def unload_ig():
ig_id = request.form.get('ig_id') form = FlaskForm()
if not ig_id: if form.validate_on_submit():
flash("Invalid package ID.", "error") ig_id = request.form.get('ig_id')
return redirect(url_for('view_igs')) if not ig_id:
flash("Invalid package ID.", "error")
processed_ig = db.session.get(ProcessedIg, ig_id) return redirect(url_for('view_igs'))
if processed_ig:
try: processed_ig = db.session.get(ProcessedIg, ig_id)
db.session.delete(processed_ig) if processed_ig:
db.session.commit() try:
flash(f"Unloaded {processed_ig.package_name}#{processed_ig.version}", "success") db.session.delete(processed_ig)
except Exception as e: db.session.commit()
flash(f"Error unloading package: {str(e)}", "error") 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: else:
flash(f"Package not found with ID: {ig_id}", "error") flash("CSRF token missing or invalid.", "error")
return redirect(url_for('view_igs')) return redirect(url_for('view_igs'))
@app.route('/view-ig/<int:processed_ig_id>') @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')] 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')] base_list = [t for t in processed_ig.resource_types_info if not t.get('is_profile')]
examples_by_type = processed_ig.examples or {} 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}", 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, 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') @app.route('/get-structure')
def get_structure_definition(): def get_structure_definition():
@ -332,7 +341,6 @@ def get_structure_definition():
if not all([package_name, package_version, resource_identifier]): if not all([package_name, package_version, resource_identifier]):
return jsonify({"error": "Missing query parameters"}), 400 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)) tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], services._construct_tgz_filename(package_name, package_version))
sd_data = None sd_data = None
fallback_used = False fallback_used = False
@ -340,13 +348,11 @@ def get_structure_definition():
if os.path.exists(tgz_path): if os.path.exists(tgz_path):
sd_data, _ = services.find_and_extract_sd(tgz_path, resource_identifier) 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: 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") 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_name = "hl7.fhir.r4.core"
core_package_version = "4.0.1" 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)) 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): if not os.path.exists(core_tgz_path):
logger.debug(f"Core package {core_package_name}#{core_package_version} not found, attempting to download") 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)}") 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 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): if os.path.exists(core_tgz_path):
sd_data, _ = services.find_and_extract_sd(core_tgz_path, resource_identifier) sd_data, _ = services.find_and_extract_sd(core_tgz_path, resource_identifier)
if sd_data is None: if sd_data is None:
@ -415,55 +420,59 @@ def get_package_metadata():
return jsonify({'dependency_mode': metadata['dependency_mode']}) return jsonify({'dependency_mode': metadata['dependency_mode']})
return jsonify({'error': 'Metadata not found'}), 404 return jsonify({'error': 'Metadata not found'}), 404
# API Endpoint: Import IG Package
@app.route('/api/import-ig', methods=['POST']) @app.route('/api/import-ig', methods=['POST'])
def api_import_ig(): def api_import_ig():
# Check API key
auth_error = check_api_key() auth_error = check_api_key()
if auth_error: if auth_error:
return auth_error return auth_error
# Validate request
if not request.is_json: if not request.is_json:
return jsonify({"status": "error", "message": "Request must be JSON"}), 400 return jsonify({"status": "error", "message": "Request must be JSON"}), 400
data = request.get_json() data = request.get_json()
package_name = data.get('package_name') package_name = data.get('package_name')
version = data.get('version') 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: if not package_name or not version:
return jsonify({"status": "error", "message": "Missing package_name or version"}), 400 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 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-]+(\.[a-zA-Z0-9-]+)+$', package_name) and
re.match(r'^[a-zA-Z0-9\.\-]+$', version)): re.match(r'^[a-zA-Z0-9\.\-]+$', version)):
return jsonify({"status": "error", "message": "Invalid package name or version format"}), 400 return jsonify({"status": "error", "message": "Invalid package name or version format"}), 400
# Validate dependency mode
valid_modes = ['recursive', 'patch-canonical', 'tree-shaking'] valid_modes = ['recursive', 'patch-canonical', 'tree-shaking']
if dependency_mode not in valid_modes: 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 return jsonify({"status": "error", "message": f"Invalid dependency mode: {dependency_mode}. Must be one of {valid_modes}"}), 400
try: try:
# Import package and dependencies
result = services.import_package_and_dependencies(package_name, version, dependency_mode=dependency_mode) result = services.import_package_and_dependencies(package_name, version, dependency_mode=dependency_mode)
if result['errors'] and not result['downloaded']: if result['errors'] and not result['downloaded']:
return jsonify({"status": "error", "message": f"Failed to import {package_name}#{version}: {result['errors'][0]}"}), 500 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 # Check for duplicates
packages = [] packages = []
packages_dir = app.config['FHIR_PACKAGES_DIR'] packages_dir = app.config['FHIR_PACKAGES_DIR']
if os.path.exists(packages_dir): if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir): for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'): if filename.endswith('.tgz'):
# Split on the last hyphen to separate name and version
last_hyphen_index = filename.rfind('-') last_hyphen_index = filename.rfind('-')
if last_hyphen_index != -1 and filename.endswith('.tgz'): if last_hyphen_index != -1 and filename.endswith('.tgz'):
name = filename[:last_hyphen_index] name = filename[:last_hyphen_index]
version = filename[last_hyphen_index + 1:-4] version = filename[last_hyphen_index + 1:-4]
# Replace underscores with dots to match FHIR package naming convention
name = name.replace('_', '.') name = name.replace('_', '.')
if version[0].isdigit() or version in ('preview', 'current', 'latest'): if version[0].isdigit() or version in ('preview', 'current', 'latest'):
packages.append({'name': name, 'version': version, 'filename': filename}) packages.append({'name': name, 'version': version, 'filename': filename})
@ -476,7 +485,6 @@ def api_import_ig():
version = '' version = ''
packages.append({'name': name, 'version': version, 'filename': filename}) packages.append({'name': name, 'version': version, 'filename': filename})
# Calculate duplicates
duplicate_names = {} duplicate_names = {}
for pkg in packages: for pkg in packages:
name = pkg['name'] name = pkg['name']
@ -490,7 +498,6 @@ def api_import_ig():
versions = [pkg['version'] for pkg in pkgs] versions = [pkg['version'] for pkg in pkgs]
duplicates.append(f"{name} (exists as {', '.join(versions)})") duplicates.append(f"{name} (exists as {', '.join(versions)})")
# Deduplicate dependencies
seen = set() seen = set()
unique_dependencies = [] unique_dependencies = []
for dep in result.get('dependencies', []): for dep in result.get('dependencies', []):
@ -499,7 +506,6 @@ def api_import_ig():
seen.add(dep_str) seen.add(dep_str)
unique_dependencies.append(dep_str) unique_dependencies.append(dep_str)
# Prepare response
response = { response = {
"status": "success", "status": "success",
"message": "Package imported successfully", "message": "Package imported successfully",
@ -507,6 +513,8 @@ def api_import_ig():
"version": version, "version": version,
"dependency_mode": dependency_mode, "dependency_mode": dependency_mode,
"dependencies": unique_dependencies, "dependencies": unique_dependencies,
"complies_with_profiles": complies_with_profiles,
"imposed_profiles": imposed_profiles,
"duplicates": duplicates "duplicates": duplicates
} }
return jsonify(response), 200 return jsonify(response), 200
@ -515,15 +523,12 @@ def api_import_ig():
logger.error(f"Error in api_import_ig: {str(e)}") logger.error(f"Error in api_import_ig: {str(e)}")
return jsonify({"status": "error", "message": f"Error importing package: {str(e)}"}), 500 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']) @app.route('/api/push-ig', methods=['POST'])
def api_push_ig(): def api_push_ig():
# Check API key
auth_error = check_api_key() auth_error = check_api_key()
if auth_error: if auth_error:
return auth_error return auth_error
# Validate request
if not request.is_json: if not request.is_json:
return jsonify({"status": "error", "message": "Request must be JSON"}), 400 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]): if not all([package_name, version, fhir_server_url]):
return jsonify({"status": "error", "message": "Missing package_name, version, or fhir_server_url"}), 400 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 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-]+(\.[a-zA-Z0-9-]+)+$', package_name) and
re.match(r'^[a-zA-Z0-9\.\-]+$', version)): re.match(r'^[a-zA-Z0-9\.\-]+$', version)):
return jsonify({"status": "error", "message": "Invalid package name or version format"}), 400 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_filename = services._construct_tgz_filename(package_name, version)
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], tgz_filename) tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], tgz_filename)
if not os.path.exists(tgz_path): if not os.path.exists(tgz_path):
@ -550,21 +553,10 @@ def api_push_ig():
def generate_stream(): def generate_stream():
try: try:
# Start message
yield json.dumps({"type": "start", "message": f"Starting push for {package_name}#{version}..."}) + "\n" yield json.dumps({"type": "start", "message": f"Starting push for {package_name}#{version}..."}) + "\n"
# Extract resources from the main package
resources = [] resources = []
with tarfile.open(tgz_path, "r:gz") as tar: packages_to_push = [(package_name, version, tgz_path)]
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}"]
if include_dependencies: if include_dependencies:
yield json.dumps({"type": "progress", "message": "Processing dependencies..."}) + "\n" yield json.dumps({"type": "progress", "message": "Processing dependencies..."}) + "\n"
metadata = services.get_package_metadata(package_name, version) 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_filename = services._construct_tgz_filename(dep_name, dep_version)
dep_tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], dep_tgz_filename) dep_tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], dep_tgz_filename)
if os.path.exists(dep_tgz_path): if os.path.exists(dep_tgz_path):
with tarfile.open(dep_tgz_path, "r:gz") as tar: packages_to_push.append((dep_name, dep_version, dep_tgz_path))
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}")
yield json.dumps({"type": "progress", "message": f"Added dependency {dep_name}#{dep_version}"}) + "\n" yield json.dumps({"type": "progress", "message": f"Added dependency {dep_name}#{dep_version}"}) + "\n"
else: else:
yield json.dumps({"type": "warning", "message": f"Dependency {dep_name}#{dep_version} not found, skipping"}) + "\n" 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 = [] server_response = []
success_count = 0 success_count = 0
failure_count = 0 failure_count = 0
total_resources = len(resources) total_resources = len(resources)
yield json.dumps({"type": "progress", "message": f"Found {total_resources} resources to upload"}) + "\n" 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_type = resource.get('resourceType')
resource_id = resource.get('id') resource_id = resource.get('id')
if not resource_type or not resource_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 failure_count += 1
continue continue
# Construct the FHIR server URL for the resource
resource_url = f"{fhir_server_url.rstrip('/')}/{resource_type}/{resource_id}" 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: try:
response = requests.put(resource_url, json=resource, headers={'Content-Type': 'application/fhir+json'}) 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") server_response.append(f"Uploaded {resource_type}/{resource_id} successfully")
yield json.dumps({"type": "success", "message": f"Uploaded {resource_type}/{resource_id} successfully"}) + "\n" yield json.dumps({"type": "success", "message": f"Uploaded {resource_type}/{resource_id} successfully"}) + "\n"
success_count += 1 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: except requests.exceptions.RequestException as e:
error_msg = f"Failed to upload {resource_type}/{resource_id}: {str(e)}" error_msg = f"Failed to upload {resource_type}/{resource_id}: {str(e)}"
server_response.append(error_msg) server_response.append(error_msg)
yield json.dumps({"type": "error", "message": error_msg}) + "\n" yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1 failure_count += 1
# Final summary
summary = { summary = {
"status": "success" if failure_count == 0 else "partial", "status": "success" if failure_count == 0 else "partial",
"message": f"Push completed: {success_count} resources uploaded, {failure_count} failed", "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') 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(): with app.app_context():
logger.debug(f"Creating database at: {app.config['SQLALCHEMY_DATABASE_URI']}") logger.debug(f"Creating database at: {app.config['SQLALCHEMY_DATABASE_URI']}")
db.create_all() try:
logger.debug("Database initialization complete") db.create_all()
logger.debug("Database initialization complete")
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
raise
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True) app.run(debug=True)

View File

@ -1,19 +1,44 @@
# app/modules/fhir_ig_importer/forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Regexp from wtforms.validators import DataRequired, Regexp, Optional
class IgImportForm(FlaskForm): class IgImportForm(FlaskForm):
"""Form for specifying an IG package to import.""" package_name = StringField('Package Name', validators=[
# 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(), 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', validators=[
package_version = StringField('Package Version (e.g., 4.1.0 or current)', validators=[
DataRequired(), 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", "package_name": "hl7.fhir.au.core",
"version": "1.1.0-preview", "version": "1.1.0-preview",
"dependency_mode": "tree-shaking", "dependency_mode": "recursive",
"imported_dependencies": [ "imported_dependencies": [
{ {
"name": "hl7.fhir.r4.core", "name": "hl7.fhir.r4.core",
@ -27,5 +27,7 @@
"name": "hl7.fhir.uv.ipa", "name": "hl7.fhir.uv.ipa",
"version": "1.0.0" "version": "1.0.0"
} }
] ],
"complies_with_profiles": [],
"imposed_profiles": []
} }

View File

@ -2,5 +2,7 @@
"package_name": "hl7.fhir.r4.core", "package_name": "hl7.fhir.r4.core",
"version": "4.0.1", "version": "4.0.1",
"dependency_mode": "recursive", "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"> <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> <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>
<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> </ul>
</div> </div>
</div> </div>

View File

@ -31,7 +31,6 @@
.badge.bg-danger { .badge.bg-danger {
background-color: #dc3545 !important; background-color: #dc3545 !important;
} }
/* Ensure table rows with custom background colors are not overridden by Bootstrap */
.table tr.bg-warning { .table tr.bg-warning {
background-color: #ffc107 !important; background-color: #ffc107 !important;
} }
@ -44,7 +43,6 @@
.table tr.bg-danger { .table tr.bg-danger {
background-color: #dc3545 !important; background-color: #dc3545 !important;
} }
/* Override Bootstrap's table background to allow custom row colors to show */
.table-custom-bg { .table-custom-bg {
--bs-table-bg: transparent !important; --bs-table-bg: transparent !important;
} }
@ -64,9 +62,7 @@
<div class="card-body"> <div class="card-body">
{% if packages %} {% if packages %}
<div class="table-responsive"> <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 Dependencies</small></p>
<p class="mb-2"><small><span class="badge bg-danger text-light border me-1">Risk:</span> Duplicate Dependency with Different Versions</small></p>
--------------------------------------------------------------------------------------------------------------------------------------------->
<table class="table table-sm table-hover table-custom-bg"> <table class="table table-sm table-hover table-custom-bg">
<thead> <thead>
<tr><th>Package Name</th><th>Version</th><th>Actions</th></tr> <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> <span class="btn btn-success disabled"><i class="bi bi-check-lg"></i> Processed</span>
{% else %} {% else %}
<form method="POST" action="{{ url_for('process_ig') }}" style="display:inline;"> <form method="POST" action="{{ url_for('process_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="filename" value="{{ pkg.filename }}"> <input type="hidden" name="filename" value="{{ pkg.filename }}">
<button type="submit" class="btn btn-outline-primary"><i class="bi bi-gear"></i> Process</button> <button type="submit" class="btn btn-outline-primary"><i class="bi bi-gear"></i> Process</button>
</form> </form>
{% endif %} {% endif %}
<form method="POST" action="{{ url_for('delete_ig') }}" style="display:inline;"> <form method="POST" action="{{ url_for('delete_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="filename" value="{{ pkg.filename }}"> <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> <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> </form>
@ -106,7 +104,6 @@
</table> </table>
</div> </div>
{% if duplicate_groups %} {% 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: <p class="mt-2 small text-muted">Duplicate dependencies detected:
{% for name, versions in duplicate_groups.items() %} {% for name, versions in duplicate_groups.items() %}
{% set group_color = group_colors[name] if name in group_colors else 'bg-warning' %} {% 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"> <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> <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 method="POST" action="{{ url_for('unload_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="ig_id" value="{{ processed_ig.id }}"> <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> <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> </form>

View File

@ -12,9 +12,6 @@
<tr> <tr>
<th>Package Name</th> <th>Package Name</th>
<th>Version</th> <th>Version</th>
<!--------------------------------------------------------------------------------------------------------------if you unhide buttons unhide this--------------------------------------------
<th>Actions</th>
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -27,20 +24,6 @@
<tr {% if duplicate_group %}class="{{ group_colors[name] }}"{% endif %}> <tr {% if duplicate_group %}class="{{ group_colors[name] }}"{% endif %}>
<td>{{ name }}</td> <td>{{ name }}</td>
<td>{{ version }}</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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -65,6 +48,7 @@
<div class="col-md-6"> <div class="col-md-6">
<h2>Push IGs to FHIR Server</h2> <h2>Push IGs to FHIR Server</h2>
<form id="pushIgForm" method="POST"> <form id="pushIgForm" method="POST">
{{ form.csrf_token }}
<div class="mb-3"> <div class="mb-3">
<label for="packageSelect" class="form-label">Select Package to Push</label> <label for="packageSelect" class="form-label">Select Package to Push</label>
<select class="form-select" id="packageSelect" name="package_id" required> <select class="form-select" id="packageSelect" name="package_id" required>
@ -115,6 +99,7 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const packageSelect = document.getElementById('packageSelect'); const packageSelect = document.getElementById('packageSelect');
const dependencyModeField = document.getElementById('dependencyMode'); const dependencyModeField = document.getElementById('dependencyMode');
const csrfToken = "{{ form.csrf_token._value() }}";
// Update dependency mode when package selection changes // Update dependency mode when package selection changes
packageSelect.addEventListener('change', function() { packageSelect.addEventListener('change', function() {
@ -143,70 +128,135 @@ document.addEventListener('DOMContentLoaded', function() {
if (packageSelect.value) { if (packageSelect.value) {
packageSelect.dispatchEvent(new Event('change')); packageSelect.dispatchEvent(new Event('change'));
} }
});
document.getElementById('pushIgForm').addEventListener('submit', async function(event) { document.getElementById('pushIgForm').addEventListener('submit', async function(event) {
event.preventDefault(); event.preventDefault();
const form = event.target; const form = event.target;
const pushButton = document.getElementById('pushButton'); const pushButton = document.getElementById('pushButton');
const formData = new FormData(form); const formData = new FormData(form);
const packageId = formData.get('package_id'); const packageId = formData.get('package_id');
const [packageName, version] = packageId.split('#'); const [packageName, version] = packageId.split('#');
const fhirServerUrl = formData.get('fhir_server_url'); const fhirServerUrl = formData.get('fhir_server_url');
const includeDependencies = formData.get('include_dependencies') === 'on'; const includeDependencies = formData.get('include_dependencies') === 'on';
// Disable the button to prevent multiple submissions // Disable the button to prevent multiple submissions
pushButton.disabled = true; pushButton.disabled = true;
pushButton.textContent = 'Pushing...'; pushButton.textContent = 'Pushing...';
// Clear the console and response area // Clear the console and response area
const liveConsole = document.getElementById('liveConsole'); const liveConsole = document.getElementById('liveConsole');
const responseDiv = document.getElementById('pushResponse'); const responseDiv = document.getElementById('pushResponse');
liveConsole.innerHTML = '<div>Starting push operation...</div>'; liveConsole.innerHTML = '<div>Starting push operation...</div>';
responseDiv.innerHTML = ''; responseDiv.innerHTML = '';
try { try {
const response = await fetch('/api/push-ig', { const response = await fetch('/api/push-ig', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/x-ndjson' 'Accept': 'application/x-ndjson',
}, 'X-CSRFToken': csrfToken
body: JSON.stringify({ },
package_name: packageName, body: JSON.stringify({
version: version, package_name: packageName,
fhir_server_url: fhirServerUrl, version: version,
include_dependencies: includeDependencies, fhir_server_url: fhirServerUrl,
api_key: '{{ api_key | safe }}' // Use the API key passed from the server include_dependencies: includeDependencies,
}) api_key: '{{ api_key | safe }}'
}); })
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.message || 'Failed to push package'); throw new Error(errorData.message || 'Failed to push package');
} }
// Stream the response // Stream the response
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; 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) for (const line of lines) {
const lines = buffer.split('\n'); if (!line.trim()) continue;
buffer = lines.pop(); // Keep the last (possibly incomplete) line in the buffer try {
const data = JSON.parse(line);
const timestamp = new Date().toLocaleTimeString();
let messageClass = '';
let prefix = '';
for (const line of lines) { switch (data.type) {
if (!line.trim()) continue; // Skip empty lines 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 { try {
const data = JSON.parse(line); const data = JSON.parse(buffer);
const timestamp = new Date().toLocaleTimeString(); const timestamp = new Date().toLocaleTimeString();
let messageClass = ''; let messageClass = '';
let prefix = ''; let prefix = '';
@ -230,7 +280,6 @@ document.getElementById('pushIgForm').addEventListener('submit', async function(
prefix = '[WARNING]'; prefix = '[WARNING]';
break; break;
case 'complete': case 'complete':
// Display the final summary in the response area
if (data.data.status === 'success') { if (data.data.status === 'success') {
responseDiv.innerHTML = ` responseDiv.innerHTML = `
<div class="alert alert-success"> <div class="alert alert-success">
@ -254,7 +303,6 @@ document.getElementById('pushIgForm').addEventListener('submit', async function(
</div> </div>
`; `;
} }
// Also log the completion in the console
messageClass = data.data.status === 'success' ? 'text-success' : (data.data.status === 'partial' ? 'text-warning' : 'text-danger'); 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]'); prefix = data.data.status === 'success' ? '[SUCCESS]' : (data.data.status === 'partial' ? '[PARTIAL]' : '[ERROR]');
break; break;
@ -267,103 +315,30 @@ document.getElementById('pushIgForm').addEventListener('submit', async function(
messageDiv.className = messageClass; messageDiv.className = messageClass;
messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown message'}`; messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown message'}`;
liveConsole.appendChild(messageDiv); liveConsole.appendChild(messageDiv);
// Auto-scroll to the bottom
liveConsole.scrollTop = liveConsole.scrollHeight; liveConsole.scrollTop = liveConsole.scrollHeight;
} catch (parseError) { } 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> </script>
{% endblock %} {% endblock %}

View File

@ -37,6 +37,35 @@
</div> </div>
</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 mt-4">
<div class="card-header">Resource Types Found / Defined</div> <div class="card-header">Resource Types Found / Defined</div>
<div class="card-body"> <div class="card-body">

View File

@ -6,13 +6,13 @@
<h1 class="display-5 fw-bold text-body-emphasis">Welcome to {{ site_name }}</h1> <h1 class="display-5 fw-bold text-body-emphasis">Welcome to {{ site_name }}</h1>
<div class="col-lg-6 mx-auto"> <div class="col-lg-6 mx-auto">
<p class="lead mb-4"> <p class="lead mb-4">
Simple tool for importing and viewing FHIR Implementation Guides. Simple tool for importing, viewing, and validating FHIR Implementation Guides.
</p> </p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center"> <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('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('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> </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 %}