updated structure, new highlighting and slicing changes,

This commit is contained in:
Joshua Hare 2025-04-14 07:28:51 +10:00
parent 9b904d980f
commit f57a616979
28 changed files with 2006 additions and 2189 deletions

View File

@ -11,7 +11,6 @@ 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/

279
README.md
View File

@ -2,17 +2,18 @@
## Overview
The FHIRFLARE IG Toolkit is a Flask-based web application designed to simplify the management, processing, and deployment of FHIR Implementation Guides (IGs). It allows users to import IG packages, process them to extract resource types and profiles, and push them to a FHIR server. The application features a user-friendly interface with a live console for real-time feedback during operations like pushing IGs to a FHIR server.
The FHIRFLARE IG Toolkit is a Flask-based web application designed to simplify the management, processing, validation, and deployment of FHIR Implementation Guides (IGs). It allows users to import IG packages, process them to extract resource types and profiles, validate FHIR resources or bundles against IGs, and push IGs to a FHIR server. The application features a user-friendly interface with a live console for real-time feedback during operations like pushing IGs or validating resources.
## Features
- **Import IGs**: Download FHIR IG packages and their dependencies from a package registry.
- **Manage IGs**: View, process, and delete downloaded IGs, with duplicate detection.
- **Import IGs**: Download FHIR IG packages and their dependencies from a package registry, supporting flexible version formats (e.g., `1.2.3`, `1.1.0-preview`, `1.1.2-ballot`, `current`).
- **Manage IGs**: View, process, unload, or delete downloaded IGs, with duplicate detection and resolution.
- **Process IGs**: Extract resource types, profiles, must-support elements, examples, and profile relationships (`compliesWithProfile` and `imposeProfile`) from IGs.
- **Validate FHIR Resources/Bundles**: Validate single FHIR resources or bundles against selected IGs, with detailed error and warning reports (alpha feature, work in progress).
- **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.
- **API Support**: RESTful API endpoints for importing, pushing, and validating IGs, including profile relationship metadata.
- **Live Console**: Displays real-time logs during push and validation operations.
- **Configurable Behavior**: Options to enable/disable imposed profile validation and UI display of profile relationships.
## Technology Stack
@ -22,10 +23,10 @@ The FHIRFLARE IG Toolkit is built using the following technologies:
- **Python 3.9+**: Core programming language for the backend.
- **Flask 2.0+**: Lightweight web framework for building the application.
- **Flask-SQLAlchemy**: ORM for managing the SQLite database.
- **Flask-WTF**: Handles form creation and CSRF protection.
- **Flask-WTF**: Handles form creation, validation, and CSRF protection.
- **Jinja2**: Templating engine for rendering HTML pages.
- **Bootstrap 5**: Frontend framework for responsive UI design.
- **JavaScript (ES6)**: Client-side scripting for interactive features like the live console.
- **JavaScript (ES6)**: Client-side scripting for interactive features like the live console and JSON validation preview.
- **SQLite**: Lightweight database for storing processed IG metadata.
- **Docker**: Containerization for consistent deployment.
- **Requests**: Python library for making HTTP requests to FHIR servers.
@ -55,21 +56,29 @@ The FHIRFLARE IG Toolkit is built using the following technologies:
```
3. **Set Environment Variables**:
Set the `FLASK_SECRET_KEY` and `API_KEY` environment variables for security:
Set the `FLASK_SECRET_KEY` and `API_KEY` for security and CSRF protection:
```bash
export FLASK_SECRET_KEY='your-secure-secret-key'
export API_KEY='your-api-key'
```
4. **Run the Application Locally**:
4. **Initialize the Database**:
Ensure the `instance` directory is writable for SQLite:
```bash
mkdir -p instance
chmod -R 777 instance
```
5. **Run the Application Locally**:
Start the Flask development server:
```bash
export FLASK_APP=app.py
flask run
```
The application will be available at `http://localhost:5000`.
5. **Run with Docker**:
Build and run the application using Docker:
6. **Run with Docker**:
Build and run the application using Docker, mounting the `instance` directory for persistence:
```bash
docker build -t flare-fhir-ig-toolkit .
docker run -p 5000:5000 -e FLASK_SECRET_KEY='your-secure-secret-key' -e API_KEY='your-api-key' -v $(pwd)/instance:/app/instance flare-fhir-ig-toolkit
@ -80,232 +89,220 @@ The FHIRFLARE IG Toolkit is built using the following technologies:
1. **Import an IG**:
- Navigate to the "Import IG" tab.
- Enter a package name (e.g., `hl7.fhir.us.core`) and version (e.g., `3.1.1`).
- Click "Fetch & Download IG" to download the package and its dependencies.
- Enter a package name (e.g., `hl7.fhir.au.core`) and version (e.g., `1.1.0-preview`, `1.1.2-ballot`, or `current`).
- Choose a dependency mode (e.g., Recursive, Tree Shaking).
- Click "Import" to download the package and its dependencies.
2. **Manage IGs**:
- Go to the "Manage FHIR Packages" tab to view downloaded IGs.
- Process, delete, or view details of IGs. Duplicates are highlighted for resolution.
- Go to the "Manage FHIR Packages" tab to view downloaded and processed IGs.
- Process IGs to extract metadata, unload processed IGs from the database, or delete packages from the filesystem.
- Duplicates are highlighted for resolution (e.g., multiple versions of the same package).
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.
- Profile relationships are displayed if enabled via `DISPLAY_PROFILE_RELATIONSHIPS`.
4. **Push IGs to a FHIR Server**:
4. **Validate FHIR Resources/Bundles**:
- Navigate to the "Validate FHIR Sample" tab.
- Select or enter a package (e.g., `hl7.fhir.au.core#1.1.0-preview`).
- Choose validation mode (Single Resource or Bundle).
- Paste FHIR JSON (e.g., a Patient or AllergyIntolerance resource).
- Submit to validate, viewing errors and warnings in the report.
- Note: Validation is in alpha; report issues to GitHub (remove PHI).
5. **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 validation against imposed profiles (if enabled via `VALIDATE_IMPOSED_PROFILES`) and progress shown in the live console.
5. **API Usage**:
6. **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"}'
-d '{"package_name": "hl7.fhir.au.core", "version": "1.1.0-preview", "api_key": "your-api-key"}'
```
Response includes `complies_with_profiles` and `imposed_profiles` if present.
Response includes `complies_with_profiles`, `imposed_profiles`, and duplicates.
- **Push IG**: `POST /api/push-ig`
```bash
curl -X POST http://localhost:5000/api/push-ig \
-H "Content-Type: application/json" \
-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.au.core", "version": "1.1.0-preview", "fhir_server_url": "http://hapi.fhir.org/baseR4", "include_dependencies": true, "api_key": "your-api-key"}'
```
Resources are validated against imposed profiles before pushing.
- **Validate Resource/Bundle**: Not yet exposed via API; use the UI at `/validate-sample`.
## 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.
- **`VALIDATE_IMPOSED_PROFILES`**: Set to `True` (default) to validate resources against imposed profiles during push operations. Set to `False` to skip:
```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.
- **`DISPLAY_PROFILE_RELATIONSHIPS`**: Set to `True` (default) to show `compliesWithProfile` and `imposeProfile` in the UI. Set to `False` to hide:
```python
app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = False
```
- **`FHIR_PACKAGES_DIR`**: Directory for storing `.tgz` packages and metadata (default: `/app/instance/fhir_packages`).
- **`SECRET_KEY`**: Required for CSRF protection and session security:
```python
app.config['SECRET_KEY'] = 'your-secure-secret-key'
```
## Testing
The project includes a comprehensive test suite to ensure the reliability of the application. Tests cover the UI, API endpoints, database operations, file handling, and security features like CSRF protection.
The project includes a test suite to ensure reliability, covering UI, API, database, file operations, and security features.
### Test Prerequisites
- **pytest**: For running the tests.
- **unittest-mock**: For mocking dependencies in tests.
- **pytest**: For running tests.
- **unittest-mock**: For mocking dependencies.
Install the test dependencies:
Install test dependencies:
```bash
pip install pytest unittest-mock
pip install pytest pytest-mock
```
### Running Tests
1. **Navigate to the Project Root**:
1. **Navigate to Project Root**:
```bash
cd /path/to/fhirflare-ig-toolkit
```
2. **Run the Tests**:
Run the test suite using `pytest`:
2. **Run Tests**:
```bash
pytest tests/test_app.py -v
```
- The `-v` flag provides verbose output, showing the status of each test.
- Alternatively, run from the `tests/` directory with:
```bash
cd tests/
pytest test_app.py -v
```
- `-v` provides verbose output.
- Tests are in `tests/test_app.py`, covering 27 cases.
### Test Coverage
The test suite includes 27 test cases covering the following areas:
Tests include:
- **UI Pages**:
- Homepage (`/`): Rendering and content.
- 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, including profile relationships.
- Homepage, Import IG, Manage IGs, Push IGs, Validate Sample, View Processed IG.
- Form rendering, submissions, and error handling (e.g., invalid JSON, CSRF).
- **API Endpoints**:
- `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).
- **Database Operations**:
- Processing IGs: Storing processed IG data in the database.
- Unloading IGs: Removing processed IG records.
- Viewing processed IGs: Retrieving and displaying processed IG data.
- `POST /api/import-ig`: Success, invalid key, duplicates, profile relationships.
- `POST /api/push-ig`: Success, validation, errors.
- `GET /get-structure`, `GET /get-example`: Success and failure cases.
- **Database**:
- Processing, unloading, and viewing IGs.
- **File Operations**:
- Processing IG packages: Extracting data from `.tgz` files, including profile relationships.
- Deleting IG packages: Removing `.tgz` files from the filesystem.
- Package processing, deletion.
- **Security**:
- Secret Key: CSRF protection for form submissions.
- Flash Messages: Session integrity for flash messages.
- CSRF protection, flash messages, secret key.
### Example Test Output
A successful test run will look like this:
```
================================================================ test session starts =================================================================
platform linux -- Python 3.9.22, pytest-8.3.5, pluggy-1.5.0 -- /usr/local/bin/python3.9
cachedir: .pytest_cache
platform linux -- Python 3.9.22, pytest-8.3.5, pluggy-1.5.0
rootdir: /app/tests
collected 27 items
test_app.py::TestFHIRFlareIGToolkit::test_homepage PASSED [ 3%]
test_app.py::TestFHIRFlareIGToolkit::test_import_ig_page PASSED [ 7%]
test_app.py::TestFHIRFlareIGToolkit::test_import_ig_success PASSED [ 11%]
test_app.py::TestFHIRFlareIGToolkit::test_import_ig_failure PASSED [ 14%]
test_app.py::TestFHIRFlareIGToolkit::test_import_ig_invalid_input PASSED [ 18%]
test_app.py::TestFHIRFlareIGToolkit::test_view_igs_no_packages PASSED [ 22%]
test_app.py::TestFHIRFlareIGToolkit::test_view_igs_with_packages PASSED [ 25%]
test_app.py::TestFHIRFlareIGToolkit::test_process_ig_success PASSED [ 29%]
test_app.py::TestFHIRFlareIGToolkit::test_process_ig_invalid_file PASSED [ 33%]
test_app.py::TestFHIRFlareIGToolkit::test_delete_ig_success PASSED [ 37%]
test_app.py::TestFHIRFlareIGToolkit::test_delete_ig_file_not_found PASSED [ 40%]
test_app.py::TestFHIRFlareIGToolkit::test_unload_ig_success PASSED [ 44%]
test_app.py::TestFHIRFlareIGToolkit::test_unload_ig_invalid_id PASSED [ 48%]
test_app.py::TestFHIRFlareIGToolkit::test_view_processed_ig PASSED [ 51%]
test_app.py::TestFHIRFlareIGToolkit::test_push_igs_page PASSED [ 55%]
test_app.py::TestFHIRFlareIGToolkit::test_api_import_ig_success PASSED [ 59%]
test_app.py::TestFHIRFlareIGToolkit::test_api_import_ig_invalid_api_key PASSED [ 62%]
test_app.py::TestFHIRFlareIGToolkit::test_api_import_ig_missing_params PASSED [ 66%]
test_app.py::TestFHIRFlareIGToolkit::test_api_push_ig_success PASSED [ 70%]
test_app.py::TestFHIRFlareIGToolkit::test_api_push_ig_invalid_api_key PASSED [ 74%]
test_app.py::TestFHIRFlareIGToolkit::test_api_push_ig_package_not_found PASSED [ 77%]
test_app.py::TestFHIRFlareIGToolkit::test_secret_key_csrf PASSED [ 81%]
test_app.py::TestFHIRFlareIGToolkit::test_secret_key_flash_messages PASSED [ 85%]
test_app.py::TestFHIRFlareIGToolkit::test_get_structure_definition_success PASSED [ 88%]
test_app.py::TestFHIRFlareIGToolkit::test_get_structure_definition_not_found PASSED [ 92%]
test_app.py::TestFHIRFlareIGToolkit::test_get_example_content_success PASSED [ 96%]
test_app.py::TestFHIRFlareIGToolkit::test_get_example_content_invalid_path PASSED [100%]
...
test_app.py::TestFHIRFlareIGToolkit::test_validate_sample_page PASSED [ 85%]
test_app.py::TestFHIRFlareIGToolkit::test_validate_sample_success PASSED [ 88%]
...
============================================================= 27 passed in 1.23s ==============================================================
```
### Troubleshooting Tests
- **ModuleNotFoundError**: If you encounter `ModuleNotFoundError: No module named 'app'`, ensure youre running the tests from the project root (`/app/`) or that the `sys.path` modification in `test_app.py` is correctly adding the parent directory.
- **Missing Templates**: If tests fail with `TemplateNotFound`, ensure all required templates (`index.html`, `import_ig.html`, etc.) are in the `/app/templates/` directory.
- **Missing Dependencies**: If tests fail due to missing `services.py` or its functions, ensure `services.py` is present in `/app/` and contains the required functions (`import_package_and_dependencies`, `process_package_file`, etc.).
- **ModuleNotFoundError**: Ensure `app.py`, `services.py`, and `forms.py` are in `/app/`. Run tests from the project root.
- **TemplateNotFound**: Verify templates (`validate_sample.html`, etc.) are in `/app/templates/`.
- **Database Errors**: Ensure `instance/fhir_ig.db` is writable (`chmod 777 instance`).
- **Mock Failures**: Check `tests/test_app.py` for correct mocking of `services.py` functions.
## Development Notes
### 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 and profile relationships—a common challenge in FHIR development.
The toolkit addresses the need for a user-friendly FHIR IG management tool, with recent enhancements for resource validation and flexible version handling (e.g., `1.1.0-preview`).
### Technical Decisions
- **Flask**: Chosen for its lightweight and flexible nature, making it ideal for a small to medium-sized web application.
- **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.
- **Flask**: Lightweight and flexible for web development.
- **SQLite**: Simple for development; consider PostgreSQL for production.
- **Bootstrap 5**: Responsive UI with custom CSS for duplicate highlighting.
- **Flask-WTF**: Robust form validation and CSRF protection.
- **Docker**: Ensures consistent deployment.
- **Flexible Versioning**: Supports non-standard version formats for FHIR IGs (e.g., `-preview`, `-ballot`).
- **Validation**: Alpha feature for validating FHIR resources/bundles, with ongoing improvements to FHIRPath handling.
### Recent Updates
- **Version Format Support**: Added support for flexible IG version formats (e.g., `1.1.0-preview`, `1.1.2-ballot`, `current`) in `forms.py`.
- **CSRF Protection**: Fixed missing CSRF tokens in `cp_downloaded_igs.html` and `cp_push_igs.html`, ensuring secure form submissions.
- **Form Handling**: Updated `validate_sample.html` and `app.py` to use `version` instead of `package_version`, aligning with `ValidationForm`.
- **Validation Feature**: Added alpha support for validating FHIR resources/bundles against IGs, with error/warning reports (UI only).
### 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.
- **CSRF Errors**: Ensure `SECRET_KEY` is set and forms include `{{ form.csrf_token }}`. Check logs for `flask_wtf.csrf` errors.
- **Version Validation**: Previously restricted to `x.y.z`; now supports suffixes like `-preview`. Report any import issues.
- **Validation Accuracy**: Resource validation is alpha; FHIRPath logic may miss complex constraints. Report anomalies to GitHub (remove PHI).
- **Package Parsing**: Non-standard `.tgz` filenames may parse incorrectly. Fallback treats them as name-only packages.
- **Permissions**: Ensure `instance` directory is writable (`chmod -R 777 instance`) to avoid database or file errors.
### 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.
- [ ] **Sorting Versions**: Sort package versions in `/view-igs` (e.g., ascending).
- [ ] **Duplicate Resolution**: Add options to keep latest version or merge resources.
- [ ] **Production Database**: Support PostgreSQL for scalability.
- [ ] **Validation Enhancements**: Improve FHIRPath handling for complex constraints; add API endpoint for validation.
- [ ] **Error Reporting**: Enhance UI feedback for validation errors with specific element paths.
**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.)
- ~~Inbound API for IG Packages: Develop API endpoints to allow external tools to push IG packages to FHIRFLARE. The API should automatically resolve dependencies, return a list of dependencies, and identify any duplicate dependencies.~~ (Implemented as `POST /api/import-ig`.)
- ~~Outbound API for Pushing IGs to FHIR Servers: Create an outbound API to push a chosen IG (with its dependencies) to a FHIR server, or allow pushing a single IG without dependencies. The API should process the servers responses and provide feedback.~~ (Implemented as `POST /api/push-ig`.)
**Completed Items**:
- ~~Testing: Comprehensive test suite for UI, API, and database.~~
- ~~Inbound API: `POST /api/import-ig` with dependency and profile support.~~
- ~~Outbound API: `POST /api/push-ig` with validation and feedback.~~
- ~~Flexible Versioning: Support for `-preview`, `-ballot`, etc.~~
- ~~CSRF Fixes: Secured forms in `cp_downloaded_igs.html`, `cp_push_igs.html`.~~
- ~~Resource Validation: UI for validating resources/bundles (alpha).~~
### Far-Distant Improvements
- **Cache Service for IGs**: Implement a cache service to store all IGs, allowing for quick querying of package metadata without reprocessing. This could use an in-memory store like Redis to improve performance.
- **Database Index Optimization**: Modify the database structure to use a composite index on `package_name` and `version` (e.g., `ProcessedIg.package_name` + `ProcessedIg.version` as a unique key). This would allow the `/view-igs` page and API endpoints to directly query specific packages (e.g., `/api/ig/hl7.fhir.us.core/1.0.0`) without scanning the entire table.
### Contributing
Contributions are welcome! To contribute:
1. Fork the repository.
2. Create a new branch (`git checkout -b feature/your-feature`).
3. Make your changes and commit them (`git commit -m "Add your feature"`).
4. Push to your branch (`git push origin feature/your-feature`).
5. Open a Pull Request.
Please ensure your code follows the projects coding style and includes appropriate tests.
### Troubleshooting
- **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.
- **Cache Service**: Use Redis to cache IG metadata for faster queries.
- **Database Optimization**: Add composite index on `ProcessedIg.package_name` and `ProcessedIg.version` for efficient lookups.
## Directory Structure
- `app.py`: Main Flask application file.
- `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.
- `test_app.py`: Test suite for the application.
- `requirements.txt`: List of Python dependencies.
- `Dockerfile`: Docker configuration for containerized deployment.
- `app.py`: Main Flask application.
- `services.py`: Logic for IG import, processing, validation, and pushing.
- `forms.py`: Form definitions for import and validation.
- `templates/`: HTML templates (`validate_sample.html`, `cp_downloaded_igs.html`, etc.).
- `instance/`: SQLite database (`fhir_ig.db`) and packages (`fhir_packages/`).
- `tests/test_app.py`: Test suite with 27 cases.
- `requirements.txt`: Python dependencies.
- `Dockerfile`: Docker configuration.
## Contributing
Contributions are welcome! Please fork the repository, create a feature branch, and submit a pull request with your changes.
Contributions are welcome! To contribute:
1. Fork the repository.
2. Create a feature branch (`git checkout -b feature/your-feature`).
3. Commit changes (`git commit -m "Add your feature"`).
4. Push to your branch (`git push origin feature/your-feature`).
5. Open a Pull Request.
Ensure code follows style guidelines and includes tests.
## Troubleshooting
- **CSRF Errors**: Verify `SECRET_KEY` is set and forms include `{{ form.csrf_token }}`. Check browser DevTools for POST data.
- **Import Fails**: Confirm package name/version (e.g., `hl7.fhir.au.core#1.1.0-preview`) and internet connectivity.
- **Validation Errors**: Alpha feature; report issues to GitHub with JSON samples (remove PHI).
- **Database Issues**: Ensure `instance/fhir_ig.db` is writable (`chmod 777 instance`).
- **Docker Volume**: Mount `instance` directory to persist data:
```bash
docker run -v $(pwd)/instance:/app/instance ...
```
## License
This project is licensed under the Apache 2.0 License. See the `LICENSE` file for details.
Licensed under the Apache 2.0 License. See `LICENSE` for details.

View File

@ -1,28 +0,0 @@
# app/modules/fhir_ig_importer/__init__.py
from flask import Blueprint
# --- Module Metadata ---
metadata = {
'module_id': 'fhir_ig_importer', # Matches folder name
'display_name': 'FHIR IG Importer',
'description': 'Imports FHIR Implementation Guide packages from a registry.',
'version': '0.1.0',
# No main nav items, will be accessed via Control Panel
'nav_items': []
}
# --- End Module Metadata ---
# Define Blueprint
# We'll mount this under the control panel later
bp = Blueprint(
metadata['module_id'],
__name__,
template_folder='templates',
# Define a URL prefix if mounting standalone, but we'll likely register
# it under /control-panel via app/__init__.py later
# url_prefix='/fhir-importer'
)
# Import routes after creating blueprint
from . import routes, forms # Import forms too

1015
app.py

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Regexp, Optional
from wtforms.validators import DataRequired, Regexp, Optional, ValidationError
import json
class IgImportForm(FlaskForm):
package_name = StringField('Package Name', validators=[
@ -26,19 +28,21 @@ class ValidationForm(FlaskForm):
('single', 'Single Resource'),
('bundle', 'Bundle')
], default='single')
sample_input = TextAreaField('Sample Input', validators=[DataRequired()])
sample_input = TextAreaField('Sample Input', validators=[
DataRequired(),
lambda form, field: validate_json(field.data, form.mode.data)
])
submit = SubmitField('Validate')
def validate_json(field, mode):
def validate_json(data, 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):
parsed = json.loads(data)
if mode == 'single' and not isinstance(parsed, dict):
raise ValueError("Single resource mode requires a JSON object.")
if mode == 'bundle' and (not isinstance(data, dict) or data.get('resourceType') != 'Bundle'):
if mode == 'bundle' and (not isinstance(parsed, dict) or parsed.get('resourceType') != 'Bundle'):
raise ValueError("Bundle mode requires a JSON object with resourceType 'Bundle'.")
except json.JSONDecodeError:
raise ValueError("Invalid JSON format.")
raise ValidationError("Invalid JSON format.")
except ValueError as e:
raise ValueError(str(e))
raise ValidationError(str(e))

Binary file not shown.

View File

@ -1,21 +0,0 @@
{
"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": "recursive",
"dependency_mode": "tree-shaking",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
@ -29,5 +29,6 @@
}
],
"complies_with_profiles": [],
"imposed_profiles": []
"imposed_profiles": [],
"timestamp": "2025-04-13T13:43:59.139683+00:00"
}

View File

@ -1,8 +1,9 @@
{
"package_name": "hl7.fhir.r4.core",
"version": "4.0.1",
"dependency_mode": "recursive",
"dependency_mode": "tree-shaking",
"imported_dependencies": [],
"complies_with_profiles": [],
"imposed_profiles": []
"imposed_profiles": [],
"timestamp": "2025-04-13T13:44:17.228391+00:00"
}

View File

@ -1,13 +0,0 @@
{
"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

@ -1,21 +0,0 @@
{
"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": []
}

View File

@ -1,13 +0,0 @@
{
"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

@ -1,17 +0,0 @@
{
"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

@ -1,13 +0,0 @@
{
"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": []
}

View File

@ -1,13 +0,0 @@
{
"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": []
}

162
routes.py
View File

@ -1,162 +0,0 @@
# app/modules/fhir_ig_importer/routes.py
import requests
import os
import tarfile # Needed for find_and_extract_sd
import gzip
import json
import io
import re
from flask import (render_template, redirect, url_for, flash, request,
current_app, jsonify, send_file)
from flask_login import login_required
from app.decorators import admin_required
from werkzeug.utils import secure_filename
from . import bp
from .forms import IgImportForm
# Import the services module
from . import services
# Import ProcessedIg model for get_structure_definition
from app.models import ProcessedIg
from app import db
# --- Helper: Find/Extract SD ---
# Moved from services.py to be local to routes that use it, or keep in services and call services.find_and_extract_sd
def find_and_extract_sd(tgz_path, resource_identifier):
"""Helper to find and extract SD json from a given tgz path by ID, Name, or Type."""
sd_data = None; found_path = None; logger = current_app.logger # Use current_app logger
if not tgz_path or not os.path.exists(tgz_path): logger.error(f"File not found in find_and_extract_sd: {tgz_path}"); return None, None
try:
with tarfile.open(tgz_path, "r:gz") as tar:
logger.debug(f"Searching for SD matching '{resource_identifier}' in {os.path.basename(tgz_path)}")
for member in tar:
if member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json'):
if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']: continue
fileobj = None
try:
fileobj = tar.extractfile(member)
if fileobj:
content_bytes = fileobj.read(); content_string = content_bytes.decode('utf-8-sig'); data = json.loads(content_string)
if isinstance(data, dict) and data.get('resourceType') == 'StructureDefinition':
sd_id = data.get('id'); sd_name = data.get('name'); sd_type = data.get('type')
if resource_identifier == sd_type or resource_identifier == sd_id or resource_identifier == sd_name:
sd_data = data; found_path = member.name; logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}"); break
except Exception as e: logger.warning(f"Could not read/parse potential SD {member.name}: {e}")
finally:
if fileobj: fileobj.close()
if sd_data is None: logger.warning(f"SD matching '{resource_identifier}' not found within archive {os.path.basename(tgz_path)}")
except Exception as e: logger.error(f"Error reading archive {tgz_path} in find_and_extract_sd: {e}", exc_info=True); raise
return sd_data, found_path
# --- End Helper ---
# --- Route for the main import page ---
@bp.route('/import-ig', methods=['GET', 'POST'])
@login_required
@admin_required
def import_ig():
"""Handles FHIR IG recursive download using services."""
form = IgImportForm()
template_context = {"title": "Import FHIR IG", "form": form, "results": None }
if form.validate_on_submit():
package_name = form.package_name.data; package_version = form.package_version.data
template_context.update(package_name=package_name, package_version=package_version)
flash(f"Starting full import for {package_name}#{package_version}...", "info"); current_app.logger.info(f"Calling import service for: {package_name}#{package_version}")
try:
# Call the CORRECT orchestrator service function
import_results = services.import_package_and_dependencies(package_name, package_version)
template_context["results"] = import_results
# Flash summary messages
dl_count = len(import_results.get('downloaded', {})); proc_count = len(import_results.get('processed', set())); error_count = len(import_results.get('errors', []))
if dl_count > 0: flash(f"Downloaded/verified {dl_count} package file(s).", "success")
if proc_count < dl_count and dl_count > 0 : flash(f"Dependency data extraction failed for {dl_count - proc_count} package(s).", "warning")
if error_count > 0: flash(f"{error_count} total error(s) occurred.", "danger")
elif dl_count == 0 and error_count == 0: flash("No packages needed downloading or initial package failed.", "info")
elif error_count == 0: flash("Import process completed successfully.", "success")
except Exception as e:
fatal_error = f"Critical unexpected error during import: {e}"; template_context["fatal_error"] = fatal_error; current_app.logger.error(f"Critical import error: {e}", exc_info=True); flash(fatal_error, "danger")
return render_template('fhir_ig_importer/import_ig_page.html', **template_context)
return render_template('fhir_ig_importer/import_ig_page.html', **template_context)
# --- Route to get StructureDefinition elements ---
@bp.route('/get-structure')
@login_required
@admin_required
def get_structure_definition():
"""API endpoint to fetch SD elements and pre-calculated Must Support paths."""
package_name = request.args.get('package_name'); package_version = request.args.get('package_version'); resource_identifier = request.args.get('resource_type')
error_response_data = {"elements": [], "must_support_paths": []}
if not all([package_name, package_version, resource_identifier]): error_response_data["error"] = "Missing query parameters"; return jsonify(error_response_data), 400
current_app.logger.info(f"Request for structure: {package_name}#{package_version} / {resource_identifier}")
# Find the primary package file
package_dir_name = 'fhir_packages'; download_dir = os.path.join(current_app.instance_path, package_dir_name)
# Use service helper for consistency
filename = services._construct_tgz_filename(package_name, package_version)
tgz_path = os.path.join(download_dir, filename)
if not os.path.exists(tgz_path): error_response_data["error"] = f"Package file not found: {filename}"; return jsonify(error_response_data), 404
sd_data = None; found_path = None; error_msg = None
try:
# Call the local helper function correctly
sd_data, found_path = find_and_extract_sd(tgz_path, resource_identifier)
# Fallback check
if sd_data is None:
core_pkg_name = "hl7.fhir.r4.core"; core_pkg_version = "4.0.1" # TODO: Make dynamic
core_filename = services._construct_tgz_filename(core_pkg_name, core_pkg_version)
core_tgz_path = os.path.join(download_dir, core_filename)
if os.path.exists(core_tgz_path):
current_app.logger.info(f"Trying fallback search in {core_pkg_name}...")
sd_data, found_path = find_and_extract_sd(core_tgz_path, resource_identifier) # Call local helper
else: current_app.logger.warning(f"Core package {core_tgz_path} not found.")
except Exception as e:
error_msg = f"Error searching package(s): {e}"; current_app.logger.error(error_msg, exc_info=True); error_response_data["error"] = error_msg; return jsonify(error_response_data), 500
if sd_data is None: error_msg = f"SD for '{resource_identifier}' not found."; error_response_data["error"] = error_msg; return jsonify(error_response_data), 404
# Extract elements
elements = sd_data.get('snapshot', {}).get('element', [])
if not elements: elements = sd_data.get('differential', {}).get('element', [])
# Fetch pre-calculated Must Support paths from DB
must_support_paths = [];
try:
stmt = db.select(ProcessedIg).filter_by(package_name=package_name, package_version=package_version); processed_ig_record = db.session.scalar(stmt)
if processed_ig_record: all_ms_paths_dict = processed_ig_record.must_support_elements; must_support_paths = all_ms_paths_dict.get(resource_identifier, [])
else: current_app.logger.warning(f"No ProcessedIg record found for {package_name}#{package_version}")
except Exception as e: current_app.logger.error(f"Error fetching MS paths from DB: {e}", exc_info=True)
current_app.logger.info(f"Returning {len(elements)} elements for {resource_identifier} from {found_path or 'Unknown File'}")
return jsonify({"elements": elements, "must_support_paths": must_support_paths})
# --- Route to get raw example file content ---
@bp.route('/get-example')
@login_required
@admin_required
def get_example_content():
# ... (Function remains the same as response #147) ...
package_name = request.args.get('package_name'); package_version = request.args.get('package_version'); example_member_path = request.args.get('filename')
if not all([package_name, package_version, example_member_path]): return jsonify({"error": "Missing query parameters"}), 400
current_app.logger.info(f"Request for example: {package_name}#{package_version} / {example_member_path}")
package_dir_name = 'fhir_packages'; download_dir = os.path.join(current_app.instance_path, package_dir_name)
pkg_filename = services._construct_tgz_filename(package_name, package_version) # Use service helper
tgz_path = os.path.join(download_dir, pkg_filename)
if not os.path.exists(tgz_path): return jsonify({"error": f"Package file not found: {pkg_filename}"}), 404
# Basic security check on member path
safe_member_path = secure_filename(example_member_path.replace("package/","")) # Allow paths within package/
if not example_member_path.startswith('package/') or '..' in example_member_path: return jsonify({"error": "Invalid example file path."}), 400
try:
with tarfile.open(tgz_path, "r:gz") as tar:
try: example_member = tar.getmember(example_member_path) # Use original path here
except KeyError: return jsonify({"error": f"Example file '{example_member_path}' not found."}), 404
example_fileobj = tar.extractfile(example_member)
if not example_fileobj: return jsonify({"error": "Could not extract example file."}), 500
try: content_bytes = example_fileobj.read()
finally: example_fileobj.close()
return content_bytes # Return raw bytes
except tarfile.TarError as e: err_msg = f"Error reading {tgz_path}: {e}"; current_app.logger.error(err_msg); return jsonify({"error": err_msg}), 500
except Exception as e: err_msg = f"Unexpected error getting example {example_member_path}: {e}"; current_app.logger.error(err_msg, exc_info=True); return jsonify({"error": err_msg}), 500

File diff suppressed because it is too large Load Diff

View File

@ -17,35 +17,16 @@
</div>
<div class="container mt-4">
<!-- Add custom CSS for badge and table row colors -->
<style>
.badge.bg-warning {
background-color: #ffc107 !important;
}
.badge.bg-info {
background-color: #0dcaf0 !important;
}
.badge.bg-secondary {
background-color: #6c757d !important;
}
.badge.bg-danger {
background-color: #dc3545 !important;
}
.table tr.bg-warning {
background-color: #ffc107 !important;
}
.table tr.bg-info {
background-color: #0dcaf0 !important;
}
.table tr.bg-secondary {
background-color: #6c757d !important;
}
.table tr.bg-danger {
background-color: #dc3545 !important;
}
.table-custom-bg {
--bs-table-bg: transparent !important;
}
.badge.bg-warning { background-color: #ffc107 !important; }
.badge.bg-info { background-color: #0dcaf0 !important; }
.badge.bg-secondary { background-color: #6c757d !important; }
.badge.bg-danger { background-color: #dc3545 !important; }
.table tr.bg-warning { background-color: #ffc107 !important; }
.table tr.bg-info { background-color: #0dcaf0 !important; }
.table tr.bg-secondary { background-color: #6c757d !important; }
.table tr.bg-danger { background-color: #dc3545 !important; }
.table-custom-bg { --bs-table-bg: transparent !important; }
</style>
<div class="d-flex justify-content-between align-items-center mb-4">
@ -56,6 +37,7 @@
</div>
<div class="row g-4">
<!-- LEFT: Downloaded Packages -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-folder-symlink me-1"></i> Downloaded Packages ({{ packages|length }})</div>
@ -70,9 +52,10 @@
<tbody>
{% for pkg in packages %}
{% set is_processed = (pkg.name, pkg.version) in processed_ids %}
{% set is_duplicate = pkg.name in duplicate_names and duplicate_names[pkg.name]|length > 1 %}
{% set group_color = group_colors[pkg.name] if (is_duplicate and pkg.name in group_colors) else 'bg-warning' if is_duplicate else 'bg-light' %}
<tr class="{% if is_duplicate %}{{ group_color }} text-dark{% endif %}">
<!---{% set is_duplicate = pkg.name in duplicate_names and duplicate_names[pkg.name]|length > 1 %}--->
{% set is_duplicate = pkg.name in duplicate_groups %}
{% set group_color = group_colors[pkg.name] if (is_duplicate and pkg.name in group_colors) else 'bg-warning' if is_duplicate else '' %}
<tr class="{% if group_color %}{{ group_color }} text-dark{% endif %}">
<td>
<code>{{ pkg.name }}</code>
{% if is_duplicate %}
@ -120,13 +103,14 @@
</div>
</div>
<!-- RIGHT: Processed Packages -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-check-circle me-1"></i> Processed Packages ({{ processed_list|length }})</div>
<div class="card-body">
{% if processed_list %}
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
<p class="mb-2 small text-muted">Resource Types in the list will be both Profile and Base Type:
<p class="mb-2 small text-muted">Resource Types in the list will be both Profile and Base Type:</p>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
@ -135,21 +119,19 @@
<tbody>
{% for processed_ig in processed_list %}
<tr>
<td>
<code>{{ processed_ig.package_name }}</code>
</td>
<td><code>{{ processed_ig.package_name }}</code></td>
<td>{{ processed_ig.version }}</td>
<td>
{% set types_info = processed_ig.resource_types_info %}
{% if types_info %}<div class="d-flex flex-wrap gap-1">
{% for type_info in types_info %}
{% if type_info.must_support %}
<span class="badge bg-warning text-dark border" title="Has Must Support">{{ type_info.name }}</span>
{% else %}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
{% endfor %}
</div>{% else %}<small class="text-muted">N/A</small>{% endif %}
{% if types_info %}
<div class="d-flex flex-wrap gap-1">
{% for type_info in types_info %}
<span class="badge {% if type_info.must_support %}bg-warning text-dark{% else %}bg-light text-dark{% endif %} border" title="{% if type_info.must_support %}Has Must Support{% endif %}">{{ type_info.name }}</span>
{% endfor %}
</div>
{% else %}
<small class="text-muted">N/A</small>
{% endif %}
</td>
<td>
<div class="btn-group-vertical btn-group-sm w-100">
@ -178,4 +160,4 @@
{% block scripts %}
{{ super() }}
{% endblock %}
{% endblock %}

View File

@ -70,21 +70,38 @@
<div class="card-header">Resource Types Found / Defined</div>
<div class="card-body">
{% if profile_list or base_list %}
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
<p class="mb-2">
<small>
<span class="badge bg-warning text-dark border me-1" style="font-size: 0.8em;" title="Profile contains Must Support elements">MS</span> = Contains Must Support Elements<br>
<span class="badge bg-info text-dark border me-1" style="font-size: 0.8em;" title="An optional Extension which contains Must Support sub-elements (e.g., value[x], type)">Optional MS Ext</span> = Optional Extension with Must Support Sub-Elements
</small>
</p>
{% if profile_list %}
<p class="mb-2"><small><span class="badge bg-primary text-light border me-1">Examples</span> = Examples will be displayed when selecting profile Types if contained in the IG</small></p>
<h6>Profiles Defined ({{ profile_list|length }}):</h6>
<div class="d-flex flex-wrap gap-1 mb-3 resource-type-list">
{% for type_info in profile_list %}
<a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}"
data-resource-type="{{ type_info.name }}"
aria-label="View structure for {{ type_info.name }}">
data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}"
data-resource-type="{{ type_info.name }}" {# Use name which is usually the profile ID #}
aria-label="View structure for {{ type_info.name }}">
{# --- Badge Logic --- #}
{% if type_info.must_support %}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{# Check if it's marked as optional_usage (Extension with internal MS) #}
{% if optional_usage_elements.get(type_info.name) %}
{# Blue/Info badge for Optional Extension with MS #}
<span class="badge bg-info text-dark border" title="Optional Extension with Must Support sub-elements (e.g., value[x], type)">{{ type_info.name }}</span>
{% else %}
{# Yellow/Warning badge for standard Profile with MS #}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{% endif %}
{% else %}
{# Default light badge if no Must Support #}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
{# --- End Badge Logic --- #}
</a>
{% endfor %}
</div>
@ -92,23 +109,23 @@
<p class="text-muted"><em>No profiles defined.</em></p>
{% endif %}
{% if base_list %}
<p class="mb-2"><small><span class="badge bg-primary text-light border me-1">Examples</span> = - Examples will be displayed when selecting Base Types if contained in the IG</small></p>
<h6>Base Resource Types Referenced ({{ base_list|length }}):</h6>
<div class="d-flex flex-wrap gap-1 resource-type-list">
{% for type_info in base_list %}
<a href="#" class="resource-type-link text-decoration-none"
<h6>Base Resource Types Referenced ({{ base_list|length }}):</h6>
<div class="d-flex flex-wrap gap-1 resource-type-list">
{% for type_info in base_list %}
<a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}"
data-resource-type="{{ type_info.name }}"
data-resource-type="{{ type_info.name }}" {# Use name which is the resource type #}
aria-label="View structure for {{ type_info.name }}">
{% if type_info.must_support %}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{% else %}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
</a>
{% endfor %}
</div>
{# Base types usually don't have MS directly, but check just in case #}
{% if type_info.must_support %}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements (Unusual for base type reference)">{{ type_info.name }}</span>
{% else %}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted"><em>No base resource types referenced.</em></p>
{% endif %}
@ -176,9 +193,7 @@
{% else %}
<div class="alert alert-warning" role="alert">Processed IG details not available.</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
@ -206,12 +221,12 @@ document.addEventListener('DOMContentLoaded', function() {
const exampleBaseUrl = "{{ url_for('get_example_content') }}";
// Debugging: Log initial data
console.log("Examples Data:", examplesData);
// console.log("Examples Data:", examplesData);
// Debug all clicks to identify interference
document.addEventListener('click', function(event) {
console.log("Click event on:", event.target, "Class:", event.target.className);
}, true);
// Optional: Debug clicks if needed
// document.addEventListener('click', function(event) {
// console.log("Click event on:", event.target, "Class:", event.target.className);
// }, true);
// Handle clicks on resource-type-link within resource-type-list containers
document.querySelectorAll('.resource-type-list').forEach(container => {
@ -223,7 +238,7 @@ document.addEventListener('DOMContentLoaded', function() {
const pkgName = link.dataset.packageName;
const pkgVersion = link.dataset.packageVersion;
const resourceType = link.dataset.resourceType;
const resourceType = link.dataset.resourceType; // This is the Profile ID or Resource Type Name
if (!pkgName || !pkgVersion || !resourceType) {
console.error("Missing data attributes:", { pkgName, pkgVersion, resourceType });
return;
@ -233,86 +248,104 @@ document.addEventListener('DOMContentLoaded', function() {
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
console.log("Fetching structure from:", structureFetchUrl);
// Update titles and reset displays
structureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
rawStructureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
structureDisplay.innerHTML = '';
rawStructureContent.textContent = '';
structureDisplay.innerHTML = ''; // Clear previous structure
rawStructureContent.textContent = ''; // Clear previous raw structure
structureLoading.style.display = 'block';
rawStructureLoading.style.display = 'block';
structureFallbackMessage.style.display = 'none';
structureDisplayWrapper.style.display = 'block';
rawStructureWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'none';
structureDisplayWrapper.style.display = 'block'; // Show structure section
rawStructureWrapper.style.display = 'block'; // Show raw structure section
exampleDisplayWrapper.style.display = 'none'; // Hide examples initially
// Fetch Structure Definition
fetch(structureFetchUrl)
.then(response => {
console.log("Structure fetch response status:", response.status);
// Try to parse JSON regardless of status to get error message from body
return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
})
.then(result => {
if (!result.ok) {
// Throw error using message from JSON body if available
throw new Error(result.data.error || `HTTP error ${result.status}`);
}
console.log("Structure data received:", result.data);
// Display fallback message if needed
if (result.data.fallback_used) {
structureFallbackMessage.style.display = 'block';
structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${result.data.source_package}.`;
} else {
structureFallbackMessage.style.display = 'none';
}
// Render the tree and raw JSON
renderStructureTree(result.data.elements, result.data.must_support_paths || []);
rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
rawStructureContent.textContent = JSON.stringify(result.data, null, 2); // Pretty print raw SD
// Populate examples for the clicked resource/profile ID
populateExampleSelector(resourceType);
})
.catch(error => {
console.error("Error fetching structure:", error);
structureFallbackMessage.style.display = 'none';
structureDisplay.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
rawStructureContent.textContent = `Error: ${error.message}`;
structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`;
rawStructureContent.textContent = `Error loading structure: ${error.message}`;
// Still try to show examples if structure fails
populateExampleSelector(resourceType);
})
.finally(() => {
// Hide loading spinners
structureLoading.style.display = 'none';
rawStructureLoading.style.display = 'none';
});
});
});
// Function to populate the example dropdown
function populateExampleSelector(resourceOrProfileIdentifier) {
console.log("Populating examples for:", resourceOrProfileIdentifier);
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier; // Update example card title
const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
console.log("Available examples:", availableExamples);
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
exampleContentWrapper.style.display = 'none';
// Reset example section
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>'; // Clear previous options
exampleContentWrapper.style.display = 'none'; // Hide content area
exampleFilename.textContent = '';
exampleContentRaw.textContent = '';
exampleContentJson.textContent = '';
if (availableExamples.length > 0) {
// Add new options
availableExamples.forEach(filePath => {
const filename = filePath.split('/').pop();
const filename = filePath.split('/').pop(); // Get filename from path
const option = document.createElement('option');
option.value = filePath;
option.textContent = filename;
exampleSelect.appendChild(option);
});
exampleSelectorWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'block';
exampleSelectorWrapper.style.display = 'block'; // Show dropdown
exampleDisplayWrapper.style.display = 'block'; // Show example card
} else {
exampleSelectorWrapper.style.display = 'none';
exampleDisplayWrapper.style.display = 'none';
exampleSelectorWrapper.style.display = 'none'; // Hide dropdown if no examples
exampleDisplayWrapper.style.display = 'none'; // Hide example card if no examples
}
}
// Event listener for example selection change
if (exampleSelect) {
exampleSelect.addEventListener('change', function(event) {
const selectedFilePath = this.value;
console.log("Selected example file:", selectedFilePath);
// Reset example content areas
exampleContentRaw.textContent = '';
exampleContentJson.textContent = '';
exampleFilename.textContent = '';
exampleContentWrapper.style.display = 'none';
if (!selectedFilePath) return;
exampleContentWrapper.style.display = 'none'; // Hide content initially
if (!selectedFilePath) return; // Do nothing if "-- Select --" is chosen
// Prepare URL to fetch example content
const exampleParams = new URLSearchParams({
package_name: "{{ processed_ig.package_name }}",
package_version: "{{ processed_ig.version }}",
@ -320,182 +353,280 @@ document.addEventListener('DOMContentLoaded', function() {
});
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
console.log("Fetching example from:", exampleFetchUrl);
exampleLoading.style.display = 'block';
exampleLoading.style.display = 'block'; // Show loading spinner
// Fetch example content
fetch(exampleFetchUrl)
.then(response => {
console.log("Example fetch response status:", response.status);
if (!response.ok) {
// Try to get error text from body
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
}
return response.text();
return response.text(); // Get content as text
})
.then(content => {
console.log("Example content received:", content);
console.log("Example content received."); // Avoid logging potentially large content
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
exampleContentRaw.textContent = content;
exampleContentRaw.textContent = content; // Display raw content
// Try to parse and pretty-print if it's JSON
try {
const jsonContent = JSON.parse(content);
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
} catch (e) {
console.error("Error parsing JSON:", e);
exampleContentJson.textContent = 'Not valid JSON';
console.warn("Example content is not valid JSON:", e.message);
exampleContentJson.textContent = '(Not valid JSON)'; // Indicate if not JSON
}
exampleContentWrapper.style.display = 'block';
exampleContentWrapper.style.display = 'block'; // Show content area
})
.catch(error => {
console.error("Error fetching example:", error);
exampleFilename.textContent = 'Error';
exampleFilename.textContent = 'Error loading example';
exampleContentRaw.textContent = `Error: ${error.message}`;
exampleContentJson.textContent = `Error: ${error.message}`;
exampleContentWrapper.style.display = 'block';
exampleContentWrapper.style.display = 'block'; // Show error in content area
})
.finally(() => {
exampleLoading.style.display = 'none';
exampleLoading.style.display = 'none'; // Hide loading spinner
});
});
}
// Function to build the hierarchical data structure for the tree
function buildTreeData(elements) {
const treeRoot = { children: {}, element: null, name: 'Root' };
const nodeMap = { 'Root': treeRoot };
elements.forEach(el => {
console.log("Building tree with elements:", elements.length);
elements.forEach((el, index) => {
const path = el.path;
if (!path) return;
const id = el.id || null;
const sliceName = el.sliceName || null;
console.log(`Element ${index}: path=${path}, id=${id}, sliceName=${sliceName}`);
if (!path) {
console.warn(`Skipping element ${index} with no path`);
return;
}
const parts = path.split('.');
let currentPath = '';
let parentNode = treeRoot;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const currentPartName = part.includes('[') ? part.substring(0, part.indexOf('[')) : part;
currentPath = i === 0 ? currentPartName : `${currentPath}.${part}`;
currentPath = i === 0 ? part : `${currentPath}.${part}`;
// For extensions, append sliceName to path if present
let nodeKey = part;
if (part === 'extension' && i === parts.length - 1 && sliceName) {
nodeKey = `${part}:${sliceName}`;
currentPath = id || currentPath; // Use id for precise matching in extensions
}
if (!nodeMap[currentPath]) {
const newNode = { children: {}, element: null, name: part, path: currentPath };
parentNode.children[part] = newNode;
const newNode = {
children: {},
element: null,
name: nodeKey,
path: currentPath
};
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.');
parentNode = nodeMap[parentPath] || treeRoot;
parentNode.children[nodeKey] = newNode;
nodeMap[currentPath] = newNode;
console.log(`Created node: path=${currentPath}, name=${nodeKey}`);
}
if (i === parts.length - 1) {
nodeMap[currentPath].element = el;
const targetNode = nodeMap[currentPath];
targetNode.element = el;
console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`);
}
parentNode = nodeMap[currentPath];
}
});
return Object.values(treeRoot.children);
const treeData = Object.values(treeRoot.children);
console.log("Tree data constructed:", treeData);
return treeData;
}
// Function to render a single node (and its children recursively) as an <li>
function renderNodeAsLi(node, mustSupportPathsSet, level = 0) {
if (!node || !node.element) return '';
if (!node || !node.element) {
console.warn("Skipping render for invalid node:", node);
return '';
}
const el = node.element;
const path = el.path || 'N/A';
const id = el.id || null;
const sliceName = el.sliceName || null;
const min = el.min !== undefined ? el.min : '';
const max = el.max || '';
const short = el.short || '';
const definition = el.definition || '';
const isMustSupport = mustSupportPathsSet.has(path);
const mustSupportDisplay = isMustSupport ? '<i class="bi bi-check-circle-fill text-warning"></i>' : '';
console.log(`Rendering node: path=${path}, id=${id}, sliceName=${sliceName}`);
console.log(` MustSupportPathsSet contains path: ${mustSupportPathsSet.has(path)}`);
if (id) {
console.log(` MustSupportPathsSet contains id: ${mustSupportPathsSet.has(id)}`);
}
// Check MS for path, id, or normalized extension path
let isMustSupport = mustSupportPathsSet.has(path) || (id && mustSupportPathsSet.has(id));
if (!isMustSupport && path.startsWith('Extension.extension')) {
const basePath = path.split(':')[0];
const baseId = id ? id.split(':')[0] : null;
isMustSupport = mustSupportPathsSet.has(basePath) || (baseId && mustSupportPathsSet.has(baseId));
console.log(` Extension check: basePath=${basePath}, baseId=${baseId}, isMustSupport=${isMustSupport}`);
}
console.log(` Final isMustSupport for ${path}: ${isMustSupport}`);
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
const mustSupportDisplay = isMustSupport ? '<i class="bi bi-check-circle-fill text-warning ms-1" title="Must Support"></i>' : '';
const hasChildren = Object.keys(node.children).length > 0;
const collapseId = `collapse-${path.replace(/[\.\:]/g, '-')}`;
console.log(`Rendering node: ${path}, Collapse ID: ${collapseId}`); // Debug collapse IDs
const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
const padding = level * 20;
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
let typeString = 'N/A';
if (el.type && el.type.length > 0) {
typeString = el.type.map(t => {
let s = t.code || '';
if (t.targetProfile && t.targetProfile.length > 0) {
const targetTypes = t.targetProfile.map(p => p.split('/').pop());
s += `(<span class="text-muted">${targetTypes.join('|')}</span>)`;
let profiles = t.targetProfile || t.profile || [];
if (profiles.length > 0) {
const targetTypes = profiles.map(p => (p || '').split('/').pop()).filter(Boolean).join(', ');
if (targetTypes) {
s += `(<span class="text-muted fst-italic" title="${profiles.join(', ')}">${targetTypes}</span>)`;
}
}
return s;
}).join(' | ');
}
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
let childrenHtml = '';
if (hasChildren) {
childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`;
Object.values(node.children).sort((a, b) => a.name.localeCompare(b.name)).forEach(childNode => {
childrenHtml += `<ul class="collapse list-group list-group-flush structure-subtree" id="${collapseId}">`;
Object.values(node.children).sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name)).forEach(childNode => {
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, level + 1);
});
childrenHtml += `</ul>`;
}
let itemHtml = `<li class="${liClass}">`;
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-bs-toggle="collapse" data-bs-target="#${collapseId}" role="button" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}"><span style="display: inline-block; width: 1.2em; text-align: center;">`;
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}">`;
itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`;
if (hasChildren) {
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
}
itemHtml += `</span><code class="fw-bold ms-1">${node.name}</code></div>`;
itemHtml += `</span>`;
itemHtml += `<code class="fw-bold ms-1" title="${path}">${node.name}</code>`;
itemHtml += `</div>`;
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
let descriptionTooltipAttrs = '';
if (definition) {
const escapedDefinition = definition
.replace(/\\/g, '\\\\') // Escape backslashes
.replace(/"/g, '\\"') // Escape double quotes
.replace(/'/g, "\\'") // Escape single quotes
.replace(/\n/g, '\\n') // Escape newlines
.replace(/\r/g, '\\r') // Escape carriage returns
.replace(/\t/g, '\\t'); // Escape tabs
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, ' ');
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
}
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short}</div>`;
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short || (definition ? '(definition only)' : '')}</div>`;
itemHtml += `</div>`;
itemHtml += childrenHtml;
itemHtml += `</li>`;
return itemHtml;
}
function renderStructureTree(elements, mustSupportPaths) {
function renderStructureTree(elements, mustSupportPaths, resourceType) {
if (!elements || elements.length === 0) {
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found.</em></p>';
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found in Structure Definition.</em></p>';
return;
}
const mustSupportPathsSet = new Set(mustSupportPaths || []);
let html = '<p><small><span class="badge bg-warning text-dark border me-1">MS</span> = Must Support (Row Highlighted)</small></p>';
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">MS</div><div class="col-lg-3 col-md-4">Description</div></div>`;
html += '<ul class="list-group list-group-flush">';
buildTreeData(elements).sort((a, b) => a.name.localeCompare(b.name)).forEach(rootNode => {
html += renderNodeAsLi(rootNode, mustSupportPathsSet, 0);
// Filter must_support_paths to avoid generic extension paths
const validMustSupportPaths = mustSupportPaths.filter(path => {
if (path.includes('.extension') && !path.includes(':')) {
return false; // Exclude generic Patient.extension
}
return true;
});
const mustSupportPathsSet = new Set(validMustSupportPaths);
console.log("Must Support Paths Set for Tree:", mustSupportPathsSet);
let html = '<p><small>' +
'<i class="bi bi-check-circle-fill text-warning ms-1"></i> = Must Support (yellow row)<br>' +
'<i class="bi bi-check-circle-fill text-info ms-1"></i> = Optional (blue row)</small></p>';
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">Status</div><div class="col-lg-3 col-md-4">Description</div></div>`;
html += '<ul class="list-group list-group-flush structure-tree-root">';
const treeData = buildTreeData(elements);
treeData.sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name));
treeData.forEach(rootNode => {
html += renderNodeAsLi(rootNode, mustSupportPathsSet, resourceType, 0);
});
html += '</ul>';
structureDisplay.innerHTML = html;
var tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
const tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(tooltipTriggerEl => {
if (tooltipTriggerEl.getAttribute('title')) {
new bootstrap.Tooltip(tooltipTriggerEl);
}
});
// Initialize collapse toggles and chevron icons *after* rendering
const collapseToggles = structureDisplay.querySelectorAll('.collapse-toggle');
collapseToggles.forEach(toggleEl => {
const collapseId = toggleEl.getAttribute('data-bs-target');
const collapseEl = document.querySelector(collapseId);
structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => {
const collapseTargetId = toggleEl.getAttribute('data-bs-target');
if (!collapseTargetId) return;
const collapseEl = document.querySelector(collapseTargetId);
const toggleIcon = toggleEl.querySelector('.toggle-icon');
if (collapseEl && toggleIcon) {
// Initially hide the collapse element
collapseEl.style.display = 'none';
collapseEl.classList.remove('show');
toggleIcon.classList.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right');
// Handle click on the toggle element
toggleEl.addEventListener('click', (event) => {
toggleEl.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation(); // Stop propagation to prevent conflicts
event.stopPropagation();
const isCurrentlyShown = collapseEl.classList.contains('show');
toggleIcon.classList.toggle('bi-chevron-right', isCurrentlyShown);
toggleIcon.classList.toggle('bi-chevron-down', !isCurrentlyShown);
});
// Toggle visibility with a slight delay
const isExpanding = collapseEl.style.display === 'none';
toggleIcon.classList.replace(isExpanding ? 'bi-chevron-right' : 'bi-chevron-down', isExpanding ? 'bi-chevron-down' : 'bi-chevron-right');
// Use setTimeout for a slight delay before showing/hiding
setTimeout(() => {
collapseEl.style.display = isExpanding ? 'block' : 'none'; // Or 'flex', 'grid', etc.
}, 5); // A very small delay (milliseconds)
collapseEl.addEventListener('show.bs.collapse', () => {
toggleIcon.classList.remove('bi-chevron-right');
toggleIcon.classList.add('bi-chevron-down');
});
collapseEl.addEventListener('hide.bs.collapse', () => {
toggleIcon.classList.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right');
});
}
});
}
} catch (e) {
console.error("JavaScript error in cp_view_processed_ig.html:", e);
const body = document.querySelector('body');
if (body) {
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger m-3';
errorDiv.textContent = 'A critical error occurred while initializing the page view. Please check the console for details.';
body.insertBefore(errorDiv, body.firstChild);
}
}
});
</script>
{% endblock %}

View File

@ -1,3 +1,4 @@
<!-- templates/validate_sample.html -->
{% extends "base.html" %}
{% block content %}
@ -26,11 +27,15 @@
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]; }">
<select class="form-select" id="packageSelect" name="packageSelect" onchange="updatePackageFields(this)">
<option value="">-- Select a Package --</option>
{% for pkg in packages %}
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
{% endfor %}
{% if packages %}
{% for pkg in packages %}
<option value="{{ pkg.id }}">{{ pkg.text }}</option>
{% endfor %}
{% else %}
<option value="" disabled>No packages available</option>
{% endif %}
</select>
</div>
<label for="{{ form.package_name.id }}" class="form-label">Package Name</label>
@ -122,7 +127,39 @@
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('validationForm');
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
const packageSelect = document.getElementById('packageSelect');
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
const versionInput = document.getElementById('{{ form.version.id }}');
// Update package_name and version fields from dropdown
function updatePackageFields(select) {
const value = select.value;
console.log('Selected package:', value);
if (value) {
const parts = value.split('#');
if (parts.length === 2 && parts[0] && parts[1]) {
packageNameInput.value = parts[0];
versionInput.value = parts[1];
} else {
console.warn('Invalid package format:', value);
packageNameInput.value = '';
versionInput.value = '';
}
} else {
packageNameInput.value = '';
versionInput.value = '';
}
}
// Initialize dropdown with current form values
if (packageNameInput.value && versionInput.value) {
const currentValue = `${packageNameInput.value}#${versionInput.value}`;
const option = packageSelect.querySelector(`option[value="${currentValue}"]`);
if (option) {
packageSelect.value = currentValue;
}
}
// Client-side JSON validation preview
sampleInput.addEventListener('input', function() {
try {
@ -134,6 +171,9 @@ document.addEventListener('DOMContentLoaded', function() {
this.classList.add('is-invalid');
}
});
// Debug dropdown options
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => ({value: opt.value, text: opt.textContent})));
});
</script>
{% endblock %}

155
templates/woooo Normal file
View File

@ -0,0 +1,155 @@
// Function to build the hierarchical data structure for the tree
function buildTreeData(elements) {
const treeRoot = { children: {}, element: null, name: 'Root' };
const nodeMap = { 'Root': treeRoot };
console.log("Building tree with elements:", elements.length);
elements.forEach((el, index) => {
const path = el.path;
const id = el.id || null;
const sliceName = el.sliceName || null;
console.log(`Element ${index}: path=${path}, id=${id}, sliceName=${sliceName}`);
if (!path) {
console.warn(`Skipping element ${index} with no path`);
return;
}
const parts = path.split('.');
let currentPath = '';
let parentNode = treeRoot;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
currentPath = i === 0 ? part : `${currentPath}.${part}`;
// For extensions, append sliceName to path if present
let nodeKey = part;
if (part === 'extension' && i === parts.length - 1 && sliceName) {
nodeKey = `${part}:${sliceName}`;
currentPath = id || currentPath; // Use id for precise matching in extensions
}
if (!nodeMap[currentPath]) {
const newNode = {
children: {},
element: null,
name: nodeKey,
path: currentPath
};
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.');
parentNode = nodeMap[parentPath] || treeRoot;
parentNode.children[nodeKey] = newNode;
nodeMap[currentPath] = newNode;
console.log(`Created node: path=${currentPath}, name=${nodeKey}`);
}
if (i === parts.length - 1) {
const targetNode = nodeMap[currentPath];
targetNode.element = el;
console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`);
}
parentNode = nodeMap[currentPath];
}
});
const treeData = Object.values(treeRoot.children);
console.log("Tree data constructed:", treeData);
return treeData;
}
// Function to render a single node (and its children recursively) as an <li>
function renderNodeAsLi(node, mustSupportPathsSet, level = 0) {
if (!node || !node.element) {
console.warn("Skipping render for invalid node:", node);
return '';
}
const el = node.element;
const path = el.path || 'N/A';
const id = el.id || null;
const sliceName = el.sliceName || null;
const min = el.min !== undefined ? el.min : '';
const max = el.max || '';
const short = el.short || '';
const definition = el.definition || '';
console.log(`Rendering node: path=${path}, id=${id}, sliceName=${sliceName}`);
console.log(` MustSupportPathsSet contains path: ${mustSupportPathsSet.has(path)}`);
if (id) {
console.log(` MustSupportPathsSet contains id: ${mustSupportPathsSet.has(id)}`);
}
// Check MS for path, id, or normalized extension path
let isMustSupport = mustSupportPathsSet.has(path) || (id && mustSupportPathsSet.has(id));
if (!isMustSupport && path.startsWith('Extension.extension')) {
const basePath = path.split(':')[0];
const baseId = id ? id.split(':')[0] : null;
isMustSupport = mustSupportPathsSet.has(basePath) || (baseId && mustSupportPathsSet.has(baseId));
console.log(` Extension check: basePath=${basePath}, baseId=${baseId}, isMustSupport=${isMustSupport}`);
}
console.log(` Final isMustSupport for ${path}: ${isMustSupport}`);
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
const mustSupportDisplay = isMustSupport ? '<i class="bi bi-check-circle-fill text-warning ms-1" title="Must Support"></i>' : '';
const hasChildren = Object.keys(node.children).length > 0;
const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
const padding = level * 20;
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
let typeString = 'N/A';
if (el.type && el.type.length > 0) {
typeString = el.type.map(t => {
let s = t.code || '';
let profiles = t.targetProfile || t.profile || [];
if (profiles.length > 0) {
const targetTypes = profiles.map(p => (p || '').split('/').pop()).filter(Boolean).join(', ');
if (targetTypes) {
s += `(<span class="text-muted fst-italic" title="${profiles.join(', ')}">${targetTypes}</span>)`;
}
}
return s;
}).join(' | ');
}
let childrenHtml = '';
if (hasChildren) {
childrenHtml += `<ul class="collapse list-group list-group-flush structure-subtree" id="${collapseId}">`;
Object.values(node.children).sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name)).forEach(childNode => {
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, level + 1);
});
childrenHtml += `</ul>`;
}
let itemHtml = `<li class="${liClass}">`;
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-bs-toggle="collapse" data-bs-target="#${collapseId}" role="button" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}">`;
itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`;
if (hasChildren) {
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
}
itemHtml += `</span>`;
itemHtml += `<code class="fw-bold ms-1" title="${path}">${node.name}</code>`;
itemHtml += `</div>`;
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
let descriptionTooltipAttrs = '';
if (definition) {
const escapedDefinition = definition
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, ' ');
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
}
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short || (definition ? '(definition only)' : '')}</div>`;
itemHtml += `</div>`;
itemHtml += childrenHtml;
itemHtml += `</li>`;
return itemHtml;
}