mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-09-17 18:35:02 +00:00
deploy: b10d9a8cecb20165b91803f6b363103301de0984
This commit is contained in:
parent
a9bd01e50a
commit
eea23f7874
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
23
.github/ct/chart-schema.yaml
vendored
23
.github/ct/chart-schema.yaml
vendored
@ -1,23 +0,0 @@
|
||||
name: str()
|
||||
home: str()
|
||||
version: str()
|
||||
apiVersion: str()
|
||||
appVersion: any(str(), num(), required=False)
|
||||
type: str()
|
||||
dependencies: any(required=False)
|
||||
description: str()
|
||||
keywords: list(str(), required=False)
|
||||
sources: list(str(), required=False)
|
||||
maintainers: list(include('maintainer'), required=False)
|
||||
icon: str(required=False)
|
||||
engine: str(required=False)
|
||||
condition: str(required=False)
|
||||
tags: str(required=False)
|
||||
deprecated: bool(required=False)
|
||||
kubeVersion: str(required=False)
|
||||
annotations: map(str(), str(), required=False)
|
||||
---
|
||||
maintainer:
|
||||
name: str()
|
||||
email: str(required=False)
|
||||
url: str(required=False)
|
15
.github/ct/config.yaml
vendored
15
.github/ct/config.yaml
vendored
@ -1,15 +0,0 @@
|
||||
debug: true
|
||||
remote: origin
|
||||
chart-yaml-schema: .github/ct/chart-schema.yaml
|
||||
validate-maintainers: false
|
||||
validate-chart-schema: true
|
||||
validate-yaml: true
|
||||
check-version-increment: true
|
||||
chart-dirs:
|
||||
- charts
|
||||
helm-extra-args: --timeout 300s
|
||||
upgrade: true
|
||||
skip-missing-values: true
|
||||
release-label: release
|
||||
release-name-template: "helm-v{{ .Version }}"
|
||||
target-branch: master
|
84
.github/workflows/build-images.yaml
vendored
84
.github/workflows/build-images.yaml
vendored
@ -1,84 +0,0 @@
|
||||
name: Build Container Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "image/v*"
|
||||
paths-ignore:
|
||||
- "charts/**"
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- "charts/**"
|
||||
env:
|
||||
IMAGES: docker.io/hapiproject/hapi
|
||||
PLATFORMS: linux/amd64,linux/arm64/v8
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Container meta for default (distroless) image
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
tags: |
|
||||
type=match,pattern=image/(.*),group=1,enable=${{github.event_name != 'pull_request'}}
|
||||
|
||||
|
||||
- name: Container meta for tomcat image
|
||||
id: docker_tomcat_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
tags: |
|
||||
type=match,pattern=image/(.*),group=1,enable=${{github.event_name != 'pull_request'}}
|
||||
flavor: |
|
||||
suffix=-tomcat,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Build and push default (distroless) image
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
target: default
|
||||
|
||||
- name: Build and push tomcat image
|
||||
id: docker_build_tomcat
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_tomcat_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_tomcat_meta.outputs.labels }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
target: tomcat
|
41
.github/workflows/chart-release.yaml
vendored
41
.github/workflows/chart-release.yaml
vendored
@ -1,41 +0,0 @@
|
||||
name: Release Charts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "charts/**"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Add workspace as safe directory
|
||||
run: |
|
||||
git config --global --add safe.directory /__w/FHIRFLARE-IG-Toolkit/FHIRFLARE-IG-Toolkit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Update dependencies
|
||||
run: find charts/ ! -path charts/ -maxdepth 1 -type d -exec helm dependency update {} \;
|
||||
|
||||
- name: Add Helm Repositories
|
||||
run: |
|
||||
helm repo add hapifhir https://hapifhir.github.io/hapi-fhir-jpaserver-starter/
|
||||
helm repo update
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@be16258da8010256c6e82849661221415f031968 # v1.5.0
|
||||
with:
|
||||
config: .github/ct/config.yaml
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
73
.github/workflows/chart-test.yaml
vendored
73
.github/workflows/chart-test.yaml
vendored
@ -1,73 +0,0 @@
|
||||
name: Lint and Test Charts
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "charts/**"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-22.04
|
||||
container: quay.io/helmpack/chart-testing:v3.11.0@sha256:f2fd21d30b64411105c7eafb1862783236a219d29f2292219a09fe94ca78ad2a
|
||||
steps:
|
||||
- name: Install helm-docs
|
||||
working-directory: /tmp
|
||||
env:
|
||||
HELM_DOCS_URL: https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Linux_x86_64.tar.gz
|
||||
run: |
|
||||
curl -LSs $HELM_DOCS_URL | tar xz && \
|
||||
mv ./helm-docs /usr/local/bin/helm-docs && \
|
||||
chmod +x /usr/local/bin/helm-docs && \
|
||||
helm-docs --version
|
||||
|
||||
- name: Add workspace as safe directory
|
||||
run: |
|
||||
git config --global --add safe.directory /__w/hapi-fhir-jpaserver-starter/hapi-fhir-jpaserver-starter
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if documentation is up-to-date
|
||||
run: helm-docs && git diff --exit-code HEAD
|
||||
|
||||
- name: Run chart-testing (lint)
|
||||
run: ct lint --config .github/ct/config.yaml
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
k8s-version: [1.30.8, 1.31.4, 1.32.0]
|
||||
needs:
|
||||
- lint
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@e6669bcd63d7cb57cb4380c33043eebe5d111992 # v2.6.1
|
||||
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
run: |
|
||||
changed=$(ct list-changed --config .github/ct/config.yaml)
|
||||
if [[ -n "$changed" ]]; then
|
||||
echo "::set-output name=changed::true"
|
||||
fi
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@dda0770415bac9fc20092cacbc54aa298604d140 # v1.8.0
|
||||
if: ${{ steps.list-changed.outputs.changed == 'true' }}
|
||||
with:
|
||||
cluster_name: kind-cluster-k8s-${{ matrix.k8s-version }}
|
||||
node_image: kindest/node:v${{ matrix.k8s-version }}
|
||||
|
||||
- name: Run chart-testing (install)
|
||||
run: ct install --config .github/ct/config.yaml
|
||||
if: ${{ steps.list-changed.outputs.changed == 'true' }}
|
58
.github/workflows/docker-publish.yml
vendored
58
.github/workflows/docker-publish.yml
vendored
@ -1,58 +0,0 @@
|
||||
# This workflow builds and pushes a multi-architecture Docker image to GitHub Container Registry (ghcr.io).
|
||||
#
|
||||
# The Docker meta step is required because GitHub repository names can contain uppercase letters, but Docker image tags must be lowercase.
|
||||
# The docker/metadata-action@v5 normalizes the repository name to lowercase, ensuring the build and push steps use a valid image tag.
|
||||
#
|
||||
# This workflow builds for both AMD64 and ARM64 architectures using Docker Buildx and QEMU emulation.
|
||||
|
||||
name: Build and Push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- '*' # This will run the workflow on any branch
|
||||
workflow_dispatch: # This enables manual triggering
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Set normalized image name
|
||||
run: |
|
||||
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||
echo "IMAGE_NAME=$(echo ${{ steps.meta.outputs.tags }} | sed 's/:main/:latest/')" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IMAGE_NAME=${{ steps.meta.outputs.tags }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build and push multi-architecture Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,6 +0,0 @@
|
||||
/instance/
|
||||
/logs/
|
||||
/.pydevproject
|
||||
/__pycache__/
|
||||
/myenv/
|
||||
/tmp/
|
23
.project
23
.project
@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>FHIRFLARE-IG-Toolkit</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.python.pydev.PyDevBuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.wst.validation.validationbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.wst.jsdt.core.jsNature</nature>
|
||||
<nature>org.python.pydev.pythonNature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.JRE_CONTAINER"/>
|
||||
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.baseBrowserLibrary"/>
|
||||
<classpathentry kind="src" path=""/>
|
||||
<classpathentry kind="output" path=""/>
|
||||
</classpath>
|
@ -1 +0,0 @@
|
||||
org.eclipse.wst.jsdt.launching.JRE_CONTAINER
|
@ -1 +0,0 @@
|
||||
Global
|
@ -1,211 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM --- Configuration ---
|
||||
set REPO_URL=https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git
|
||||
set CLONE_DIR=hapi-fhir-jpaserver
|
||||
set SOURCE_CONFIG_DIR=hapi-fhir-setup
|
||||
set CONFIG_FILE=application.yaml
|
||||
|
||||
REM --- Define Paths ---
|
||||
set SOURCE_CONFIG_PATH=..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%
|
||||
set DEST_CONFIG_PATH=%CLONE_DIR%\target\classes\%CONFIG_FILE%
|
||||
|
||||
REM === CORRECTED: Prompt for Version ===
|
||||
:GetModeChoice
|
||||
SET "APP_MODE=" REM Clear the variable first
|
||||
echo Select Installation Mode:
|
||||
echo 1. Standalone (Includes local HAPI FHIR Server - Requires Git & Maven)
|
||||
echo 2. Lite (Excludes local HAPI FHIR Server - No Git/Maven needed)
|
||||
CHOICE /C 12 /N /M "Enter your choice (1 or 2):"
|
||||
|
||||
IF ERRORLEVEL 2 (
|
||||
SET APP_MODE=lite
|
||||
goto :ModeSet
|
||||
)
|
||||
IF ERRORLEVEL 1 (
|
||||
SET APP_MODE=standalone
|
||||
goto :ModeSet
|
||||
)
|
||||
REM If somehow neither was chosen (e.g., Ctrl+C), loop back
|
||||
echo Invalid input. Please try again.
|
||||
goto :GetModeChoice
|
||||
|
||||
:ModeSet
|
||||
IF "%APP_MODE%"=="" (
|
||||
echo Invalid choice detected after checks. Exiting.
|
||||
goto :eof
|
||||
)
|
||||
echo Selected Mode: %APP_MODE%
|
||||
echo.
|
||||
REM === END CORRECTION ===
|
||||
|
||||
|
||||
REM === Conditionally Execute HAPI Setup ===
|
||||
IF "%APP_MODE%"=="standalone" (
|
||||
echo Running Standalone setup including HAPI FHIR...
|
||||
echo.
|
||||
|
||||
REM --- Step 0: Clean up previous clone (optional) ---
|
||||
echo Checking for existing directory: %CLONE_DIR%
|
||||
if exist "%CLONE_DIR%" (
|
||||
echo Found existing directory, removing it...
|
||||
rmdir /s /q "%CLONE_DIR%"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to remove existing directory: %CLONE_DIR%
|
||||
goto :error
|
||||
)
|
||||
echo Existing directory removed.
|
||||
) else (
|
||||
echo Directory does not exist, proceeding with clone.
|
||||
)
|
||||
echo.
|
||||
|
||||
REM --- Step 1: Clone the HAPI FHIR server repository ---
|
||||
echo Cloning repository: %REPO_URL% into %CLONE_DIR%...
|
||||
git clone "%REPO_URL%" "%CLONE_DIR%"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to clone repository. Check Git installation and network connection.
|
||||
goto :error
|
||||
)
|
||||
echo Repository cloned successfully.
|
||||
echo.
|
||||
|
||||
REM --- Step 2: Navigate into the cloned directory ---
|
||||
echo Changing directory to %CLONE_DIR%...
|
||||
cd "%CLONE_DIR%"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to change directory to %CLONE_DIR%.
|
||||
goto :error
|
||||
)
|
||||
echo Current directory: %CD%
|
||||
echo.
|
||||
|
||||
REM --- Step 3: Build the HAPI server using Maven ---
|
||||
echo ===> "Starting Maven build (Step 3)...""
|
||||
cmd /c "mvn clean package -DskipTests=true -Pboot"
|
||||
echo ===> Maven command finished. Checking error level...
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Maven build failed or cmd /c failed
|
||||
cd ..
|
||||
goto :error
|
||||
)
|
||||
echo Maven build completed successfully. ErrorLevel: %errorlevel%
|
||||
echo.
|
||||
|
||||
REM --- Step 4: Copy the configuration file ---
|
||||
echo ===> "Starting file copy (Step 4)..."
|
||||
echo Copying configuration file...
|
||||
echo Source: %SOURCE_CONFIG_PATH%
|
||||
echo Destination: target\classes\%CONFIG_FILE%
|
||||
xcopy "%SOURCE_CONFIG_PATH%" "target\classes\" /Y /I
|
||||
echo ===> xcopy command finished. Checking error level...
|
||||
if errorlevel 1 (
|
||||
echo WARNING: Failed to copy configuration file. Check if the source file exists.
|
||||
echo The script will continue, but the server might use default configuration.
|
||||
) else (
|
||||
echo Configuration file copied successfully. ErrorLevel: %errorlevel%
|
||||
)
|
||||
echo.
|
||||
|
||||
REM --- Step 5: Navigate back to the parent directory ---
|
||||
echo ===> "Changing directory back (Step 5)..."
|
||||
cd ..
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to change back to the parent directory. ErrorLevel: %errorlevel%
|
||||
goto :error
|
||||
)
|
||||
echo Current directory: %CD%
|
||||
echo.
|
||||
|
||||
) ELSE (
|
||||
echo Running Lite setup, skipping HAPI FHIR build...
|
||||
REM Ensure the hapi-fhir-jpaserver directory doesn't exist or is empty if Lite mode is chosen after a standalone attempt
|
||||
if exist "%CLONE_DIR%" (
|
||||
echo Found existing HAPI directory in Lite mode. Removing it to avoid build issues...
|
||||
rmdir /s /q "%CLONE_DIR%"
|
||||
)
|
||||
REM Create empty target directories expected by Dockerfile COPY, even if not used
|
||||
mkdir "%CLONE_DIR%\target\classes" 2> nul
|
||||
mkdir "%CLONE_DIR%\custom" 2> nul
|
||||
REM Create a placeholder empty WAR file to satisfy Dockerfile COPY
|
||||
echo. > "%CLONE_DIR%\target\ROOT.war"
|
||||
echo. > "%CLONE_DIR%\target\classes\application.yaml"
|
||||
echo Placeholder files created for Lite mode build.
|
||||
echo.
|
||||
)
|
||||
|
||||
REM === Modify docker-compose.yml to set APP_MODE ===
|
||||
echo Updating docker-compose.yml with APP_MODE=%APP_MODE%...
|
||||
(
|
||||
echo version: '3.8'
|
||||
echo services:
|
||||
echo fhirflare:
|
||||
echo build:
|
||||
echo context: .
|
||||
echo dockerfile: Dockerfile
|
||||
echo ports:
|
||||
echo - "5000:5000"
|
||||
echo - "8080:8080" # Keep port exposed, even if Tomcat isn't running useful stuff in Lite
|
||||
echo volumes:
|
||||
echo - ./instance:/app/instance
|
||||
echo - ./static/uploads:/app/static/uploads
|
||||
echo - ./instance/hapi-h2-data/:/app/h2-data # Keep volume mounts consistent
|
||||
echo - ./logs:/app/logs
|
||||
echo environment:
|
||||
echo - FLASK_APP=app.py
|
||||
echo - FLASK_ENV=development
|
||||
echo - NODE_PATH=/usr/lib/node_modules
|
||||
echo - APP_MODE=%APP_MODE%
|
||||
echo - APP_BASE_URL=http://localhost:5000
|
||||
echo - HAPI_FHIR_URL=http://localhost:8080/fhir
|
||||
echo command: supervisord -c /etc/supervisord.conf
|
||||
) > docker-compose.yml.tmp
|
||||
|
||||
REM Check if docker-compose.yml.tmp was created successfully
|
||||
if not exist docker-compose.yml.tmp (
|
||||
echo ERROR: Failed to create temporary docker-compose file.
|
||||
goto :error
|
||||
)
|
||||
|
||||
REM Replace the original docker-compose.yml
|
||||
del docker-compose.yml /Q > nul 2>&1
|
||||
ren docker-compose.yml.tmp docker-compose.yml
|
||||
echo docker-compose.yml updated successfully.
|
||||
echo.
|
||||
|
||||
REM --- Step 6: Build Docker images ---
|
||||
echo ===> Starting Docker build (Step 6)...
|
||||
docker-compose build --no-cache
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Docker Compose build failed. Check Docker installation and docker-compose.yml file. ErrorLevel: %errorlevel%
|
||||
goto :error
|
||||
)
|
||||
echo Docker images built successfully. ErrorLevel: %errorlevel%
|
||||
echo.
|
||||
|
||||
REM --- Step 7: Start Docker containers ---
|
||||
echo ===> Starting Docker containers (Step 7)...
|
||||
docker-compose up -d
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Docker Compose up failed. Check Docker installation and container configurations. ErrorLevel: %errorlevel%
|
||||
goto :error
|
||||
)
|
||||
echo Docker containers started successfully. ErrorLevel: %errorlevel%
|
||||
echo.
|
||||
|
||||
echo ====================================
|
||||
echo Script finished successfully! (Mode: %APP_MODE%)
|
||||
echo ====================================
|
||||
goto :eof
|
||||
|
||||
:error
|
||||
echo ------------------------------------
|
||||
echo An error occurred. Script aborted.
|
||||
echo ------------------------------------
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:eof
|
||||
echo Script execution finished.
|
||||
pause
|
@ -1,26 +0,0 @@
|
||||
Docker Commands.MD
|
||||
|
||||
|
||||
<HAPI-server.>
|
||||
to pull and clone:
|
||||
git clone https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git hapi-fhir-jpaserver
|
||||
|
||||
to build:
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
|
||||
to run:
|
||||
java -jar target/ROOT.war
|
||||
|
||||
|
||||
<rest-of-the-app:>
|
||||
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
|
||||
|
||||
|
||||
<useful-stuff:>
|
||||
|
||||
cp <CONTAINERID>:/app/PATH/Filename.ext . - . copies to the root folder you ran it from
|
||||
|
||||
docker exec -it <CONTAINERID> bash - to get a bash - session in the container -
|
57
Dockerfile
57
Dockerfile
@ -1,57 +0,0 @@
|
||||
# Base image with Python and Java
|
||||
FROM tomcat:10.1-jdk17
|
||||
|
||||
# Install build dependencies, Node.js 18, and coreutils (for stdbuf)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv curl coreutils \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install specific versions of GoFSH and SUSHI
|
||||
# REMOVED pip install fhirpath from this line
|
||||
RUN npm install -g gofsh fsh-sushi
|
||||
|
||||
# Set up Python environment
|
||||
WORKDIR /app
|
||||
RUN python3 -m venv /app/venv
|
||||
ENV PATH="/app/venv/bin:$PATH"
|
||||
|
||||
# ADDED: Uninstall old fhirpath just in case it's in requirements.txt
|
||||
RUN pip uninstall -y fhirpath || true
|
||||
# ADDED: Install the new fhirpathpy library
|
||||
RUN pip install --no-cache-dir fhirpathpy
|
||||
|
||||
# Copy Flask files
|
||||
COPY requirements.txt .
|
||||
# Install requirements (including Pydantic - check version compatibility if needed)
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY services.py .
|
||||
COPY forms.py .
|
||||
COPY package.py .
|
||||
COPY templates/ templates/
|
||||
COPY static/ static/
|
||||
COPY tests/ tests/
|
||||
|
||||
# Ensure /tmp, /app/h2-data, /app/static/uploads, and /app/logs are writable
|
||||
RUN mkdir -p /tmp /app/h2-data /app/static/uploads /app/logs && chmod 777 /tmp /app/h2-data /app/static/uploads /app/logs
|
||||
|
||||
# Copy pre-built HAPI WAR and configuration
|
||||
COPY hapi-fhir-jpaserver/target/ROOT.war /usr/local/tomcat/webapps/
|
||||
COPY hapi-fhir-jpaserver/target/classes/application.yaml /usr/local/tomcat/conf/
|
||||
COPY hapi-fhir-jpaserver/target/classes/application.yaml /app/config/application.yaml
|
||||
COPY hapi-fhir-jpaserver/target/classes/application.yaml /usr/local/tomcat/webapps/app/config/application.yaml
|
||||
COPY hapi-fhir-jpaserver/custom/ /usr/local/tomcat/webapps/custom/
|
||||
|
||||
# Install supervisord
|
||||
RUN pip install supervisor
|
||||
|
||||
# Configure supervisord
|
||||
COPY supervisord.conf /etc/supervisord.conf
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 5000 8080
|
||||
|
||||
# Start supervisord
|
||||
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
|
@ -1,3 +0,0 @@
|
||||
# License
|
||||
|
||||
This project, FHIRFLARE-IG-Toolkit, is licensed under the Apache License, Version 2.0.
|
855
README.html
Normal file
855
README.html
Normal file
@ -0,0 +1,855 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<!-- Begin Jekyll SEO tag v2.8.0 -->
|
||||
<title>FHIRFLARE IG Toolkit | Helm chart for deploying the fhirflare-ig-toolkit application</title>
|
||||
<meta name="generator" content="Jekyll v3.9.5" />
|
||||
<meta property="og:title" content="FHIRFLARE IG Toolkit" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta name="description" content="Helm chart for deploying the fhirflare-ig-toolkit application" />
|
||||
<meta property="og:description" content="Helm chart for deploying the fhirflare-ig-toolkit application" />
|
||||
<meta property="og:site_name" content="FHIRFLARE IG Toolkit" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta property="twitter:title" content="FHIRFLARE IG Toolkit" />
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"WebPage","description":"Helm chart for deploying the fhirflare-ig-toolkit application","headline":"FHIRFLARE IG Toolkit","url":"/README.html"}</script>
|
||||
<!-- End Jekyll SEO tag -->
|
||||
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=b10d9a8cecb20165b91803f6b363103301de0984">
|
||||
<script src="/assets/js/scale.fix.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
|
||||
<!--[if lt IE 9]>
|
||||
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<!-- start custom head snippets, customize with your own _includes/head-custom.html file -->
|
||||
|
||||
<!-- Setup Google Analytics -->
|
||||
|
||||
|
||||
|
||||
<!-- You can set your favicon here -->
|
||||
<!-- link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" -->
|
||||
|
||||
<!-- end custom head snippets -->
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<header >
|
||||
<h1>FHIRFLARE IG Toolkit</h1>
|
||||
|
||||
<p>Helm chart for deploying the fhirflare-ig-toolkit application</p>
|
||||
|
||||
<p class="view"><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit">View the Project on GitHub <small></small></a></p>
|
||||
<ul>
|
||||
|
||||
<li><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/zipball/gh-pages">Download <strong>ZIP File</strong></a></li>
|
||||
<li><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/tarball/gh-pages">Download <strong>TAR Ball</strong></a></li>
|
||||
|
||||
<li><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit">View On <strong>GitHub</strong></a></li>
|
||||
</ul>
|
||||
</header>
|
||||
<section>
|
||||
|
||||
<h1 id="fhirflare-ig-toolkit">FHIRFLARE IG Toolkit</h1>
|
||||
|
||||
<p><img src="/static/FHIRFLARE.png" alt="FHIRFLARE Logo" /></p>
|
||||
|
||||
<h2 id="overview">Overview</h2>
|
||||
|
||||
<p>The FHIRFLARE IG Toolkit is a Flask-based web application designed to streamline the management, processing, validation, and deployment of FHIR Implementation Guides (IGs) and test data. It offers a user-friendly interface for importing IG packages, extracting metadata, validating FHIR resources or bundles, pushing IGs to FHIR servers, converting FHIR resources to FHIR Shorthand (FSH), uploading complex test data sets with dependency management, and retrieving/splitting FHIR bundles. The toolkit includes live consoles for real-time feedback, making it an essential tool for FHIR developers and implementers.</p>
|
||||
|
||||
<p>The application can run in two modes:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Standalone:</strong> Includes a Dockerized Flask frontend, SQLite database, and an embedded HAPI FHIR server for local validation and interaction.</li>
|
||||
<li><strong>Lite:</strong> Includes only the Dockerized Flask frontend and SQLite database, excluding the local HAPI FHIR server. Requires connection to external FHIR servers for certain features.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="installation-modes-lite-vs-standalone">Installation Modes (Lite vs. Standalone)</h2>
|
||||
|
||||
<p>This toolkit offers two primary installation modes to suit different needs:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Standalone Version:</strong>
|
||||
<ul>
|
||||
<li>Includes the full FHIRFLARE Toolkit application <strong>and</strong> an embedded HAPI FHIR server running locally within the Docker environment.</li>
|
||||
<li>Allows for local FHIR resource validation using HAPI FHIR’s capabilities.</li>
|
||||
<li>Enables the “Use Local HAPI” option in the FHIR API Explorer and FHIR UI Operations pages, proxying requests to the internal HAPI server (<code class="language-plaintext highlighter-rouge">http://localhost:8080/fhir</code>).</li>
|
||||
<li>Requires Git and Maven during the initial build process (via the <code class="language-plaintext highlighter-rouge">.bat</code> script or manual steps) to prepare the HAPI FHIR server.</li>
|
||||
<li>Ideal for users who want a self-contained environment for development and testing or who don’t have readily available external FHIR servers.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Lite Version:</strong>
|
||||
<ul>
|
||||
<li>Includes the FHIRFLARE Toolkit application <strong>without</strong> the embedded HAPI FHIR server.</li>
|
||||
<li>Requires users to provide URLs for external FHIR servers when using features like the FHIR API Explorer and FHIR UI Operations pages. The “Use Local HAPI” option will be disabled in the UI.</li>
|
||||
<li>Resource validation relies solely on local checks against downloaded StructureDefinitions, which may be less comprehensive than HAPI FHIR’s validation (e.g., for terminology bindings or complex invariants).</li>
|
||||
<li><strong>Does not require Git or Maven</strong> for setup if using the <code class="language-plaintext highlighter-rouge">.bat</code> script or running the pre-built Docker image.</li>
|
||||
<li>Ideal for users who primarily want to use the IG management, processing, and FSH conversion features, or who will always connect to existing external FHIR servers.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="features">Features</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Import IGs:</strong> Download FHIR IG packages and dependencies from a package registry, supporting flexible version formats (e.g., <code class="language-plaintext highlighter-rouge">1.2.3</code>, <code class="language-plaintext highlighter-rouge">1.1.0-preview</code>, <code class="language-plaintext highlighter-rouge">current</code>) and dependency pulling modes (Recursive, Patch Canonical, Tree Shaking).</li>
|
||||
<li><strong>Enhanced Package Search and Import:</strong>
|
||||
<ul>
|
||||
<li>Interactive page (<code class="language-plaintext highlighter-rouge">/search-and-import</code>) to search for FHIR IG packages from configured registries.</li>
|
||||
<li>Displays package details, version history, dependencies, and dependents.</li>
|
||||
<li>Utilizes a local database cache (<code class="language-plaintext highlighter-rouge">CachedPackage</code>) for faster subsequent searches.</li>
|
||||
<li>Background task to refresh the package cache from registries (<code class="language-plaintext highlighter-rouge">/api/refresh-cache-task</code>).</li>
|
||||
<li>Direct import from search results.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Manage IGs:</strong> View, process, unload, or delete downloaded IGs, with duplicate detection and resolution.</li>
|
||||
<li><strong>Process IGs:</strong> Extract resource types, profiles, must-support elements, examples, and profile relationships (<code class="language-plaintext highlighter-rouge">structuredefinition-compliesWithProfile</code> and <code class="language-plaintext highlighter-rouge">structuredefinition-imposeProfile</code>).</li>
|
||||
<li><strong>Validate FHIR Resources/Bundles:</strong> Validate single FHIR resources or bundles against selected IGs, with detailed error and warning reports (alpha feature). <em>Note: Lite version uses local SD checks only.</em></li>
|
||||
<li><strong>Push IGs:</strong> Upload IG resources (and optionally dependencies) to a target FHIR server. Features include:
|
||||
<ul>
|
||||
<li>Real-time console output.</li>
|
||||
<li>Authentication support (Bearer Token).</li>
|
||||
<li>Filtering by resource type or specific files to skip.</li>
|
||||
<li>Semantic comparison to skip uploading identical resources (override with <strong>Force Upload</strong> option).</li>
|
||||
<li>Correct handling of canonical resources (searching by URL/version before deciding POST/PUT).</li>
|
||||
<li>Dry run mode for simulation.</li>
|
||||
<li>Verbose logging option.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Upload Test Data:</strong> Upload complex sets of test data (individual JSON/XML files or ZIP archives) to a target FHIR server. Features include:
|
||||
<ul>
|
||||
<li>Robust parsing of JSON and XML (using <code class="language-plaintext highlighter-rouge">fhir.resources</code> library when available).</li>
|
||||
<li>Automatic dependency analysis based on resource references within the uploaded set.</li>
|
||||
<li>Topological sorting to ensure resources are uploaded in the correct order.</li>
|
||||
<li>Cycle detection in dependencies.</li>
|
||||
<li>Choice of individual resource uploads or a single transaction bundle.</li>
|
||||
<li><strong>Optional Pre-Upload Validation:</strong> Validate resources against a selected profile package before uploading.</li>
|
||||
<li><strong>Optional Conditional Uploads (Individual Mode):</strong> Check resource existence (GET) and use conditional <code class="language-plaintext highlighter-rouge">If-Match</code> headers for updates (PUT) or create resources (PUT/POST). Falls back to simple PUT if unchecked.</li>
|
||||
<li>Configurable error handling (stop on first error or continue).</li>
|
||||
<li>Authentication support (Bearer Token).</li>
|
||||
<li>Streaming progress log via the UI.</li>
|
||||
<li>Handles large numbers of files using a custom form parser.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Profile Relationships:</strong> Display and validate <code class="language-plaintext highlighter-rouge">compliesWithProfile</code> and <code class="language-plaintext highlighter-rouge">imposeProfile</code> extensions in the UI (configurable).</li>
|
||||
<li><strong>FSH Converter:</strong> Convert FHIR JSON/XML resources to FHIR Shorthand (FSH) using GoFSH, with advanced options (Package context, Output styles, Log levels, FHIR versions, Fishing Trip, Dependencies, Indentation, Meta Profile handling, Alias File, No Alias). Includes a waiting spinner.</li>
|
||||
<li><strong>Retrieve and Split Bundles:</strong>
|
||||
<ul>
|
||||
<li>Retrieve specified resource types as bundles from a FHIR server.</li>
|
||||
<li>Optionally fetch referenced resources, either individually or as full bundles for each referenced type.</li>
|
||||
<li>Split uploaded ZIP files containing bundles into individual resource JSON files.</li>
|
||||
<li>Download retrieved/split resources as a ZIP archive.</li>
|
||||
<li>Streaming progress log via the UI for retrieval operations.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>FHIR Interaction UIs:</strong> Explore FHIR server capabilities and interact with resources using the “FHIR API Explorer” (simple GET/POST/PUT/DELETE) and “FHIR UI Operations” (Swagger-like interface based on CapabilityStatement). <em>Note: Lite version requires custom server URLs.</em></li>
|
||||
<li><strong>HAPI FHIR Configuration (Standalone Mode):</strong>
|
||||
<ul>
|
||||
<li>A dedicated page (<code class="language-plaintext highlighter-rouge">/config-hapi</code>) to view and edit the <code class="language-plaintext highlighter-rouge">application.yaml</code> configuration for the embedded HAPI FHIR server.</li>
|
||||
<li>Allows modification of HAPI FHIR properties directly from the UI.</li>
|
||||
<li>Option to restart the HAPI FHIR server (Tomcat) to apply changes.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>API Support:</strong> RESTful API endpoints for importing, pushing, retrieving metadata, validating, uploading test data, and retrieving/splitting bundles.</li>
|
||||
<li><strong>Live Console:</strong> Real-time logs for push, validation, upload test data, FSH conversion, and bundle retrieval operations.</li>
|
||||
<li><strong>Configurable Behavior:</strong> Control validation modes, display options via <code class="language-plaintext highlighter-rouge">app.config</code>.</li>
|
||||
<li><strong>Theming:</strong> Supports light and dark modes.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="technology-stack">Technology Stack</h2>
|
||||
|
||||
<ul>
|
||||
<li>Python 3.12+, Flask 2.3.3, Flask-SQLAlchemy 3.0.5, Flask-WTF 1.2.1</li>
|
||||
<li>Jinja2, Bootstrap 5.3.3, JavaScript (ES6), Lottie-Web 5.12.2</li>
|
||||
<li>SQLite</li>
|
||||
<li>Docker, Docker Compose, Supervisor</li>
|
||||
<li>Node.js 18+ (for GoFSH/SUSHI), GoFSH, SUSHI</li>
|
||||
<li>HAPI FHIR (Standalone version only)</li>
|
||||
<li>Requests 2.31.0, Tarfile, Logging, Werkzeug</li>
|
||||
<li>fhir.resources (optional, for robust XML parsing)</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="prerequisites">Prerequisites</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Docker:</strong> Required for containerized deployment (both versions).</li>
|
||||
<li><strong>Git & Maven:</strong> Required <strong>only</strong> for building the <strong>Standalone</strong> version from source using the <code class="language-plaintext highlighter-rouge">.bat</code> script or manual steps. Not required for the Lite version build or for running pre-built Docker Hub images.</li>
|
||||
<li><strong>Windows:</strong> Required if using the <code class="language-plaintext highlighter-rouge">.bat</code> scripts.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="setup-instructions">Setup Instructions</h2>
|
||||
|
||||
<h3 id="running-pre-built-images-general-users">Running Pre-built Images (General Users)</h3>
|
||||
|
||||
<p>This is the easiest way to get started without needing Git or Maven. Choose the version you need:</p>
|
||||
|
||||
<p><strong>Lite Version (No local HAPI FHIR):</strong></p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Pull the latest Lite image</span>
|
||||
docker pull ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest
|
||||
|
||||
<span class="c"># Run the Lite version (maps port 5000 for the UI)</span>
|
||||
<span class="c"># You'll need to create local directories for persistent data first:</span>
|
||||
<span class="c"># mkdir instance logs static static/uploads instance/hapi-h2-data</span>
|
||||
docker run <span class="nt">-d</span> <span class="se">\</span>
|
||||
<span class="nt">-p</span> 5000:5000 <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./instance:/app/instance <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./static/uploads:/app/static/uploads <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./instance/hapi-h2-data:/app/h2-data <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./logs:/app/logs <span class="se">\</span>
|
||||
<span class="nt">--name</span> fhirflare-lite <span class="se">\</span>
|
||||
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest
|
||||
Standalone Version <span class="o">(</span>Includes <span class="nb">local </span>HAPI FHIR<span class="o">)</span>:
|
||||
</code></pre></div></div>
|
||||
<p>Bash</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Pull the latest Standalone image</span>
|
||||
docker pull ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
|
||||
|
||||
<span class="c"># Run the Standalone version (maps ports 5000 and 8080)</span>
|
||||
<span class="c"># You'll need to create local directories for persistent data first:</span>
|
||||
<span class="c"># mkdir instance logs static static/uploads instance/hapi-h2-data</span>
|
||||
docker run <span class="nt">-d</span> <span class="se">\</span>
|
||||
<span class="nt">-p</span> 5000:5000 <span class="se">\</span>
|
||||
<span class="nt">-p</span> 8080:8080 <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./instance:/app/instance <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./static/uploads:/app/static/uploads <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./instance/hapi-h2-data:/app/h2-data <span class="se">\</span>
|
||||
<span class="nt">-v</span> ./logs:/app/logs <span class="se">\</span>
|
||||
<span class="nt">--name</span> fhirflare-standalone <span class="se">\</span>
|
||||
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Building from Source (Developers)
|
||||
Using Windows .bat Scripts (Standalone Version Only):</p>
|
||||
|
||||
<p>First Time Setup:</p>
|
||||
|
||||
<p>Run Build and Run for first time.bat:</p>
|
||||
|
||||
<p>Code snippet</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> <span class="s2">"<project folder>"</span>
|
||||
git clone <span class="o">[</span>https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git]<span class="o">(</span>https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git<span class="o">)</span> hapi-fhir-jpaserver
|
||||
copy .<span class="se">\\</span>hapi-fhir-Setup<span class="se">\\</span>target<span class="se">\\</span>classes<span class="se">\\</span>application.yaml .<span class="se">\\</span>hapi-fhir-jpaserver<span class="se">\\</span>target<span class="se">\\</span>classes<span class="se">\\</span>application.yaml
|
||||
mvn clean package <span class="nt">-DskipTests</span><span class="o">=</span><span class="nb">true</span> <span class="nt">-Pboot</span>
|
||||
docker-compose build <span class="nt">--no-cache</span>
|
||||
docker-compose up <span class="nt">-d</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>This clones the HAPI FHIR server, copies configuration, builds the project, and starts the containers.</p>
|
||||
|
||||
<p>Subsequent Runs:</p>
|
||||
|
||||
<p>Run Run.bat:</p>
|
||||
|
||||
<p>Code snippet</p>
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> <span class="s2">"<project folder>"</span>
|
||||
docker-compose up <span class="nt">-d</span>
|
||||
</code></pre></div></div>
|
||||
<p>This starts the Flask app (port 5000) and HAPI FHIR server (port 8080).</p>
|
||||
|
||||
<p>Access the Application:</p>
|
||||
|
||||
<ul>
|
||||
<li>Flask UI: http://localhost:5000</li>
|
||||
<li>HAPI FHIR server: http://localhost:8080</li>
|
||||
<li>Manual Setup (Linux/MacOS/Windows):</li>
|
||||
</ul>
|
||||
|
||||
<p>Preparation (Standalone Version Only):</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> <project folder>
|
||||
git clone <span class="o">[</span>https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git]<span class="o">(</span>https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git<span class="o">)</span> hapi-fhir-jpaserver
|
||||
<span class="nb">cp</span> ./hapi-fhir-Setup/target/classes/application.yaml ./hapi-fhir-jpaserver/target/classes/application.yaml
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Build:</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Build HAPI FHIR (Standalone Version Only)</span>
|
||||
mvn clean package <span class="nt">-DskipTests</span><span class="o">=</span><span class="nb">true</span> <span class="nt">-Pboot</span>
|
||||
|
||||
<span class="c"># Build Docker Image (Specify APP_MODE=lite in docker-compose.yml for Lite version)</span>
|
||||
docker-compose build <span class="nt">--no-cache</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Run:</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker-compose up <span class="nt">-d</span>
|
||||
Access the Application:
|
||||
</code></pre></div></div>
|
||||
|
||||
<ul>
|
||||
<li>Flask UI: http://localhost:5000</li>
|
||||
<li>HAPI FHIR server (Standalone only): http://localhost:8080</li>
|
||||
<li>Local Development (Without Docker):</li>
|
||||
</ul>
|
||||
|
||||
<p>Clone the Repository:</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="o">[</span>https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git]<span class="o">(</span>https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git<span class="o">)</span>
|
||||
<span class="nb">cd </span>FHIRFLARE-IG-Toolkit
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Install Dependencies:</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python <span class="nt">-m</span> venv venv
|
||||
<span class="nb">source </span>venv/bin/activate <span class="c"># On Windows: venv\Scripts\activate</span>
|
||||
pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Install Node.js, GoFSH, and SUSHI (for FSH Converter):</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Example for Debian/Ubuntu</span>
|
||||
curl <span class="nt">-fsSL</span> <span class="o">[</span>https://deb.nodesource.com/setup_18.x]<span class="o">(</span>https://deb.nodesource.com/setup_18.x<span class="o">)</span> | <span class="nb">sudo </span>bash -
|
||||
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> nodejs
|
||||
<span class="c"># Install globally</span>
|
||||
npm <span class="nb">install</span> <span class="nt">-g</span> gofsh fsh-sushi
|
||||
Set Environment Variables:
|
||||
</code></pre></div></div>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">FLASK_SECRET_KEY</span><span class="o">=</span><span class="s1">'your-secure-secret-key'</span>
|
||||
<span class="nb">export </span><span class="nv">API_KEY</span><span class="o">=</span><span class="s1">'your-api-key'</span>
|
||||
<span class="c"># Optional: Set APP_MODE to 'lite' if desired</span>
|
||||
<span class="c"># export APP_MODE='lite'</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Initialize Directories:</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> instance static/uploads logs
|
||||
<span class="c"># Ensure write permissions if needed</span>
|
||||
<span class="c"># chmod -R 777 instance static/uploads logs</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Run the Application:</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">FLASK_APP</span><span class="o">=</span>app.py
|
||||
flask run
|
||||
</code></pre></div></div>
|
||||
<p>Access at http://localhost:5000.</p>
|
||||
|
||||
<p>Usage
|
||||
Import an IG</p>
|
||||
<h3 id="search-view-details-and-import-packages">Search, View Details, and Import Packages</h3>
|
||||
<p>Navigate to <strong>Search and Import Packages</strong> (<code class="language-plaintext highlighter-rouge">/search-and-import</code>).</p>
|
||||
<ol>
|
||||
<li>The page will load a list of available FHIR Implementation Guide packages from a local cache or by fetching from configured registries.
|
||||
<ul>
|
||||
<li>A loading animation and progress messages are shown if fetching from registries.</li>
|
||||
<li>The timestamp of the last cache update is displayed.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Use the search bar to filter packages by name or author.</li>
|
||||
<li>Packages are paginated for easier Browse.</li>
|
||||
<li>For each package, you can:
|
||||
<ul>
|
||||
<li>View its latest official and absolute versions.</li>
|
||||
<li>Click on the package name to navigate to a <strong>detailed view</strong> (<code class="language-plaintext highlighter-rouge">/package-details/<name></code>) showing:
|
||||
<ul>
|
||||
<li>Comprehensive metadata (author, FHIR version, canonical URL, description).</li>
|
||||
<li>A full list of available versions with publication dates.</li>
|
||||
<li>Declared dependencies.</li>
|
||||
<li>Other packages that depend on it (dependents).</li>
|
||||
<li>Version history (logs).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Directly import a specific version using the “Import” button on the search page or the details page.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Cache Management:</strong>
|
||||
<ul>
|
||||
<li>A “Clear & Refresh Cache” button is available to trigger a background task (<code class="language-plaintext highlighter-rouge">/api/refresh-cache-task</code>) that clears the local database and in-memory cache and fetches the latest package information from all configured registries. Progress is shown via a live log.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<ul>
|
||||
<li>Enter a package name (e.g., hl7.fhir.au.core) and version (e.g., 1.1.0-preview).</li>
|
||||
<li>Choose a dependency mode:</li>
|
||||
<li>Current Recursive: Import all dependencies listed in package.json recursively.</li>
|
||||
<li>Patch Canonical Versions: Import only canonical FHIR packages (e.g., hl7.fhir.r4.core).</li>
|
||||
<li>Tree Shaking: Import only dependencies containing resources actually used by the main package.</li>
|
||||
<li>Click Import to download the package and dependencies.</li>
|
||||
</ul>
|
||||
|
||||
<p>Manage IGs
|
||||
Go to Manage FHIR Packages (/view-igs) to view downloaded and processed IGs.</p>
|
||||
|
||||
<p>Actions:</p>
|
||||
<ul>
|
||||
<li>Process: Extract metadata (resource types, profiles, must-support elements, examples).</li>
|
||||
<li>Unload: Remove processed IG data from the database.</li>
|
||||
<li>Delete: Remove package files from the filesystem.</li>
|
||||
</ul>
|
||||
|
||||
<p>Duplicates are highlighted for resolution.</p>
|
||||
|
||||
<p>View Processed IGs</p>
|
||||
|
||||
<p>After processing, view IG details (/view-ig/<id>), including:</id></p>
|
||||
|
||||
<ul>
|
||||
<li>Resource types and profiles.</li>
|
||||
<li>Must-support elements and examples.</li>
|
||||
<li>Profile relationships (compliesWithProfile, imposeProfile) if enabled (DISPLAY_PROFILE_RELATIONSHIPS).</li>
|
||||
</ul>
|
||||
|
||||
<p>Interactive StructureDefinition viewer (Differential, Snapshot, Must Support, Key Elements, Constraints, Terminology, Search Params).</p>
|
||||
<ul>
|
||||
<li>Validate FHIR Resources/Bundles</li>
|
||||
<li>Navigate to Validate FHIR Sample (/validate-sample).</li>
|
||||
</ul>
|
||||
|
||||
<p>Select a package (e.g., hl7.fhir.au.core#1.1.0-preview).</p>
|
||||
|
||||
<ul>
|
||||
<li>Choose Single Resource or Bundle mode.</li>
|
||||
<li>Paste or upload FHIR JSON/XML (e.g., a Patient resource).</li>
|
||||
<li>Submit to view validation errors/warnings. Note: Alpha feature; report issues to GitHub (remove PHI).</li>
|
||||
<li>Push IGs to a FHIR Server</li>
|
||||
<li>Go to Push IGs (/push-igs).</li>
|
||||
</ul>
|
||||
|
||||
<p>Select a downloaded package.</p>
|
||||
|
||||
<ul>
|
||||
<li>Enter the Target FHIR Server URL.</li>
|
||||
<li>Configure Authentication (None, Bearer Token).</li>
|
||||
<li>Choose options: Include Dependencies, Force Upload (skips comparison check), Dry Run, Verbose Log.</li>
|
||||
<li>Optionally filter by Resource Types (comma-separated) or Skip Specific Files (paths within package, comma/newline separated).</li>
|
||||
<li>Click Push to FHIR Server to upload resources. Canonical resources are checked before upload. Identical resources are skipped unless Force Upload is checked.</li>
|
||||
<li>Monitor progress in the live console.</li>
|
||||
<li>Upload Test Data</li>
|
||||
<li>Navigate to Upload Test Data (/upload-test-data).</li>
|
||||
<li>Enter the Target FHIR Server URL.</li>
|
||||
<li>Configure Authentication (None, Bearer Token).</li>
|
||||
<li>Select one or more .json, .xml files, or a single .zip file containing test resources.</li>
|
||||
<li>Optionally check Validate Resources Before Upload? and select a Validation Profile Package.</li>
|
||||
<li>Choose Upload Mode:</li>
|
||||
<li>Individual Resources: Uploads each resource one by one in dependency order.</li>
|
||||
<li>Transaction Bundle: Uploads all resources in a single transaction.</li>
|
||||
<li>Optionally check Use Conditional Upload (Individual Mode Only)? to use If-Match headers for updates.</li>
|
||||
<li>Choose Error Handling:</li>
|
||||
<li>Stop on First Error: Halts the process if any validation or upload fails.</li>
|
||||
<li>Continue on Error: Reports errors but attempts to process/upload remaining resources.</li>
|
||||
<li>Click Upload and Process. The tool parses files, optionally validates, analyzes dependencies, topologically sorts resources, and uploads them according to selected options.</li>
|
||||
<li>Monitor progress in the streaming log output.</li>
|
||||
</ul>
|
||||
|
||||
<p>Convert FHIR to FSH</p>
|
||||
<ul>
|
||||
<li>Navigate to FSH Converter (/fsh-converter).</li>
|
||||
</ul>
|
||||
|
||||
<p>Optionally select a package for context (e.g., hl7.fhir.au.core#1.1.0-preview).
|
||||
Choose input mode:
|
||||
Upload File: Upload a FHIR JSON/XML file.
|
||||
Paste Text: Paste FHIR JSON/XML content.
|
||||
Configure options:
|
||||
Output Style: file-per-definition, group-by-fsh-type, group-by-profile, single-file.
|
||||
Log Level: error, warn, info, debug.
|
||||
FHIR Version: R4, R4B, R5, or auto-detect.
|
||||
Fishing Trip: Enable round-trip validation with SUSHI, generating a comparison report.
|
||||
Dependencies: Specify additional packages (e.g., hl7.fhir.us.core@6.1.0, one per line).
|
||||
Indent Rules: Enable context path indentation for readable FSH.
|
||||
Meta Profile: Choose only-one, first, or none for meta.profile handling.
|
||||
Alias File: Upload an FSH file with aliases (e.g., $MyAlias = http://example.org).
|
||||
No Alias: Disable automatic alias generation.
|
||||
Click Convert to FSH to generate and display FSH output, with a waiting spinner (light/dark theme) during processing.
|
||||
If Fishing Trip is enabled, view the comparison report via the “Click here for SUSHI Validation” badge button.
|
||||
Download the result as a .fsh file.
|
||||
Retrieve and Split Bundles
|
||||
Navigate to Retrieve/Split Data (/retrieve-split-data).</p>
|
||||
|
||||
<p>Retrieve Bundles from Server:</p>
|
||||
|
||||
<ul>
|
||||
<li>Enter the FHIR Server URL (defaults to the proxy if empty).</li>
|
||||
<li>Select one or more Resource Types to retrieve (e.g., Patient, Observation).</li>
|
||||
<li>Optionally check Fetch Referenced Resources.</li>
|
||||
<li>If checked, further optionally check Fetch Full Reference Bundles to retrieve entire bundles for each referenced type (e.g., all Patients if a Patient is referenced) instead of individual resources by ID.</li>
|
||||
<li>Click Retrieve Bundles.</li>
|
||||
<li>Monitor progress in the streaming log. A ZIP file containing the retrieved bundles/resources will be prepared for download.</li>
|
||||
</ul>
|
||||
|
||||
<p>Split Uploaded Bundles:</p>
|
||||
|
||||
<ul>
|
||||
<li>Upload a ZIP file containing FHIR bundles (JSON format).</li>
|
||||
<li>Click Split Bundles.</li>
|
||||
<li>A ZIP file containing individual resources extracted from the bundles will be prepared for download.</li>
|
||||
<li>Explore FHIR Operations</li>
|
||||
<li>Navigate to FHIR UI Operations (/fhir-ui-operations).</li>
|
||||
</ul>
|
||||
|
||||
<p>Toggle between local HAPI (/fhir) or a custom FHIR server.</p>
|
||||
|
||||
<ul>
|
||||
<li>Click Fetch Metadata to load the server’s CapabilityStatement.</li>
|
||||
<li>Select a resource type (e.g., Patient, Observation) or System to view operations:</li>
|
||||
<li>System operations: GET /metadata, POST /, GET /_history, GET/POST /$diff, POST /$reindex, POST /$expunge, etc.</li>
|
||||
<li>Resource operations: GET Patient/:id, POST Observation/_search, etc.</li>
|
||||
<li>Use Try it out to input parameters or request bodies, then Execute to view results in JSON, XML, or narrative formats.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="configure-embedded-hapi-fhir-server-standalone-mode">Configure Embedded HAPI FHIR Server (Standalone Mode)</h3>
|
||||
<p>For users running the <strong>Standalone version</strong>, which includes an embedded HAPI FHIR server.</p>
|
||||
<ol>
|
||||
<li>Navigate to <strong>Configure HAPI FHIR</strong> (<code class="language-plaintext highlighter-rouge">/config-hapi</code>).</li>
|
||||
<li>The page displays the content of the HAPI FHIR server’s <code class="language-plaintext highlighter-rouge">application.yaml</code> file.</li>
|
||||
<li>You can edit the configuration directly in the text area.
|
||||
<ul>
|
||||
<li><em>Caution: Incorrect modifications can break the HAPI FHIR server.</em></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Click <strong>Save Configuration</strong> to apply your changes to the <code class="language-plaintext highlighter-rouge">application.yaml</code> file.</li>
|
||||
<li>Click <strong>Restart Tomcat</strong> to restart the HAPI FHIR server and load the new configuration. The restart process may take a few moments.</li>
|
||||
</ol>
|
||||
|
||||
<p>API Usage
|
||||
Import IG
|
||||
Bash</p>
|
||||
|
||||
<p>curl -X POST http://localhost:5000/api/import-ig <br />
|
||||
-H “Content-Type: application/json” <br />
|
||||
-H “X-API-Key: your-api-key” <br />
|
||||
-d ‘{“package_name”: “hl7.fhir.au.core”, “version”: “1.1.0-preview”, “dependency_mode”: “recursive”}’
|
||||
Returns complies_with_profiles, imposed_profiles, and duplicate_packages_present info.</p>
|
||||
|
||||
<h3 id="refresh-package-cache-background-task">Refresh Package Cache (Background Task)</h3>
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:5000/api/refresh-cache-task <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Push IG
|
||||
Bash</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:5000/api/push-ig <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"Accept: application/x-ndjson"</span> <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span> <span class="se">\</span>
|
||||
<span class="nt">-d</span> <span class="s1">'{
|
||||
"package_name": "hl7.fhir.au.core",
|
||||
"version": "1.1.0-preview",
|
||||
"fhir_server_url": "http://localhost:8080/fhir",
|
||||
"include_dependencies": true,
|
||||
"force_upload": false,
|
||||
"dry_run": false,
|
||||
"verbose": false,
|
||||
"auth_type": "none"
|
||||
}'</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Returns a streaming NDJSON response with progress and final summary.</p>
|
||||
|
||||
<p>Upload Test Data
|
||||
Bash</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:5000/api/upload-test-data <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span> <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"Accept: application/x-ndjson"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"fhir_server_url=http://your-fhir-server/fhir"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"auth_type=bearerToken"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"auth_token=YOUR_TOKEN"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"upload_mode=individual"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"error_handling=continue"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"validate_before_upload=true"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"validation_package_id=hl7.fhir.r4.core#4.0.1"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"use_conditional_uploads=true"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"test_data_files=@/path/to/your/patient.json"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"test_data_files=@/path/to/your/observations.zip"</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Returns a streaming NDJSON response with progress and final summary. Uses multipart/form-data for file uploads.</p>
|
||||
|
||||
<p>Retrieve Bundles
|
||||
Bash</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:5000/api/retrieve-bundles <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span> <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"Accept: application/x-ndjson"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"fhir_server_url=http://your-fhir-server/fhir"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"resources=Patient"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"resources=Observation"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"validate_references=true"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"fetch_reference_bundles=false"</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Returns a streaming NDJSON response with progress. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive (e.g., /tmp/retrieved_bundles_datetime.zip).</p>
|
||||
|
||||
<p>Split Bundles
|
||||
Bash</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:5000/api/split-bundles <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span> <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"Accept: application/x-ndjson"</span> <span class="se">\</span>
|
||||
<span class="nt">-F</span> <span class="s2">"split_bundle_zip_path=@/path/to/your/bundles.zip"</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Returns a streaming NDJSON response. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive of split resources.</p>
|
||||
|
||||
<p>Validate Resource/Bundle
|
||||
Not yet exposed via API; use the UI at /validate-sample.</p>
|
||||
|
||||
<p>Configuration Options
|
||||
Located in app.py:</p>
|
||||
|
||||
<ul>
|
||||
<li>VALIDATE_IMPOSED_PROFILES: (Default: True) Validates resources against imposed profiles during push.</li>
|
||||
<li>DISPLAY_PROFILE_RELATIONSHIPS: (Default: True) Shows compliesWithProfile and imposeProfile in the UI.</li>
|
||||
<li>FHIR_PACKAGES_DIR: (Default: /app/instance/fhir_packages) Stores .tgz packages and metadata.</li>
|
||||
<li>UPLOAD_FOLDER: (Default: /app/static/uploads) Stores GoFSH output files and FSH comparison reports.</li>
|
||||
<li>SECRET_KEY: Required for CSRF protection and sessions. Set via environment variable or directly.</li>
|
||||
<li>API_KEY: Required for API authentication. Set via environment variable or directly.</li>
|
||||
<li>MAX_CONTENT_LENGTH: (Default: Flask default) Max size for HTTP request body (e.g., 16 * 1024 * 1024 for 16MB). Important for large uploads.</li>
|
||||
<li>MAX_FORM_PARTS: (Default: Werkzeug default, often 1000) Default max number of form parts. Overridden for /api/upload-test-data by CustomFormDataParser.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="get-hapi-fhir-configuration-standalone-mode">Get HAPI FHIR Configuration (Standalone Mode)</h3>
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> GET http://localhost:5000/api/config <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span>
|
||||
</code></pre></div></div>
|
||||
<p>Save HAPI FHIR Configuration:</p>
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:5000/api/config <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span> <span class="se">\</span>
|
||||
<span class="nt">-d</span> <span class="s1">'{"your_yaml_key": "your_value", ...}'</span> <span class="c"># Send the full YAML content as JSON</span>
|
||||
</code></pre></div></div>
|
||||
<p>Restart HAPI FHIR Server:</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:5000/api/restart-tomcat <span class="se">\</span>
|
||||
<span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Testing
|
||||
The project includes a test suite covering UI, API, database, file operations, and security.</p>
|
||||
|
||||
<p>Test Prerequisites:</p>
|
||||
|
||||
<p>pytest: For running tests.
|
||||
pytest-mock: For mocking dependencies. Install: pip install pytest pytest-mock
|
||||
Running Tests:</p>
|
||||
|
||||
<p>Bash</p>
|
||||
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> <project folder>
|
||||
pytest tests/test_app.py <span class="nt">-v</span>
|
||||
</code></pre></div></div>
|
||||
<p>Test Coverage:</p>
|
||||
|
||||
<ul>
|
||||
<li>UI Pages: Homepage, Import IG, Manage IGs, Push IGs, Validate Sample, View Processed IG, FSH Converter, Upload Test Data, Retrieve/Split Data.</li>
|
||||
<li>API Endpoints: POST /api/import-ig, POST /api/push-ig, GET /get-structure, GET /get-example, POST /api/upload-test-data, POST /api/retrieve-bundles, POST /api/split-bundles.</li>
|
||||
<li>Database: IG processing, unloading, viewing.</li>
|
||||
<li>File Operations: Package processing, deletion, FSH output, ZIP handling.</li>
|
||||
<li>Security: CSRF protection, flash messages, secret key.</li>
|
||||
<li>FSH Converter: Form submission, file/text input, GoFSH execution, Fishing Trip comparison.</li>
|
||||
<li>Upload Test Data: Parsing, dependency graph, sorting, upload modes, validation, conditional uploads.</li>
|
||||
</ul>
|
||||
|
||||
<p>Development Notes</p>
|
||||
|
||||
<p>Background</p>
|
||||
|
||||
<p>The toolkit addresses the need for a comprehensive FHIR IG management tool, with recent enhancements for resource validation, FSH conversion with advanced GoFSH features, flexible versioning, improved IG pushing, dependency-aware test data uploading, and bundle retrieval/splitting, making it a versatile platform for FHIR developers.</p>
|
||||
|
||||
<p>Technical Decisions</p>
|
||||
|
||||
<ul>
|
||||
<li>Flask: Lightweight and flexible for web development.</li>
|
||||
<li>SQLite: Simple for development; consider PostgreSQL for production.</li>
|
||||
<li>Bootstrap 5.3.3: Responsive UI with custom styling.</li>
|
||||
<li>Lottie-Web: Renders themed animations for FSH conversion waiting spinner.</li>
|
||||
<li>GoFSH/SUSHI: Integrated via Node.js for advanced FSH conversion and round-trip validation.</li>
|
||||
<li>Docker: Ensures consistent deployment with Flask and HAPI FHIR.</li>
|
||||
<li>Flexible Versioning: Supports non-standard IG versions (e.g., -preview, -ballot).</li>
|
||||
<li>Live Console/Streaming: Real-time feedback for complex operations (Push, Upload Test Data, FSH, Retrieve Bundles).</li>
|
||||
<li>Validation: Alpha feature with ongoing FHIRPath improvements.</li>
|
||||
<li>Dependency Management: Uses topological sort for Upload Test Data feature.</li>
|
||||
<li>Form Parsing: Uses custom Werkzeug parser for Upload Test Data to handle large numbers of files.</li>
|
||||
</ul>
|
||||
|
||||
<p>Recent Updates</p>
|
||||
|
||||
<ul>
|
||||
<li>Enhanced package search page with caching, detailed views (dependencies, dependents, version history), and background cache refresh.</li>
|
||||
<li>Upload Test Data Enhancements (April 2025):</li>
|
||||
<li>Added optional Pre-Upload Validation against selected IG profiles.</li>
|
||||
<li>Added optional Conditional Uploads (GET + POST/PUT w/ If-Match) for individual mode.</li>
|
||||
<li>Implemented robust XML parsing using fhir.resources library (when available).</li>
|
||||
<li>Fixed 413 Request Entity Too Large errors for large file counts using a custom Werkzeug FormDataParser.</li>
|
||||
<li>Path: templates/upload_test_data.html, app.py, services.py, forms.py.</li>
|
||||
<li>Push IG Enhancements (April 2025):</li>
|
||||
<li>Added semantic comparison to skip uploading identical resources.</li>
|
||||
<li>Added “Force Upload” option to bypass comparison.</li>
|
||||
<li>Improved handling of canonical resources (search before PUT/POST).</li>
|
||||
<li>Added filtering by specific files to skip during push.</li>
|
||||
<li>More detailed summary report in stream response.</li>
|
||||
<li>Path: templates/cp_push_igs.html, app.py, services.py.</li>
|
||||
<li>Waiting Spinner for FSH Converter (April 2025):</li>
|
||||
<li>Added a themed (light/dark) Lottie animation spinner during FSH execution.</li>
|
||||
<li>Path: templates/fsh_converter.html, static/animations/, static/js/lottie-web.min.js.</li>
|
||||
<li>Advanced FSH Converter (April 2025):</li>
|
||||
<li>Added support for GoFSH advanced options: –fshing-trip, –dependency, –indent, –meta-profile, –alias-file, –no-alias.</li>
|
||||
<li>Displays Fishing Trip comparison reports.</li>
|
||||
<li>Path: templates/fsh_converter.html, app.py, services.py, forms.py.</li>
|
||||
<li>(New) Retrieve and Split Data (May 2025):</li>
|
||||
<li>Added UI and API for retrieving bundles from a FHIR server by resource type.</li>
|
||||
<li>Added options to fetch referenced resources (individually or as full type bundles).</li>
|
||||
<li>Added functionality to split uploaded ZIP files of bundles into individual resources.</li>
|
||||
<li>Streaming log for retrieval and ZIP download for results.</li>
|
||||
<li>Paths: templates/retrieve_split_data.html, app.py, services.py, forms.py.</li>
|
||||
<li>Known Issues and Workarounds</li>
|
||||
<li>Favicon 404: Clear browser cache or verify /app/static/favicon.ico.</li>
|
||||
<li>CSRF Errors: Set FLASK_SECRET_KEY and ensure in forms.</li>
|
||||
<li>Import Fails: Check package name/version and connectivity.</li>
|
||||
<li>Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).</li>
|
||||
<li>Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.</li>
|
||||
<li>Permissions: Ensure instance/ and static/uploads/ are writable.</li>
|
||||
<li>GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation.</li>
|
||||
<li>Upload Test Data XML Parsing: Relies on fhir.resources library for full validation; basic parsing used as fallback. Complex XML structures might not be fully analyzed for dependencies with basic parsing. Prefer JSON for reliable dependency analysis.</li>
|
||||
<li>413 Request Entity Too Large: Primarily handled by CustomFormDataParser for /api/upload-test-data. Check the parser’s max_form_parts limit if still occurring. MAX_CONTENT_LENGTH in app.py controls overall size. Reverse proxy limits (client_max_body_size in Nginx) might also apply.</li>
|
||||
</ul>
|
||||
|
||||
<p>Future Improvements</p>
|
||||
|
||||
<ul>
|
||||
<li>Upload Test Data: Improve XML parsing further (direct XML->fhir.resource object if possible), add visual progress bar, add upload order preview, implement transaction bundle size splitting, add ‘Clear Target Server’ option (with confirmation).</li>
|
||||
<li>Validation: Enhance FHIRPath for complex constraints; add API endpoint.</li>
|
||||
<li>Sorting: Sort IG versions in /view-igs (e.g., ascending).</li>
|
||||
<li>Duplicate Resolution: Options to keep latest version or merge resources.</li>
|
||||
<li>Production Database: Support PostgreSQL.</li>
|
||||
<li>Error Reporting: Detailed validation error paths in the UI.</li>
|
||||
<li>FSH Enhancements: Add API endpoint for FSH conversion; support inline instance construction.</li>
|
||||
<li>FHIR Operations: Add complex parameter support (e.g., /$diff with left/right).</li>
|
||||
<li>Retrieve/Split Data: Add option to filter resources during retrieval (e.g., by date, specific IDs).</li>
|
||||
</ul>
|
||||
|
||||
<p>Completed Items</p>
|
||||
|
||||
<ul>
|
||||
<li>Testing suite with basic coverage.</li>
|
||||
<li>API endpoints for POST /api/import-ig and POST /api/push-ig.</li>
|
||||
<li>Flexible versioning (-preview, -ballot).</li>
|
||||
<li>CSRF fixes for forms.</li>
|
||||
<li>Resource validation UI (alpha).</li>
|
||||
<li>FSH Converter with advanced GoFSH features and waiting spinner.</li>
|
||||
<li>Push IG enhancements (force upload, semantic comparison, canonical handling, skip files).</li>
|
||||
<li>Upload Test Data feature with dependency sorting, multiple upload modes, pre-upload validation, conditional uploads, robust XML parsing, and fix for large file counts.</li>
|
||||
<li>Retrieve and Split Data functionality with reference fetching and ZIP download.</li>
|
||||
<li>Far-Distant Improvements</li>
|
||||
<li>Cache Service: Use Redis for IG metadata caching.</li>
|
||||
<li>Database Optimization: Composite index on ProcessedIg.package_name and ProcessedIg.version.</li>
|
||||
</ul>
|
||||
|
||||
<p>Directory Structure</p>
|
||||
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FHIRFLARE-IG-Toolkit/
|
||||
├── app.py <span class="c"># Main Flask application</span>
|
||||
├── Build and Run <span class="k">for </span>first time.bat <span class="c"># Windows script for first-time Docker setup</span>
|
||||
├── docker-compose.yml <span class="c"># Docker Compose configuration</span>
|
||||
├── Dockerfile <span class="c"># Docker configuration</span>
|
||||
├── forms.py <span class="c"># Form definitions</span>
|
||||
├── LICENSE.md <span class="c"># Apache 2.0 License</span>
|
||||
├── README.md <span class="c"># Project documentation</span>
|
||||
├── requirements.txt <span class="c"># Python dependencies</span>
|
||||
├── Run.bat <span class="c"># Windows script for running Docker</span>
|
||||
├── services.py <span class="c"># Logic for IG import, processing, validation, pushing, FSH conversion, test data upload, retrieve/split</span>
|
||||
├── supervisord.conf <span class="c"># Supervisor configuration</span>
|
||||
├── hapi-fhir-Setup/
|
||||
│ ├── README.md <span class="c"># HAPI FHIR setup instructions</span>
|
||||
│ └── target/
|
||||
│ └── classes/
|
||||
│ └── application.yaml <span class="c"># HAPI FHIR configuration</span>
|
||||
├── instance/
|
||||
│ ├── fhir_ig.db <span class="c"># SQLite database</span>
|
||||
│ ├── fhir_ig.db.old <span class="c"># Database backup</span>
|
||||
│ └── fhir_packages/ <span class="c"># Stored IG packages and metadata</span>
|
||||
│ ├── ... <span class="o">(</span>example packages<span class="o">)</span> ...
|
||||
├── logs/
|
||||
│ ├── flask.log <span class="c"># Flask application logs</span>
|
||||
│ ├── flask_err.log <span class="c"># Flask error logs</span>
|
||||
│ ├── supervisord.log <span class="c"># Supervisor logs</span>
|
||||
│ ├── supervisord.pid <span class="c"># Supervisor PID file</span>
|
||||
│ ├── tomcat.log <span class="c"># Tomcat logs for HAPI FHIR</span>
|
||||
│ └── tomcat_err.log <span class="c"># Tomcat error logs</span>
|
||||
├── static/
|
||||
│ ├── animations/
|
||||
│ │ ├── loading-dark.json <span class="c"># Dark theme spinner animation</span>
|
||||
│ │ └── loading-light.json <span class="c"># Light theme spinner animation</span>
|
||||
│ ├── favicon.ico <span class="c"># Application favicon</span>
|
||||
│ ├── FHIRFLARE.png <span class="c"># Application logo</span>
|
||||
│ ├── js/
|
||||
│ │ └── lottie-web.min.js <span class="c"># Lottie library for spinner</span>
|
||||
│ └── uploads/
|
||||
│ ├── output.fsh <span class="c"># Generated FSH output (temp location)</span>
|
||||
│ └── fsh_output/ <span class="c"># GoFSH output directory</span>
|
||||
│ ├── ... <span class="o">(</span>example GoFSH output<span class="o">)</span> ...
|
||||
├── templates/
|
||||
│ ├── base.html <span class="c"># Base template</span>
|
||||
│ ├── cp_downloaded_igs.html <span class="c"># UI for managing IGs</span>
|
||||
│ ├── cp_push_igs.html <span class="c"># UI for pushing IGs</span>
|
||||
│ ├── cp_view_processed_ig.html <span class="c"># UI for viewing processed IGs</span>
|
||||
│ ├── fhir_ui.html <span class="c"># UI for FHIR API explorer</span>
|
||||
│ ├── fhir_ui_operations.html <span class="c"># UI for FHIR server operations</span>
|
||||
│ ├── fsh_converter.html <span class="c"># UI for FSH conversion</span>
|
||||
│ ├── import_ig.html <span class="c"># UI for importing IGs</span>
|
||||
│ ├── index.html <span class="c"># Homepage</span>
|
||||
│ ├── retrieve_split_data.html <span class="c"># UI for Retrieve and Split Data</span>
|
||||
│ ├── upload_test_data.html <span class="c"># UI for Uploading Test Data</span>
|
||||
│ ├── validate_sample.html <span class="c"># UI for validating resources/bundles</span>
|
||||
│ ├── config_hapi.html <span class="c"># UI for HAPI FHIR Configuration</span>
|
||||
│ └── _form_helpers.html <span class="c"># Form helper macros</span>
|
||||
├── tests/
|
||||
│ └── test_app.py <span class="c"># Test suite</span>
|
||||
└── hapi-fhir-jpaserver/ <span class="c"># HAPI FHIR server resources (if Standalone)</span>
|
||||
</code></pre></div></div>
|
||||
|
||||
<p>Contributing</p>
|
||||
|
||||
<ol>
|
||||
<li>Fork the repository.</li>
|
||||
<li>Create a feature branch (git checkout -b feature/your-feature).</li>
|
||||
<li>Commit changes (git commit -m “Add your feature”).</li>
|
||||
<li>Push to your branch (git push origin feature/your-feature).</li>
|
||||
<li>Open a Pull Request.</li>
|
||||
<li>Ensure code follows PEP 8 and includes tests in tests/test_app.py.</li>
|
||||
</ol>
|
||||
|
||||
<p>Troubleshooting</p>
|
||||
|
||||
<ul>
|
||||
<li>Favicon 404: Clear browser cache or verify /app/static/favicon.ico: docker exec -it <container_name> curl http://localhost:5000/static/favicon.ico</container_name></li>
|
||||
<li>CSRF Errors: Set FLASK_SECRET_KEY and ensure in forms.</li>
|
||||
<li>Import Fails: Check package name/version and connectivity.</li>
|
||||
<li>Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).</li>
|
||||
<li>Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.</li>
|
||||
<li>Permissions: Ensure instance/ and static/uploads/ are writable: chmod -R 777 instance static/uploads logs</li>
|
||||
<li>GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation: docker exec -it <container_name> sushi --version</container_name></li>
|
||||
<li>413 Request Entity Too Large: Increase MAX_CONTENT_LENGTH and MAX_FORM_PARTS in app.py. If using a reverse proxy (e.g., Nginx), increase its client_max_body_size setting as well. Ensure the application/container is fully restarted/rebuilt.</li>
|
||||
</ul>
|
||||
|
||||
<p>License</p>
|
||||
|
||||
<p>Licensed under the Apache 2.0 License. See LICENSE.md for details.</p>
|
||||
|
||||
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
|
||||
<p>Project maintained by <a href="https://github.com/Sudo-JHare">Sudo-JHare</a></p>
|
||||
|
||||
<p>Hosted on GitHub Pages — Theme by <a href="https://github.com/orderedlist">orderedlist</a></p>
|
||||
</footer>
|
||||
<!--[if !IE]><script>fixScale(document);</script><![endif]-->
|
||||
</body>
|
||||
</html>
|
661
README.md
661
README.md
@ -1,661 +0,0 @@
|
||||
# FHIRFLARE IG Toolkit
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
The FHIRFLARE IG Toolkit is a Flask-based web application designed to streamline the management, processing, validation, and deployment of FHIR Implementation Guides (IGs) and test data. It offers a user-friendly interface for importing IG packages, extracting metadata, validating FHIR resources or bundles, pushing IGs to FHIR servers, converting FHIR resources to FHIR Shorthand (FSH), uploading complex test data sets with dependency management, and retrieving/splitting FHIR bundles. The toolkit includes live consoles for real-time feedback, making it an essential tool for FHIR developers and implementers.
|
||||
|
||||
The application can run in two modes:
|
||||
|
||||
* **Standalone:** Includes a Dockerized Flask frontend, SQLite database, and an embedded HAPI FHIR server for local validation and interaction.
|
||||
* **Lite:** Includes only the Dockerized Flask frontend and SQLite database, excluding the local HAPI FHIR server. Requires connection to external FHIR servers for certain features.
|
||||
|
||||
## Installation Modes (Lite vs. Standalone)
|
||||
|
||||
This toolkit offers two primary installation modes to suit different needs:
|
||||
|
||||
* **Standalone Version:**
|
||||
* Includes the full FHIRFLARE Toolkit application **and** an embedded HAPI FHIR server running locally within the Docker environment.
|
||||
* Allows for local FHIR resource validation using HAPI FHIR's capabilities.
|
||||
* Enables the "Use Local HAPI" option in the FHIR API Explorer and FHIR UI Operations pages, proxying requests to the internal HAPI server (`http://localhost:8080/fhir`).
|
||||
* Requires Git and Maven during the initial build process (via the `.bat` script or manual steps) to prepare the HAPI FHIR server.
|
||||
* Ideal for users who want a self-contained environment for development and testing or who don't have readily available external FHIR servers.
|
||||
|
||||
* **Lite Version:**
|
||||
* Includes the FHIRFLARE Toolkit application **without** the embedded HAPI FHIR server.
|
||||
* Requires users to provide URLs for external FHIR servers when using features like the FHIR API Explorer and FHIR UI Operations pages. The "Use Local HAPI" option will be disabled in the UI.
|
||||
* Resource validation relies solely on local checks against downloaded StructureDefinitions, which may be less comprehensive than HAPI FHIR's validation (e.g., for terminology bindings or complex invariants).
|
||||
* **Does not require Git or Maven** for setup if using the `.bat` script or running the pre-built Docker image.
|
||||
* Ideal for users who primarily want to use the IG management, processing, and FSH conversion features, or who will always connect to existing external FHIR servers.
|
||||
|
||||
## Features
|
||||
|
||||
* **Import IGs:** Download FHIR IG packages and dependencies from a package registry, supporting flexible version formats (e.g., `1.2.3`, `1.1.0-preview`, `current`) and dependency pulling modes (Recursive, Patch Canonical, Tree Shaking).
|
||||
* **Enhanced Package Search and Import:**
|
||||
* Interactive page (`/search-and-import`) to search for FHIR IG packages from configured registries.
|
||||
* Displays package details, version history, dependencies, and dependents.
|
||||
* Utilizes a local database cache (`CachedPackage`) for faster subsequent searches.
|
||||
* Background task to refresh the package cache from registries (`/api/refresh-cache-task`).
|
||||
* Direct import from search results.
|
||||
* **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 (`structuredefinition-compliesWithProfile` and `structuredefinition-imposeProfile`).
|
||||
* **Validate FHIR Resources/Bundles:** Validate single FHIR resources or bundles against selected IGs, with detailed error and warning reports (alpha feature). *Note: Lite version uses local SD checks only.*
|
||||
* **Push IGs:** Upload IG resources (and optionally dependencies) to a target FHIR server. Features include:
|
||||
* Real-time console output.
|
||||
* Authentication support (Bearer Token).
|
||||
* Filtering by resource type or specific files to skip.
|
||||
* Semantic comparison to skip uploading identical resources (override with **Force Upload** option).
|
||||
* Correct handling of canonical resources (searching by URL/version before deciding POST/PUT).
|
||||
* Dry run mode for simulation.
|
||||
* Verbose logging option.
|
||||
* **Upload Test Data:** Upload complex sets of test data (individual JSON/XML files or ZIP archives) to a target FHIR server. Features include:
|
||||
* Robust parsing of JSON and XML (using `fhir.resources` library when available).
|
||||
* Automatic dependency analysis based on resource references within the uploaded set.
|
||||
* Topological sorting to ensure resources are uploaded in the correct order.
|
||||
* Cycle detection in dependencies.
|
||||
* Choice of individual resource uploads or a single transaction bundle.
|
||||
* **Optional Pre-Upload Validation:** Validate resources against a selected profile package before uploading.
|
||||
* **Optional Conditional Uploads (Individual Mode):** Check resource existence (GET) and use conditional `If-Match` headers for updates (PUT) or create resources (PUT/POST). Falls back to simple PUT if unchecked.
|
||||
* Configurable error handling (stop on first error or continue).
|
||||
* Authentication support (Bearer Token).
|
||||
* Streaming progress log via the UI.
|
||||
* Handles large numbers of files using a custom form parser.
|
||||
* **Profile Relationships:** Display and validate `compliesWithProfile` and `imposeProfile` extensions in the UI (configurable).
|
||||
* **FSH Converter:** Convert FHIR JSON/XML resources to FHIR Shorthand (FSH) using GoFSH, with advanced options (Package context, Output styles, Log levels, FHIR versions, Fishing Trip, Dependencies, Indentation, Meta Profile handling, Alias File, No Alias). Includes a waiting spinner.
|
||||
* **Retrieve and Split Bundles:**
|
||||
* Retrieve specified resource types as bundles from a FHIR server.
|
||||
* Optionally fetch referenced resources, either individually or as full bundles for each referenced type.
|
||||
* Split uploaded ZIP files containing bundles into individual resource JSON files.
|
||||
* Download retrieved/split resources as a ZIP archive.
|
||||
* Streaming progress log via the UI for retrieval operations.
|
||||
* **FHIR Interaction UIs:** Explore FHIR server capabilities and interact with resources using the "FHIR API Explorer" (simple GET/POST/PUT/DELETE) and "FHIR UI Operations" (Swagger-like interface based on CapabilityStatement). *Note: Lite version requires custom server URLs.*
|
||||
* **HAPI FHIR Configuration (Standalone Mode):**
|
||||
* A dedicated page (`/config-hapi`) to view and edit the `application.yaml` configuration for the embedded HAPI FHIR server.
|
||||
* Allows modification of HAPI FHIR properties directly from the UI.
|
||||
* Option to restart the HAPI FHIR server (Tomcat) to apply changes.
|
||||
* **API Support:** RESTful API endpoints for importing, pushing, retrieving metadata, validating, uploading test data, and retrieving/splitting bundles.
|
||||
* **Live Console:** Real-time logs for push, validation, upload test data, FSH conversion, and bundle retrieval operations.
|
||||
* **Configurable Behavior:** Control validation modes, display options via `app.config`.
|
||||
* **Theming:** Supports light and dark modes.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
* Python 3.12+, Flask 2.3.3, Flask-SQLAlchemy 3.0.5, Flask-WTF 1.2.1
|
||||
* Jinja2, Bootstrap 5.3.3, JavaScript (ES6), Lottie-Web 5.12.2
|
||||
* SQLite
|
||||
* Docker, Docker Compose, Supervisor
|
||||
* Node.js 18+ (for GoFSH/SUSHI), GoFSH, SUSHI
|
||||
* HAPI FHIR (Standalone version only)
|
||||
* Requests 2.31.0, Tarfile, Logging, Werkzeug
|
||||
* fhir.resources (optional, for robust XML parsing)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* **Docker:** Required for containerized deployment (both versions).
|
||||
* **Git & Maven:** Required **only** for building the **Standalone** version from source using the `.bat` script or manual steps. Not required for the Lite version build or for running pre-built Docker Hub images.
|
||||
* **Windows:** Required if using the `.bat` scripts.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Running Pre-built Images (General Users)
|
||||
|
||||
This is the easiest way to get started without needing Git or Maven. Choose the version you need:
|
||||
|
||||
**Lite Version (No local HAPI FHIR):**
|
||||
|
||||
```bash
|
||||
# Pull the latest Lite image
|
||||
docker pull ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest
|
||||
|
||||
# Run the Lite version (maps port 5000 for the UI)
|
||||
# You'll need to create local directories for persistent data first:
|
||||
# mkdir instance logs static static/uploads instance/hapi-h2-data
|
||||
docker run -d \
|
||||
-p 5000:5000 \
|
||||
-v ./instance:/app/instance \
|
||||
-v ./static/uploads:/app/static/uploads \
|
||||
-v ./instance/hapi-h2-data:/app/h2-data \
|
||||
-v ./logs:/app/logs \
|
||||
--name fhirflare-lite \
|
||||
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest
|
||||
Standalone Version (Includes local HAPI FHIR):
|
||||
|
||||
Bash
|
||||
|
||||
# Pull the latest Standalone image
|
||||
docker pull ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
|
||||
|
||||
# Run the Standalone version (maps ports 5000 and 8080)
|
||||
# You'll need to create local directories for persistent data first:
|
||||
# mkdir instance logs static static/uploads instance/hapi-h2-data
|
||||
docker run -d \
|
||||
-p 5000:5000 \
|
||||
-p 8080:8080 \
|
||||
-v ./instance:/app/instance \
|
||||
-v ./static/uploads:/app/static/uploads \
|
||||
-v ./instance/hapi-h2-data:/app/h2-data \
|
||||
-v ./logs:/app/logs \
|
||||
--name fhirflare-standalone \
|
||||
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
|
||||
Building from Source (Developers)
|
||||
Using Windows .bat Scripts (Standalone Version Only):
|
||||
|
||||
First Time Setup:
|
||||
|
||||
Run Build and Run for first time.bat:
|
||||
|
||||
Code snippet
|
||||
|
||||
cd "<project folder>"
|
||||
git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver
|
||||
copy .\\hapi-fhir-Setup\\target\\classes\\application.yaml .\\hapi-fhir-jpaserver\\target\\classes\\application.yaml
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
This clones the HAPI FHIR server, copies configuration, builds the project, and starts the containers.
|
||||
|
||||
Subsequent Runs:
|
||||
|
||||
Run Run.bat:
|
||||
|
||||
Code snippet
|
||||
|
||||
cd "<project folder>"
|
||||
docker-compose up -d
|
||||
This starts the Flask app (port 5000) and HAPI FHIR server (port 8080).
|
||||
|
||||
Access the Application:
|
||||
|
||||
Flask UI: http://localhost:5000
|
||||
HAPI FHIR server: http://localhost:8080
|
||||
Manual Setup (Linux/MacOS/Windows):
|
||||
|
||||
Preparation (Standalone Version Only):
|
||||
|
||||
Bash
|
||||
|
||||
cd <project folder>
|
||||
git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver
|
||||
cp ./hapi-fhir-Setup/target/classes/application.yaml ./hapi-fhir-jpaserver/target/classes/application.yaml
|
||||
Build:
|
||||
|
||||
Bash
|
||||
|
||||
# Build HAPI FHIR (Standalone Version Only)
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
|
||||
# Build Docker Image (Specify APP_MODE=lite in docker-compose.yml for Lite version)
|
||||
docker-compose build --no-cache
|
||||
Run:
|
||||
|
||||
Bash
|
||||
|
||||
docker-compose up -d
|
||||
Access the Application:
|
||||
|
||||
Flask UI: http://localhost:5000
|
||||
HAPI FHIR server (Standalone only): http://localhost:8080
|
||||
Local Development (Without Docker):
|
||||
|
||||
Clone the Repository:
|
||||
|
||||
Bash
|
||||
|
||||
git clone [https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git](https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git)
|
||||
cd FHIRFLARE-IG-Toolkit
|
||||
Install Dependencies:
|
||||
|
||||
Bash
|
||||
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
Install Node.js, GoFSH, and SUSHI (for FSH Converter):
|
||||
|
||||
Bash
|
||||
|
||||
# Example for Debian/Ubuntu
|
||||
curl -fsSL [https://deb.nodesource.com/setup_18.x](https://deb.nodesource.com/setup_18.x) | sudo bash -
|
||||
sudo apt-get install -y nodejs
|
||||
# Install globally
|
||||
npm install -g gofsh fsh-sushi
|
||||
Set Environment Variables:
|
||||
|
||||
Bash
|
||||
|
||||
export FLASK_SECRET_KEY='your-secure-secret-key'
|
||||
export API_KEY='your-api-key'
|
||||
# Optional: Set APP_MODE to 'lite' if desired
|
||||
# export APP_MODE='lite'
|
||||
Initialize Directories:
|
||||
|
||||
Bash
|
||||
|
||||
mkdir -p instance static/uploads logs
|
||||
# Ensure write permissions if needed
|
||||
# chmod -R 777 instance static/uploads logs
|
||||
Run the Application:
|
||||
|
||||
Bash
|
||||
|
||||
export FLASK_APP=app.py
|
||||
flask run
|
||||
Access at http://localhost:5000.
|
||||
|
||||
Usage
|
||||
Import an IG
|
||||
### Search, View Details, and Import Packages
|
||||
Navigate to **Search and Import Packages** (`/search-and-import`).
|
||||
1. The page will load a list of available FHIR Implementation Guide packages from a local cache or by fetching from configured registries.
|
||||
* A loading animation and progress messages are shown if fetching from registries.
|
||||
* The timestamp of the last cache update is displayed.
|
||||
2. Use the search bar to filter packages by name or author.
|
||||
3. Packages are paginated for easier Browse.
|
||||
4. For each package, you can:
|
||||
* View its latest official and absolute versions.
|
||||
* Click on the package name to navigate to a **detailed view** (`/package-details/<name>`) showing:
|
||||
* Comprehensive metadata (author, FHIR version, canonical URL, description).
|
||||
* A full list of available versions with publication dates.
|
||||
* Declared dependencies.
|
||||
* Other packages that depend on it (dependents).
|
||||
* Version history (logs).
|
||||
* Directly import a specific version using the "Import" button on the search page or the details page.
|
||||
5. **Cache Management:**
|
||||
* A "Clear & Refresh Cache" button is available to trigger a background task (`/api/refresh-cache-task`) that clears the local database and in-memory cache and fetches the latest package information from all configured registries. Progress is shown via a live log.
|
||||
|
||||
Enter a package name (e.g., hl7.fhir.au.core) and version (e.g., 1.1.0-preview).
|
||||
Choose a dependency mode:
|
||||
Current Recursive: Import all dependencies listed in package.json recursively.
|
||||
Patch Canonical Versions: Import only canonical FHIR packages (e.g., hl7.fhir.r4.core).
|
||||
Tree Shaking: Import only dependencies containing resources actually used by the main package.
|
||||
Click Import to download the package and dependencies.
|
||||
Manage IGs
|
||||
Go to Manage FHIR Packages (/view-igs) to view downloaded and processed IGs.
|
||||
|
||||
Actions:
|
||||
Process: Extract metadata (resource types, profiles, must-support elements, examples).
|
||||
Unload: Remove processed IG data from the database.
|
||||
Delete: Remove package files from the filesystem.
|
||||
Duplicates are highlighted for resolution.
|
||||
View Processed IGs
|
||||
After processing, view IG details (/view-ig/<id>), including:
|
||||
|
||||
Resource types and profiles.
|
||||
Must-support elements and examples.
|
||||
Profile relationships (compliesWithProfile, imposeProfile) if enabled (DISPLAY_PROFILE_RELATIONSHIPS).
|
||||
Interactive StructureDefinition viewer (Differential, Snapshot, Must Support, Key Elements, Constraints, Terminology, Search Params).
|
||||
Validate FHIR Resources/Bundles
|
||||
Navigate to Validate FHIR Sample (/validate-sample).
|
||||
|
||||
Select a package (e.g., hl7.fhir.au.core#1.1.0-preview).
|
||||
Choose Single Resource or Bundle mode.
|
||||
Paste or upload FHIR JSON/XML (e.g., a Patient resource).
|
||||
Submit to view validation errors/warnings. Note: Alpha feature; report issues to GitHub (remove PHI).
|
||||
Push IGs to a FHIR Server
|
||||
Go to Push IGs (/push-igs).
|
||||
|
||||
Select a downloaded package.
|
||||
Enter the Target FHIR Server URL.
|
||||
Configure Authentication (None, Bearer Token).
|
||||
Choose options: Include Dependencies, Force Upload (skips comparison check), Dry Run, Verbose Log.
|
||||
Optionally filter by Resource Types (comma-separated) or Skip Specific Files (paths within package, comma/newline separated).
|
||||
Click Push to FHIR Server to upload resources. Canonical resources are checked before upload. Identical resources are skipped unless Force Upload is checked.
|
||||
Monitor progress in the live console.
|
||||
Upload Test Data
|
||||
Navigate to Upload Test Data (/upload-test-data).
|
||||
|
||||
Enter the Target FHIR Server URL.
|
||||
Configure Authentication (None, Bearer Token).
|
||||
Select one or more .json, .xml files, or a single .zip file containing test resources.
|
||||
Optionally check Validate Resources Before Upload? and select a Validation Profile Package.
|
||||
Choose Upload Mode:
|
||||
Individual Resources: Uploads each resource one by one in dependency order.
|
||||
Transaction Bundle: Uploads all resources in a single transaction.
|
||||
Optionally check Use Conditional Upload (Individual Mode Only)? to use If-Match headers for updates.
|
||||
Choose Error Handling:
|
||||
Stop on First Error: Halts the process if any validation or upload fails.
|
||||
Continue on Error: Reports errors but attempts to process/upload remaining resources.
|
||||
Click Upload and Process. The tool parses files, optionally validates, analyzes dependencies, topologically sorts resources, and uploads them according to selected options.
|
||||
Monitor progress in the streaming log output.
|
||||
Convert FHIR to FSH
|
||||
Navigate to FSH Converter (/fsh-converter).
|
||||
|
||||
Optionally select a package for context (e.g., hl7.fhir.au.core#1.1.0-preview).
|
||||
Choose input mode:
|
||||
Upload File: Upload a FHIR JSON/XML file.
|
||||
Paste Text: Paste FHIR JSON/XML content.
|
||||
Configure options:
|
||||
Output Style: file-per-definition, group-by-fsh-type, group-by-profile, single-file.
|
||||
Log Level: error, warn, info, debug.
|
||||
FHIR Version: R4, R4B, R5, or auto-detect.
|
||||
Fishing Trip: Enable round-trip validation with SUSHI, generating a comparison report.
|
||||
Dependencies: Specify additional packages (e.g., hl7.fhir.us.core@6.1.0, one per line).
|
||||
Indent Rules: Enable context path indentation for readable FSH.
|
||||
Meta Profile: Choose only-one, first, or none for meta.profile handling.
|
||||
Alias File: Upload an FSH file with aliases (e.g., $MyAlias = http://example.org).
|
||||
No Alias: Disable automatic alias generation.
|
||||
Click Convert to FSH to generate and display FSH output, with a waiting spinner (light/dark theme) during processing.
|
||||
If Fishing Trip is enabled, view the comparison report via the "Click here for SUSHI Validation" badge button.
|
||||
Download the result as a .fsh file.
|
||||
Retrieve and Split Bundles
|
||||
Navigate to Retrieve/Split Data (/retrieve-split-data).
|
||||
|
||||
Retrieve Bundles from Server:
|
||||
|
||||
Enter the FHIR Server URL (defaults to the proxy if empty).
|
||||
Select one or more Resource Types to retrieve (e.g., Patient, Observation).
|
||||
Optionally check Fetch Referenced Resources.
|
||||
If checked, further optionally check Fetch Full Reference Bundles to retrieve entire bundles for each referenced type (e.g., all Patients if a Patient is referenced) instead of individual resources by ID.
|
||||
Click Retrieve Bundles.
|
||||
Monitor progress in the streaming log. A ZIP file containing the retrieved bundles/resources will be prepared for download.
|
||||
Split Uploaded Bundles:
|
||||
|
||||
Upload a ZIP file containing FHIR bundles (JSON format).
|
||||
Click Split Bundles.
|
||||
A ZIP file containing individual resources extracted from the bundles will be prepared for download.
|
||||
Explore FHIR Operations
|
||||
Navigate to FHIR UI Operations (/fhir-ui-operations).
|
||||
|
||||
Toggle between local HAPI (/fhir) or a custom FHIR server.
|
||||
Click Fetch Metadata to load the server’s CapabilityStatement.
|
||||
Select a resource type (e.g., Patient, Observation) or System to view operations:
|
||||
System operations: GET /metadata, POST /, GET /_history, GET/POST /$diff, POST /$reindex, POST /$expunge, etc.
|
||||
Resource operations: GET Patient/:id, POST Observation/_search, etc.
|
||||
Use Try it out to input parameters or request bodies, then Execute to view results in JSON, XML, or narrative formats.
|
||||
|
||||
### Configure Embedded HAPI FHIR Server (Standalone Mode)
|
||||
For users running the **Standalone version**, which includes an embedded HAPI FHIR server.
|
||||
1. Navigate to **Configure HAPI FHIR** (`/config-hapi`).
|
||||
2. The page displays the content of the HAPI FHIR server's `application.yaml` file.
|
||||
3. You can edit the configuration directly in the text area.
|
||||
* *Caution: Incorrect modifications can break the HAPI FHIR server.*
|
||||
4. Click **Save Configuration** to apply your changes to the `application.yaml` file.
|
||||
5. Click **Restart Tomcat** to restart the HAPI FHIR server and load the new configuration. The restart process may take a few moments.
|
||||
|
||||
API Usage
|
||||
Import IG
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/import-ig \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"package_name": "hl7.fhir.au.core", "version": "1.1.0-preview", "dependency_mode": "recursive"}'
|
||||
Returns complies_with_profiles, imposed_profiles, and duplicate_packages_present info.
|
||||
|
||||
### Refresh Package Cache (Background Task)
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/refresh-cache-task \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
Push IG
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/push-ig \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{
|
||||
"package_name": "hl7.fhir.au.core",
|
||||
"version": "1.1.0-preview",
|
||||
"fhir_server_url": "http://localhost:8080/fhir",
|
||||
"include_dependencies": true,
|
||||
"force_upload": false,
|
||||
"dry_run": false,
|
||||
"verbose": false,
|
||||
"auth_type": "none"
|
||||
}'
|
||||
Returns a streaming NDJSON response with progress and final summary.
|
||||
|
||||
Upload Test Data
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/upload-test-data \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
-F "fhir_server_url=http://your-fhir-server/fhir" \
|
||||
-F "auth_type=bearerToken" \
|
||||
-F "auth_token=YOUR_TOKEN" \
|
||||
-F "upload_mode=individual" \
|
||||
-F "error_handling=continue" \
|
||||
-F "validate_before_upload=true" \
|
||||
-F "validation_package_id=hl7.fhir.r4.core#4.0.1" \
|
||||
-F "use_conditional_uploads=true" \
|
||||
-F "test_data_files=@/path/to/your/patient.json" \
|
||||
-F "test_data_files=@/path/to/your/observations.zip"
|
||||
Returns a streaming NDJSON response with progress and final summary. Uses multipart/form-data for file uploads.
|
||||
|
||||
Retrieve Bundles
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/retrieve-bundles \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
-F "fhir_server_url=http://your-fhir-server/fhir" \
|
||||
-F "resources=Patient" \
|
||||
-F "resources=Observation" \
|
||||
-F "validate_references=true" \
|
||||
-F "fetch_reference_bundles=false"
|
||||
Returns a streaming NDJSON response with progress. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive (e.g., /tmp/retrieved_bundles_datetime.zip).
|
||||
|
||||
Split Bundles
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/split-bundles \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
-F "split_bundle_zip_path=@/path/to/your/bundles.zip"
|
||||
Returns a streaming NDJSON response. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive of split resources.
|
||||
|
||||
Validate Resource/Bundle
|
||||
Not yet exposed via API; use the UI at /validate-sample.
|
||||
|
||||
Configuration Options
|
||||
Located in app.py:
|
||||
|
||||
VALIDATE_IMPOSED_PROFILES: (Default: True) Validates resources against imposed profiles during push.
|
||||
DISPLAY_PROFILE_RELATIONSHIPS: (Default: True) Shows compliesWithProfile and imposeProfile in the UI.
|
||||
FHIR_PACKAGES_DIR: (Default: /app/instance/fhir_packages) Stores .tgz packages and metadata.
|
||||
UPLOAD_FOLDER: (Default: /app/static/uploads) Stores GoFSH output files and FSH comparison reports.
|
||||
SECRET_KEY: Required for CSRF protection and sessions. Set via environment variable or directly.
|
||||
API_KEY: Required for API authentication. Set via environment variable or directly.
|
||||
MAX_CONTENT_LENGTH: (Default: Flask default) Max size for HTTP request body (e.g., 16 * 1024 * 1024 for 16MB). Important for large uploads.
|
||||
MAX_FORM_PARTS: (Default: Werkzeug default, often 1000) Default max number of form parts. Overridden for /api/upload-test-data by CustomFormDataParser.
|
||||
|
||||
### Get HAPI FHIR Configuration (Standalone Mode)
|
||||
```bash
|
||||
curl -X GET http://localhost:5000/api/config \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
Save HAPI FHIR Configuration:
|
||||
curl -X POST http://localhost:5000/api/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"your_yaml_key": "your_value", ...}' # Send the full YAML content as JSON
|
||||
|
||||
Restart HAPI FHIR Server:
|
||||
curl -X POST http://localhost:5000/api/restart-tomcat \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
Testing
|
||||
The project includes a test suite covering UI, API, database, file operations, and security.
|
||||
|
||||
Test Prerequisites:
|
||||
|
||||
pytest: For running tests.
|
||||
pytest-mock: For mocking dependencies. Install: pip install pytest pytest-mock
|
||||
Running Tests:
|
||||
|
||||
Bash
|
||||
|
||||
cd <project folder>
|
||||
pytest tests/test_app.py -v
|
||||
Test Coverage:
|
||||
|
||||
UI Pages: Homepage, Import IG, Manage IGs, Push IGs, Validate Sample, View Processed IG, FSH Converter, Upload Test Data, Retrieve/Split Data.
|
||||
API Endpoints: POST /api/import-ig, POST /api/push-ig, GET /get-structure, GET /get-example, POST /api/upload-test-data, POST /api/retrieve-bundles, POST /api/split-bundles.
|
||||
Database: IG processing, unloading, viewing.
|
||||
File Operations: Package processing, deletion, FSH output, ZIP handling.
|
||||
Security: CSRF protection, flash messages, secret key.
|
||||
FSH Converter: Form submission, file/text input, GoFSH execution, Fishing Trip comparison.
|
||||
Upload Test Data: Parsing, dependency graph, sorting, upload modes, validation, conditional uploads.
|
||||
Development Notes
|
||||
Background
|
||||
The toolkit addresses the need for a comprehensive FHIR IG management tool, with recent enhancements for resource validation, FSH conversion with advanced GoFSH features, flexible versioning, improved IG pushing, dependency-aware test data uploading, and bundle retrieval/splitting, making it a versatile platform for FHIR developers.
|
||||
|
||||
Technical Decisions
|
||||
Flask: Lightweight and flexible for web development.
|
||||
SQLite: Simple for development; consider PostgreSQL for production.
|
||||
Bootstrap 5.3.3: Responsive UI with custom styling.
|
||||
Lottie-Web: Renders themed animations for FSH conversion waiting spinner.
|
||||
GoFSH/SUSHI: Integrated via Node.js for advanced FSH conversion and round-trip validation.
|
||||
Docker: Ensures consistent deployment with Flask and HAPI FHIR.
|
||||
Flexible Versioning: Supports non-standard IG versions (e.g., -preview, -ballot).
|
||||
Live Console/Streaming: Real-time feedback for complex operations (Push, Upload Test Data, FSH, Retrieve Bundles).
|
||||
Validation: Alpha feature with ongoing FHIRPath improvements.
|
||||
Dependency Management: Uses topological sort for Upload Test Data feature.
|
||||
Form Parsing: Uses custom Werkzeug parser for Upload Test Data to handle large numbers of files.
|
||||
Recent Updates
|
||||
* Enhanced package search page with caching, detailed views (dependencies, dependents, version history), and background cache refresh.
|
||||
Upload Test Data Enhancements (April 2025):
|
||||
Added optional Pre-Upload Validation against selected IG profiles.
|
||||
Added optional Conditional Uploads (GET + POST/PUT w/ If-Match) for individual mode.
|
||||
Implemented robust XML parsing using fhir.resources library (when available).
|
||||
Fixed 413 Request Entity Too Large errors for large file counts using a custom Werkzeug FormDataParser.
|
||||
Path: templates/upload_test_data.html, app.py, services.py, forms.py.
|
||||
Push IG Enhancements (April 2025):
|
||||
Added semantic comparison to skip uploading identical resources.
|
||||
Added "Force Upload" option to bypass comparison.
|
||||
Improved handling of canonical resources (search before PUT/POST).
|
||||
Added filtering by specific files to skip during push.
|
||||
More detailed summary report in stream response.
|
||||
Path: templates/cp_push_igs.html, app.py, services.py.
|
||||
Waiting Spinner for FSH Converter (April 2025):
|
||||
Added a themed (light/dark) Lottie animation spinner during FSH execution.
|
||||
Path: templates/fsh_converter.html, static/animations/, static/js/lottie-web.min.js.
|
||||
Advanced FSH Converter (April 2025):
|
||||
Added support for GoFSH advanced options: --fshing-trip, --dependency, --indent, --meta-profile, --alias-file, --no-alias.
|
||||
Displays Fishing Trip comparison reports.
|
||||
Path: templates/fsh_converter.html, app.py, services.py, forms.py.
|
||||
(New) Retrieve and Split Data (May 2025):
|
||||
Added UI and API for retrieving bundles from a FHIR server by resource type.
|
||||
Added options to fetch referenced resources (individually or as full type bundles).
|
||||
Added functionality to split uploaded ZIP files of bundles into individual resources.
|
||||
Streaming log for retrieval and ZIP download for results.
|
||||
Paths: templates/retrieve_split_data.html, app.py, services.py, forms.py.
|
||||
Known Issues and Workarounds
|
||||
Favicon 404: Clear browser cache or verify /app/static/favicon.ico.
|
||||
CSRF Errors: Set FLASK_SECRET_KEY and ensure {{ form.hidden_tag() }} in forms.
|
||||
Import Fails: Check package name/version and connectivity.
|
||||
Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).
|
||||
Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.
|
||||
Permissions: Ensure instance/ and static/uploads/ are writable.
|
||||
GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation.
|
||||
Upload Test Data XML Parsing: Relies on fhir.resources library for full validation; basic parsing used as fallback. Complex XML structures might not be fully analyzed for dependencies with basic parsing. Prefer JSON for reliable dependency analysis.
|
||||
413 Request Entity Too Large: Primarily handled by CustomFormDataParser for /api/upload-test-data. Check the parser's max_form_parts limit if still occurring. MAX_CONTENT_LENGTH in app.py controls overall size. Reverse proxy limits (client_max_body_size in Nginx) might also apply.
|
||||
|
||||
|
||||
Future Improvements
|
||||
Upload Test Data: Improve XML parsing further (direct XML->fhir.resource object if possible), add visual progress bar, add upload order preview, implement transaction bundle size splitting, add 'Clear Target Server' option (with confirmation).
|
||||
Validation: Enhance FHIRPath for complex constraints; add API endpoint.
|
||||
Sorting: Sort IG versions in /view-igs (e.g., ascending).
|
||||
Duplicate Resolution: Options to keep latest version or merge resources.
|
||||
Production Database: Support PostgreSQL.
|
||||
Error Reporting: Detailed validation error paths in the UI.
|
||||
FSH Enhancements: Add API endpoint for FSH conversion; support inline instance construction.
|
||||
FHIR Operations: Add complex parameter support (e.g., /$diff with left/right).
|
||||
Retrieve/Split Data: Add option to filter resources during retrieval (e.g., by date, specific IDs).
|
||||
Completed Items
|
||||
Testing suite with basic coverage.
|
||||
API endpoints for POST /api/import-ig and POST /api/push-ig.
|
||||
Flexible versioning (-preview, -ballot).
|
||||
CSRF fixes for forms.
|
||||
Resource validation UI (alpha).
|
||||
FSH Converter with advanced GoFSH features and waiting spinner.
|
||||
Push IG enhancements (force upload, semantic comparison, canonical handling, skip files).
|
||||
Upload Test Data feature with dependency sorting, multiple upload modes, pre-upload validation, conditional uploads, robust XML parsing, and fix for large file counts.
|
||||
Retrieve and Split Data functionality with reference fetching and ZIP download.
|
||||
Far-Distant Improvements
|
||||
Cache Service: Use Redis for IG metadata caching.
|
||||
Database Optimization: Composite index on ProcessedIg.package_name and ProcessedIg.version.
|
||||
|
||||
|
||||
Directory Structure
|
||||
FHIRFLARE-IG-Toolkit/
|
||||
├── app.py # Main Flask application
|
||||
├── Build and Run for first time.bat # Windows script for first-time Docker setup
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── Dockerfile # Docker configuration
|
||||
├── forms.py # Form definitions
|
||||
├── LICENSE.md # Apache 2.0 License
|
||||
├── README.md # Project documentation
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Run.bat # Windows script for running Docker
|
||||
├── services.py # Logic for IG import, processing, validation, pushing, FSH conversion, test data upload, retrieve/split
|
||||
├── supervisord.conf # Supervisor configuration
|
||||
├── hapi-fhir-Setup/
|
||||
│ ├── README.md # HAPI FHIR setup instructions
|
||||
│ └── target/
|
||||
│ └── classes/
|
||||
│ └── application.yaml # HAPI FHIR configuration
|
||||
├── instance/
|
||||
│ ├── fhir_ig.db # SQLite database
|
||||
│ ├── fhir_ig.db.old # Database backup
|
||||
│ └── fhir_packages/ # Stored IG packages and metadata
|
||||
│ ├── ... (example packages) ...
|
||||
├── logs/
|
||||
│ ├── flask.log # Flask application logs
|
||||
│ ├── flask_err.log # Flask error logs
|
||||
│ ├── supervisord.log # Supervisor logs
|
||||
│ ├── supervisord.pid # Supervisor PID file
|
||||
│ ├── tomcat.log # Tomcat logs for HAPI FHIR
|
||||
│ └── tomcat_err.log # Tomcat error logs
|
||||
├── static/
|
||||
│ ├── animations/
|
||||
│ │ ├── loading-dark.json # Dark theme spinner animation
|
||||
│ │ └── loading-light.json # Light theme spinner animation
|
||||
│ ├── favicon.ico # Application favicon
|
||||
│ ├── FHIRFLARE.png # Application logo
|
||||
│ ├── js/
|
||||
│ │ └── lottie-web.min.js # Lottie library for spinner
|
||||
│ └── uploads/
|
||||
│ ├── output.fsh # Generated FSH output (temp location)
|
||||
│ └── fsh_output/ # GoFSH output directory
|
||||
│ ├── ... (example GoFSH output) ...
|
||||
├── templates/
|
||||
│ ├── base.html # Base template
|
||||
│ ├── cp_downloaded_igs.html # UI for managing IGs
|
||||
│ ├── cp_push_igs.html # UI for pushing IGs
|
||||
│ ├── cp_view_processed_ig.html # UI for viewing processed IGs
|
||||
│ ├── fhir_ui.html # UI for FHIR API explorer
|
||||
│ ├── fhir_ui_operations.html # UI for FHIR server operations
|
||||
│ ├── fsh_converter.html # UI for FSH conversion
|
||||
│ ├── import_ig.html # UI for importing IGs
|
||||
│ ├── index.html # Homepage
|
||||
│ ├── retrieve_split_data.html # UI for Retrieve and Split Data
|
||||
│ ├── upload_test_data.html # UI for Uploading Test Data
|
||||
│ ├── validate_sample.html # UI for validating resources/bundles
|
||||
│ ├── config_hapi.html # UI for HAPI FHIR Configuration
|
||||
│ └── _form_helpers.html # Form helper macros
|
||||
├── tests/
|
||||
│ └── test_app.py # Test suite
|
||||
└── hapi-fhir-jpaserver/ # HAPI FHIR server resources (if Standalone)
|
||||
|
||||
Contributing
|
||||
Fork the repository.
|
||||
Create a feature branch (git checkout -b feature/your-feature).
|
||||
Commit changes (git commit -m "Add your feature").
|
||||
Push to your branch (git push origin feature/your-feature).
|
||||
Open a Pull Request.
|
||||
Ensure code follows PEP 8 and includes tests in tests/test_app.py.
|
||||
|
||||
Troubleshooting
|
||||
Favicon 404: Clear browser cache or verify /app/static/favicon.ico: docker exec -it <container_name> curl http://localhost:5000/static/favicon.ico
|
||||
CSRF Errors: Set FLASK_SECRET_KEY and ensure {{ form.hidden_tag() }} in forms.
|
||||
Import Fails: Check package name/version and connectivity.
|
||||
Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).
|
||||
Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.
|
||||
Permissions: Ensure instance/ and static/uploads/ are writable: chmod -R 777 instance static/uploads logs
|
||||
GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation: docker exec -it <container_name> sushi --version
|
||||
413 Request Entity Too Large: Increase MAX_CONTENT_LENGTH and MAX_FORM_PARTS in app.py. If using a reverse proxy (e.g., Nginx), increase its client_max_body_size setting as well. Ensure the application/container is fully restarted/rebuilt.
|
||||
License
|
||||
Licensed under the Apache 2.0 License. See LICENSE.md for details.
|
@ -1,151 +0,0 @@
|
||||
# Integrating FHIRVINE as a Module in FHIRFLARE
|
||||
|
||||
## Overview
|
||||
|
||||
FHIRFLARE is a Flask-based FHIR Implementation Guide (IG) toolkit for managing and validating FHIR packages. This guide explains how to integrate FHIRVINE—a SMART on FHIR proxy—as a module within FHIRFLARE, enabling OAuth2 authentication and FHIR request proxying directly in the application. This modular approach embeds FHIRVINE’s functionality into FHIRFLARE, avoiding the need for a separate proxy service.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- FHIRFLARE repository cloned: `https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit`.
|
||||
- FHIRVINE repository cloned: `<fhirvine-repository-url>`.
|
||||
- Python 3.11 and dependencies installed (`requirements.txt` from both projects).
|
||||
- A FHIR server (e.g., `http://hapi.fhir.org/baseR4`).
|
||||
|
||||
## Integration Steps
|
||||
|
||||
### 1. Prepare FHIRFLARE Structure
|
||||
|
||||
Ensure FHIRFLARE’s file structure supports modular integration. It should look like:
|
||||
|
||||
```
|
||||
FHIRFLARE-IG-Toolkit/
|
||||
├── app.py
|
||||
├── services.py
|
||||
├── templates/
|
||||
├── static/
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### 2. Copy FHIRVINE Files into FHIRFLARE
|
||||
|
||||
FHIRVINE’s core functionality (OAuth2 proxy, app registration) will be integrated as a Flask Blueprint.
|
||||
|
||||
- **Copy Files**:
|
||||
|
||||
- Copy `smart_proxy.py`, `forms.py`, `models.py`, and `app.py` (relevant parts) from FHIRVINE into a new `fhirvine/` directory in FHIRFLARE:
|
||||
|
||||
```
|
||||
FHIRFLARE-IG-Toolkit/
|
||||
├── fhirvine/
|
||||
│ ├── smart_proxy.py
|
||||
│ ├── forms.py
|
||||
│ ├── models.py
|
||||
│ └── __init__.py
|
||||
```
|
||||
|
||||
- Copy FHIRVINE’s templates (e.g., `app_gallery/`, `configure/`, `test_client.html`) into `FHIRFLARE-IG-Toolkit/templates/` while maintaining their folder structure.
|
||||
|
||||
- **Add Dependencies**:
|
||||
|
||||
- Add FHIRVINE’s dependencies to `requirements.txt` (e.g., `authlib`, `flasgger`, `flask-sqlalchemy`).
|
||||
|
||||
### 3. Modify FHIRVINE Code as a Module
|
||||
|
||||
- **Create Blueprint in** `fhirvine/__init__.py`:
|
||||
|
||||
```python
|
||||
from flask import Blueprint
|
||||
|
||||
fhirvine_bp = Blueprint('fhirvine', __name__, template_folder='templates')
|
||||
|
||||
from .smart_proxy import *
|
||||
```
|
||||
|
||||
This registers FHIRVINE as a Flask Blueprint.
|
||||
|
||||
- **Update** `smart_proxy.py`:
|
||||
|
||||
- Replace direct `app.route` decorators with `fhirvine_bp.route`. For example:
|
||||
|
||||
```python
|
||||
@fhirvine_bp.route('/authorize', methods=['GET', 'POST'])
|
||||
def authorize():
|
||||
# Existing authorization logic
|
||||
```
|
||||
|
||||
### 4. Integrate FHIRVINE Blueprint into FHIRFLARE
|
||||
|
||||
- **Update** `app.py` **in FHIRFLARE**:
|
||||
|
||||
- Import and register the FHIRVINE Blueprint:
|
||||
|
||||
```python
|
||||
from fhirvine import fhirvine_bp
|
||||
from fhirvine.models import database, RegisteredApp, OAuthToken, AuthorizationCode, Configuration
|
||||
from fhirvine.smart_proxy import configure_oauth
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY='your-secure-random-key',
|
||||
SQLALCHEMY_DATABASE_URI='sqlite:////app/instance/fhirflare.db',
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
FHIR_SERVER_URL='http://hapi.fhir.org/baseR4',
|
||||
PROXY_TIMEOUT=10,
|
||||
TOKEN_DURATION=3600,
|
||||
REFRESH_TOKEN_DURATION=86400,
|
||||
ALLOWED_SCOPES='openid profile launch launch/patient patient/*.read offline_access'
|
||||
)
|
||||
|
||||
database.init_app(app)
|
||||
configure_oauth(app, db=database, registered_app_model=RegisteredApp, oauth_token_model=OAuthToken, auth_code_model=AuthorizationCode)
|
||||
|
||||
app.register_blueprint(fhirvine_bp, url_prefix='/fhirvine')
|
||||
```
|
||||
|
||||
### 5. Update FHIRFLARE Templates
|
||||
|
||||
- **Add FHIRVINE Links to Navbar**:
|
||||
|
||||
- In `templates/base.html`, add links to FHIRVINE features:
|
||||
|
||||
```html
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('fhirvine.app_gallery') }}">App Gallery</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('fhirvine.test_client') }}">Test Client</a>
|
||||
</li>
|
||||
```
|
||||
|
||||
### 6. Run and Test
|
||||
|
||||
- **Install Dependencies**:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- **Run FHIRFLARE**:
|
||||
|
||||
```bash
|
||||
flask db upgrade
|
||||
flask run --host=0.0.0.0 --port=8080
|
||||
```
|
||||
|
||||
- **Access FHIRVINE Features**:
|
||||
|
||||
- App Gallery: `http://localhost:8080/fhirvine/app-gallery`
|
||||
- Test Client: `http://localhost:8080/fhirvine/test-client`
|
||||
- Proxy Requests: Use `/fhirvine/oauth2/proxy/<path>` within FHIRFLARE.
|
||||
|
||||
## Using FHIRVINE in FHIRFLARE
|
||||
|
||||
- **Register Apps**: Use `/fhirvine/app-gallery` to register SMART apps within FHIRFLARE.
|
||||
- **Authenticate**: Use `/fhirvine/oauth2/authorize` for OAuth2 flows.
|
||||
- **Proxy FHIR Requests**: FHIRFLARE can now make FHIR requests via `/fhirvine/oauth2/proxy`, leveraging FHIRVINE’s authentication.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Route Conflicts**: Ensure no overlapping routes between FHIRFLARE and FHIRVINE.
|
||||
- **Database Issues**: Verify `SQLALCHEMY_DATABASE_URI` points to the same database.
|
||||
- **Logs**: Check `flask run` logs for errors.
|
137
assets/css/style.css
Normal file
137
assets/css/style.css
Normal file
@ -0,0 +1,137 @@
|
||||
/* generated by rouge http://rouge.jneen.net/ original base16 by Chris Kempson (https://github.com/chriskempson/base16)
|
||||
*/
|
||||
@import url("https://fonts.googleapis.com/css?family=Lato:300italic,700italic,300,700");
|
||||
.highlight table td { padding: 5px; }
|
||||
|
||||
.highlight table pre { margin: 0; }
|
||||
|
||||
.highlight, .highlight .w { color: #d0d0d0; }
|
||||
|
||||
.highlight .err { color: #151515; background-color: #ac4142; }
|
||||
|
||||
.highlight .c, .highlight .cd, .highlight .cm, .highlight .c1, .highlight .cs { color: #888; }
|
||||
|
||||
.highlight .cp { color: #f4bf75; }
|
||||
|
||||
.highlight .nt { color: #f4bf75; }
|
||||
|
||||
.highlight .o, .highlight .ow { color: #d0d0d0; }
|
||||
|
||||
.highlight .p, .highlight .pi { color: #d0d0d0; }
|
||||
|
||||
.highlight .gi { color: #90a959; }
|
||||
|
||||
.highlight .gd { color: #ac4142; }
|
||||
|
||||
.highlight .gh { color: #6a9fb5; font-weight: bold; }
|
||||
|
||||
.highlight .k, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kv { color: #aa759f; }
|
||||
|
||||
.highlight .kc { color: #d28445; }
|
||||
|
||||
.highlight .kt { color: #d28445; }
|
||||
|
||||
.highlight .kd { color: #d28445; }
|
||||
|
||||
.highlight .s, .highlight .sb, .highlight .sc, .highlight .sd, .highlight .s2, .highlight .sh, .highlight .sx, .highlight .s1 { color: #90a959; }
|
||||
|
||||
.highlight .sr { color: #75b5aa; }
|
||||
|
||||
.highlight .si { color: #8f5536; }
|
||||
|
||||
.highlight .se { color: #8f5536; }
|
||||
|
||||
.highlight .nn { color: #f4bf75; }
|
||||
|
||||
.highlight .nc { color: #f4bf75; }
|
||||
|
||||
.highlight .no { color: #f4bf75; }
|
||||
|
||||
.highlight .na { color: #6a9fb5; }
|
||||
|
||||
.highlight .m, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .il, .highlight .mo, .highlight .mb, .highlight .mx { color: #90a959; }
|
||||
|
||||
.highlight .ss { color: #90a959; }
|
||||
|
||||
html { background: #6C7989; background: #6C7989 linear-gradient(#6C7989, #434B55) fixed; height: 100%; }
|
||||
|
||||
body { padding: 50px 0; margin: 0; font: 14px/1.5 Lato, "Helvetica Neue", Helvetica, Arial, sans-serif; color: #555; font-weight: 300; background: url("../images/checker.png") fixed; min-height: calc(100% - 100px); }
|
||||
|
||||
.wrapper { width: 740px; margin: 0 auto; background: #DEDEDE; border-radius: 8px; box-shadow: rgba(0, 0, 0, 0.2) 0 0 0 1px, rgba(0, 0, 0, 0.45) 0 3px 10px; }
|
||||
|
||||
header, section, footer { display: block; }
|
||||
|
||||
a { color: #069; text-decoration: none; }
|
||||
|
||||
p { margin: 0 0 20px; padding: 0; }
|
||||
|
||||
strong { color: #222; font-weight: 700; }
|
||||
|
||||
header { border-radius: 8px 8px 0 0; background: #C6EAFA; background: linear-gradient(#DDFBFC, #C6EAFA); position: relative; padding: 15px 20px; border-bottom: 1px solid #B2D2E1; }
|
||||
header h1 { margin: 0; padding: 0; font-size: 24px; line-height: 1.2; color: #069; text-shadow: rgba(255, 255, 255, 0.9) 0 1px 0; }
|
||||
header.without-description h1 { margin: 10px 0; }
|
||||
header p { margin: 0; color: #61778B; width: 300px; font-size: 13px; }
|
||||
header p.view { display: none; font-weight: 700; text-shadow: rgba(255, 255, 255, 0.9) 0 1px 0; -webkit-font-smoothing: antialiased; }
|
||||
header p.view a { color: #06c; }
|
||||
header p.view small { font-weight: 400; }
|
||||
header ul { margin: 0; padding: 0; list-style: none; position: absolute; z-index: 1; right: 20px; top: 20px; height: 38px; padding: 1px 0; background: #5198DF; background: linear-gradient(#77B9FB, #3782CD); border-radius: 5px; box-shadow: inset rgba(255, 255, 255, 0.45) 0 1px 0, inset rgba(0, 0, 0, 0.2) 0 -1px 0; width: auto; }
|
||||
header ul:before { content: ''; position: absolute; z-index: -1; left: -5px; top: -4px; right: -5px; bottom: -6px; background: rgba(0, 0, 0, 0.1); border-radius: 8px; box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 0, inset rgba(255, 255, 255, 0.7) 0 -1px 0; }
|
||||
header ul li { width: 79px; float: left; border-right: 1px solid #3A7CBE; height: 38px; }
|
||||
header ul li.single { border: none; }
|
||||
header ul li + li { width: 78px; border-left: 1px solid #8BBEF3; }
|
||||
header ul li + li + li { border-right: none; width: 79px; }
|
||||
header ul a { line-height: 1; font-size: 11px; color: #fff; color: rgba(255, 255, 255, 0.8); display: block; text-align: center; font-weight: 400; padding-top: 6px; height: 40px; text-shadow: rgba(0, 0, 0, 0.4) 0 -1px 0; }
|
||||
header ul a strong { font-size: 14px; display: block; color: #fff; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
section { padding: 15px 20px; font-size: 15px; border-top: 1px solid #fff; background: linear-gradient(#fafafa, #DEDEDE 700px); border-radius: 0 0 8px 8px; position: relative; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 { color: #222; padding: 0; margin: 0 0 20px; line-height: 1.2; }
|
||||
|
||||
p, ul, ol, table, pre, dl { margin: 0 0 20px; }
|
||||
|
||||
h1, h2, h3 { line-height: 1.1; }
|
||||
|
||||
h1 { font-size: 28px; }
|
||||
|
||||
h2 { color: #393939; }
|
||||
|
||||
h3, h4, h5, h6 { color: #494949; }
|
||||
|
||||
blockquote { margin: 0 -20px 20px; padding: 15px 20px 1px 40px; font-style: italic; background: #ccc; background: rgba(0, 0, 0, 0.06); color: #222; }
|
||||
|
||||
img { max-width: 100%; }
|
||||
|
||||
code, pre { font-family: Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal; color: #333; font-size: 12px; overflow-x: auto; }
|
||||
|
||||
pre { padding: 20px; background: #3A3C42; color: #f8f8f2; margin: 0 -20px 20px; }
|
||||
pre code { color: #f8f8f2; }
|
||||
li pre { margin-left: -60px; padding-left: 60px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
th, td { text-align: left; padding: 5px 10px; border-bottom: 1px solid #aaa; }
|
||||
|
||||
dt { color: #222; font-weight: 700; }
|
||||
|
||||
th { color: #222; }
|
||||
|
||||
small { font-size: 11px; }
|
||||
|
||||
hr { border: 0; background: #aaa; height: 1px; margin: 0 0 20px; }
|
||||
|
||||
kbd { background-color: #fafbfc; border: 1px solid #c6cbd1; border-bottom-color: #959da5; border-radius: 3px; box-shadow: inset 0 -1px 0 #959da5; color: #444d56; display: inline-block; font-size: 11px; line-height: 10px; padding: 3px 5px; vertical-align: middle; }
|
||||
|
||||
footer { width: 640px; margin: 0 auto; padding: 20px 0 0; color: #ccc; overflow: hidden; }
|
||||
footer a { color: #fff; font-weight: bold; }
|
||||
footer p { float: left; }
|
||||
footer p + p { float: right; }
|
||||
|
||||
@media print, screen and (max-width: 740px) { body { padding: 0; }
|
||||
.wrapper { border-radius: 0; box-shadow: none; width: 100%; }
|
||||
footer { border-radius: 0; padding: 20px; width: auto; }
|
||||
footer p { float: none; margin: 0; }
|
||||
footer p + p { float: none; } }
|
||||
@media print, screen and (max-width: 580px) { header ul { display: none; }
|
||||
header p.view { display: block; }
|
||||
header p { width: 100%; } }
|
||||
@media print { header p.view a small:before { content: 'at https://github.com/'; } }
|
BIN
assets/images/checker.png
Normal file
BIN
assets/images/checker.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 108 B |
20
assets/js/scale.fix.js
Normal file
20
assets/js/scale.fix.js
Normal file
@ -0,0 +1,20 @@
|
||||
fixScale = function(doc) {
|
||||
|
||||
var addEvent = 'addEventListener',
|
||||
type = 'gesturestart',
|
||||
qsa = 'querySelectorAll',
|
||||
scales = [1, 1],
|
||||
meta = qsa in doc ? doc[qsa]('meta[name=viewport]') : [];
|
||||
|
||||
function fix() {
|
||||
meta.content = 'width=device-width,minimum-scale=' + scales[0] + ',maximum-scale=' + scales[1];
|
||||
doc.removeEventListener(type, fix, true);
|
||||
}
|
||||
|
||||
if ((meta = meta[meta.length - 1]) && addEvent in doc) {
|
||||
fix();
|
||||
scales = [.25, 1.6];
|
||||
doc[addEvent](type, fix, true);
|
||||
}
|
||||
|
||||
};
|
1
charts/.gitignore
vendored
1
charts/.gitignore
vendored
@ -1 +0,0 @@
|
||||
/hapi-fhir-jpaserver-0.20.0.tgz
|
1
charts/fhirflare-ig-toolkit/.gitignore
vendored
1
charts/fhirflare-ig-toolkit/.gitignore
vendored
@ -1 +0,0 @@
|
||||
/rendered/
|
@ -1,16 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: fhirflare-ig-toolkit
|
||||
version: 0.4.0
|
||||
description: Helm chart for deploying the fhirflare-ig-toolkit application
|
||||
type: application
|
||||
appVersion: "latest"
|
||||
icon: https://github.com/jgsuess/FHIRFLARE-IG-Toolkit/raw/main/static/FHIRFLARE.png
|
||||
keywords:
|
||||
- fhir
|
||||
- healthcare
|
||||
- ig-toolkit
|
||||
- implementation-guide
|
||||
home: https://github.com/jgsuess/FHIRFLARE-IG-Toolkit
|
||||
maintainers:
|
||||
- name: Jörn Guy Süß
|
||||
email: jgsuess@gmail.com
|
@ -1,152 +0,0 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "fhirflare-ig-toolkit.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "fhirflare-ig-toolkit.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "fhirflare-ig-toolkit.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "fhirflare-ig-toolkit.labels" -}}
|
||||
helm.sh/chart: {{ include "fhirflare-ig-toolkit.chart" . }}
|
||||
{{ include "fhirflare-ig-toolkit.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "fhirflare-ig-toolkit.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "fhirflare-ig-toolkit.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "hapi-fhir-jpaserver.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified postgresql name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.postgresql.fullname" -}}
|
||||
{{- $name := default "postgresql" .Values.postgresql.nameOverride -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Get the Postgresql credentials secret name.
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.postgresql.secretName" -}}
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
{{- if .Values.postgresql.auth.existingSecret -}}
|
||||
{{- printf "%s" .Values.postgresql.auth.existingSecret -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s" (include "hapi-fhir-jpaserver.postgresql.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- else }}
|
||||
{{- if .Values.externalDatabase.existingSecret -}}
|
||||
{{- printf "%s" .Values.externalDatabase.existingSecret -}}
|
||||
{{- else -}}
|
||||
{{ printf "%s-%s" (include "hapi-fhir-jpaserver.fullname" .) "external-db" }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Get the Postgresql credentials secret key.
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.postgresql.secretKey" -}}
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
{{- if .Values.postgresql.auth.username -}}
|
||||
{{- printf "%s" .Values.postgresql.auth.secretKeys.userPasswordKey -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s" .Values.postgresql.auth.secretKeys.adminPasswordKey -}}
|
||||
{{- end -}}
|
||||
{{- else }}
|
||||
{{- if .Values.externalDatabase.existingSecret -}}
|
||||
{{- printf "%s" .Values.externalDatabase.existingSecretKey -}}
|
||||
{{- else -}}
|
||||
{{- printf "postgres-password" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Add environment variables to configure database values
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.database.host" -}}
|
||||
{{- ternary (include "hapi-fhir-jpaserver.postgresql.fullname" .) .Values.externalDatabase.host .Values.postgresql.enabled -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Add environment variables to configure database values
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.database.user" -}}
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
{{- printf "%s" .Values.postgresql.auth.username | default "postgres" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s" .Values.externalDatabase.user -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Add environment variables to configure database values
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.database.name" -}}
|
||||
{{- ternary .Values.postgresql.auth.database .Values.externalDatabase.database .Values.postgresql.enabled -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Add environment variables to configure database values
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.database.port" -}}
|
||||
{{- ternary "5432" .Values.externalDatabase.port .Values.postgresql.enabled -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create the JDBC URL from the host, port and database name.
|
||||
*/}}
|
||||
{{- define "hapi-fhir-jpaserver.database.jdbcUrl" -}}
|
||||
{{- $host := (include "hapi-fhir-jpaserver.database.host" .) -}}
|
||||
{{- $port := (include "hapi-fhir-jpaserver.database.port" .) -}}
|
||||
{{- $name := (include "hapi-fhir-jpaserver.database.name" .) -}}
|
||||
{{- $appName := .Release.Name -}}
|
||||
{{ printf "jdbc:postgresql://%s:%d/%s?ApplicationName=%s" $host (int $port) $name $appName }}
|
||||
{{- end -}}
|
@ -1,91 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "fhirflare-ig-toolkit.fullname" . }}
|
||||
labels:
|
||||
{{- include "fhirflare-ig-toolkit.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount | default 1 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fhirflare-ig-toolkit.selectorLabels" . | nindent 6 }}
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fhirflare-ig-toolkit.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args: ["supervisord", "-c", "/etc/supervisord.conf"]
|
||||
env:
|
||||
- name: APP_BASE_URL
|
||||
value: {{ .Values.config.appBaseUrl | default "http://localhost:5000" | quote }}
|
||||
- name: APP_MODE
|
||||
value: {{ .Values.config.appMode | default "lite" | quote }}
|
||||
- name: FLASK_APP
|
||||
value: {{ .Values.config.flaskApp | default "app.py" | quote }}
|
||||
- name: FLASK_ENV
|
||||
value: {{ .Values.config.flaskEnv | default "development" | quote }}
|
||||
- name: HAPI_FHIR_URL
|
||||
value: {{ .Values.config.externalHapiServerUrl | default "http://external-hapi-fhir:8080/fhir" | quote }}
|
||||
- name: NODE_PATH
|
||||
value: {{ .Values.config.nodePath | default "/usr/lib/node_modules" | quote }}
|
||||
- name: TMPDIR
|
||||
value: "/tmp-dir"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port | default 5000 }}
|
||||
protocol: TCP
|
||||
volumeMounts:
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
- name: tmp-dir
|
||||
mountPath: /tmp-dir
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
- name: tmp-dir
|
||||
emptyDir: {}
|
||||
# Always require Intel 64-bit architecture nodes
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: amd64
|
||||
{{- with .Values.nodeSelector }}
|
||||
# Merge with user-defined nodeSelectors if any
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
@ -1,36 +0,0 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "fhirflare-ig-toolkit.fullname" . -}}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else }}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "fhirflare-ig-toolkit.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
rules:
|
||||
- http:
|
||||
paths:
|
||||
- path: /
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ $fullName }}
|
||||
port:
|
||||
number: {{ .Values.service.port | default 5000 }}
|
||||
{{- else }}
|
||||
backend:
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ .Values.service.port | default 5000 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
@ -1,18 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "fhirflare-ig-toolkit.fullname" . }}
|
||||
labels:
|
||||
{{- include "fhirflare-ig-toolkit.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type | default "ClusterIP" }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port | default 5000 }}
|
||||
targetPort: {{ .Values.service.port | default 5000 }}
|
||||
protocol: TCP
|
||||
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
|
||||
nodePort: {{ .Values.service.nodePort }}
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "fhirflare-ig-toolkit.selectorLabels" . | nindent 4 }}
|
@ -1,41 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ .Release.Name }}-fhirflare-test-endpoint"
|
||||
labels:
|
||||
helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
|
||||
app.kubernetes.io/name: {{ .Chart.Name }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/component: tests
|
||||
annotations:
|
||||
"helm.sh/hook": test
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: test-fhirflare-endpoint
|
||||
image: curlimages/curl:8.12.1
|
||||
command: ["curl", "--fail-with-body", "--retry", "5", "--retry-delay", "10"]
|
||||
args: ["http://fhirflare:5000"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
privileged: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsGroup: 65534
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
limits:
|
||||
cpu: 150m
|
||||
ephemeral-storage: 2Gi
|
||||
memory: 192Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
ephemeral-storage: 50Mi
|
||||
memory: 128Mi
|
@ -1,89 +0,0 @@
|
||||
# Default values for fhirflare-ig-toolkit
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/jgsuess/fhirflare-ig-toolkit
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# FHIRflare specific configuration
|
||||
config:
|
||||
# Application mode: "lite" means using external HAPI server, "standalone" means running with embedded HAPI server
|
||||
appMode: "lite"
|
||||
# URL for the external HAPI FHIR server when in lite mode
|
||||
externalHapiServerUrl: "http://external-hapi-fhir:8080/fhir"
|
||||
appBaseUrl: "http://localhost:5000"
|
||||
flaskApp: "app.py"
|
||||
flaskEnv: "development"
|
||||
nodePath: "/usr/lib/node_modules"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 5000
|
||||
nodePort: null
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
# podSecurityContext:
|
||||
# fsGroup: 65532
|
||||
# fsGroupChangePolicy: OnRootMismatch
|
||||
# runAsNonRoot: true
|
||||
# runAsGroup: 65532
|
||||
# runAsUser: 65532
|
||||
# seccompProfile:
|
||||
# type: RuntimeDefault
|
||||
|
||||
# securityContext:
|
||||
# allowPrivilegeEscalation: false
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# privileged: false
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsGroup: 65532
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 65532
|
||||
# seccompProfile:
|
||||
# type: RuntimeDefault
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 1Gi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
ephemeral-storage: 100Mi
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
ingress:
|
||||
# -- whether to create a primitive Ingress to expose the FHIR server HTTP endpoint
|
||||
enabled: false
|
@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# FHIRFLARE-IG-Toolkit Installation Script
|
||||
#
|
||||
# Description:
|
||||
# This script installs the FHIRFLARE-IG-Toolkit Helm chart into a Kubernetes cluster.
|
||||
# It adds the FHIRFLARE-IG-Toolkit Helm repository and then installs the chart
|
||||
# in the 'flare' namespace, creating the namespace if it doesn't exist.
|
||||
#
|
||||
# Usage:
|
||||
# ./install.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Helm (v3+)
|
||||
# - kubectl configured with access to your Kubernetes cluster
|
||||
#
|
||||
|
||||
# Add the FHIRFLARE-IG-Toolkit Helm repository
|
||||
helm repo add flare https://jgsuess.github.io/FHIRFLARE-IG-Toolkit/
|
||||
|
||||
# Install the FHIRFLARE-IG-Toolkit chart in the 'flare' namespace
|
||||
|
||||
helm install flare/fhirflare-ig-toolkit --namespace flare --create-namespace --generate-name --set hapi-fhir-jpaserver.postgresql.primary.persistence.storageClass=gp2 --atomic
|
@ -1,22 +0,0 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
fhirflare:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "8080:8080" # Keep port exposed, even if Tomcat isn't running useful stuff in Lite
|
||||
volumes:
|
||||
- ./instance:/app/instance
|
||||
- ./static/uploads:/app/static/uploads
|
||||
- ./instance/hapi-h2-data/:/app/h2-data # Keep volume mounts consistent
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
- NODE_PATH=/usr/lib/node_modules
|
||||
- APP_MODE=standalone
|
||||
- APP_BASE_URL=http://localhost:5000
|
||||
- HAPI_FHIR_URL=http://localhost:8080/fhir
|
||||
command: supervisord -c /etc/supervisord.conf
|
@ -1,22 +0,0 @@
|
||||
# This docker-compose file uses ephemeral Docker named volumes for all data storage.
|
||||
# These volumes persist only as long as the Docker volumes exist and are deleted if you run `docker-compose down -v`.
|
||||
# No data is stored on the host filesystem. If you want persistent storage, replace these with host mounts.
|
||||
services:
|
||||
fhirflare-standalone:
|
||||
image: ${FHIRFLARE_IMAGE:-ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest}
|
||||
container_name: fhirflare-standalone
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- fhirflare-instance:/app/instance
|
||||
- fhirflare-uploads:/app/static/uploads
|
||||
- fhirflare-h2-data:/app/h2-data
|
||||
- fhirflare-logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
fhirflare-instance:
|
||||
fhirflare-uploads:
|
||||
fhirflare-h2-data:
|
||||
fhirflare-logs:
|
@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and remove all containers defined in the Docker Compose file,
|
||||
# along with any anonymous volumes attached to them.
|
||||
docker compose down --volumes
|
@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run Docker Compose
|
||||
|
||||
docker compose up --detach --force-recreate --renew-anon-volumes --always-recreate-deps
|
@ -1,18 +0,0 @@
|
||||
hapi.fhir:
|
||||
ig_runtime_upload_enabled: false
|
||||
narrative_enabled: true
|
||||
logical_urls:
|
||||
- http://terminology.hl7.org/*
|
||||
- https://terminology.hl7.org/*
|
||||
- http://snomed.info/*
|
||||
- https://snomed.info/*
|
||||
- http://unitsofmeasure.org/*
|
||||
- https://unitsofmeasure.org/*
|
||||
- http://loinc.org/*
|
||||
- https://loinc.org/*
|
||||
cors:
|
||||
allow_Credentials: true
|
||||
allowed_origin:
|
||||
- '*'
|
||||
tester.home.name: FHIRFLARE Tester
|
||||
inline_resource_storage_below_size: 4000
|
@ -1,50 +0,0 @@
|
||||
services:
|
||||
fhirflare:
|
||||
image: ${FHIRFLARE_IMAGE:-ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest}
|
||||
ports:
|
||||
- "5000:5000"
|
||||
# Ephemeral Docker named volumes for all data storage. No data is stored on the host filesystem.
|
||||
volumes:
|
||||
- fhirflare-instance:/app/instance
|
||||
- fhirflare-uploads:/app/static/uploads
|
||||
- fhirflare-h2-data:/app/h2-data
|
||||
- fhirflare-logs:/app/logs
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
- NODE_PATH=/usr/lib/node_modules
|
||||
- APP_MODE=lite
|
||||
- APP_BASE_URL=http://localhost:5000
|
||||
- HAPI_FHIR_URL=http://fhir:8080/fhir
|
||||
command: supervisord -c /etc/supervisord.conf
|
||||
|
||||
fhir:
|
||||
container_name: hapi
|
||||
image: "hapiproject/hapi:v8.2.0-1"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
configs:
|
||||
- source: hapi
|
||||
target: /app/config/application.yaml
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: "postgres:17.2-bookworm"
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_PASSWORD: admin
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_DB: hapi
|
||||
volumes:
|
||||
- ./hapi.postgress.data:/var/lib/postgresql/data
|
||||
|
||||
configs:
|
||||
hapi:
|
||||
file: ./application.yaml
|
||||
|
||||
volumes:
|
||||
fhirflare-instance:
|
||||
fhirflare-uploads:
|
||||
fhirflare-h2-data:
|
||||
fhirflare-logs:
|
@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and remove all containers defined in the Docker Compose file,
|
||||
# along with any anonymous volumes attached to them.
|
||||
docker compose down --volumes
|
@ -1,19 +0,0 @@
|
||||
# FHIRFLARE IG Toolkit
|
||||
|
||||
This directory provides scripts and configuration to start and stop a FHIRFLARE instance with an attached HAPI FHIR server using Docker Compose.
|
||||
|
||||
## Usage
|
||||
|
||||
- To start the FHIRFLARE toolkit and HAPI server:
|
||||
```sh
|
||||
./docker-compose/up.sh
|
||||
```
|
||||
|
||||
- To stop and remove the containers and volumes:
|
||||
```sh
|
||||
./docker-compose/down.sh
|
||||
```
|
||||
|
||||
The web interface will be available at [http://localhost:5000](http://localhost:5000) and the HAPI FHIR server at [http://localhost:8080/fhir](http://localhost:8080/fhir).
|
||||
|
||||
For more details, see the configuration files in this directory.
|
@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run Docker Compose
|
||||
|
||||
docker compose up --detach --force-recreate --renew-anon-volumes --always-recreate-deps
|
@ -1,25 +0,0 @@
|
||||
services:
|
||||
fhirflare:
|
||||
image: ${FHIRFLARE_IMAGE:-ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest}
|
||||
ports:
|
||||
- "5000:5000"
|
||||
# Ephemeral Docker named volumes for all data storage. No data is stored on the host filesystem.
|
||||
volumes:
|
||||
- fhirflare-instance:/app/instance
|
||||
- fhirflare-uploads:/app/static/uploads
|
||||
- fhirflare-h2-data:/app/h2-data
|
||||
- fhirflare-logs:/app/logs
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
- NODE_PATH=/usr/lib/node_modules
|
||||
- APP_MODE=lite
|
||||
- APP_BASE_URL=http://localhost:5000
|
||||
- HAPI_FHIR_URL=https://cdr.fhirlab.net/fhir
|
||||
command: supervisord -c /etc/supervisord.conf
|
||||
|
||||
volumes:
|
||||
fhirflare-instance:
|
||||
fhirflare-uploads:
|
||||
fhirflare-h2-data:
|
||||
fhirflare-logs:
|
@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and remove all containers defined in the Docker Compose file,
|
||||
# along with any anonymous volumes attached to them.
|
||||
docker compose down --volumes
|
@ -1,19 +0,0 @@
|
||||
# FHIRFLARE IG Toolkit
|
||||
|
||||
This directory provides scripts and configuration to start and stop a FHIRFLARE instance with an attached HAPI FHIR server using Docker Compose.
|
||||
|
||||
## Usage
|
||||
|
||||
- To start the FHIRFLARE toolkit and HAPI server:
|
||||
```sh
|
||||
./docker-compose/up.sh
|
||||
```
|
||||
|
||||
- To stop and remove the containers and volumes:
|
||||
```sh
|
||||
./docker-compose/down.sh
|
||||
```
|
||||
|
||||
The web interface will be available at [http://localhost:5000](http://localhost:5000) and the HAPI FHIR server at [http://localhost:8080/fhir](http://localhost:8080/fhir).
|
||||
|
||||
For more details, see the configuration files in this directory.
|
@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run Docker Compose
|
||||
|
||||
docker compose up --detach --force-recreate --renew-anon-volumes --always-recreate-deps
|
@ -1,66 +0,0 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# Dockerfile for FHIRFLARE-IG-Toolkit (Optimized for Python/Flask)
|
||||
#
|
||||
# This Dockerfile builds a container for the FHIRFLARE-IG-Toolkit application.
|
||||
#
|
||||
# Key Features:
|
||||
# - Uses python:3.11-slim as the base image for a minimal, secure Python runtime.
|
||||
# - Installs Node.js and global NPM packages (gofsh, fsh-sushi) for FHIR IG tooling.
|
||||
# - Sets up a Python virtual environment and installs all Python dependencies.
|
||||
# - Installs and configures Supervisor to manage the Flask app and related processes.
|
||||
# - Copies all necessary application code, templates, static files, and configuration.
|
||||
# - Exposes ports 5000 (Flask) and 8080 (optional, for compatibility).
|
||||
# - Entrypoint runs Supervisor for process management.
|
||||
#
|
||||
# Notes:
|
||||
# - The Dockerfile is optimized for Python. Tomcat/Java is not included.
|
||||
# - Node.js is only installed if needed for FHIR IG tooling.
|
||||
# - The image is suitable for development and production with minimal changes.
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Optimized Dockerfile for Python (Flask)
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
coreutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Optional: Install Node.js if needed for GoFSH/SUSHI
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& npm install -g gofsh fsh-sushi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set workdir
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN python -m venv /app/venv \
|
||||
&& . /app/venv/bin/activate \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt \
|
||||
&& pip uninstall -y fhirpath || true \
|
||||
&& pip install --no-cache-dir fhirpathpy \
|
||||
&& pip install supervisor
|
||||
|
||||
# Copy application files
|
||||
COPY app.py .
|
||||
COPY services.py .
|
||||
COPY forms.py .
|
||||
COPY package.py .
|
||||
COPY templates/ templates/
|
||||
COPY static/ static/
|
||||
COPY tests/ tests/
|
||||
COPY supervisord.conf /etc/supervisord.conf
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 5000 8080
|
||||
|
||||
# Set environment
|
||||
ENV PATH="/app/venv/bin:$PATH"
|
||||
|
||||
# Start supervisord
|
||||
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
|
@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build FHIRFLARE-IG-Toolkit Docker image
|
||||
|
||||
# Build the image using the Dockerfile in the docker directory
|
||||
docker build -f Dockerfile -t fhirflare-ig-toolkit:latest ..
|
||||
|
||||
echo "Docker image built successfully"
|
309
forms.py
309
forms.py
@ -1,309 +0,0 @@
|
||||
# forms.py
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField, PasswordField
|
||||
from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired
|
||||
from flask import request
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RetrieveSplitDataForm(FlaskForm):
|
||||
"""Form for retrieving FHIR bundles and splitting them into individual resources."""
|
||||
fhir_server_url = StringField('FHIR Server URL', validators=[URL(), Optional()],
|
||||
render_kw={'placeholder': 'e.g., https://hapi.fhir.org/baseR4'})
|
||||
auth_type = SelectField('Authentication Type (for Custom URL)', choices=[
|
||||
('none', 'None'),
|
||||
('bearerToken', 'Bearer Token'),
|
||||
('basicAuth', 'Basic Authentication')
|
||||
], default='none', validators=[Optional()])
|
||||
auth_token = StringField('Bearer Token', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
|
||||
basic_auth_username = StringField('Username', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Username'})
|
||||
basic_auth_password = PasswordField('Password', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Password'})
|
||||
validate_references = BooleanField('Fetch Referenced Resources', default=False,
|
||||
description="If checked, fetches resources referenced by the initial bundles.")
|
||||
fetch_reference_bundles = BooleanField('Fetch Full Reference Bundles (instead of individual resources)', default=False,
|
||||
description="Requires 'Fetch Referenced Resources'. Fetches e.g. /Patient instead of Patient/id for each reference.",
|
||||
render_kw={'data-dependency': 'validate_references'})
|
||||
split_bundle_zip = FileField('Upload Bundles to Split (ZIP)', validators=[Optional()],
|
||||
render_kw={'accept': '.zip'})
|
||||
submit_retrieve = SubmitField('Retrieve Bundles')
|
||||
submit_split = SubmitField('Split Bundles')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if self.fetch_reference_bundles.data and not self.validate_references.data:
|
||||
self.fetch_reference_bundles.errors.append('Cannot fetch full reference bundles unless "Fetch Referenced Resources" is also checked.')
|
||||
return False
|
||||
if self.auth_type.data == 'bearerToken' and self.submit_retrieve.data and not self.auth_token.data:
|
||||
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected.')
|
||||
return False
|
||||
if self.auth_type.data == 'basicAuth' and self.submit_retrieve.data:
|
||||
if not self.basic_auth_username.data:
|
||||
self.basic_auth_username.errors.append('Username is required for Basic Authentication.')
|
||||
return False
|
||||
if not self.basic_auth_password.data:
|
||||
self.basic_auth_password.errors.append('Password is required for Basic Authentication.')
|
||||
return False
|
||||
if self.split_bundle_zip.data:
|
||||
if not self.split_bundle_zip.data.filename.lower().endswith('.zip'):
|
||||
self.split_bundle_zip.errors.append('File must be a ZIP file.')
|
||||
return False
|
||||
return True
|
||||
|
||||
class IgImportForm(FlaskForm):
|
||||
"""Form for importing Implementation Guides."""
|
||||
package_name = StringField('Package Name', validators=[
|
||||
DataRequired(),
|
||||
Regexp(r'^[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9]$', message="Invalid package name format.")
|
||||
], render_kw={'placeholder': 'e.g., hl7.fhir.au.core'})
|
||||
package_version = StringField('Package Version', validators=[
|
||||
DataRequired(),
|
||||
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).")
|
||||
], render_kw={'placeholder': 'e.g., 1.1.0-preview'})
|
||||
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 ManualIgImportForm(FlaskForm):
|
||||
"""Form for manual importing Implementation Guides via file or URL."""
|
||||
import_mode = SelectField('Import Mode', choices=[
|
||||
('file', 'Upload File'),
|
||||
('url', 'From URL')
|
||||
], default='file', validators=[DataRequired()])
|
||||
tgz_file = FileField('IG Package File (.tgz)', validators=[Optional()],
|
||||
render_kw={'accept': '.tgz'})
|
||||
tgz_url = StringField('IG Package URL', validators=[Optional(), URL()],
|
||||
render_kw={'placeholder': 'e.g., https://example.com/hl7.fhir.au.core-1.1.0-preview.tgz'})
|
||||
dependency_mode = SelectField('Dependency Mode', choices=[
|
||||
('recursive', 'Current Recursive'),
|
||||
('patch-canonical', 'Patch Canonical Versions'),
|
||||
('tree-shaking', 'Tree Shaking (Only Used Dependencies)')
|
||||
], default='recursive')
|
||||
resolve_dependencies = BooleanField('Resolve Dependencies', default=True,
|
||||
render_kw={'class': 'form-check-input'})
|
||||
submit = SubmitField('Import')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
mode = self.import_mode.data
|
||||
has_file = request and request.files and self.tgz_file.name in request.files and request.files[self.tgz_file.name].filename != ''
|
||||
has_url = bool(self.tgz_url.data) # Convert to boolean: True if non-empty string
|
||||
|
||||
# Ensure exactly one input method is used
|
||||
inputs_provided = sum([has_file, has_url])
|
||||
if inputs_provided != 1:
|
||||
if inputs_provided == 0:
|
||||
self.import_mode.errors.append('Please provide input for one import method (File or URL).')
|
||||
else:
|
||||
self.import_mode.errors.append('Please use only one import method at a time.')
|
||||
return False
|
||||
|
||||
# Validate based on import mode
|
||||
if mode == 'file':
|
||||
if not has_file:
|
||||
self.tgz_file.errors.append('A .tgz file is required for File import.')
|
||||
return False
|
||||
if not self.tgz_file.data.filename.lower().endswith('.tgz'):
|
||||
self.tgz_file.errors.append('File must be a .tgz file.')
|
||||
return False
|
||||
elif mode == 'url':
|
||||
if not has_url:
|
||||
self.tgz_url.errors.append('A valid URL is required for URL import.')
|
||||
return False
|
||||
if not self.tgz_url.data.lower().endswith('.tgz'):
|
||||
self.tgz_url.errors.append('URL must point to a .tgz file.')
|
||||
return False
|
||||
else:
|
||||
self.import_mode.errors.append('Invalid import mode selected.')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
class ValidationForm(FlaskForm):
|
||||
"""Form for validating FHIR samples."""
|
||||
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')
|
||||
|
||||
class FSHConverterForm(FlaskForm):
|
||||
"""Form for converting FHIR resources to FSH."""
|
||||
package = SelectField('FHIR Package (Optional)', choices=[('', 'None')], validators=[Optional()])
|
||||
input_mode = SelectField('Input Mode', choices=[
|
||||
('file', 'Upload File'),
|
||||
('text', 'Paste Text')
|
||||
], validators=[DataRequired()])
|
||||
fhir_file = FileField('FHIR Resource File (JSON/XML)', validators=[Optional()])
|
||||
fhir_text = TextAreaField('FHIR Resource Text (JSON/XML)', validators=[Optional()])
|
||||
output_style = SelectField('Output Style', choices=[
|
||||
('file-per-definition', 'File per Definition'),
|
||||
('group-by-fsh-type', 'Group by FSH Type'),
|
||||
('group-by-profile', 'Group by Profile'),
|
||||
('single-file', 'Single File')
|
||||
], validators=[DataRequired()])
|
||||
log_level = SelectField('Log Level', choices=[
|
||||
('error', 'Error'),
|
||||
('warn', 'Warn'),
|
||||
('info', 'Info'),
|
||||
('debug', 'Debug')
|
||||
], validators=[DataRequired()])
|
||||
fhir_version = SelectField('FHIR Version', choices=[
|
||||
('', 'Auto-detect'),
|
||||
('4.0.1', 'R4'),
|
||||
('4.3.0', 'R4B'),
|
||||
('5.0.0', 'R5')
|
||||
], validators=[Optional()])
|
||||
fishing_trip = BooleanField('Run Fishing Trip (Round-Trip Validation with SUSHI)', default=False)
|
||||
dependencies = TextAreaField('Dependencies (e.g., hl7.fhir.us.core@6.1.0)', validators=[Optional()])
|
||||
indent_rules = BooleanField('Indent Rules with Context Paths', default=False)
|
||||
meta_profile = SelectField('Meta Profile Handling', choices=[
|
||||
('only-one', 'Only One Profile (Default)'),
|
||||
('first', 'First Profile'),
|
||||
('none', 'Ignore Profiles')
|
||||
], validators=[DataRequired()])
|
||||
alias_file = FileField('Alias FSH File', validators=[Optional()])
|
||||
no_alias = BooleanField('Disable Alias Generation', default=False)
|
||||
submit = SubmitField('Convert to FSH')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
has_file_in_request = request and request.files and self.fhir_file.name in request.files and request.files[self.fhir_file.name].filename != ''
|
||||
if self.input_mode.data == 'file' and not has_file_in_request:
|
||||
if not self.fhir_file.data:
|
||||
self.fhir_file.errors.append('File is required when input mode is Upload File.')
|
||||
return False
|
||||
if self.input_mode.data == 'text' and not self.fhir_text.data:
|
||||
self.fhir_text.errors.append('Text input is required when input mode is Paste Text.')
|
||||
return False
|
||||
if self.input_mode.data == 'text' and self.fhir_text.data:
|
||||
try:
|
||||
content = self.fhir_text.data.strip()
|
||||
if not content: pass
|
||||
elif content.startswith('{'): json.loads(content)
|
||||
elif content.startswith('<'): ET.fromstring(content)
|
||||
else:
|
||||
self.fhir_text.errors.append('Text input must be valid JSON or XML.')
|
||||
return False
|
||||
except (json.JSONDecodeError, ET.ParseError):
|
||||
self.fhir_text.errors.append('Invalid JSON or XML format.')
|
||||
return False
|
||||
if self.dependencies.data:
|
||||
for dep in self.dependencies.data.splitlines():
|
||||
dep = dep.strip()
|
||||
if dep and not re.match(r'^[a-zA-Z0-9\-\.]+@[a-zA-Z0-9\.\-]+$', dep):
|
||||
self.dependencies.errors.append(f'Invalid dependency format: "{dep}". Use package@version (e.g., hl7.fhir.us.core@6.1.0).')
|
||||
return False
|
||||
has_alias_file_in_request = request and request.files and self.alias_file.name in request.files and request.files[self.alias_file.name].filename != ''
|
||||
alias_file_data = self.alias_file.data or (request.files.get(self.alias_file.name) if request else None)
|
||||
if alias_file_data and alias_file_data.filename:
|
||||
if not alias_file_data.filename.lower().endswith('.fsh'):
|
||||
self.alias_file.errors.append('Alias file should have a .fsh extension.')
|
||||
return True
|
||||
|
||||
class TestDataUploadForm(FlaskForm):
|
||||
"""Form for uploading FHIR test data."""
|
||||
fhir_server_url = StringField('Target FHIR Server URL', validators=[DataRequired(), URL()],
|
||||
render_kw={'placeholder': 'e.g., http://localhost:8080/fhir'})
|
||||
auth_type = SelectField('Authentication Type', choices=[
|
||||
('none', 'None'),
|
||||
('bearerToken', 'Bearer Token'),
|
||||
('basic', 'Basic Authentication')
|
||||
], default='none')
|
||||
auth_token = StringField('Bearer Token', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
|
||||
username = StringField('Username', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Username'})
|
||||
password = PasswordField('Password', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Password'})
|
||||
test_data_file = FileField('Select Test Data File(s)', validators=[InputRequired("Please select at least one file.")],
|
||||
render_kw={'multiple': True, 'accept': '.json,.xml,.zip'})
|
||||
validate_before_upload = BooleanField('Validate Resources Before Upload?', default=False,
|
||||
description="Validate resources against selected package profile before uploading.")
|
||||
validation_package_id = SelectField('Validation Profile Package (Optional)',
|
||||
choices=[('', '-- Select Package for Validation --')],
|
||||
validators=[Optional()],
|
||||
description="Select the processed IG package to use for validation.")
|
||||
upload_mode = SelectField('Upload Mode', choices=[
|
||||
('individual', 'Individual Resources'),
|
||||
('transaction', 'Transaction Bundle')
|
||||
], default='individual')
|
||||
use_conditional_uploads = BooleanField('Use Conditional Upload (Individual Mode Only)?', default=True,
|
||||
description="If checked, checks resource existence (GET) and uses If-Match (PUT) or creates (PUT). If unchecked, uses simple PUT for all.")
|
||||
error_handling = SelectField('Error Handling', choices=[
|
||||
('stop', 'Stop on First Error'),
|
||||
('continue', 'Continue on Error')
|
||||
], default='stop')
|
||||
submit = SubmitField('Upload and Process')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if self.validate_before_upload.data and not self.validation_package_id.data:
|
||||
self.validation_package_id.errors.append('Please select a package to validate against when pre-upload validation is enabled.')
|
||||
return False
|
||||
if self.use_conditional_uploads.data and self.upload_mode.data == 'transaction':
|
||||
self.use_conditional_uploads.errors.append('Conditional Uploads only apply to the "Individual Resources" mode.')
|
||||
return False
|
||||
if self.auth_type.data == 'bearerToken' and not self.auth_token.data:
|
||||
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected.')
|
||||
return False
|
||||
if self.auth_type.data == 'basic':
|
||||
if not self.username.data:
|
||||
self.username.errors.append('Username is required for Basic Authentication.')
|
||||
return False
|
||||
if not self.password.data:
|
||||
self.password.errors.append('Password is required for Basic Authentication.')
|
||||
return False
|
||||
return True
|
||||
|
||||
class FhirRequestForm(FlaskForm):
|
||||
fhir_server_url = StringField('FHIR Server URL', validators=[URL(), Optional()],
|
||||
render_kw={'placeholder': 'e.g., https://hapi.fhir.org/baseR4'})
|
||||
auth_type = SelectField('Authentication Type (for Custom URL)', choices=[
|
||||
('none', 'None'),
|
||||
('bearerToken', 'Bearer Token'),
|
||||
('basicAuth', 'Basic Authentication')
|
||||
], default='none', validators=[Optional()])
|
||||
auth_token = StringField('Bearer Token', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
|
||||
basic_auth_username = StringField('Username', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Username'})
|
||||
basic_auth_password = PasswordField('Password', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Password'})
|
||||
submit = SubmitField('Send Request')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if self.fhir_server_url.data:
|
||||
if self.auth_type.data == 'bearerToken' and not self.auth_token.data:
|
||||
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected for a custom URL.')
|
||||
return False
|
||||
if self.auth_type.data == 'basicAuth':
|
||||
if not self.basic_auth_username.data:
|
||||
self.basic_auth_username.errors.append('Username is required for Basic Authentication with a custom URL.')
|
||||
return False
|
||||
if not self.basic_auth_password.data:
|
||||
self.basic_auth_password.errors.append('Password is required for Basic Authentication with a custom URL.')
|
||||
return False
|
||||
return True
|
@ -1,111 +0,0 @@
|
||||
# Application Build and Run Guide - MANUAL STEPS
|
||||
|
||||
This guide outlines the steps to set up, build, and run the application, including the HAPI FHIR server component and the rest of the application managed via Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
* [Git](https://git-scm.com/)
|
||||
* [Maven](https://maven.apache.org/)
|
||||
* [Java Development Kit (JDK)](https://www.oracle.com/java/technologies/downloads/) (Ensure compatibility with the HAPI FHIR version)
|
||||
* [Docker](https://www.docker.com/products/docker-desktop/)
|
||||
* [Docker Compose](https://docs.docker.com/compose/install/) (Often included with Docker Desktop)
|
||||
|
||||
## Setup and Build
|
||||
|
||||
Follow these steps to clone the necessary repository and build the components.
|
||||
|
||||
### 1. Clone and Build the HAPI FHIR Server
|
||||
|
||||
First, clone the HAPI FHIR JPA Server Starter project and build the server application.
|
||||
|
||||
|
||||
# Step 1: Clone the repository
|
||||
git clone https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git hapi-fhir-jpaserver hapi-fhir-jpaserver
|
||||
|
||||
# Navigate into the cloned directory
|
||||
cd hapi-fhir-jpaserver
|
||||
|
||||
copy the folder from hapi-fhir-setup/target/classes/application.yaml to the hapi-fhir-jpaserver/target/classes/application.yaml folder created above
|
||||
|
||||
# Step 2: Build the HAPI server package (skipping tests, using 'boot' profile)
|
||||
# This creates the runnable WAR file in the 'target/' directory
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
|
||||
# Return to the parent directory (or your project root)
|
||||
cd ..
|
||||
2. Build the Rest of the Application (Docker)
|
||||
Next, build the Docker images for the remaining parts of the application as defined in your docker-compose.yml file. Run this command from the root directory where your docker-compose.yml file is located.
|
||||
|
||||
|
||||
|
||||
# Step 3: Build Docker images without using cache
|
||||
docker-compose build --no-cache
|
||||
Running the Application
|
||||
Option A: Running the Full Application (Recommended)
|
||||
Use Docker Compose to start all services, including (presumably) the HAPI FHIR server if it's configured in your docker-compose.yml. Run this from the root directory containing your docker-compose.yml.
|
||||
|
||||
|
||||
|
||||
# Step 4: Start all services defined in docker-compose.yml in detached mode
|
||||
docker-compose up -d
|
||||
Option B: Running the HAPI FHIR Server Standalone (Debugging Only)
|
||||
This method runs only the HAPI FHIR server directly using the built WAR file. Use this primarily for debugging the server in isolation.
|
||||
|
||||
|
||||
|
||||
# Navigate into the HAPI server directory where you built it
|
||||
cd hapi-fhir-jpaserver
|
||||
|
||||
# Run the WAR file directly using Java
|
||||
java -jar target/ROOT.war
|
||||
|
||||
# Note: You might need to configure ports or database connections
|
||||
# separately when running this way, depending on the application's needs.
|
||||
|
||||
# Remember to navigate back when done
|
||||
# cd ..
|
||||
Useful Docker Commands
|
||||
Here are some helpful commands for interacting with your running Docker containers:
|
||||
|
||||
Copying files from a container:
|
||||
To copy a file from a running container to your local machine's current directory:
|
||||
|
||||
|
||||
|
||||
# Syntax: docker cp <CONTAINER_ID_OR_NAME>:<PATH_IN_CONTAINER> <LOCAL_DESTINATION_PATH>
|
||||
docker cp <CONTAINER_ID>:/app/PATH/Filename.ext .
|
||||
(Replace <CONTAINER_ID>, /app/PATH/Filename.ext with actual values. . refers to the current directory on your host machine.)
|
||||
|
||||
Accessing a container's shell:
|
||||
To get an interactive bash shell inside a running container:
|
||||
|
||||
|
||||
|
||||
# Syntax: docker exec -it <CONTAINER_ID_OR_NAME> bash
|
||||
docker exec -it <CONTAINER_ID> bash
|
||||
(Replace <CONTAINER_ID> with the actual container ID or name. You can find this using docker ps.)
|
||||
|
||||
Viewing running containers:
|
||||
|
||||
|
||||
|
||||
docker ps
|
||||
Viewing application logs:
|
||||
|
||||
|
||||
|
||||
# Follow logs for all services
|
||||
docker-compose logs -f
|
||||
|
||||
# Follow logs for a specific service
|
||||
docker-compose logs -f <SERVICE_NAME>
|
||||
(Replace <SERVICE_NAME> with the name defined in your docker-compose.yml)
|
||||
|
||||
Stopping the application:
|
||||
To stop the services started with docker-compose up -d:
|
||||
|
||||
|
||||
|
||||
docker-compose down
|
@ -1,342 +0,0 @@
|
||||
#Uncomment the "servlet" and "context-path" lines below to make the fhir endpoint available at /example/path/fhir instead of the default value of /fhir
|
||||
server:
|
||||
# servlet:
|
||||
# context-path: /example/path
|
||||
port: 8080
|
||||
#Adds the option to go to eg. http://localhost:8080/actuator/health for seeing the running configuration
|
||||
#see https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints
|
||||
management:
|
||||
#The following configuration will enable the actuator endpoints at /actuator/health, /actuator/info, /actuator/prometheus, /actuator/metrics. For security purposes, only /actuator/health is enabled by default.
|
||||
endpoints:
|
||||
enabled-by-default: false
|
||||
web:
|
||||
exposure:
|
||||
include: 'health' # or e.g. 'info,health,prometheus,metrics' or '*' for all'
|
||||
endpoint:
|
||||
info:
|
||||
enabled: true
|
||||
metrics:
|
||||
enabled: true
|
||||
health:
|
||||
enabled: true
|
||||
probes:
|
||||
enabled: true
|
||||
group:
|
||||
liveness:
|
||||
include:
|
||||
- livenessState
|
||||
- readinessState
|
||||
prometheus:
|
||||
enabled: true
|
||||
prometheus:
|
||||
metrics:
|
||||
export:
|
||||
enabled: true
|
||||
spring:
|
||||
main:
|
||||
allow-circular-references: true
|
||||
flyway:
|
||||
enabled: false
|
||||
baselineOnMigrate: true
|
||||
fail-on-missing-locations: false
|
||||
datasource:
|
||||
#url: 'jdbc:h2:file:./target/database/h2'
|
||||
url: jdbc:h2:file:/app/h2-data/fhir;DB_CLOSE_DELAY=-1;AUTO_SERVER=TRUE
|
||||
#url: jdbc:h2:mem:test_mem
|
||||
username: sa
|
||||
password: null
|
||||
driverClassName: org.h2.Driver
|
||||
max-active: 15
|
||||
|
||||
# database connection pool size
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
jpa:
|
||||
properties:
|
||||
hibernate.format_sql: false
|
||||
hibernate.show_sql: false
|
||||
|
||||
#Hibernate dialect is automatically detected except Postgres and H2.
|
||||
#If using H2, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect
|
||||
#If using postgres, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect
|
||||
hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect
|
||||
# hibernate.hbm2ddl.auto: update
|
||||
# hibernate.jdbc.batch_size: 20
|
||||
# hibernate.cache.use_query_cache: false
|
||||
# hibernate.cache.use_second_level_cache: false
|
||||
# hibernate.cache.use_structured_entries: false
|
||||
# hibernate.cache.use_minimal_puts: false
|
||||
|
||||
### These settings will enable fulltext search with lucene or elastic
|
||||
hibernate.search.enabled: false
|
||||
### lucene parameters
|
||||
# hibernate.search.backend.type: lucene
|
||||
# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer
|
||||
# hibernate.search.backend.directory.type: local-filesystem
|
||||
# hibernate.search.backend.directory.root: target/lucenefiles
|
||||
# hibernate.search.backend.lucene_version: lucene_current
|
||||
### elastic parameters ===> see also elasticsearch section below <===
|
||||
# hibernate.search.backend.type: elasticsearch
|
||||
# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer
|
||||
hapi:
|
||||
fhir:
|
||||
### This flag when enabled to true, will avail evaluate measure operations from CR Module.
|
||||
### Flag is false by default, can be passed as command line argument to override.
|
||||
cr:
|
||||
enabled: false
|
||||
caregaps:
|
||||
reporter: "default"
|
||||
section_author: "default"
|
||||
cql:
|
||||
use_embedded_libraries: true
|
||||
compiler:
|
||||
### These are low-level compiler options.
|
||||
### They are not typically needed by most users.
|
||||
# validate_units: true
|
||||
# verify_only: false
|
||||
# compatibility_level: "1.5"
|
||||
error_level: Info
|
||||
signature_level: All
|
||||
# analyze_data_requirements: false
|
||||
# collapse_data_requirements: false
|
||||
# translator_format: JSON
|
||||
# enable_date_range_optimization: true
|
||||
enable_annotations: true
|
||||
enable_locators: true
|
||||
enable_results_type: true
|
||||
enable_detailed_errors: true
|
||||
# disable_list_traversal: false
|
||||
# disable_list_demotion: false
|
||||
# enable_interval_demotion: false
|
||||
# enable_interval_promotion: false
|
||||
# disable_method_invocation: false
|
||||
# require_from_keyword: false
|
||||
# disable_default_model_info_load: false
|
||||
runtime:
|
||||
debug_logging_enabled: false
|
||||
# enable_validation: false
|
||||
# enable_expression_caching: true
|
||||
terminology:
|
||||
valueset_preexpansion_mode: REQUIRE # USE_IF_PRESENT, REQUIRE, IGNORE
|
||||
valueset_expansion_mode: PERFORM_NAIVE_EXPANSION # AUTO, USE_EXPANSION_OPERATION, PERFORM_NAIVE_EXPANSION
|
||||
valueset_membership_mode: USE_EXPANSION # AUTO, USE_VALIDATE_CODE_OPERATION, USE_EXPANSION
|
||||
code_lookup_mode: USE_VALIDATE_CODE_OPERATION # AUTO, USE_VALIDATE_CODE_OPERATION, USE_CODESYSTEM_URL
|
||||
data:
|
||||
search_parameter_mode: USE_SEARCH_PARAMETERS # AUTO, USE_SEARCH_PARAMETERS, FILTER_IN_MEMORY
|
||||
terminology_parameter_mode: FILTER_IN_MEMORY # AUTO, USE_VALUE_SET_URL, USE_INLINE_CODES, FILTER_IN_MEMORY
|
||||
profile_mode: DECLARED # ENFORCED, DECLARED, OPTIONAL, TRUST, OFF
|
||||
|
||||
cdshooks:
|
||||
enabled: false
|
||||
clientIdHeaderName: client_id
|
||||
|
||||
### This enables the swagger-ui at /fhir/swagger-ui/index.html as well as the /fhir/api-docs (see https://hapifhir.io/hapi-fhir/docs/server_plain/openapi.html)
|
||||
openapi_enabled: true
|
||||
### This is the FHIR version. Choose between, DSTU2, DSTU3, R4 or R5
|
||||
fhir_version: R4
|
||||
### Flag is false by default. This flag enables runtime installation of IG's.
|
||||
ig_runtime_upload_enabled: false
|
||||
### This flag when enabled to true, will avail evaluate measure operations from CR Module.
|
||||
|
||||
### enable to use the ApacheProxyAddressStrategy which uses X-Forwarded-* headers
|
||||
### to determine the FHIR server address
|
||||
# use_apache_address_strategy: false
|
||||
### forces the use of the https:// protocol for the returned server address.
|
||||
### alternatively, it may be set using the X-Forwarded-Proto header.
|
||||
# use_apache_address_strategy_https: false
|
||||
### enables the server to overwrite defaults on HTML, css, etc. under the url pattern of eg. /content/custom **
|
||||
### Folder with custom content MUST be named custom. If omitted then default content applies
|
||||
custom_content_path: ./custom
|
||||
### enables the server host custom content. If e.g. the value ./configs/app is supplied then the content
|
||||
### will be served under /web/app
|
||||
#app_content_path: ./configs/app
|
||||
### enable to set the Server URL
|
||||
# server_address: http://hapi.fhir.org/baseR4
|
||||
# defer_indexing_for_codesystems_of_size: 101
|
||||
### Flag is true by default. This flag filters resources during package installation, allowing only those resources with a valid status (e.g. active) to be installed.
|
||||
# validate_resource_status_for_package_upload: false
|
||||
# install_transitive_ig_dependencies: true
|
||||
#implementationguides:
|
||||
### example from registry (packages.fhir.org)
|
||||
# swiss:
|
||||
# name: swiss.mednet.fhir
|
||||
# version: 0.8.0
|
||||
# reloadExisting: false
|
||||
# installMode: STORE_AND_INSTALL
|
||||
# example not from registry
|
||||
# ips_1_0_0:
|
||||
# packageUrl: https://build.fhir.org/ig/HL7/fhir-ips/package.tgz
|
||||
# name: hl7.fhir.uv.ips
|
||||
# version: 1.0.0
|
||||
# supported_resource_types:
|
||||
# - Patient
|
||||
# - Observation
|
||||
##################################################
|
||||
# Allowed Bundle Types for persistence (defaults are: COLLECTION,DOCUMENT,MESSAGE)
|
||||
##################################################
|
||||
# allowed_bundle_types: COLLECTION,DOCUMENT,MESSAGE,TRANSACTION,TRANSACTIONRESPONSE,BATCH,BATCHRESPONSE,HISTORY,SEARCHSET
|
||||
# allow_cascading_deletes: true
|
||||
# allow_contains_searches: true
|
||||
# allow_external_references: true
|
||||
# allow_multiple_delete: true
|
||||
# allow_override_default_search_params: true
|
||||
# auto_create_placeholder_reference_targets: false
|
||||
# mass_ingestion_mode_enabled: false
|
||||
### tells the server to automatically append the current version of the target resource to references at these paths
|
||||
# auto_version_reference_at_paths: Device.patient, Device.location, Device.parent, DeviceMetric.parent, DeviceMetric.source, Observation.device, Observation.subject
|
||||
# ips_enabled: false
|
||||
# default_encoding: JSON
|
||||
# default_pretty_print: true
|
||||
# default_page_size: 20
|
||||
# delete_expunge_enabled: true
|
||||
# enable_repository_validating_interceptor: true
|
||||
# enable_index_missing_fields: false
|
||||
# enable_index_of_type: true
|
||||
# enable_index_contained_resource: false
|
||||
# upliftedRefchains_enabled: true
|
||||
# resource_dbhistory_enabled: false
|
||||
### !!Extended Lucene/Elasticsearch Indexing is still a experimental feature, expect some features (e.g. _total=accurate) to not work as expected!!
|
||||
### more information here: https://hapifhir.io/hapi-fhir/docs/server_jpa/elastic.html
|
||||
advanced_lucene_indexing: false
|
||||
bulk_export_enabled: false
|
||||
bulk_import_enabled: false
|
||||
# language_search_parameter_enabled: true
|
||||
# enforce_referential_integrity_on_delete: false
|
||||
# This is an experimental feature, and does not fully support _total and other FHIR features.
|
||||
# enforce_referential_integrity_on_delete: false
|
||||
# enforce_referential_integrity_on_write: false
|
||||
# etag_support_enabled: true
|
||||
# expunge_enabled: true
|
||||
# client_id_strategy: ALPHANUMERIC
|
||||
# server_id_strategy: SEQUENTIAL_NUMERIC
|
||||
# fhirpath_interceptor_enabled: false
|
||||
# filter_search_enabled: true
|
||||
# graphql_enabled: true
|
||||
narrative_enabled: true
|
||||
mdm_enabled: false
|
||||
mdm_rules_json_location: "mdm-rules.json"
|
||||
## see: https://hapifhir.io/hapi-fhir/docs/interceptors/built_in_server_interceptors.html#jpa-server-retry-on-version-conflicts
|
||||
# userRequestRetryVersionConflictsInterceptorEnabled : false
|
||||
# local_base_urls:
|
||||
# - https://hapi.fhir.org/baseR4
|
||||
# pre_expand_value_sets: true
|
||||
# enable_task_pre_expand_value_sets: true
|
||||
# pre_expand_value_sets_default_count: 1000
|
||||
# pre_expand_value_sets_max_count: 1000
|
||||
# maximum_expansion_size: 1000
|
||||
|
||||
logical_urls:
|
||||
- http://terminology.hl7.org/*
|
||||
- https://terminology.hl7.org/*
|
||||
- http://snomed.info/*
|
||||
- https://snomed.info/*
|
||||
- http://unitsofmeasure.org/*
|
||||
- https://unitsofmeasure.org/*
|
||||
- http://loinc.org/*
|
||||
- https://loinc.org/*
|
||||
# partitioning:
|
||||
# allow_references_across_partitions: false
|
||||
# partitioning_include_in_search_hashes: false
|
||||
# conditional_create_duplicate_identifiers_enabled: false
|
||||
cors:
|
||||
allow_Credentials: true
|
||||
# These are allowed_origin patterns, see: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/cors/CorsConfiguration.html#setAllowedOriginPatterns-java.util.List-
|
||||
allowed_origin:
|
||||
- '*'
|
||||
|
||||
# Search coordinator thread pool sizes
|
||||
search-coord-core-pool-size: 20
|
||||
search-coord-max-pool-size: 100
|
||||
search-coord-queue-capacity: 200
|
||||
|
||||
# Search Prefetch Thresholds.
|
||||
|
||||
# This setting sets the number of search results to prefetch. For example, if this list
|
||||
# is set to [100, 1000, -1] then the server will initially load 100 results and not
|
||||
# attempt to load more. If the user requests subsequent page(s) of results and goes
|
||||
# past 100 results, the system will load the next 900 (up to the following threshold of 1000).
|
||||
# The system will progressively work through these thresholds.
|
||||
# A threshold of -1 means to load all results. Note that if the final threshold is a
|
||||
# number other than -1, the system will never prefetch more than the given number.
|
||||
search_prefetch_thresholds: 13,503,2003,-1
|
||||
|
||||
# comma-separated package names, will be @ComponentScan'ed by Spring to allow for creating custom Spring beans
|
||||
#custom-bean-packages:
|
||||
|
||||
# comma-separated list of fully qualified interceptor classes.
|
||||
# classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages',
|
||||
# or will be instantiated via reflection using an no-arg contructor; then registered with the server
|
||||
#custom-interceptor-classes:
|
||||
|
||||
# comma-separated list of fully qualified provider classes.
|
||||
# classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages',
|
||||
# or will be instantiated via reflection using an no-arg contructor; then registered with the server
|
||||
#custom-provider-classes:
|
||||
|
||||
# Threadpool size for BATCH'ed GETs in a bundle.
|
||||
# bundle_batch_pool_size: 10
|
||||
# bundle_batch_pool_max_size: 50
|
||||
|
||||
# logger:
|
||||
# error_format: 'ERROR - ${requestVerb} ${requestUrl}'
|
||||
# format: >-
|
||||
# Path[${servletPath}] Source[${requestHeader.x-forwarded-for}]
|
||||
# Operation[${operationType} ${operationName} ${idOrResourceName}]
|
||||
# UA[${requestHeader.user-agent}] Params[${requestParameters}]
|
||||
# ResponseEncoding[${responseEncodingNoDefault}]
|
||||
# log_exceptions: true
|
||||
# name: fhirtest.access
|
||||
# max_binary_size: 104857600
|
||||
# max_page_size: 200
|
||||
# retain_cached_searches_mins: 60
|
||||
# reuse_cached_search_results_millis: 60000
|
||||
tester:
|
||||
home:
|
||||
name: FHIRFLARE Tester
|
||||
server_address: http://localhost:8080/fhir
|
||||
refuse_to_fetch_third_party_urls: false
|
||||
fhir_version: R4
|
||||
global:
|
||||
name: Global Tester
|
||||
server_address: "http://hapi.fhir.org/baseR4"
|
||||
refuse_to_fetch_third_party_urls: false
|
||||
fhir_version: R4
|
||||
# validation:
|
||||
# requests_enabled: true
|
||||
# responses_enabled: true
|
||||
# binary_storage_enabled: true
|
||||
inline_resource_storage_below_size: 4000
|
||||
# bulk_export_enabled: true
|
||||
# subscription:
|
||||
# resthook_enabled: true
|
||||
# websocket_enabled: false
|
||||
# polling_interval_ms: 5000
|
||||
# immediately_queued: false
|
||||
# email:
|
||||
# from: some@test.com
|
||||
# host: google.com
|
||||
# port:
|
||||
# username:
|
||||
# password:
|
||||
# auth:
|
||||
# startTlsEnable:
|
||||
# startTlsRequired:
|
||||
# quitWait:
|
||||
# lastn_enabled: true
|
||||
# store_resource_in_lucene_index_enabled: true
|
||||
### This is configuration for normalized quantity search level default is 0
|
||||
### 0: NORMALIZED_QUANTITY_SEARCH_NOT_SUPPORTED - default
|
||||
### 1: NORMALIZED_QUANTITY_STORAGE_SUPPORTED
|
||||
### 2: NORMALIZED_QUANTITY_SEARCH_SUPPORTED
|
||||
# normalized_quantity_search_level: 2
|
||||
#elasticsearch:
|
||||
# debug:
|
||||
# pretty_print_json_log: false
|
||||
# refresh_after_write: false
|
||||
# enabled: false
|
||||
# password: SomePassword
|
||||
# required_index_status: YELLOW
|
||||
# rest_url: 'localhost:9200'
|
||||
# protocol: 'http'
|
||||
# schema_management_strategy: CREATE
|
||||
# username: SomeUsername
|
92
index.html
Normal file
92
index.html
Normal file
@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<!-- Begin Jekyll SEO tag v2.8.0 -->
|
||||
<title>FHIRFLARE IG Toolkit | Helm chart for deploying the fhirflare-ig-toolkit application</title>
|
||||
<meta name="generator" content="Jekyll v3.9.5" />
|
||||
<meta property="og:title" content="FHIRFLARE IG Toolkit" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta name="description" content="Helm chart for deploying the fhirflare-ig-toolkit application" />
|
||||
<meta property="og:description" content="Helm chart for deploying the fhirflare-ig-toolkit application" />
|
||||
<meta property="og:site_name" content="FHIRFLARE IG Toolkit" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta property="twitter:title" content="FHIRFLARE IG Toolkit" />
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"WebSite","description":"Helm chart for deploying the fhirflare-ig-toolkit application","headline":"FHIRFLARE IG Toolkit","name":"FHIRFLARE IG Toolkit","url":"/"}</script>
|
||||
<!-- End Jekyll SEO tag -->
|
||||
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=b10d9a8cecb20165b91803f6b363103301de0984">
|
||||
<script src="/assets/js/scale.fix.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
|
||||
<!--[if lt IE 9]>
|
||||
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<!-- start custom head snippets, customize with your own _includes/head-custom.html file -->
|
||||
|
||||
<!-- Setup Google Analytics -->
|
||||
|
||||
|
||||
|
||||
<!-- You can set your favicon here -->
|
||||
<!-- link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" -->
|
||||
|
||||
<!-- end custom head snippets -->
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<header >
|
||||
<h1>FHIRFLARE IG Toolkit</h1>
|
||||
|
||||
<p>Helm chart for deploying the fhirflare-ig-toolkit application</p>
|
||||
|
||||
<p class="view"><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit">View the Project on GitHub <small></small></a></p>
|
||||
<ul>
|
||||
|
||||
<li><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/zipball/gh-pages">Download <strong>ZIP File</strong></a></li>
|
||||
<li><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/tarball/gh-pages">Download <strong>TAR Ball</strong></a></li>
|
||||
|
||||
<li><a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit">View On <strong>GitHub</strong></a></li>
|
||||
</ul>
|
||||
</header>
|
||||
<section>
|
||||
|
||||
<h1 id="fhirflare-ig-toolkit">FHIRFLARE IG Toolkit</h1>
|
||||
|
||||
<p>Helm chart for deploying the fhirflare-ig-toolkit application</p>
|
||||
|
||||
<h2 id="overview">Overview</h2>
|
||||
|
||||
<p>FHIRFLARE-IG-Toolkit is a comprehensive solution for working with FHIR Implementation Guides.</p>
|
||||
|
||||
<h2 id="features">Features</h2>
|
||||
|
||||
<ul>
|
||||
<li>Helm chart deployment</li>
|
||||
<li>FHIR resources management</li>
|
||||
<li>Implementation Guide toolkit</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="getting-started">Getting Started</h2>
|
||||
|
||||
<p>Check out the <a href="/README.html">documentation</a> to get started with FHIRFLARE IG Toolkit.</p>
|
||||
|
||||
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
|
||||
<p>Project maintained by <a href="https://github.com/Sudo-JHare">Sudo-JHare</a></p>
|
||||
|
||||
<p>Hosted on GitHub Pages — Theme by <a href="https://github.com/orderedlist">orderedlist</a></p>
|
||||
</footer>
|
||||
<!--[if !IE]><script>fixScale(document);</script><![endif]-->
|
||||
</body>
|
||||
</html>
|
24
index.yaml
24
index.yaml
@ -1,24 +0,0 @@
|
||||
apiVersion: v1
|
||||
entries:
|
||||
fhirflare-ig-toolkit:
|
||||
- apiVersion: v2
|
||||
appVersion: latest
|
||||
created: "2025-08-04T05:53:00.152693988Z"
|
||||
description: Helm chart for deploying the fhirflare-ig-toolkit application
|
||||
digest: faef7991101501ae64e368fd2fb8021ec623d73e8cc808ea8c3df9920dcefb6a
|
||||
home: https://github.com/jgsuess/FHIRFLARE-IG-Toolkit
|
||||
icon: https://github.com/jgsuess/FHIRFLARE-IG-Toolkit/raw/main/static/FHIRFLARE.png
|
||||
keywords:
|
||||
- fhir
|
||||
- healthcare
|
||||
- ig-toolkit
|
||||
- implementation-guide
|
||||
maintainers:
|
||||
- email: jgsuess@gmail.com
|
||||
name: Jörn Guy Süß
|
||||
name: fhirflare-ig-toolkit
|
||||
type: application
|
||||
urls:
|
||||
- https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/releases/download/helm-v0.5.0/fhirflare-ig-toolkit-0.5.0.tgz
|
||||
version: 0.5.0
|
||||
generated: "2025-08-04T05:53:00.152703546Z"
|
@ -1 +0,0 @@
|
||||
Single-database configuration for Flask.
|
Binary file not shown.
@ -1,50 +0,0 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
@ -1,113 +0,0 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
@ -1,24 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
123
package.py
123
package.py
@ -1,123 +0,0 @@
|
||||
from flask import Blueprint, jsonify, current_app, render_template
|
||||
import os
|
||||
import tarfile
|
||||
import json
|
||||
from datetime import datetime
|
||||
import time
|
||||
from services import pkg_version, safe_parse_version
|
||||
|
||||
package_bp = Blueprint('package', __name__)
|
||||
|
||||
@package_bp.route('/logs/<name>')
|
||||
def logs(name):
|
||||
"""
|
||||
Fetch logs for a package, listing each version with its publication date.
|
||||
|
||||
Args:
|
||||
name (str): The name of the package.
|
||||
|
||||
Returns:
|
||||
Rendered template with logs or an error message.
|
||||
"""
|
||||
try:
|
||||
in_memory_cache = current_app.config.get('MANUAL_PACKAGE_CACHE', [])
|
||||
if not in_memory_cache:
|
||||
current_app.logger.error(f"No in-memory cache found for package logs: {name}")
|
||||
return "<p class='text-muted'>Package cache not found.</p>"
|
||||
|
||||
package_data = next((pkg for pkg in in_memory_cache if isinstance(pkg, dict) and pkg.get('name', '').lower() == name.lower()), None)
|
||||
if not package_data:
|
||||
current_app.logger.error(f"Package not found in cache: {name}")
|
||||
return "<p class='text-muted'>Package not found.</p>"
|
||||
|
||||
# Get the versions list with pubDate
|
||||
versions = package_data.get('all_versions', [])
|
||||
if not versions:
|
||||
current_app.logger.warning(f"No versions found for package: {name}. Package data: {package_data}")
|
||||
return "<p class='text-muted'>No version history found for this package.</p>"
|
||||
|
||||
current_app.logger.debug(f"Found {len(versions)} versions for package {name}: {versions[:5]}...")
|
||||
|
||||
logs = []
|
||||
now = time.time()
|
||||
for version_info in versions:
|
||||
if not isinstance(version_info, dict):
|
||||
current_app.logger.warning(f"Invalid version info for {name}: {version_info}")
|
||||
continue
|
||||
version = version_info.get('version', '')
|
||||
pub_date_str = version_info.get('pubDate', '')
|
||||
if not version or not pub_date_str:
|
||||
current_app.logger.warning(f"Skipping version info with missing version or pubDate: {version_info}")
|
||||
continue
|
||||
|
||||
# Parse pubDate and calculate "when"
|
||||
when = "Unknown"
|
||||
try:
|
||||
pub_date = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z")
|
||||
pub_time = pub_date.timestamp()
|
||||
time_diff = now - pub_time
|
||||
days_ago = int(time_diff / 86400)
|
||||
if days_ago < 1:
|
||||
hours_ago = int(time_diff / 3600)
|
||||
if hours_ago < 1:
|
||||
minutes_ago = int(time_diff / 60)
|
||||
when = f"{minutes_ago} minute{'s' if minutes_ago != 1 else ''} ago"
|
||||
else:
|
||||
when = f"{hours_ago} hour{'s' if hours_ago != 1 else ''} ago"
|
||||
else:
|
||||
when = f"{days_ago} day{'s' if days_ago != 1 else ''} ago"
|
||||
except ValueError as e:
|
||||
current_app.logger.warning(f"Failed to parse pubDate '{pub_date_str}' for version {version}: {e}")
|
||||
|
||||
logs.append({
|
||||
"version": version,
|
||||
"pubDate": pub_date_str,
|
||||
"when": when
|
||||
})
|
||||
|
||||
if not logs:
|
||||
current_app.logger.warning(f"No valid version entries with pubDate for package: {name}")
|
||||
return "<p class='text-muted'>No version history found for this package.</p>"
|
||||
|
||||
# Sort logs by version number (newest first)
|
||||
logs.sort(key=lambda x: safe_parse_version(x.get('version', '0.0.0a0')), reverse=True)
|
||||
|
||||
current_app.logger.debug(f"Rendering logs for {name} with {len(logs)} entries")
|
||||
return render_template('package.logs.html', logs=logs)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error in logs endpoint for {name}: {str(e)}", exc_info=True)
|
||||
return "<p class='text-danger'>Error loading version history.</p>", 500
|
||||
|
||||
@package_bp.route('/dependents/<name>')
|
||||
def dependents(name):
|
||||
"""
|
||||
HTMX endpoint to fetch packages that depend on the current package.
|
||||
Returns an HTML fragment with a table of dependent packages.
|
||||
"""
|
||||
in_memory_cache = current_app.config.get('MANUAL_PACKAGE_CACHE', [])
|
||||
package_data = next((pkg for pkg in in_memory_cache if isinstance(pkg, dict) and pkg.get('name', '').lower() == name.lower()), None)
|
||||
|
||||
if not package_data:
|
||||
return "<p class='text-danger'>Package not found.</p>"
|
||||
|
||||
# Find dependents: packages whose dependencies include the current package
|
||||
dependents = []
|
||||
for pkg in in_memory_cache:
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
dependencies = pkg.get('dependencies', [])
|
||||
for dep in dependencies:
|
||||
dep_name = dep.get('name', '')
|
||||
if dep_name.lower() == name.lower():
|
||||
dependents.append({
|
||||
"name": pkg.get('name', 'Unknown'),
|
||||
"version": pkg.get('latest_absolute_version', 'N/A'),
|
||||
"author": pkg.get('author', 'N/A'),
|
||||
"fhir_version": pkg.get('fhir_version', 'N/A'),
|
||||
"version_count": pkg.get('version_count', 0),
|
||||
"canonical": pkg.get('canonical', 'N/A')
|
||||
})
|
||||
break
|
||||
|
||||
return render_template('package.dependents.html', dependents=dependents)
|
@ -1,14 +0,0 @@
|
||||
Flask==2.3.3
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
Werkzeug==2.3.7
|
||||
requests==2.31.0
|
||||
Flask-WTF==1.2.1
|
||||
WTForms==3.1.2
|
||||
Pytest
|
||||
pyyaml==6.0.1
|
||||
fhir.resources==8.0.0
|
||||
Flask-Migrate==4.1.0
|
||||
cachetools
|
||||
beautifulsoup4
|
||||
feedparser==6.0.11
|
||||
flasgger
|
4985
services.py
4985
services.py
File diff suppressed because it is too large
Load Diff
233
setup_linux.sh
233
setup_linux.sh
@ -1,233 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# --- Configuration ---
|
||||
REPO_URL="https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git"
|
||||
CLONE_DIR="hapi-fhir-jpaserver"
|
||||
SOURCE_CONFIG_DIR="hapi-fhir-Setup" # Assuming this is relative to the script's parent
|
||||
CONFIG_FILE="application.yaml"
|
||||
|
||||
# --- Define Paths ---
|
||||
# Note: Adjust SOURCE_CONFIG_PATH if SOURCE_CONFIG_DIR is not a sibling directory
|
||||
# This assumes the script is run from a directory, and hapi-fhir-setup is at the same level
|
||||
SOURCE_CONFIG_PATH="../${SOURCE_CONFIG_DIR}/target/classes/${CONFIG_FILE}"
|
||||
DEST_CONFIG_PATH="${CLONE_DIR}/target/classes/${CONFIG_FILE}"
|
||||
|
||||
APP_MODE=""
|
||||
|
||||
# --- Error Handling Function ---
|
||||
handle_error() {
|
||||
echo "------------------------------------"
|
||||
echo "An error occurred: $1"
|
||||
echo "Script aborted."
|
||||
echo "------------------------------------"
|
||||
# Removed 'read -p "Press Enter to exit..."' as it's not typical for non-interactive CI/CD
|
||||
exit 1
|
||||
}
|
||||
|
||||
# === Prompt for Installation Mode ===
|
||||
get_mode_choice() {
|
||||
echo "Select Installation Mode:"
|
||||
echo "1. Standalone (Includes local HAPI FHIR Server - Requires Git & Maven)"
|
||||
echo "2. Lite (Excludes local HAPI FHIR Server - No Git/Maven needed)"
|
||||
|
||||
while true; do
|
||||
read -r -p "Enter your choice (1 or 2): " choice
|
||||
case "$choice" in
|
||||
1)
|
||||
APP_MODE="standalone"
|
||||
break
|
||||
;;
|
||||
2)
|
||||
APP_MODE="lite"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Invalid input. Please try again."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo "Selected Mode: $APP_MODE"
|
||||
echo
|
||||
}
|
||||
|
||||
# Call the function to get mode choice
|
||||
get_mode_choice
|
||||
|
||||
# === Conditionally Execute HAPI Setup ===
|
||||
if [ "$APP_MODE" = "standalone" ]; then
|
||||
echo "Running Standalone setup including HAPI FHIR..."
|
||||
echo
|
||||
|
||||
# --- Step 0: Clean up previous clone (optional) ---
|
||||
echo "Checking for existing directory: $CLONE_DIR"
|
||||
if [ -d "$CLONE_DIR" ]; then
|
||||
echo "Found existing directory, removing it..."
|
||||
rm -rf "$CLONE_DIR"
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Failed to remove existing directory: $CLONE_DIR"
|
||||
fi
|
||||
echo "Existing directory removed."
|
||||
else
|
||||
echo "Directory does not exist, proceeding with clone."
|
||||
fi
|
||||
echo
|
||||
|
||||
# --- Step 1: Clone the HAPI FHIR server repository ---
|
||||
echo "Cloning repository: $REPO_URL into $CLONE_DIR..."
|
||||
git clone "$REPO_URL" "$CLONE_DIR"
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Failed to clone repository. Check Git installation and network connection."
|
||||
fi
|
||||
echo "Repository cloned successfully."
|
||||
echo
|
||||
|
||||
# --- Step 2: Navigate into the cloned directory ---
|
||||
echo "Changing directory to $CLONE_DIR..."
|
||||
cd "$CLONE_DIR" || handle_error "Failed to change directory to $CLONE_DIR."
|
||||
echo "Current directory: $(pwd)"
|
||||
echo
|
||||
|
||||
# --- Step 3: Build the HAPI server using Maven ---
|
||||
echo "===> Starting Maven build (Step 3)..."
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: Maven build failed."
|
||||
cd ..
|
||||
handle_error "Maven build process resulted in an error."
|
||||
fi
|
||||
echo "Maven build completed successfully."
|
||||
echo
|
||||
|
||||
# --- Step 4: Copy the configuration file ---
|
||||
echo "===> Starting file copy (Step 4)..."
|
||||
echo "Copying configuration file..."
|
||||
# Corrected SOURCE_CONFIG_PATH to be relative to the new current directory ($CLONE_DIR)
|
||||
# This assumes the original script's SOURCE_CONFIG_PATH was relative to its execution location
|
||||
# If SOURCE_CONFIG_DIR is ../hapi-fhir-setup relative to script's original location:
|
||||
# Then from within CLONE_DIR, it becomes ../../hapi-fhir-setup
|
||||
# We defined SOURCE_CONFIG_PATH earlier relative to the script start.
|
||||
# So, when inside CLONE_DIR, the path from original script location should be used.
|
||||
# The original script had: set SOURCE_CONFIG_PATH=..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%
|
||||
# And then: xcopy "%SOURCE_CONFIG_PATH%" "target\classes\"
|
||||
# This implies SOURCE_CONFIG_PATH is relative to the original script's location, not the $CLONE_DIR
|
||||
# Therefore, we need to construct the correct relative path from *within* $CLONE_DIR back to the source.
|
||||
# Assuming the script is in dir X, and SOURCE_CONFIG_DIR is ../hapi-fhir-setup from X.
|
||||
# So, hapi-fhir-setup is a sibling of X's parent.
|
||||
# If CLONE_DIR is also in X, then from within CLONE_DIR, the path is ../ + original SOURCE_CONFIG_PATH
|
||||
# For simplicity and robustness, let's use an absolute path or a more clearly defined relative path from the start.
|
||||
# The original `SOURCE_CONFIG_PATH=..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%` implies
|
||||
# that `hapi-fhir-setup` is a sibling of the directory where the script *is being run from*.
|
||||
|
||||
# Let's assume the script is run from the root of FHIRFLARE-IG-Toolkit.
|
||||
# And hapi-fhir-setup is also in the root, next to this script.
|
||||
# Then SOURCE_CONFIG_PATH would be ./hapi-fhir-setup/target/classes/application.yaml
|
||||
# And from within ./hapi-fhir-jpaserver/, the path would be ../hapi-fhir-setup/target/classes/application.yaml
|
||||
|
||||
# The original batch file sets SOURCE_CONFIG_PATH as "..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%"
|
||||
# And COPIES it to "target\classes\" *while inside CLONE_DIR*.
|
||||
# This means the source path is relative to where the *cd %CLONE_DIR%* happened from.
|
||||
# Let's make it relative to the script's initial execution directory.
|
||||
INITIAL_SCRIPT_DIR=$(pwd)
|
||||
ABSOLUTE_SOURCE_CONFIG_PATH="${INITIAL_SCRIPT_DIR}/../${SOURCE_CONFIG_DIR}/target/classes/${CONFIG_FILE}" # This matches the ..\ logic
|
||||
|
||||
echo "Source: $ABSOLUTE_SOURCE_CONFIG_PATH"
|
||||
echo "Destination: target/classes/$CONFIG_FILE"
|
||||
|
||||
if [ ! -f "$ABSOLUTE_SOURCE_CONFIG_PATH" ]; then
|
||||
echo "WARNING: Source configuration file not found at $ABSOLUTE_SOURCE_CONFIG_PATH."
|
||||
echo "The script will continue, but the server might use default configuration."
|
||||
else
|
||||
cp "$ABSOLUTE_SOURCE_CONFIG_PATH" "target/classes/"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "WARNING: Failed to copy configuration file. Check if the source file exists and permissions."
|
||||
echo "The script will continue, but the server might use default configuration."
|
||||
else
|
||||
echo "Configuration file copied successfully."
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
|
||||
# --- Step 5: Navigate back to the parent directory ---
|
||||
echo "===> Changing directory back (Step 5)..."
|
||||
cd .. || handle_error "Failed to change back to the parent directory."
|
||||
echo "Current directory: $(pwd)"
|
||||
echo
|
||||
|
||||
else # APP_MODE is "lite"
|
||||
echo "Running Lite setup, skipping HAPI FHIR build..."
|
||||
# Ensure the hapi-fhir-jpaserver directory doesn't exist or is empty if Lite mode is chosen
|
||||
if [ -d "$CLONE_DIR" ]; then
|
||||
echo "Found existing HAPI directory ($CLONE_DIR) in Lite mode. Removing it..."
|
||||
rm -rf "$CLONE_DIR"
|
||||
fi
|
||||
# Create empty target directories expected by Dockerfile COPY, even if not used
|
||||
mkdir -p "${CLONE_DIR}/target/classes"
|
||||
mkdir -p "${CLONE_DIR}/custom" # This was in the original batch, ensure it's here
|
||||
# Create a placeholder empty WAR file and application.yaml to satisfy Dockerfile COPY
|
||||
touch "${CLONE_DIR}/target/ROOT.war"
|
||||
touch "${CLONE_DIR}/target/classes/application.yaml"
|
||||
echo "Placeholder files and directories created for Lite mode build in $CLONE_DIR."
|
||||
echo
|
||||
fi
|
||||
|
||||
# === Modify docker-compose.yml to set APP_MODE ===
|
||||
echo "Updating docker-compose.yml with APP_MODE=$APP_MODE..."
|
||||
DOCKER_COMPOSE_TMP="docker-compose.yml.tmp"
|
||||
DOCKER_COMPOSE_ORIG="docker-compose.yml"
|
||||
|
||||
cat << EOF > "$DOCKER_COMPOSE_TMP"
|
||||
version: '3.8'
|
||||
services:
|
||||
fhirflare:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "8080:8080" # Keep port exposed, even if Tomcat isn't running useful stuff in Lite
|
||||
volumes:
|
||||
- ./instance:/app/instance
|
||||
- ./static/uploads:/app/static/uploads
|
||||
- ./instance/hapi-h2-data/:/app/h2-data # Keep volume mounts consistent
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
- NODE_PATH=/usr/lib/node_modules
|
||||
- APP_MODE=${APP_MODE}
|
||||
- APP_BASE_URL=http://localhost:5000
|
||||
- HAPI_FHIR_URL=http://localhost:8080/fhir
|
||||
command: supervisord -c /etc/supervisord.conf
|
||||
EOF
|
||||
|
||||
if [ ! -f "$DOCKER_COMPOSE_TMP" ]; then
|
||||
handle_error "Failed to create temporary docker-compose file ($DOCKER_COMPOSE_TMP)."
|
||||
fi
|
||||
|
||||
# Replace the original docker-compose.yml
|
||||
mv "$DOCKER_COMPOSE_TMP" "$DOCKER_COMPOSE_ORIG"
|
||||
echo "docker-compose.yml updated successfully."
|
||||
echo
|
||||
|
||||
# --- Step 6: Build Docker images ---
|
||||
echo "===> Starting Docker build (Step 6)..."
|
||||
docker-compose build --no-cache
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Docker Compose build failed. Check Docker installation and docker-compose.yml file."
|
||||
fi
|
||||
echo "Docker images built successfully."
|
||||
echo
|
||||
|
||||
# --- Step 7: Start Docker containers ---
|
||||
echo "===> Starting Docker containers (Step 7)..."
|
||||
docker-compose up -d
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Docker Compose up failed. Check Docker installation and container configurations."
|
||||
fi
|
||||
echo "Docker containers started successfully."
|
||||
echo
|
||||
|
||||
echo "===================================="
|
||||
echo "Script finished successfully! (Mode: $APP_MODE)"
|
||||
echo "===================================="
|
||||
exit 0
|
@ -1,36 +0,0 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/app/logs/supervisord.log
|
||||
logfile_maxbytes=50MB
|
||||
logfile_backups=10
|
||||
pidfile=/app/logs/supervisord.pid
|
||||
|
||||
[program:flask]
|
||||
command=/app/venv/bin/python /app/app.py
|
||||
directory=/app
|
||||
environment=FLASK_APP="app.py",FLASK_ENV="development",NODE_PATH="/usr/lib/node_modules"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=10
|
||||
stopwaitsecs=10
|
||||
stdout_logfile=/app/logs/flask.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
stderr_logfile=/app/logs/flask_err.log
|
||||
stderr_logfile_maxbytes=10MB
|
||||
stderr_logfile_backups=5
|
||||
|
||||
[program:tomcat]
|
||||
command=/usr/local/tomcat/bin/catalina.sh run
|
||||
directory=/usr/local/tomcat
|
||||
environment=SPRING_CONFIG_LOCATION="file:/usr/local/tomcat/conf/application.yaml",NODE_PATH="/usr/lib/node_modules"
|
||||
autostart=false
|
||||
autorestart=false
|
||||
startsecs=30
|
||||
stopwaitsecs=30
|
||||
stdout_logfile=/app/logs/tomcat.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
stderr_logfile=/app/logs/tomcat_err.log
|
||||
stderr_logfile_maxbytes=10MB
|
||||
stderr_logfile_backups=5
|
@ -1,24 +0,0 @@
|
||||
{# templates/_flash_messages.html #}
|
||||
|
||||
{# Check if there are any flashed messages #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{# Loop through messages and display them as Bootstrap alerts #}
|
||||
{% for category, message in messages %}
|
||||
{# Map Flask message categories (e.g., 'error', 'success', 'warning') to Bootstrap alert classes #}
|
||||
{% set alert_class = 'alert-info' %} {# Default class #}
|
||||
{% if category == 'error' or category == 'danger' %}
|
||||
{% set alert_class = 'alert-danger' %}
|
||||
{% elif category == 'success' %}
|
||||
{% set alert_class = 'alert-success' %}
|
||||
{% elif category == 'warning' %}
|
||||
{% set alert_class = 'alert-warning' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="alert {{ alert_class }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
@ -1,42 +0,0 @@
|
||||
{# app/templates/_form_helpers.html #}
|
||||
{% macro render_field(field, label_visible=true) %}
|
||||
<div class="form-group mb-3">
|
||||
{% if field.type == "BooleanField" %}
|
||||
<div class="form-check">
|
||||
{{ field(class="form-check-input" + (" is-invalid" if field.errors else ""), **kwargs) }}
|
||||
{% if label_visible and field.label %}
|
||||
<label class="form-check-label" for="{{ field.id }}">{{ field.label.text }}</label>
|
||||
{% endif %}
|
||||
{% if field.description %}
|
||||
<small class="form-text text-muted">{{ field.description }}</small>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if label_visible and field.label %}
|
||||
{{ field.label(class="form-label") }}
|
||||
{% endif %}
|
||||
{% set css_class = 'form-control ' + kwargs.pop('class', '') %}
|
||||
{% if field.errors %}
|
||||
{% set css_class = css_class + ' is-invalid' %}
|
||||
{% endif %}
|
||||
{{ field(class=css_class, **kwargs) }}
|
||||
{% if field.description %}
|
||||
<small class="form-text text-muted">{{ field.description }}</small>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
@ -1,60 +0,0 @@
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="fsh-converter-form" method="POST" enctype="multipart/form-data" class="form">
|
||||
{{ form.hidden_tag() }}
|
||||
{{ render_field(form.package) }}
|
||||
{{ render_field(form.input_mode) }}
|
||||
<div id="file-upload" style="display: none;">
|
||||
{{ render_field(form.fhir_file) }}
|
||||
</div>
|
||||
<div id="text-input" style="display: none;">
|
||||
{{ render_field(form.fhir_text) }}
|
||||
</div>
|
||||
{{ render_field(form.output_style) }}
|
||||
{{ render_field(form.log_level) }}
|
||||
{{ render_field(form.fhir_version) }}
|
||||
{{ render_field(form.fishing_trip) }}
|
||||
{{ render_field(form.dependencies, placeholder="One per line, e.g., hl7.fhir.us.core@6.1.0") }}
|
||||
{{ render_field(form.indent_rules) }}
|
||||
{{ render_field(form.meta_profile) }}
|
||||
{{ render_field(form.alias_file) }}
|
||||
{{ render_field(form.no_alias) }}
|
||||
<div class="d-grid gap-2 d-sm-flex">
|
||||
{{ form.submit(class="btn btn-success", id="submit-btn") }}
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger mt-4">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if fsh_output %}
|
||||
<div class="alert alert-success mt-4">Conversion successful!</div>
|
||||
<h3 class="mt-4">FSH Output</h3>
|
||||
<pre class="p-3">{{ fsh_output }}</pre>
|
||||
<a href="{{ url_for('download_fsh') }}" class="btn btn-primary">Download FSH</a>
|
||||
{% if comparison_report %}
|
||||
<h3 class="mt-4">Fishing Trip Comparison Report</h3>
|
||||
<a href="{{ url_for('static', filename='uploads/fsh_output/fshing-trip-comparison.html') }}" class="badge bg-primary text-white text-decoration-none mb-3" target="_blank">Click here for SUSHI Validation</a>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if comparison_report.differences %}
|
||||
<p class="text-warning">Differences found in round-trip validation:</p>
|
||||
<ul>
|
||||
{% for diff in comparison_report.differences %}
|
||||
<li>{{ diff.path }}: {{ diff.description }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-success">No differences found in round-trip validation.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
@ -1,113 +0,0 @@
|
||||
{# templates/_search_results_table.html #}
|
||||
{# This partial template renders the search results table and pagination #}
|
||||
|
||||
{% if packages %}
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Package</th>
|
||||
<th scope="col">Latest</th>
|
||||
<th scope="col">Author</th>
|
||||
<th scope="col">FHIR</th>
|
||||
<th scope="col">Versions</th>
|
||||
<!-- <th scope="col">Canonical</th> -->
|
||||
{# Add Dependencies header if needed based on your data #}
|
||||
{# <th scope="col">Dependencies</th> #}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pkg in packages %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-box-seam me-2"></i>
|
||||
{# Link to package details page - adjust if endpoint name is different #}
|
||||
<a class="text-primary"
|
||||
href="{{ url_for('package_details_view', name=pkg.name) }}"
|
||||
{# Optional: Add HTMX GET for inline details if desired #}
|
||||
{# hx-get="{{ url_for('package_details', name=pkg.name) }}" #}
|
||||
{# hx-target="#search-results" #}
|
||||
>
|
||||
{{ pkg.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ pkg.display_version }}</td>
|
||||
<td>{{ pkg.author or '' }}</td>
|
||||
<td>{{ pkg.fhir_version or '' }}</td>
|
||||
<td>{{ pkg.version_count }}</td>
|
||||
<!-- <td>{{ pkg.canonical or '' }}</td> -->
|
||||
{# Add Dependencies data if needed #}
|
||||
{# <td>{{ pkg.dependencies | join(', ') if pkg.dependencies else '' }}</td> #}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# Pagination Controls - Ensure 'pagination' object is passed from the route #}
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
{# Previous Page Link #}
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
{# Use hx-get for HTMX-powered pagination within the results area #}
|
||||
<a class="page-link"
|
||||
href="{{ url_for('search_and_import', page=pagination.prev_num, search=request.args.get('search', '')) }}" {# Keep standard link for non-JS fallback #}
|
||||
hx-get="{{ url_for('api_search_packages', page=pagination.prev_num, search=request.args.get('search', '')) }}" {# HTMX target #}
|
||||
hx-target="#search-results"
|
||||
hx-indicator=".htmx-indicator"
|
||||
aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">«</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{# Page Number Links #}
|
||||
{% for p in pagination.iter_pages %} {# Iterate over the pre-computed list #}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('search_and_import', page=p, search=request.args.get('search', '')) }}"
|
||||
hx-get="{{ url_for('api_search_packages', page=p, search=request.args.get('search', '')) }}"
|
||||
hx-target="#search-results"
|
||||
hx-indicator=".htmx-indicator">
|
||||
{{ p }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
{# Ellipsis for skipped pages #}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Next Page Link #}
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('search_and_import', page=pagination.next_num, search=request.args.get('search', '')) }}"
|
||||
hx-get="{{ url_for('api_search_packages', page=pagination.next_num, search=request.args.get('search', '')) }}"
|
||||
hx-target="#search-results"
|
||||
hx-indicator=".htmx-indicator"
|
||||
aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">»</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %} {# End pagination nav #}
|
||||
|
||||
{% elif request and request.args.get('search') %}
|
||||
{# Message when search term is present but no results found #}
|
||||
<p class="text-muted text-center mt-3">No packages found matching your search term.</p>
|
||||
{% else %}
|
||||
{# Initial message before any search #}
|
||||
<p class="text-muted text-center mt-3">Start typing in the search box above to find packages.</p>
|
||||
{% endif %}
|
@ -1,66 +0,0 @@
|
||||
{% 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="" width="192" height="192">
|
||||
<h1 class="display-5 fw-bold text-body-emphasis">About FHIRFLARE IG Toolkit{% if app_mode == 'lite' %} (Lite Version){% endif %}</h1>
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
A comprehensive toolkit designed for developers and implementers working with FHIR® Implementation Guides (IGs).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<h2>Overview</h2>
|
||||
<p>The FHIRFLARE IG Toolkit is a web application built to simplify the lifecycle of managing, processing, validating, and deploying FHIR Implementation Guides. It provides a central, user-friendly interface to handle common tasks associated with FHIR IGs, streamlining workflows and improving productivity.</p>
|
||||
<p>Whether you're downloading the latest IG versions, checking compliance, converting resources to FHIR Shorthand (FSH), or pushing guides to a test server, this toolkit aims to be an essential companion.</p>
|
||||
{% if app_mode == 'lite' %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="bi bi-info-circle-fill me-2"></i>You are currently running the <strong>Lite Version</strong> of the toolkit. This version excludes the built-in HAPI FHIR server and relies on external FHIR servers for functionalities like the API Explorer and Operations UI. Validation uses local StructureDefinition checks.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success" role="alert">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>You are currently running the <strong>Standalone Version</strong> of the toolkit, which includes a built-in HAPI FHIR server (accessible via the `/fhir` proxy) for local validation and exploration.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h2 class="mt-4">Core Features</h2>
|
||||
<ul>
|
||||
<li><strong>IG Package Management:</strong> Import FHIR IG packages directly from the registry using various version formats (e.g., `1.1.0-preview`, `current`), with flexible dependency handling (Recursive, Patch Canonical, Tree Shaking). View, process, unload, or delete downloaded packages, with detection of duplicate dependencies.</li>
|
||||
<li><strong>IG Processing & Viewing:</strong> Extract and display key information from processed IGs, including defined profiles, referenced resource types, must-support elements, and examples. Visualize profile relationships like `compliesWithProfile` and `imposeProfile`.</li>
|
||||
<li><strong>FHIR Validation:</strong> Validate individual FHIR resources or entire Bundles against the profiles defined within a selected IG. Provides detailed error and warning feedback. (Note: Validation accuracy is still under development, especially for complex constraints).</li>
|
||||
<li><strong>FHIR Server Interaction:</strong>
|
||||
<ul>
|
||||
<li>Push processed IGs (including dependencies) to a target FHIR server with real-time console feedback.</li>
|
||||
<li>Explore FHIR server capabilities and interact with resources using the "FHIR API Explorer" (GET/POST/PUT/DELETE) and "FHIR UI Operations" pages, supporting both the local HAPI server (in Standalone mode) and external servers.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>FHIR Shorthand (FSH) Conversion:</strong> Convert FHIR JSON or XML resources to FSH using the integrated GoFSH tool. Offers advanced options like context package selection, various output styles, FHIR version selection, dependency loading, alias file usage, and round-trip validation ("Fishing Trip") with SUSHI. Includes a loading indicator during conversion.</li>
|
||||
<li><strong>API Support:</strong> Provides basic API endpoints for programmatic import and push operations.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="mt-4">Technology</h2>
|
||||
<p>The toolkit leverages a combination of technologies:</p>
|
||||
<ul>
|
||||
<li><strong>Backend:</strong> Python with the Flask web framework and SQLAlchemy for database interaction (SQLite).</li>
|
||||
<li><strong>Frontend:</strong> HTML, Bootstrap 5 for styling, and JavaScript for interactivity and dynamic content loading. Uses Lottie-Web for animations.</li>
|
||||
<li><strong>FHIR Tooling:</strong> Integrates GoFSH and SUSHI (via Node.js) for FSH conversion and validation. Utilizes the HAPI FHIR server (in Standalone mode) for robust FHIR validation and operations.</li>
|
||||
<li><strong>Deployment:</strong> Runs within a Docker container managed by Docker Compose and Supervisor, ensuring a consistent environment.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="mt-4">Get Involved</h2>
|
||||
<p>This is an open-source project. Contributions, feedback, and bug reports are welcome!</p>
|
||||
<ul>
|
||||
<li><strong>GitHub Repository:</strong> <a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit" target="_blank" rel="noopener noreferrer">Sudo-JHare/FHIRFLARE-IG-Toolkit</a></li>
|
||||
<li><strong>Report an Issue:</strong> <a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/issues/new/choose" target="_blank" rel="noopener noreferrer">Raise an Issue</a></li>
|
||||
<li><strong>Discussions:</strong> <a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/discussions" target="_blank" rel="noopener noreferrer">Project Discussions</a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,905 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="{% if request.cookies.get('theme') == 'dark' %}dark{% else %}light{% endif %}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" xintegrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-okaidia.min.css" rel="stylesheet" />
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/animation.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
||||
<title>{% if app_mode == 'lite' %}(Lite Version) {% endif %}{% if title %}{{ title }} - {% endif %}{{ site_name }}</title>
|
||||
<style>
|
||||
/* Prevent flash of unstyled content */
|
||||
:root { visibility: hidden; }
|
||||
[data-theme="dark"], [data-theme="light"] { visibility: visible; }
|
||||
|
||||
/* Default (Light Theme) Styles */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
h1, h5 {
|
||||
font-weight: 600;
|
||||
}
|
||||
.navbar-light {
|
||||
background-color: #ffffff !important;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
color: #007bff !important;
|
||||
}
|
||||
.nav-link {
|
||||
color: #333 !important;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: #007bff !important;
|
||||
}
|
||||
.nav-link.active {
|
||||
color: #007bff !important;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #007bff;
|
||||
}
|
||||
.dropdown-menu {
|
||||
list-style: none !important;
|
||||
position: absolute;
|
||||
background-color: #ffffff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
}
|
||||
.dropdown-item {
|
||||
padding: 0.5rem 1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.dropdown-item.active {
|
||||
background-color: #007bff;
|
||||
color: white !important;
|
||||
}
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
.text-muted {
|
||||
color: #6c757d !important;
|
||||
}
|
||||
.form-check-input {
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
margin-top: 0.25rem;
|
||||
display: inline-block !important;
|
||||
border: 1px solid #6c757d;
|
||||
}
|
||||
.form-check-input:checked {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
.form-check-label {
|
||||
font-size: 1rem;
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.form-check-label i {
|
||||
font-size: 1.2rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.form-check {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.navbar-controls {
|
||||
gap: 0.5rem;
|
||||
padding-right: 0;
|
||||
}
|
||||
/* Footer Styles */
|
||||
footer {
|
||||
background-color: #ffffff;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: inline-flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.footer-left a,
|
||||
.footer-right a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.footer-left a:hover,
|
||||
.footer-right a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Alert Styles */
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.alert-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border-color: #c3e6cb;
|
||||
}
|
||||
.alert-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border-color: #bee5eb;
|
||||
}
|
||||
|
||||
/* --- Unified Button and Badge Styles --- */
|
||||
|
||||
/* Add some basic adjustments for Prism compatibility if needed */
|
||||
/* Ensure pre takes full width and code block behaves */
|
||||
.highlight-code pre, pre[class*="language-"] {
|
||||
margin: 0.5em 0;
|
||||
overflow: auto; /* Allow scrolling */
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none; /* Adjust if theme adds text shadow */
|
||||
white-space: pre-wrap; /* Wrap long lines */
|
||||
word-break: break-all; /* Break long words/tokens */
|
||||
}
|
||||
/* Adjust padding if needed based on theme */
|
||||
:not(pre) > code[class*="language-"], pre[class*="language-"] {
|
||||
background: inherit; /* Ensure background comes from pre or theme */
|
||||
}
|
||||
|
||||
/* General Button Theme (Primary, Secondary) */
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
border-color: #004085;
|
||||
}
|
||||
.btn-outline-primary {
|
||||
color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
.btn-outline-primary:hover {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* --- Prism.js Theme Overrides for Light Mode --- */
|
||||
|
||||
/* Target code blocks only when NOT in dark theme */
|
||||
html:not([data-theme="dark"]) .token.punctuation,
|
||||
html:not([data-theme="dark"]) .token.operator,
|
||||
html:not([data-theme="dark"]) .token.entity,
|
||||
html:not([data-theme="dark"]) .token.url,
|
||||
html:not([data-theme="dark"]) .token.symbol,
|
||||
html:not([data-theme="dark"]) .token.number,
|
||||
html:not([data-theme="dark"]) .token.boolean,
|
||||
html:not([data-theme="dark"]) .token.variable,
|
||||
html:not([data-theme="dark"]) .token.constant,
|
||||
html:not([data-theme="dark"]) .token.property,
|
||||
html:not([data-theme="dark"]) .token.regex,
|
||||
html:not([data-theme="dark"]) .token.inserted,
|
||||
html:not([data-theme="dark"]) .token.null, /* Target null specifically */
|
||||
html:not([data-theme="dark"]) .language-css .token.string,
|
||||
html:not([data-theme="dark"]) .style .token.string,
|
||||
html:not([data-theme="dark"]) .token.important,
|
||||
html:not([data-theme="dark"]) .token.bold {
|
||||
/* Choose a darker color that works on your light code background */
|
||||
/* Example: A dark grey or the default text color */
|
||||
color: #333 !important; /* You might need !important to override theme */
|
||||
}
|
||||
|
||||
/* You might need to adjust other tokens as well based on the specific theme */
|
||||
/* Example: Make strings slightly darker too if needed */
|
||||
html:not([data-theme="dark"]) .token.string {
|
||||
color: #005cc5 !important; /* Example: A shade of blue */
|
||||
}
|
||||
|
||||
/* Ensure background of highlighted code isn't overridden by theme in light mode */
|
||||
html:not([data-theme="dark"]) pre[class*="language-"] {
|
||||
background: #e9ecef !important; /* Match your light theme pre background */
|
||||
}
|
||||
html:not([data-theme="dark"]) :not(pre) > code[class*="language-"] {
|
||||
background: #e9ecef !important; /* Match inline code background */
|
||||
color: #333 !important; /* Ensure inline code text is dark */
|
||||
}
|
||||
|
||||
/* --- End Prism.js Overrides --- */
|
||||
|
||||
/* --- Theme Styles for FSH Code Blocks --- */
|
||||
pre, pre code {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.875em;
|
||||
white-space: pre-wrap; /* Allow wrapping */
|
||||
word-break: break-all; /* Break long lines */
|
||||
}
|
||||
|
||||
/* Target pre blocks specifically used for FSH output if needed */
|
||||
/* If the pre block has a specific class or ID, use that */
|
||||
#fsh-output pre, /* Targets any pre within the output container */
|
||||
._fsh_output pre /* Targets any pre within the partial template content */
|
||||
{
|
||||
background-color: #e9ecef; /* Light theme background */
|
||||
color: #212529; /* Light theme text */
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
max-height: 600px; /* Adjust as needed */
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#fsh-output pre code,
|
||||
._fsh_output pre code {
|
||||
background-color: transparent !important; /* Ensure code tag doesn't override pre bg */
|
||||
padding: 0;
|
||||
color: inherit; /* Inherit text color from pre */
|
||||
font-size: inherit; /* Inherit font size from pre */
|
||||
display: block; /* Make code fill pre */
|
||||
}
|
||||
|
||||
/* Dark Theme Override */
|
||||
html[data-theme="dark"] #fsh-output pre,
|
||||
html[data-theme="dark"] ._fsh_output pre {
|
||||
background-color: #495057; /* Dark theme background */
|
||||
color: #f8f9fa; /* Dark theme text */
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] #fsh-output pre code,
|
||||
html[data-theme="dark"] ._fsh_output pre code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* --- Theme Styles for FHIR UI Operations (fhir_ui_operations.html) --- */
|
||||
|
||||
/* Operation Block Container */
|
||||
.opblock {
|
||||
border: 1px solid #dee2e6; /* Light theme border */
|
||||
background: #ffffff; /* Light theme background */
|
||||
color: #212529; /* Light theme text */
|
||||
}
|
||||
html[data-theme="dark"] .opblock {
|
||||
border-color: #495057; /* Dark theme border */
|
||||
background: #343a40; /* Dark theme background (card bg) */
|
||||
color: #f8f9fa; /* Dark theme text */
|
||||
}
|
||||
|
||||
/* Operation Summary (Header) */
|
||||
.opblock-summary {
|
||||
background: #f8f9fa; /* Light theme summary bg */
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
color: #212529; /* Default text color for summary */
|
||||
}
|
||||
.opblock-summary:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
html[data-theme="dark"] .opblock-summary {
|
||||
background: #40464c; /* Darker summary bg */
|
||||
border-bottom-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
html[data-theme="dark"] .opblock-summary:hover {
|
||||
background: #495057;
|
||||
}
|
||||
/* Keep method badge colors fixed - DO NOT override .opblock-summary-method */
|
||||
.opblock-summary-description {
|
||||
color: #495057; /* Slightly muted description text */
|
||||
}
|
||||
html[data-theme="dark"] .opblock-summary-description {
|
||||
color: #adb5bd; /* Lighter muted text for dark */
|
||||
}
|
||||
|
||||
/* Operation Body (Expanded Section) */
|
||||
.opblock-body {
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
html[data-theme="dark"] .opblock-body {
|
||||
border-top-color: #495057;
|
||||
}
|
||||
|
||||
/* Section Headers within Body */
|
||||
.opblock-section-header {
|
||||
border-bottom: 1px solid #eee;
|
||||
color: inherit; /* Inherit from .opblock */
|
||||
}
|
||||
html[data-theme="dark"] .opblock-section-header {
|
||||
border-bottom-color: #495057;
|
||||
}
|
||||
.opblock-title {
|
||||
color: inherit; /* Inherit from .opblock */
|
||||
}
|
||||
|
||||
/* Parameters Table */
|
||||
.parameters-table th,
|
||||
.parameters-table td {
|
||||
border: 1px solid #dee2e6;
|
||||
color: inherit; /* Inherit from .opblock */
|
||||
}
|
||||
html[data-theme="dark"] .parameters-table th,
|
||||
html[data-theme="dark"] .parameters-table td {
|
||||
border-color: #495057;
|
||||
}
|
||||
.parameter__type,
|
||||
.parameter__in {
|
||||
color: #6c757d; /* Use standard muted color */
|
||||
}
|
||||
html[data-theme="dark"] .parameter__type,
|
||||
html[data-theme="dark"] .parameter__in {
|
||||
color: #adb5bd; /* Use standard dark muted color */
|
||||
}
|
||||
.parameters-col_description input[type="text"],
|
||||
.parameters-col_description input[type="number"] {
|
||||
background-color: #fff; /* Match light form controls */
|
||||
border: 1px solid #ced4da;
|
||||
color: #212529;
|
||||
}
|
||||
html[data-theme="dark"] .parameters-col_description input[type="text"],
|
||||
html[data-theme="dark"] .parameters-col_description input[type="number"] {
|
||||
background-color: #495057; /* Match dark form controls */
|
||||
border-color: #6c757d;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Request Body Textarea */
|
||||
.request-body-textarea {
|
||||
background-color: #fff; /* Match light form controls */
|
||||
border: 1px solid #ced4da;
|
||||
color: #212529;
|
||||
}
|
||||
html[data-theme="dark"] .request-body-textarea {
|
||||
background-color: #495057; /* Match dark form controls */
|
||||
border-color: #6c757d;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Responses Table */
|
||||
.responses-table th,
|
||||
.responses-table td {
|
||||
border: 1px solid #dee2e6;
|
||||
color: inherit;
|
||||
}
|
||||
html[data-theme="dark"] .responses-table th,
|
||||
html[data-theme="dark"] .responses-table td {
|
||||
border-color: #495057;
|
||||
}
|
||||
|
||||
/* Example/Schema Tabs & Controls */
|
||||
.response-control-media-type {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
html[data-theme="dark"] .response-control-media-type {
|
||||
background-color: #40464c; /* Match summary header bg */
|
||||
}
|
||||
.tablinks.badge { /* Default inactive tab */
|
||||
background: #dee2e6 !important; /* Light grey inactive */
|
||||
color: #333 !important;
|
||||
}
|
||||
.tablinks.badge.active { /* Default active tab */
|
||||
background: #007bff !important; /* Use primary color */
|
||||
color: white !important;
|
||||
}
|
||||
html[data-theme="dark"] .tablinks.badge { /* Dark inactive tab */
|
||||
background: #6c757d !important; /* Dark grey inactive */
|
||||
color: white !important;
|
||||
}
|
||||
html[data-theme="dark"] .tablinks.badge.active { /* Dark active tab */
|
||||
background: #4dabf7 !important; /* Use dark primary color */
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Code/Output Blocks */
|
||||
.highlight-code,
|
||||
.request-url-output,
|
||||
.curl-output,
|
||||
.execute-wrapper pre.response-output-content {
|
||||
background: #e9ecef; /* Light theme pre background */
|
||||
color: #212529;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
html[data-theme="dark"] .highlight-code,
|
||||
html[data-theme="dark"] .request-url-output,
|
||||
html[data-theme="dark"] .curl-output,
|
||||
html[data-theme="dark"] .execute-wrapper pre.response-output-content {
|
||||
background: #495057; /* Dark theme pre background */
|
||||
color: #f8f9fa;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
/* Narrative Response Box */
|
||||
.execute-wrapper div.response-output-narrative {
|
||||
background: #ffffff;
|
||||
color: #212529;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
html[data-theme="dark"] .execute-wrapper div.response-output-narrative {
|
||||
background: #343a40; /* Match card body */
|
||||
color: #f8f9fa;
|
||||
border: 1px solid #495057;
|
||||
}
|
||||
|
||||
/* Response Status Text */
|
||||
.execute-wrapper .response-status[style*="color: green"] { color: #198754 !important; }
|
||||
.execute-wrapper .response-status[style*="color: red"] { color: #dc3545 !important; }
|
||||
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: green"] { color: #20c997 !important; }
|
||||
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: red"] { color: #e63946 !important; }
|
||||
|
||||
/* Specific Action Buttons (Success, Danger, Warning, Info) */
|
||||
.btn-success { background-color: #198754; border-color: #198754; color: white; }
|
||||
.btn-success:hover { background-color: #157347; border-color: #146c43; }
|
||||
.btn-outline-success { color: #198754; border-color: #198754; }
|
||||
.btn-outline-success:hover { background-color: #198754; color: white; }
|
||||
|
||||
.btn-danger { background-color: #dc3545; border-color: #dc3545; color: white; }
|
||||
.btn-danger:hover { background-color: #bb2d3b; border-color: #b02a37; }
|
||||
.btn-outline-danger { color: #dc3545; border-color: #dc3545; }
|
||||
.btn-outline-danger:hover { background-color: #dc3545; color: white; }
|
||||
|
||||
.btn-warning { background-color: #ffc107; border-color: #ffc107; color: #000; }
|
||||
.btn-warning:hover { background-color: #ffca2c; border-color: #ffc720; }
|
||||
.btn-outline-warning { color: #ffc107; border-color: #ffc107; }
|
||||
.btn-outline-warning:hover { background-color: #ffc107; color: #000; }
|
||||
|
||||
.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #000; }
|
||||
.btn-info:hover { background-color: #31d2f2; border-color: #25cff2; }
|
||||
.btn-outline-info { color: #0dcaf0; border-color: #0dcaf0; }
|
||||
.btn-outline-info:hover { background-color: #0dcaf0; color: #000; }
|
||||
|
||||
/* General Badge Theme */
|
||||
.badge {
|
||||
padding: 0.4em 0.6em; /* Slightly larger padding */
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Specific Badge Backgrounds (Add more as needed) */
|
||||
.badge.bg-primary { background-color: #007bff !important; color: white !important; }
|
||||
.badge.bg-secondary { background-color: #6c757d !important; color: white !important; }
|
||||
.badge.bg-success { background-color: #198754 !important; color: white !important; }
|
||||
.badge.bg-danger { background-color: #dc3545 !important; color: white !important; }
|
||||
.badge.bg-warning { background-color: #ffc107 !important; color: #000 !important; }
|
||||
.badge.bg-info { background-color: #0dcaf0 !important; color: #000 !important; }
|
||||
.badge.bg-light { background-color: #f8f9fa !important; color: #000 !important; border: 1px solid #dee2e6; }
|
||||
.badge.bg-dark { background-color: #343a40 !important; color: white !important; }
|
||||
|
||||
/* Copy Button Specific Style */
|
||||
.btn-copy { /* Optional: Add a specific class if needed, or style .btn-outline-secondary directly */
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* --- Dark Theme Overrides --- */
|
||||
html[data-theme="dark"] .btn-primary { background-color: #4dabf7; border-color: #4dabf7; color: #000; }
|
||||
html[data-theme="dark"] .btn-primary:hover { background-color: #68bcef; border-color: #5fb7ea; }
|
||||
html[data-theme="dark"] .btn-outline-primary { color: #4dabf7; border-color: #4dabf7; }
|
||||
html[data-theme="dark"] .btn-outline-primary:hover { background-color: #4dabf7; color: #000; }
|
||||
|
||||
html[data-theme="dark"] .btn-secondary { background-color: #6c757d; border-color: #6c757d; color: white; }
|
||||
html[data-theme="dark"] .btn-secondary:hover { background-color: #5a6268; border-color: #545b62; }
|
||||
html[data-theme="dark"] .btn-outline-secondary { color: #adb5bd; border-color: #6c757d; } /* Lighter text/border */
|
||||
html[data-theme="dark"] .btn-outline-secondary:hover { background-color: #6c757d; color: white; }
|
||||
|
||||
html[data-theme="dark"] .btn-success { background-color: #20c997; border-color: #20c997; color: #000; }
|
||||
html[data-theme="dark"] .btn-success:hover { background-color: #3ce0ac; border-color: #36d8a3; }
|
||||
html[data-theme="dark"] .btn-outline-success { color: #20c997; border-color: #20c997; }
|
||||
html[data-theme="dark"] .btn-outline-success:hover { background-color: #20c997; color: #000; }
|
||||
|
||||
/* Keep danger as is */
|
||||
html[data-theme="dark"] .btn-danger { background-color: #e63946; border-color: #e63946; color: white; }
|
||||
html[data-theme="dark"] .btn-danger:hover { background-color: #c82b39; border-color: #bd2130; }
|
||||
html[data-theme="dark"] .btn-outline-danger { color: #e63946; border-color: #e63946; }
|
||||
html[data-theme="dark"] .btn-outline-danger:hover { background-color: #e63946; color: white; }
|
||||
|
||||
html[data-theme="dark"] .btn-warning { background-color: #ffca2c; border-color: #ffca2c; color: #000; }
|
||||
html[data-theme="dark"] .btn-warning:hover { background-color: #ffd24a; border-color: #ffce3d; }
|
||||
html[data-theme="dark"] .btn-outline-warning { color: #ffca2c; border-color: #ffca2c; }
|
||||
html[data-theme="dark"] .btn-outline-warning:hover { background-color: #ffca2c; color: #000; }
|
||||
|
||||
html[data-theme="dark"] .btn-info { background-color: #22b8cf; border-color: #22b8cf; color: #000; }
|
||||
html[data-theme="dark"] .btn-info:hover { background-color: #44cee3; border-color: #38cae0; }
|
||||
html[data-theme="dark"] .btn-outline-info { color: #22b8cf; border-color: #22b8cf; }
|
||||
html[data-theme="dark"] .btn-outline-info:hover { background-color: #22b8cf; color: #000; }
|
||||
|
||||
/* Dark Theme Badges */
|
||||
html[data-theme="dark"] .badge.bg-primary { background-color: #4dabf7 !important; color: #000 !important; }
|
||||
html[data-theme="dark"] .badge.bg-secondary { background-color: #6c757d !important; color: white !important; }
|
||||
html[data-theme="dark"] .badge.bg-success { background-color: #20c997 !important; color: #000 !important; }
|
||||
html[data-theme="dark"] .badge.bg-danger { background-color: #e63946 !important; color: white !important; }
|
||||
html[data-theme="dark"] .badge.bg-warning { background-color: #ffca2c !important; color: #000 !important; }
|
||||
html[data-theme="dark"] .badge.bg-info { background-color: #22b8cf !important; color: #000 !important; }
|
||||
html[data-theme="dark"] .badge.bg-light { background-color: #495057 !important; color: #f8f9fa !important; border: 1px solid #6c757d; }
|
||||
html[data-theme="dark"] .badge.bg-dark { background-color: #adb5bd !important; color: #000 !important; }
|
||||
|
||||
/* --- Themed Backgrounds for Code/Structure View (cp_view_processed_ig.html) --- */
|
||||
|
||||
/* Styling for <pre> blocks containing code */
|
||||
#raw-structure-wrapper pre,
|
||||
#example-content-wrapper pre {
|
||||
background-color: #e9ecef; /* Light theme background */
|
||||
color: #212529; /* Light theme text */
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
max-height: 400px; /* Keep existing max-height */
|
||||
overflow-y: auto; /* Keep existing overflow */
|
||||
white-space: pre-wrap; /* Ensure wrapping */
|
||||
word-break: break-all; /* Break long strings */
|
||||
}
|
||||
|
||||
/* Ensure code tag inside inherits background and has appropriate font */
|
||||
#raw-structure-wrapper pre code,
|
||||
#example-content-wrapper pre code {
|
||||
background-color: transparent !important; /* Let pre handle background */
|
||||
padding: 0;
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.875em;
|
||||
color: inherit; /* Inherit text color from pre */
|
||||
display: block; /* Make code block fill pre */
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Styling for the Structure Definition tree list items */
|
||||
.structure-tree-root .list-group-item {
|
||||
background-color: #ffffff; /* Light theme default background */
|
||||
border-color: #dee2e6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.structure-tree-root .list-group-item-warning {
|
||||
background-color: #fff3cd; /* Bootstrap light theme warning */
|
||||
border-color: #ffeeba;
|
||||
/*color: #856404; Ensure text is readable */
|
||||
}
|
||||
|
||||
/* --- Dark Theme Overrides for Code/Structure View --- */
|
||||
html[data-theme="dark"] #raw-structure-wrapper pre,
|
||||
html[data-theme="dark"] #example-content-wrapper pre {
|
||||
background-color: #495057; /* Dark theme background */
|
||||
color: #f8f9fa; /* Dark theme text */
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
/* Dark theme code text color already inherited, but explicit doesn't hurt */
|
||||
html[data-theme="dark"] #raw-structure-wrapper pre code,
|
||||
html[data-theme="dark"] #example-content-wrapper pre code {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Dark theme structure list items */
|
||||
html[data-theme="dark"] .structure-tree-root .list-group-item {
|
||||
background-color: #343a40; /* Dark theme card background */
|
||||
border-color: #495057;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
|
||||
background-color: #664d03; /* Darker warning background */
|
||||
border-color: #997404;
|
||||
/*color: #ffecb5; Lighter text for contrast */
|
||||
}
|
||||
|
||||
/* Dark Theme Styles */
|
||||
html[data-theme="dark"] body {
|
||||
background-color: #212529;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
html[data-theme="dark"] .navbar-light {
|
||||
background-color: #343a40 !important;
|
||||
border-bottom: 1px solid #495057;
|
||||
}
|
||||
html[data-theme="dark"] .navbar-brand {
|
||||
color: #4dabf7 !important;
|
||||
}
|
||||
html[data-theme="dark"] .nav-link {
|
||||
color: #f8f9fa !important;
|
||||
}
|
||||
html[data-theme="dark"] .nav-link:hover,
|
||||
html[data-theme="dark"] .nav-link.active {
|
||||
color: #4dabf7 !important;
|
||||
border-bottom-color: #4dabf7;
|
||||
}
|
||||
html[data-theme="dark"] .dropdown-menu {
|
||||
background-color: #495057;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
html[data-theme="dark"] .dropdown-item {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
html[data-theme="dark"] .dropdown-item:hover {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
html[data-theme="dark"] .dropdown-item.active {
|
||||
background-color: #4dabf7;
|
||||
color: white !important;
|
||||
}
|
||||
html[data-theme="dark"] .card {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #495057;
|
||||
}
|
||||
html[data-theme="dark"] .text-muted {
|
||||
color: #adb5bd !important;
|
||||
}
|
||||
html[data-theme="dark"] h1,
|
||||
html[data-theme="dark"] h5 {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
html[data-theme="dark"] .form-label {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
html[data-theme="dark"] .form-control {
|
||||
background-color: #495057;
|
||||
color: #f8f9fa;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
html[data-theme="dark"] .form-control::placeholder {
|
||||
color: #adb5bd;
|
||||
}
|
||||
html[data-theme="dark"] .form-select {
|
||||
background-color: #495057;
|
||||
color: #f8f9fa;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
html[data-theme="dark"] .alert-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
html[data-theme="dark"] .alert-success {
|
||||
background-color: #198754;
|
||||
color: white;
|
||||
border-color: #198754;
|
||||
}
|
||||
html[data-theme="dark"] .alert-info {
|
||||
background-color: #0dcaf0;
|
||||
color: white;
|
||||
border-color: #0dcaf0;
|
||||
}
|
||||
html[data-theme="dark"] .form-check-input {
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
html[data-theme="dark"] .form-check-input:checked {
|
||||
background-color: #4dabf7;
|
||||
border-color: #4dabf7;
|
||||
}
|
||||
html[data-theme="dark"] footer {
|
||||
background-color: #343a40;
|
||||
border-top: 1px solid #495057;
|
||||
}
|
||||
html[data-theme="dark"] .footer-left a,
|
||||
html[data-theme="dark"] .footer-right a {
|
||||
color: #4dabf7;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.navbar-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
.navbar-controls .form-check {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
footer {
|
||||
height: auto;
|
||||
padding: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.footer-left a,
|
||||
.footer-right a {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100 {% block body_class %}{% endblock %}">
|
||||
<nav class="navbar navbar-expand-lg navbar-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('index') }}">{{ site_name }}{% if app_mode == 'lite' %} (Lite){% endif %}</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse d-flex justify-content-between" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'index' else '' }}" aria-current="page" href="{{ url_for('index') }}"><i class="fas fa-house me-1"></i> Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.path == '/search-and-import' %}active{% endif %}" href="{{ url_for('search_and_import') }}"><i class="fas fa-download me-1"></i> Search and Import</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'manual_import_ig' else '' }}" href="{{ url_for('manual_import_ig') }}"><i class="fas fa-folder-open me-1"></i> Manual Import</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'view_igs' else '' }}" href="{{ url_for('view_igs') }}"><i class="fas fa-folder-open me-1"></i> Manage FHIR Packages</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'push_igs' else '' }}" href="{{ url_for('push_igs') }}"><i class="fas fa-upload me-1"></i> Push IGs</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'upload_test_data' else '' }}" href="{{ url_for('upload_test_data') }}"><i class="bi bi-clipboard-data me-1"></i>Upload Test Data</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'validate_sample' else '' }}" href="{{ url_for('validate_sample') }}"><i class="fas fa-check-circle me-1"></i> Validate FHIR Sample</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'retrieve_split_data' else '' }}" href="{{ url_for('retrieve_split_data') }}"><i class="fas bi-cloud-download me-1"></i> Retrieve & Split Data</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'fhir_ui' else '' }}" href="{{ url_for('fhir_ui') }}"><i class="fas fa-cloud-download-alt me-1"></i> FHIR API Explorer</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'fhir_ui_operations' else '' }}" href="{{ url_for('fhir_ui_operations') }}"><i class="bi bi-gear me-1"></i> FHIR UI Operations</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a>
|
||||
</li>
|
||||
{% if app_mode != 'lite' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="navbar-controls d-flex align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="themeToggle" onchange="toggleTheme()" aria-label="Toggle dark mode" {% if request.cookies.get('theme') == 'dark' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="themeToggle">
|
||||
<i class="fas fa-sun"></i>
|
||||
<i class="fas fa-moon d-none"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1">
|
||||
<div class="container mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mt-3">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert
|
||||
{{ 'alert-danger' if category == 'error' else
|
||||
'alert-success' if category == 'success' else
|
||||
'alert-info' }}
|
||||
alert-dismissible fade show" role="alert">
|
||||
<i class="fas
|
||||
{{ 'fa-exclamation-triangle me-2' if category == 'error' else
|
||||
'fa-check-circle me-2' if category == 'success' else
|
||||
'fa-info-circle me-2' }}"></i>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="main-footer" role="contentinfo">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="footer-left">
|
||||
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit" target="_blank" rel="noreferrer" aria-label="Project Github">Project Github</a>
|
||||
<a href="/about" aria-label="Learn about this project">What is FHIRFLARE</a>
|
||||
<a href="https://www.hl7.org/fhir/" target="_blank" rel="noreferrer" aria-label="Visit FHIR website">FHIR</a>
|
||||
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/issues/new/choose" class="text-danger text-decoration-none" aria-label="FHIRFLARE support"><i class="fas fa-exclamation-circle me-1"></i> Raise an Issue</a>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'flasgger.apidocs' else '' }}" href="{{ url_for('flasgger.apidocs') }}" aria-label="API Documentation"><i class="fas fa-book-open me-1"></i> API Docs</a>
|
||||
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/discussions" target="_blank" rel="noreferrer" aria-label="Project Discussion">Project Discussions</a>
|
||||
<a href="https://github.com/Sudo-JHare" aria-label="Developer">Developer</a>
|
||||
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/blob/main/LICENSE.md" aria-label="License">License</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
const sunIcon = document.querySelector('.fa-sun');
|
||||
const moonIcon = document.querySelector('.fa-moon');
|
||||
const newTheme = toggle.checked ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
sunIcon.classList.toggle('d-none', toggle.checked);
|
||||
moonIcon.classList.toggle('d-none', !toggle.checked);
|
||||
document.cookie = `theme=${newTheme}; path=/; max-age=31536000`;
|
||||
if (typeof loadLottieAnimation === 'function') {
|
||||
loadLottieAnimation(newTheme);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const savedTheme = localStorage.getItem('theme') || getCookie('theme') || 'light';
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const sunIcon = document.querySelector('.fa-sun');
|
||||
const moonIcon = document.querySelector('.fa-moon');
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
themeToggle.checked = savedTheme === 'dark';
|
||||
sunIcon.classList.toggle('d-none', savedTheme === 'dark');
|
||||
moonIcon.classList.toggle('d-none', savedTheme !== 'dark');
|
||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
tooltips.forEach(t => new bootstrap.Tooltip(t));
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
@ -1,234 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1 class="mb-4">HAPI FHIR Configuration Manager</h1>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div id="loading" class="text-center my-4">
|
||||
<p>Loading configuration...</p>
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="error" class="alert alert-danger d-none" role="alert"></div>
|
||||
<div id="config-form" class="d-none">
|
||||
<div class="mb-3">
|
||||
<button id="save-btn" class="btn btn-primary me-2">Save Configuration</button>
|
||||
<button id="restart-btn" class="btn btn-success">Restart HAPI Server</button>
|
||||
<span id="restart-status" class="ms-3 text-success d-none"></span>
|
||||
</div>
|
||||
<div id="config-sections"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Configuration descriptions from HAPI FHIR JPA documentation
|
||||
const CONFIG_DESCRIPTIONS = {
|
||||
'hapi.fhir.narrative_enabled': 'Enables or disables the generation of FHIR resource narratives (human-readable summaries). Set to false to reduce processing overhead. Default: false.',
|
||||
'hapi.fhir.mdm_enabled': 'Enables Master Data Management (MDM) features for linking and matching resources (e.g., Patient records). Requires mdm_rules_json_location to be set. Default: false.',
|
||||
'hapi.fhir.mdm_rules_json_location': 'Path to the JSON file defining MDM rules for resource matching. Example: "mdm-rules.json".',
|
||||
'hapi.fhir.cors.allow_Credentials': 'Allows credentials (e.g., cookies, authorization headers) in CORS requests. Set to true to support authenticated cross-origin requests. Default: true.',
|
||||
'hapi.fhir.cors.allowed_origin': 'List of allowed origins for CORS requests. Use ["*"] to allow all origins or specify domains (e.g., ["https://example.com"]). Default: ["*"].',
|
||||
'hapi.fhir.tester.home.name': 'Name of the local tester configuration for HAPI FHIR testing UI. Example: "Local Tester".',
|
||||
'hapi.fhir.tester.home.server_address': 'URL of the local FHIR server for testing. Example: "http://localhost:8080/fhir".',
|
||||
'hapi.fhir.tester.home.refuse_to_fetch_third_party_urls': 'Prevents the tester from fetching third-party URLs. Set to true for security. Default: false.',
|
||||
'hapi.fhir.tester.home.fhir_version': 'FHIR version for the local tester (e.g., "R4", "R5"). Default: R4.',
|
||||
'hapi.fhir.tester.global.name': 'Name of the global tester configuration for HAPI FHIR testing UI. Example: "Global Tester".',
|
||||
'hapi.fhir.tester.global.server_address': 'URL of the global FHIR server for testing. Example: "http://hapi.fhir.org/baseR4".',
|
||||
'hapi.fhir.tester.global.refuse_to_fetch_third_party_urls': 'Prevents the global tester from fetching third-party URLs. Default: false.',
|
||||
'hapi.fhir.tester.global.fhir_version': 'FHIR version for the global tester (e.g., "R4"). Default: R4.',
|
||||
'hapi.fhir.cr.enabled': 'Enables Clinical Reasoning (CR) module for operations like evaluate-measure. Default: false.',
|
||||
'hapi.fhir.cr.caregaps.reporter': 'Reporter mode for care gaps in the CR module. Default: "default".',
|
||||
'hapi.fhir.cr.caregaps.section_author': 'Author identifier for care gap sections. Default: "default".',
|
||||
'hapi.fhir.cr.cql.use_embedded_libraries': 'Uses embedded CQL libraries for Clinical Quality Language processing. Default: true.',
|
||||
'hapi.fhir.inline_resource_storage_below_size': 'Maximum size (in bytes) for storing FHIR resources inline in the database. Default: 4000.',
|
||||
'hapi.fhir.advanced_lucene_indexing': 'Enables experimental advanced Lucene/Elasticsearch indexing for enhanced search capabilities. Default: false. Note: May not support all FHIR features like _total=accurate.',
|
||||
'hapi.fhir.enable_index_of_type': 'Enables indexing of resource types for faster searches. Default: true.',
|
||||
// Add more descriptions as needed based on your application.yaml
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const configForm = document.getElementById('config-form');
|
||||
const loading = document.getElementById('loading');
|
||||
const errorDiv = document.getElementById('error');
|
||||
const configSections = document.getElementById('config-sections');
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const restartStatus = document.getElementById('restart-status');
|
||||
let configData = {};
|
||||
|
||||
// Fetch initial configuration
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
configData = data;
|
||||
renderConfigSections();
|
||||
loading.classList.add('d-none');
|
||||
configForm.classList.remove('d-none');
|
||||
// Initialize Bootstrap tooltips
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
new bootstrap.Tooltip(el);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to load configuration');
|
||||
loading.classList.add('d-none');
|
||||
});
|
||||
|
||||
// Render configuration sections
|
||||
function renderConfigSections() {
|
||||
configSections.innerHTML = '';
|
||||
Object.entries(configData).forEach(([section, values]) => {
|
||||
const sectionDiv = document.createElement('div');
|
||||
sectionDiv.className = 'card mb-3';
|
||||
sectionDiv.innerHTML = `
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">${section.charAt(0).toUpperCase() + section.slice(1)}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${renderInputs(values, [section])}
|
||||
</div>
|
||||
`;
|
||||
configSections.appendChild(sectionDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// Render inputs recursively with descriptions
|
||||
function renderInputs(obj, path) {
|
||||
let html = '';
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
const inputId = path.concat(key).join('-');
|
||||
const configPath = path.concat(key).join('.');
|
||||
const description = CONFIG_DESCRIPTIONS[configPath] || 'No description available.';
|
||||
if (typeof value === 'boolean') {
|
||||
html += `
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="${inputId}" ${value ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="${inputId}">
|
||||
${key}
|
||||
<i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip" data-bs-placement="top" title="${description}"></i>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
html += `
|
||||
<div class="mb-2">
|
||||
<label for="${inputId}" class="form-label">
|
||||
${key}
|
||||
<i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip" data-bs-placement="top" title="${description}"></i>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="${inputId}" value="${value}">
|
||||
</div>
|
||||
`;
|
||||
} else if (Array.isArray(value)) {
|
||||
html += `
|
||||
<div class="mb-2">
|
||||
<label for="${inputId}" class="form-label">
|
||||
${key} (comma-separated)
|
||||
<i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip" data-bs-placement="top" title="${description}"></i>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="${inputId}" value="${value.join(',')}">
|
||||
</div>
|
||||
`;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
html += `
|
||||
<div class="border-start ps-3 ms-3">
|
||||
<h4>${key}</h4>
|
||||
${renderInputs(value, path.concat(key))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
// Update config data on input change
|
||||
configSections.addEventListener('change', (e) => {
|
||||
const path = e.target.id.split('-');
|
||||
let value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
||||
if (e.target.type === 'text' && e.target.value.includes(',')) {
|
||||
value = e.target.value.split(',').map(v => v.trim());
|
||||
}
|
||||
updateConfig(path, value);
|
||||
});
|
||||
|
||||
// Update config object
|
||||
function updateConfig(path, value) {
|
||||
let current = configData;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
current = current[path[i]];
|
||||
}
|
||||
current[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
saveBtn.addEventListener('click', () => {
|
||||
fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(configData)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showError(data.error);
|
||||
} else {
|
||||
showSuccess('Configuration saved successfully');
|
||||
}
|
||||
})
|
||||
.catch(() => showError('Failed to save configuration'));
|
||||
});
|
||||
|
||||
// Restart HAPI server
|
||||
restartBtn.addEventListener('click', () => {
|
||||
restartStatus.textContent = 'Restarting...';
|
||||
restartStatus.classList.remove('d-none');
|
||||
fetch('/api/restart-tomcat', { method: 'POST' })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showError(data.error);
|
||||
restartStatus.classList.add('d-none');
|
||||
} else {
|
||||
restartStatus.textContent = 'HAPI server restarted successfully';
|
||||
setTimeout(() => {
|
||||
restartStatus.classList.add('d-none');
|
||||
restartStatus.textContent = '';
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showError('Failed to restart HAPI server');
|
||||
restartStatus.classList.add('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.classList.remove('d-none');
|
||||
setTimeout(() => errorDiv.classList.add('d-none'), 5000);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
function showSuccess(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
`;
|
||||
document.querySelector('.container').insertBefore(alert, configForm);
|
||||
setTimeout(() => alert.remove(), 5000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,168 +0,0 @@
|
||||
{% 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">Manage & Process FHIR Packages</h1>
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
This is the starting Point for your Journey through the IG's
|
||||
</p>
|
||||
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
|
||||
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||
<a href="{{ url_for('search_and_import') }}" class="btn btn-primary btn-lg px-4 gap-3">Import IGs</a>
|
||||
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4 disabled">Manage FHIR Packages</a>
|
||||
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
|
||||
</div>
|
||||
------------------------------------------------------------------------------------------------------------------------------------------------->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<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; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-journal-arrow-down me-2"></i>Manage FHIR Packages</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('search_and_import') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a>
|
||||
</div>
|
||||
</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>
|
||||
<div class="card-body">
|
||||
{% if packages %}
|
||||
<div class="table-responsive">
|
||||
<p class="mb-2"><small><span class="badge bg-danger text-light border me-1">Risk:</span> = Duplicate Dependencies</small></p>
|
||||
<table class="table table-sm table-hover table-custom-bg">
|
||||
<thead>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pkg in packages %}
|
||||
{% set is_processed = (pkg.name, pkg.version) in processed_ids %}
|
||||
{% 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 %}
|
||||
<span class="badge bg-danger text-light ms-1">Duplicate</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ pkg.version }}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if is_processed %}
|
||||
<span class="btn btn-success disabled"><i class="bi bi-check-lg"></i> Processed</span>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('process_ig') }}" style="display:inline;">
|
||||
{{ form.csrf_token }}
|
||||
<input type="hidden" name="filename" value="{{ pkg.filename }}">
|
||||
<button type="submit" class="btn btn-outline-primary"><i class="bi bi-gear"></i> Process</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('delete_ig') }}" style="display:inline;">
|
||||
{{ form.csrf_token }}
|
||||
<input type="hidden" name="filename" value="{{ pkg.filename }}">
|
||||
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Are you sure you want to delete {{ pkg.name }}#{{ pkg.version }}?');"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if duplicate_groups %}
|
||||
<p class="mt-2 small text-muted">Duplicate dependencies detected:
|
||||
{% for name, versions in duplicate_groups.items() %}
|
||||
{% set group_color = group_colors[name] if name in group_colors else 'bg-warning' %}
|
||||
<span class="badge {{ group_color }} text-dark">{{ name }} ({{ versions|join(', ') }})</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="mt-2 small text-muted">No duplicates detected.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted">No downloaded FHIR packages found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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<br>
|
||||
<span class="badge bg-info text-dark border me-1">Optional MS Ext</span> = Optional Extension with Must Support Sub-Elements
|
||||
</small></p>
|
||||
<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>
|
||||
<tr><th>Package Name</th><th>Version</th><th>Resource Types</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for processed_ig in processed_list %}
|
||||
<tr>
|
||||
<td><code>{{ processed_ig.package_name }}</code></td>
|
||||
<td>{{ processed_ig.version }}</td>
|
||||
<td>
|
||||
{% set types_info = processed_ig.resource_types_info %}
|
||||
{% if types_info %}
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for type_info in types_info %}
|
||||
<span class="badge {% if type_info.must_support %}{% if type_info.optional_usage %}bg-info{% else %}bg-warning{% endif %} text-dark{% else %}bg-light text-dark{% endif %} border"
|
||||
title="{% if type_info.must_support %}{% if type_info.optional_usage %}Optional Extension with Must Support{% else %}Has Must Support{% endif %}{% 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">
|
||||
<a href="{{ url_for('view_ig', processed_ig_id=processed_ig.id) }}" class="btn btn-outline-info w-100" title="View Details"><i class="bi bi-search"></i> View</a>
|
||||
<form method="POST" action="{{ url_for('unload_ig') }}" style="display:inline;">
|
||||
{{ form.csrf_token }}
|
||||
<input type="hidden" name="ig_id" value="{{ processed_ig.id }}">
|
||||
<button type="submit" class="btn btn-outline-warning w-100 mt-1" onclick="return confirm('Are you sure you want to unload {{ processed_ig.package_name }}#{{ processed_ig.version }}?');"><i class="bi bi-x-lg"></i> Unload</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No packages recorded as processed yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
@ -1,501 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{# Import form helpers for CSRF token and field rendering #}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row">
|
||||
{# Left Column: Downloaded IGs List & Report Area #}
|
||||
<div class="col-md-6">
|
||||
<h2><i class="bi bi-box-seam me-2"></i>Downloaded IGs</h2>
|
||||
{% if packages %}
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package Name</th>
|
||||
<th>Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pkg in packages %}
|
||||
{% set name = pkg.name %}
|
||||
{% set version = pkg.version %}
|
||||
{% set duplicate_group = (duplicate_groups or {}).get(name) %}
|
||||
{% set color_class = group_colors[name] if (duplicate_group and group_colors and name in group_colors) else '' %}
|
||||
<tr {% if color_class %}class="{{ color_class }}"{% endif %}>
|
||||
<td><code>{{ name }}</code></td>
|
||||
<td>{{ version }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if duplicate_groups %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<strong><i class="bi bi-exclamation-triangle-fill me-2"></i>Duplicate Packages Detected:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
{% for name, versions in duplicate_groups.items() %}
|
||||
<li>{{ name }} (Versions: {{ versions | join(', ') }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="mt-2 mb-0"><small>Consider resolving duplicates by deleting unused versions.</small></p>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted">No packages downloaded yet. Use the "Import IG" tab.</p>
|
||||
{% endif %}
|
||||
|
||||
{# Push Response Area #}
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4><i class="bi bi-file-earmark-text me-2"></i>Push Report</h4>
|
||||
<div id="reportActions" style="display: none;">
|
||||
<button id="copyReportBtn" class="btn btn-sm btn-outline-secondary me-2" title="Copy Report Text">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
<button id="downloadReportBtn" class="btn btn-sm btn-outline-secondary" title="Download Report as Text File">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pushResponse" class="border p-3 rounded bg-light" style="min-height: 100px;">
|
||||
<span class="text-muted">Report summary will appear here after pushing...</span>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show mt-2" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>{# End Left Column #}
|
||||
|
||||
{# Right Column: Push IGs Form and Console #}
|
||||
<div class="col-md-6">
|
||||
<h2><i class="bi bi-upload me-2"></i>Push IGs to FHIR Server</h2>
|
||||
<form id="pushIgForm">
|
||||
{{ form.csrf_token if form else '' }}
|
||||
|
||||
{# Package Selection #}
|
||||
<div class="mb-3">
|
||||
<label for="packageSelect" class="form-label">Select Package to Push</label>
|
||||
<select class="form-select" id="packageSelect" name="package_id" required>
|
||||
<option value="" disabled selected>Select a package...</option>
|
||||
{% for pkg in packages %}
|
||||
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Dependency Mode Display #}
|
||||
<div class="mb-3">
|
||||
<label for="dependencyMode" class="form-label">Dependency Mode Used During Import</label>
|
||||
<input type="text" class="form-control" id="dependencyMode" readonly placeholder="Select package to view mode...">
|
||||
</div>
|
||||
|
||||
{# FHIR Server URL #}
|
||||
<div class="mb-3">
|
||||
<label for="fhirServerUrl" class="form-label">Target FHIR Server URL</label>
|
||||
<input type="url" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="e.g., http://localhost:8080/fhir" required>
|
||||
</div>
|
||||
|
||||
{# Authentication Section #}
|
||||
<div class="row g-3 mb-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label for="authType" class="form-label">Authentication</label>
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="apiKey">Toolkit API Key (Internal)</option>
|
||||
<option value="bearerToken">Bearer Token</option>
|
||||
<option value="basic">Basic Authentication</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
{# Bearer Token Input #}
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
<label for="authToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="authToken" name="auth_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
{# Basic Auth Inputs #}
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Basic Auth Username">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Basic Auth Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Checkboxes Row #}
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="includeDependencies" name="include_dependencies" checked>
|
||||
<label class="form-check-label" for="includeDependencies">Include Dependencies</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="forceUpload" name="force_upload">
|
||||
<label class="form-check-label" for="forceUpload">Force Upload</label>
|
||||
<small class="form-text text-muted d-block">Force upload all resources.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="dryRun" name="dry_run">
|
||||
<label class="form-check-label" for="dryRun">Dry Run</label>
|
||||
<small class="form-text text-muted d-block">Simulate only.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="verbose" name="verbose">
|
||||
<label class="form-check-label" for="verbose">Verbose Log</label>
|
||||
<small class="form-text text-muted d-block">Show detailed log.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Resource Type Filter #}
|
||||
<div class="mb-3">
|
||||
<label for="resourceTypesFilter" class="form-label">Filter Resource Types <small class="text-muted">(Optional)</small></label>
|
||||
<textarea class="form-control" id="resourceTypesFilter" name="resource_types_filter" rows="1" placeholder="Comma-separated, e.g., StructureDefinition, ValueSet"></textarea>
|
||||
</div>
|
||||
|
||||
{# Skip Files Filter #}
|
||||
<div class="mb-3">
|
||||
<label for="skipFilesFilter" class="form-label">Skip Specific Files <small class="text-muted">(Optional)</small></label>
|
||||
<textarea class="form-control" id="skipFilesFilter" name="skip_files" rows="2" placeholder="Enter full file paths within package (e.g., package/examples/bad.json), one per line or comma-separated."></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100" id="pushButton">
|
||||
<i class="bi bi-cloud-upload me-2"></i>Push to FHIR Server
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{# Live Console #}
|
||||
<div class="mt-4">
|
||||
<h4><i class="bi bi-terminal me-2"></i>Live Console</h4>
|
||||
<div id="liveConsole" class="border p-3 rounded bg-dark text-light" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
|
||||
<span class="text-muted">Console output will appear here...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div> {# End Right Column #}
|
||||
</div> {# End row #}
|
||||
</div> {# End container-fluid #}
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- DOM Element References ---
|
||||
const packageSelect = document.getElementById('packageSelect');
|
||||
const dependencyModeField = document.getElementById('dependencyMode');
|
||||
const pushIgForm = document.getElementById('pushIgForm');
|
||||
const pushButton = document.getElementById('pushButton');
|
||||
const liveConsole = document.getElementById('liveConsole');
|
||||
const responseDiv = document.getElementById('pushResponse');
|
||||
const reportActions = document.getElementById('reportActions');
|
||||
const copyReportBtn = document.getElementById('copyReportBtn');
|
||||
const downloadReportBtn = document.getElementById('downloadReportBtn');
|
||||
const authTypeSelect = document.getElementById('authType');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerTokenInput');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const authTokenInput = document.getElementById('authToken');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const resourceTypesFilterInput = document.getElementById('resourceTypesFilter');
|
||||
const skipFilesFilterInput = document.getElementById('skipFilesFilter');
|
||||
const dryRunCheckbox = document.getElementById('dryRun');
|
||||
const verboseCheckbox = document.getElementById('verbose');
|
||||
const forceUploadCheckbox = document.getElementById('forceUpload');
|
||||
const includeDependenciesCheckbox = document.getElementById('includeDependencies');
|
||||
const fhirServerUrlInput = document.getElementById('fhirServerUrl');
|
||||
|
||||
// --- CSRF Token ---
|
||||
const csrfTokenInput = pushIgForm ? pushIgForm.querySelector('input[name="csrf_token"]') : null;
|
||||
const csrfToken = csrfTokenInput ? csrfTokenInput.value : "";
|
||||
if (!csrfToken) { console.warn("CSRF token not found in form."); }
|
||||
|
||||
// --- Helper: Sanitize text for display ---
|
||||
const sanitizeText = (str) => str ? String(str).replace(/</g, "<").replace(/>/g, ">") : "";
|
||||
|
||||
// --- Event Listeners ---
|
||||
// Update dependency mode display
|
||||
if (packageSelect && dependencyModeField) {
|
||||
packageSelect.addEventListener('change', function() {
|
||||
const packageId = this.value;
|
||||
dependencyModeField.value = '';
|
||||
if (packageId) {
|
||||
const [packageName, version] = packageId.split('#');
|
||||
fetch(`/get-package-metadata?package_name=${packageName}&version=${version}`)
|
||||
.then(response => response.ok ? response.json() : Promise.reject(`Metadata fetch failed: ${response.status}`))
|
||||
.then(data => {
|
||||
dependencyModeField.value = (data && data.dependency_mode) ? data.dependency_mode : 'Unknown';
|
||||
})
|
||||
.catch(error => { console.error('Error fetching metadata:', error); dependencyModeField.value = 'Error'; });
|
||||
}
|
||||
});
|
||||
if (packageSelect.value) { packageSelect.dispatchEvent(new Event('change')); }
|
||||
}
|
||||
|
||||
// Show/Hide Auth Inputs
|
||||
if (authTypeSelect && authInputsGroup && bearerTokenInput && basicAuthInputs) {
|
||||
authTypeSelect.addEventListener('change', function() {
|
||||
authInputsGroup.style.display = (this.value === 'bearerToken' || this.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = this.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = this.value === 'basic' ? 'block' : 'none';
|
||||
// Clear inputs when switching
|
||||
if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = '';
|
||||
if (this.value !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (this.value !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
});
|
||||
authInputsGroup.style.display = (authTypeSelect.value === 'bearerToken' || authTypeSelect.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authTypeSelect.value === 'basic' ? 'block' : 'none';
|
||||
} else {
|
||||
console.error("Auth elements not found.");
|
||||
}
|
||||
|
||||
// Report Action Button Listeners
|
||||
if (copyReportBtn && responseDiv) {
|
||||
copyReportBtn.addEventListener('click', () => {
|
||||
const reportAlert = responseDiv.querySelector('.alert');
|
||||
const reportText = reportAlert ? reportAlert.innerText || reportAlert.textContent : '';
|
||||
if (reportText && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(reportText)
|
||||
.then(() => {
|
||||
const originalIcon = copyReportBtn.innerHTML;
|
||||
copyReportBtn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
|
||||
setTimeout(() => { copyReportBtn.innerHTML = originalIcon; }, 2000);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy report text: ', err);
|
||||
alert('Failed to copy report text.');
|
||||
});
|
||||
} else if (!navigator.clipboard) {
|
||||
alert('Clipboard API not available in this browser.');
|
||||
} else {
|
||||
alert('No report content found to copy.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadReportBtn && responseDiv) {
|
||||
downloadReportBtn.addEventListener('click', () => {
|
||||
const reportAlert = responseDiv.querySelector('.alert');
|
||||
const reportText = reportAlert ? reportAlert.innerText || reportAlert.textContent : '';
|
||||
const packageId = packageSelect ? packageSelect.value.replace('#','-') : 'fhir-ig';
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `push-report-${packageId}-${timestamp}.txt`;
|
||||
|
||||
if (reportText) {
|
||||
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
alert('No report content found to download.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form Submission
|
||||
if (pushIgForm) {
|
||||
pushIgForm.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Get form values
|
||||
const packageId = packageSelect ? packageSelect.value : null;
|
||||
const fhirServerUrl = fhirServerUrlInput ? fhirServerUrlInput.value.trim() : null;
|
||||
if (!packageId || !fhirServerUrl) { alert('Please select package and enter FHIR Server URL.'); return; }
|
||||
const [packageName, version] = packageId.split('#');
|
||||
const auth_type = authTypeSelect ? authTypeSelect.value : 'none';
|
||||
const auth_token = (auth_type === 'bearerToken' && authTokenInput) ? authTokenInput.value : null;
|
||||
const username = (auth_type === 'basic' && usernameInput) ? usernameInput.value.trim() : null;
|
||||
const password = (auth_type === 'basic' && passwordInput) ? passwordInput.value : null;
|
||||
const resource_types_filter_raw = resourceTypesFilterInput ? resourceTypesFilterInput.value.trim() : '';
|
||||
const resource_types_filter = resource_types_filter_raw ? resource_types_filter_raw.split(',').map(s => s.trim()).filter(s => s) : null;
|
||||
const skip_files_raw = skipFilesFilterInput ? skipFilesFilterInput.value.trim() : '';
|
||||
const skip_files = skip_files_raw ? skip_files_raw.split(/[\s,\n]+/).map(s => s.trim()).filter(s => s) : null;
|
||||
const dry_run = dryRunCheckbox ? dryRunCheckbox.checked : false;
|
||||
const isVerboseChecked = verboseCheckbox ? verboseCheckbox.checked : false;
|
||||
const include_dependencies = includeDependenciesCheckbox ? includeDependenciesCheckbox.checked : true;
|
||||
const force_upload = forceUploadCheckbox ? forceUploadCheckbox.checked : false;
|
||||
|
||||
// Validate Basic Auth inputs
|
||||
if (auth_type === 'basic') {
|
||||
if (!username) { alert('Please enter a username for Basic Authentication.'); return; }
|
||||
if (!password) { alert('Please enter a password for Basic Authentication.'); return; }
|
||||
}
|
||||
|
||||
// UI Updates
|
||||
if (pushButton) {
|
||||
pushButton.disabled = true;
|
||||
pushButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Processing...';
|
||||
}
|
||||
if (liveConsole) {
|
||||
liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting ${dry_run ? 'DRY RUN ' : ''}${force_upload ? 'FORCE ' : ''}push for ${packageName}#${version}...</div>`;
|
||||
}
|
||||
if (responseDiv) { responseDiv.innerHTML = '<span class="text-muted">Processing...</span>'; }
|
||||
if (reportActions) { reportActions.style.display = 'none'; }
|
||||
const internalApiKey = {{ api_key | default("") | tojson }};
|
||||
|
||||
try {
|
||||
// API Fetch
|
||||
const response = await fetch('/api/push-ig', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey },
|
||||
body: JSON.stringify({
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
fhir_server_url: fhirServerUrl,
|
||||
include_dependencies: include_dependencies,
|
||||
auth_type: auth_type,
|
||||
auth_token: auth_token,
|
||||
username: username,
|
||||
password: password,
|
||||
resource_types_filter: resource_types_filter,
|
||||
skip_files: skip_files,
|
||||
dry_run: dry_run,
|
||||
verbose: isVerboseChecked,
|
||||
force_upload: force_upload
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}`); }
|
||||
if (!response.body) { throw new Error("Response body is missing."); }
|
||||
|
||||
// Stream Processing
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n'); buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-light'; let prefix = '[INFO]'; let shouldDisplay = false;
|
||||
|
||||
switch (data.type) {
|
||||
case 'start': case 'error': case 'complete': shouldDisplay = true; break;
|
||||
case 'success': case 'warning': case 'info': case 'progress': if (isVerboseChecked) { shouldDisplay = true; } break;
|
||||
default: if (isVerboseChecked) { shouldDisplay = true; console.warn("Unknown type:", data.type); prefix = '[UNKNOWN]'; } break;
|
||||
}
|
||||
|
||||
if (shouldDisplay && liveConsole) {
|
||||
if(data.type==='error'){prefix='[ERROR]';messageClass='text-danger';}
|
||||
else if(data.type==='complete'){const s=data.data?.status||'info';if(s==='success'){prefix='[SUCCESS]';messageClass='text-success';}else if(s==='partial'){prefix='[PARTIAL]';messageClass='text-warning';}else{prefix='[ERROR]';messageClass='text-danger';}}
|
||||
else if(data.type==='start'){prefix='[START]';messageClass='text-info';}
|
||||
else if(data.type==='success'){prefix='[SUCCESS]';messageClass='text-success';}
|
||||
else if(data.type==='warning'){prefix='[WARNING]';messageClass='text-warning';}
|
||||
else if(data.type==='info'){prefix='[INFO]';messageClass='text-info';}
|
||||
else{prefix='[PROGRESS]';messageClass='text-light';}
|
||||
|
||||
const messageDiv = document.createElement('div'); messageDiv.className = messageClass;
|
||||
const messageText = (data.type === 'complete' && data.data) ? data.data.message : data.message;
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(messageText) || 'Empty message.'}`;
|
||||
liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
|
||||
if (data.type === 'complete' && responseDiv) {
|
||||
const summaryData = data.data || {}; let alertClass = 'alert-info'; let statusText = 'Info'; let pushedPkgs = 'None'; let failHtml = ''; let skipHtml = '';
|
||||
const isDryRun = summaryData.dry_run || false; const isForceUpload = summaryData.force_upload || false;
|
||||
const typeFilterUsed = summaryData.resource_types_filter ? summaryData.resource_types_filter.join(', ') : 'All';
|
||||
const fileFilterUsed = summaryData.skip_files_filter ? summaryData.skip_files_filter.join(', ') : 'None';
|
||||
|
||||
if (summaryData.pushed_packages_summary?.length > 0) { pushedPkgs = summaryData.pushed_packages_summary.map(p => `${sanitizeText(p.id)} (${sanitizeText(p.resource_count)} resources)`).join('<br>'); }
|
||||
if (summaryData.status === 'success') { alertClass = 'alert-success'; statusText = 'Success';} else if (summaryData.status === 'partial') { alertClass = 'alert-warning'; statusText = 'Partial Success'; } else { alertClass = 'alert-danger'; statusText = 'Error'; }
|
||||
if (summaryData.failed_details?.length > 0) { failHtml = '<hr><strong>Failures:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.failed_details.forEach(f => {failHtml += `<li><strong>${sanitizeText(f.resource)}:</strong> ${sanitizeText(f.error)}</li>`;}); failHtml += '</ul>';}
|
||||
if (summaryData.skipped_details?.length > 0) { skipHtml = '<hr><strong>Skipped:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.skipped_details.forEach(s => {skipHtml += `<li><strong>${sanitizeText(s.resource)}:</strong> ${sanitizeText(s.reason)}</li>`;}); skipHtml += '</ul>';}
|
||||
|
||||
responseDiv.innerHTML = `<div class="alert ${alertClass} mt-0"><strong>${isDryRun?'[DRY RUN] ':''}${isForceUpload?'[FORCE] ':''}${statusText}:</strong> ${sanitizeText(summaryData.message)||'Complete.'}<hr><strong>Target:</strong> ${sanitizeText(summaryData.target_server)}<br><strong>Package:</strong> ${sanitizeText(summaryData.package_name)}#${sanitizeText(summaryData.version)}<br><strong>Config:</strong> Deps=${summaryData.included_dependencies?'Yes':'No'}, Types=${sanitizeText(typeFilterUsed)}, SkipFiles=${sanitizeText(fileFilterUsed)}, DryRun=${isDryRun?'Yes':'No'}, Force=${isForceUpload?'Yes':'No'}, Verbose=${isVerboseChecked?'Yes':'No'}<br><strong>Stats:</strong> Attempt=${sanitizeText(summaryData.resources_attempted)}, Success=${sanitizeText(summaryData.success_count)}, Fail=${sanitizeText(summaryData.failure_count)}, Skip=${sanitizeText(summaryData.skipped_count)}<br><strong>Pushed Pkgs:</strong><br><div style="padding-left:15px;">${pushedPkgs}</div>${failHtml}${skipHtml}</div>`;
|
||||
if (reportActions) { reportActions.style.display = 'block'; }
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Stream parse error:', parseError);
|
||||
if (liveConsole) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-danger';
|
||||
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] Stream parse error: ${sanitizeText(parseError.message)}`;
|
||||
liveConsole.appendChild(errDiv);
|
||||
liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process Final Buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim());
|
||||
if (data.type === 'complete' && responseDiv) {
|
||||
// Same summary rendering as above
|
||||
const summaryData = data.data || {};
|
||||
let alertClass = 'alert-info'; let statusText = 'Info'; let pushedPkgs = 'None'; let failHtml = ''; let skipHtml = '';
|
||||
const isDryRun = summaryData.dry_run || false; const isForceUpload = summaryData.force_upload || false;
|
||||
const typeFilterUsed = summaryData.resource_types_filter ? summaryData.resource_types_filter.join(', ') : 'All';
|
||||
const fileFilterUsed = summaryData.skip_files_filter ? summaryData.skip_files_filter.join(', ') : 'None';
|
||||
|
||||
if (summaryData.pushed_packages_summary?.length > 0) { pushedPkgs = summaryData.pushed_packages_summary.map(p => `${sanitizeText(p.id)} (${sanitizeText(p.resource_count)} resources)`).join('<br>'); }
|
||||
if (summaryData.status === 'success') { alertClass = 'alert-success'; statusText = 'Success';} else if (summaryData.status === 'partial') { alertClass = 'alert-warning'; statusText = 'Partial Success'; } else { alertClass = 'alert-danger'; statusText = 'Error'; }
|
||||
if (summaryData.failed_details?.length > 0) { failHtml = '<hr><strong>Failures:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.failed_details.forEach(f => {failHtml += `<li><strong>${sanitizeText(f.resource)}:</strong> ${sanitizeText(f.error)}</li>`;}); failHtml += '</ul>';}
|
||||
if (summaryData.skipped_details?.length > 0) { skipHtml = '<hr><strong>Skipped:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.skipped_details.forEach(s => {skipHtml += `<li><strong>${sanitizeText(s.resource)}:</strong> ${sanitizeText(s.reason)}</li>`;}); skipHtml += '</ul>';}
|
||||
|
||||
responseDiv.innerHTML = `<div class="alert ${alertClass} mt-0"><strong>${isDryRun?'[DRY RUN] ':''}${isForceUpload?'[FORCE] ':''}${statusText}:</strong> ${sanitizeText(summaryData.message)||'Complete.'}<hr><strong>Target:</strong> ${sanitizeText(summaryData.target_server)}<br><strong>Package:</strong> ${sanitizeText(summaryData.package_name)}#${sanitizeText(summaryData.version)}<br><strong>Config:</strong> Deps=${summaryData.included_dependencies?'Yes':'No'}, Types=${sanitizeText(typeFilterUsed)}, SkipFiles=${sanitizeText(fileFilterUsed)}, DryRun=${isDryRun?'Yes':'No'}, Force=${isForceUpload?'Yes':'No'}, Verbose=${isVerboseChecked?'Yes':'No'}<br><strong>Stats:</strong> Attempt=${sanitizeText(summaryData.resources_attempted)}, Success=${sanitizeText(summaryData.success_count)}, Fail=${sanitizeText(summaryData.failure_count)}, Skip=${sanitizeText(summaryData.skipped_count)}<br><strong>Pushed Pkgs:</strong><br><div style="padding-left:15px;">${pushedPkgs}</div>${failHtml}${skipHtml}</div>`;
|
||||
if (reportActions) { reportActions.style.display = 'block'; }
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Final buffer parse error:', parseError);
|
||||
if (liveConsole) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-danger';
|
||||
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] Final buffer parse error: ${sanitizeText(parseError.message)}`;
|
||||
liveConsole.appendChild(errDiv);
|
||||
liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Push operation failed:", error);
|
||||
if (liveConsole) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-danger';
|
||||
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] ${sanitizeText(error.message || error)}`;
|
||||
liveConsole.appendChild(errDiv);
|
||||
liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
if (responseDiv) {
|
||||
responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message || error)}</div>`;
|
||||
}
|
||||
if (reportActions) { reportActions.style.display = 'none'; }
|
||||
} finally {
|
||||
if (pushButton) {
|
||||
pushButton.disabled = false;
|
||||
pushButton.innerHTML = '<i class="bi bi-cloud-upload me-2"></i>Push to FHIR Server';
|
||||
}
|
||||
}
|
||||
});
|
||||
} else { console.error("Push IG Form element not found."); }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
File diff suppressed because it is too large
Load Diff
@ -1,473 +0,0 @@
|
||||
{% 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">FHIR API Explorer</h1>
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
Interact with FHIR servers using GET, POST, PUT, or DELETE requests. Toggle between local HAPI or a custom server to explore resources or perform searches.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-cloud-arrow-down me-2"></i>Send FHIR Request</div>
|
||||
<div class="card-body">
|
||||
<form id="fhirRequestForm" onsubmit="return false;">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">FHIR Server</label>
|
||||
<div class="input-group">
|
||||
<button type="button" class="btn btn-outline-primary" id="toggleServer">
|
||||
<span id="toggleLabel">Use Local HAPI</span>
|
||||
</button>
|
||||
<input type="text" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="e.g., https://hapi.fhir.org/baseR4" style="display: none;" aria-describedby="fhirServerHelp">
|
||||
</div>
|
||||
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (http://localhost:8080/fhir) or enter a custom FHIR server URL.</small>
|
||||
</div>
|
||||
<div class="mb-3" id="authSection" style="display: none;">
|
||||
<label class="form-label">Authentication</label>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="basic">Basic Authentication</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
<label for="bearerToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="fhirPath" class="form-label">FHIR Path</label>
|
||||
<input type="text" class="form-control" id="fhirPath" name="fhir_path" placeholder="e.g., Patient/wang-li" required aria-describedby="fhirPathHelp">
|
||||
<small id="fhirPathHelp" class="form-text text-muted">Enter a resource path (e.g., Patient, Observation/example) or '_search' for search queries.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Request Type</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<input type="radio" class="btn-check" name="method" id="get" value="GET" checked>
|
||||
<label class="btn btn-outline-success" for="get"><span class="badge bg-success">GET</span></label>
|
||||
<input type="radio" class="btn-check" name="method" id="post" value="POST">
|
||||
<label class="btn btn-outline-primary" for="post"><span class="badge bg-primary">POST</span></label>
|
||||
<input type="radio" class="btn-check" name="method" id="put" value="PUT">
|
||||
<label class="btn btn-outline-warning" for="put"><span class="badge bg-warning text-dark">PUT</span></label>
|
||||
<input type="radio" class="btn-check" name="method" id="delete" value="DELETE">
|
||||
<label class="btn btn-outline-danger" for="delete"><span class="badge bg-danger">DELETE</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" id="requestBodyGroup" style="display: none;">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<label for="requestBody" class="form-label me-2 mb-0">Request Body</label>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-copy" id="copyRequestBody" title="Copy to Clipboard"><i class="bi bi-clipboard"></i></button>
|
||||
</div>
|
||||
<textarea class="form-control" id="requestBody" name="request_body" rows="6" placeholder="For POST: JSON resource (e.g., {'resourceType': 'Patient', ...}) or search params (e.g., name=John&birthdate=gt2000)"></textarea>
|
||||
<div id="jsonError" class="text-danger mt-1" style="display: none;"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="sendRequest">Send Request</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" id="responseCard" style="display: none;">
|
||||
<div class="card-header">Response</div>
|
||||
<div class="card-body">
|
||||
<h5>Status: <span id="responseStatus" class="badge"></span></h5>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h6 class="me-2 mb-0">Headers</h6>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-copy" id="copyResponseHeaders" title="Copy to Clipboard"><i class="bi bi-clipboard"></i></button>
|
||||
</div>
|
||||
<pre id="responseHeaders" class="border p-2 bg-light mb-3" style="max-height: 200px; overflow-y: auto;"></pre>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h6 class="me-2 mb-0">Body</h6>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-copy" id="copyResponseBody" title="Copy to Clipboard"><i class="bi bi-clipboard"></i></button>
|
||||
</div>
|
||||
<pre id="responseBody" class="border p-2 bg-light" style="max-height: 400px; overflow-y: auto;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log("DOMContentLoaded event fired.");
|
||||
|
||||
// --- Get DOM Elements & Check Existence ---
|
||||
const form = document.getElementById('fhirRequestForm');
|
||||
const sendButton = document.getElementById('sendRequest');
|
||||
const fhirPathInput = document.getElementById('fhirPath');
|
||||
const requestBodyInput = document.getElementById('requestBody');
|
||||
const requestBodyGroup = document.getElementById('requestBodyGroup');
|
||||
const jsonError = document.getElementById('jsonError');
|
||||
const responseCard = document.getElementById('responseCard');
|
||||
const responseStatus = document.getElementById('responseStatus');
|
||||
const responseHeaders = document.getElementById('responseHeaders');
|
||||
const responseBody = document.getElementById('responseBody');
|
||||
const methodRadios = document.getElementsByName('method');
|
||||
const toggleServerButton = document.getElementById('toggleServer');
|
||||
const toggleLabel = document.getElementById('toggleLabel');
|
||||
const fhirServerUrlInput = document.getElementById('fhirServerUrl');
|
||||
const copyRequestBodyButton = document.getElementById('copyRequestBody');
|
||||
const copyResponseHeadersButton = document.getElementById('copyResponseHeaders');
|
||||
const copyResponseBodyButton = document.getElementById('copyResponseBody');
|
||||
const authSection = document.getElementById('authSection');
|
||||
const authTypeSelect = document.getElementById('authType');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerToken');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
// Basic check for critical elements
|
||||
if (!form || !sendButton || !fhirPathInput || !responseCard || !toggleServerButton || !fhirServerUrlInput || !responseStatus || !responseHeaders || !responseBody || !toggleLabel) {
|
||||
console.error("One or more critical UI elements could not be found. Script execution halted.");
|
||||
alert("Error initializing UI components. Please check the console.");
|
||||
return;
|
||||
}
|
||||
console.log("All critical elements checked/found.");
|
||||
|
||||
// --- State Variable ---
|
||||
let useLocalHapi = true;
|
||||
|
||||
// --- Get App Mode from Flask Context ---
|
||||
const appMode = '{{ app_mode | default("standalone") | lower }}';
|
||||
console.log('App Mode Detected:', appMode);
|
||||
|
||||
// --- Helper Functions ---
|
||||
function validateRequestBody(method, path) {
|
||||
if (!requestBodyInput || !jsonError) return (method === 'POST' || method === 'PUT') ? '' : undefined;
|
||||
const bodyValue = requestBodyInput.value.trim();
|
||||
requestBodyInput.classList.remove('is-invalid');
|
||||
jsonError.style.display = 'none';
|
||||
|
||||
if (!bodyValue) return '';
|
||||
|
||||
const isSearch = path && path.endsWith('_search');
|
||||
const isJson = bodyValue.startsWith('{') || bodyValue.startsWith('[');
|
||||
const isXml = bodyValue.startsWith('<');
|
||||
const isForm = !isJson && !isXml;
|
||||
|
||||
if (method === 'POST' && isSearch && isForm) {
|
||||
return bodyValue;
|
||||
} else if (method === 'POST' || method === 'PUT') {
|
||||
if (isJson) {
|
||||
try { JSON.parse(bodyValue); return bodyValue; }
|
||||
catch (e) { jsonError.textContent = `Invalid JSON: ${e.message}`; }
|
||||
} else if (isXml) {
|
||||
return bodyValue;
|
||||
} else {
|
||||
jsonError.textContent = 'Request body must be valid JSON or XML for PUT/POST (unless using POST _search with form parameters).';
|
||||
}
|
||||
requestBodyInput.classList.add('is-invalid');
|
||||
jsonError.style.display = 'block';
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cleanFhirPath(path) {
|
||||
if (!path) return '';
|
||||
return path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
async function copyToClipboard(text, button) {
|
||||
if (text === null || text === undefined || !button || !navigator.clipboard) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(text));
|
||||
const originalIcon = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
|
||||
setTimeout(() => { if (button.isConnected) button.innerHTML = originalIcon; }, 1500);
|
||||
} catch (err) { console.error('Copy failed:', err); }
|
||||
}
|
||||
|
||||
function updateServerToggleUI() {
|
||||
if (appMode === 'lite') {
|
||||
useLocalHapi = false;
|
||||
toggleServerButton.disabled = true;
|
||||
toggleServerButton.classList.add('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'none';
|
||||
toggleServerButton.setAttribute('aria-disabled', 'true');
|
||||
toggleServerButton.title = "Local HAPI server is unavailable in Lite mode";
|
||||
toggleLabel.textContent = 'Use Custom URL';
|
||||
fhirServerUrlInput.style.display = 'block';
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
||||
fhirServerUrlInput.required = true;
|
||||
authSection.style.display = 'block';
|
||||
} else {
|
||||
toggleServerButton.disabled = false;
|
||||
toggleServerButton.classList.remove('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'auto';
|
||||
toggleServerButton.removeAttribute('aria-disabled');
|
||||
toggleServerButton.title = "Toggle between Local HAPI and Custom URL";
|
||||
toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL';
|
||||
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
|
||||
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
|
||||
fhirServerUrlInput.required = !useLocalHapi;
|
||||
authSection.style.display = useLocalHapi ? 'none' : 'block';
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
updateAuthInputsUI();
|
||||
console.log(`UI Updated: useLocalHapi=${useLocalHapi}, Button Disabled=${toggleServerButton.disabled}, Input Visible=${fhirServerUrlInput.style.display !== 'none'}, Input Required=${fhirServerUrlInput.required}`);
|
||||
}
|
||||
|
||||
function updateAuthInputsUI() {
|
||||
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) return;
|
||||
const authType = authTypeSelect.value;
|
||||
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
|
||||
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
|
||||
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
}
|
||||
|
||||
function toggleServer() {
|
||||
if (appMode === 'lite') {
|
||||
console.log("Toggle ignored: Lite mode active.");
|
||||
return;
|
||||
}
|
||||
useLocalHapi = !useLocalHapi;
|
||||
if (useLocalHapi && fhirServerUrlInput) {
|
||||
fhirServerUrlInput.value = '';
|
||||
}
|
||||
updateServerToggleUI();
|
||||
console.log(`Server toggled: Now using ${useLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
|
||||
}
|
||||
|
||||
function updateRequestBodyVisibility() {
|
||||
const selectedMethod = document.querySelector('input[name="method"]:checked')?.value;
|
||||
if (!requestBodyGroup || !selectedMethod) return;
|
||||
const showBody = (selectedMethod === 'POST' || selectedMethod === 'PUT');
|
||||
requestBodyGroup.style.display = showBody ? 'block' : 'none';
|
||||
if (!showBody) {
|
||||
if (requestBodyInput) requestBodyInput.value = '';
|
||||
if (jsonError) jsonError.style.display = 'none';
|
||||
if (requestBodyInput) requestBodyInput.classList.remove('is-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initial Setup ---
|
||||
updateServerToggleUI();
|
||||
updateRequestBodyVisibility();
|
||||
|
||||
// --- Event Listeners ---
|
||||
toggleServerButton.addEventListener('click', toggleServer);
|
||||
if (methodRadios) { methodRadios.forEach(radio => { radio.addEventListener('change', updateRequestBodyVisibility); }); }
|
||||
if (requestBodyInput && fhirPathInput) { requestBodyInput.addEventListener('input', () => validateRequestBody(document.querySelector('input[name="method"]:checked')?.value, fhirPathInput.value)); }
|
||||
if (copyRequestBodyButton && requestBodyInput) { copyRequestBodyButton.addEventListener('click', () => copyToClipboard(requestBodyInput.value, copyRequestBodyButton)); }
|
||||
if (copyResponseHeadersButton && responseHeaders) { copyResponseHeadersButton.addEventListener('click', () => copyToClipboard(responseHeaders.textContent, copyResponseHeadersButton)); }
|
||||
if (copyResponseBodyButton && responseBody) { copyResponseBodyButton.addEventListener('click', () => copyToClipboard(responseBody.textContent, copyResponseBodyButton)); }
|
||||
if (authTypeSelect) { authTypeSelect.addEventListener('change', updateAuthInputsUI); }
|
||||
|
||||
// --- Send Request Button Listener ---
|
||||
sendButton.addEventListener('click', async function() {
|
||||
console.log("Send Request button clicked.");
|
||||
sendButton.disabled = true;
|
||||
sendButton.textContent = 'Sending...';
|
||||
responseCard.style.display = 'none';
|
||||
responseStatus.textContent = '';
|
||||
responseHeaders.textContent = '';
|
||||
responseBody.textContent = '';
|
||||
responseStatus.className = 'badge';
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
if (requestBodyInput) requestBodyInput.classList.remove('is-invalid');
|
||||
if (jsonError) jsonError.style.display = 'none';
|
||||
if (bearerTokenInput) bearerTokenInput.classList.remove('is-invalid');
|
||||
if (usernameInput) usernameInput.classList.remove('is-invalid');
|
||||
if (passwordInput) passwordInput.classList.remove('is-invalid');
|
||||
|
||||
// --- Get Values ---
|
||||
const path = fhirPathInput.value.trim();
|
||||
const method = document.querySelector('input[name="method"]:checked')?.value;
|
||||
const customUrl = fhirServerUrlInput.value.trim();
|
||||
let body = undefined;
|
||||
const authType = authTypeSelect ? authTypeSelect.value : 'none';
|
||||
const bearerToken = bearerTokenInput ? bearerTokenInput.value.trim() : '';
|
||||
const username = usernameInput ? usernameInput.value.trim() : '';
|
||||
const password = passwordInput ? passwordInput.value : '';
|
||||
|
||||
// --- Basic Input Validation ---
|
||||
if (!path) {
|
||||
alert('Please enter a FHIR Path.');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (!method) {
|
||||
alert('Please select a Request Type.');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (!useLocalHapi && !customUrl) {
|
||||
alert('Please enter a custom FHIR Server URL.');
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (!useLocalHapi && customUrl) {
|
||||
try { new URL(customUrl); }
|
||||
catch (_) {
|
||||
alert('Invalid custom FHIR Server URL format.');
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validate Authentication ---
|
||||
if (!useLocalHapi) {
|
||||
if (authType === 'bearer' && !bearerToken) {
|
||||
alert('Please enter a Bearer Token.');
|
||||
bearerTokenInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (authType === 'basic' && (!username || !password)) {
|
||||
alert('Please enter both Username and Password for Basic Authentication.');
|
||||
if (!username) usernameInput.classList.add('is-invalid');
|
||||
if (!password) passwordInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validate & Get Body ---
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
body = validateRequestBody(method, path);
|
||||
if (body === null) {
|
||||
alert('Request body contains invalid JSON/Format.');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (body === '') body = '';
|
||||
}
|
||||
|
||||
// --- Determine Fetch URL and Headers ---
|
||||
const cleanedPath = cleanFhirPath(path);
|
||||
const finalFetchUrl = '/fhir/' + cleanedPath;
|
||||
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
|
||||
|
||||
if (body !== undefined) {
|
||||
if (body.trim().startsWith('{')) { headers['Content-Type'] = 'application/fhir+json'; }
|
||||
else if (body.trim().startsWith('<')) { headers['Content-Type'] = 'application/fhir+xml'; }
|
||||
else if (method === 'POST' && path.endsWith('_search') && body && !body.trim().startsWith('{') && !body.trim().startsWith('<')) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; }
|
||||
else if (body) { headers['Content-Type'] = 'application/fhir+json'; }
|
||||
}
|
||||
|
||||
if (!useLocalHapi && customUrl) {
|
||||
headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, '');
|
||||
console.log("Adding header X-Target-FHIR-Server:", headers['X-Target-FHIR-Server']);
|
||||
if (authType === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||
console.log("Adding header Authorization: Bearer <truncated>");
|
||||
} else if (authType === 'basic') {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
console.log("Adding header Authorization: Basic <redacted>");
|
||||
}
|
||||
}
|
||||
|
||||
const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfTokenInput ? csrfTokenInput.value : null;
|
||||
if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
console.log("CSRF Token added for local request.");
|
||||
} else if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && !csrfToken) {
|
||||
console.warn("CSRF token input not found for local modifying request.");
|
||||
}
|
||||
|
||||
console.log(`Executing Fetch: Method=${method}, URL=${finalFetchUrl}, LocalHAPI=${useLocalHapi}`);
|
||||
console.log("Request Headers:", { ...headers, Authorization: headers.Authorization ? '<redacted>' : undefined });
|
||||
if (body !== undefined) console.log("Request Body (first 300 chars):", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
|
||||
|
||||
// --- Make the Fetch Request ---
|
||||
try {
|
||||
const response = await fetch(finalFetchUrl, {
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body
|
||||
});
|
||||
|
||||
responseCard.style.display = 'block';
|
||||
responseStatus.textContent = `${response.status} ${response.statusText}`;
|
||||
responseStatus.className = `badge ${response.ok ? 'bg-success' : 'bg-danger'}`;
|
||||
|
||||
let headerText = '';
|
||||
response.headers.forEach((value, key) => { headerText += `${key}: ${value}\n`; });
|
||||
responseHeaders.textContent = headerText.trim();
|
||||
|
||||
const responseContentType = response.headers.get('content-type') || '';
|
||||
let responseBodyText = await response.text();
|
||||
let displayBody = responseBodyText;
|
||||
|
||||
if (responseContentType.includes('json') && responseBodyText.trim()) {
|
||||
try { displayBody = JSON.stringify(JSON.parse(responseBodyText), null, 2); }
|
||||
catch (e) { console.warn("Failed to pretty-print JSON response:", e); }
|
||||
} else if (responseContentType.includes('xml') && responseBodyText.trim()) {
|
||||
try {
|
||||
let formattedXml = '';
|
||||
let indent = 0;
|
||||
const xmlLines = responseBodyText.replace(/>\s*</g, '><').split(/(<[^>]+>)/);
|
||||
for (let i = 0; i < xmlLines.length; i++) {
|
||||
const node = xmlLines[i];
|
||||
if (!node || node.trim().length === 0) continue;
|
||||
if (node.match(/^<\/\w/)) indent--;
|
||||
formattedXml += ' '.repeat(Math.max(0, indent)) + node.trim() + '\n';
|
||||
if (node.match(/^<\w/) && !node.match(/\/>$/) && !node.match(/^<\?/)) indent++;
|
||||
}
|
||||
displayBody = formattedXml.trim();
|
||||
} catch (e) { console.warn("Basic XML formatting failed:", e); }
|
||||
}
|
||||
|
||||
responseBody.textContent = displayBody;
|
||||
|
||||
if (typeof Prism !== 'undefined') {
|
||||
responseBody.className = 'border p-2 bg-light';
|
||||
if (responseContentType.includes('json')) {
|
||||
responseBody.classList.add('language-json');
|
||||
} else if (responseContentType.includes('xml')) {
|
||||
responseBody.classList.add('language-xml');
|
||||
}
|
||||
Prism.highlightElement(responseBody);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
responseCard.style.display = 'block';
|
||||
responseStatus.textContent = `Network Error`;
|
||||
responseStatus.className = 'badge bg-danger';
|
||||
responseHeaders.textContent = 'N/A';
|
||||
let errorDetail = `Error: ${error.message}\n\n`;
|
||||
if (useLocalHapi) {
|
||||
errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl}. Ensure the toolkit server and the local HAPI FHIR server (at http://localhost:8080/fhir) are running.`;
|
||||
} else {
|
||||
errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl} or the proxy could not connect to the target server (${customUrl}). Ensure the toolkit server is running and the target server is accessible.`;
|
||||
}
|
||||
responseBody.textContent = errorDetail;
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
File diff suppressed because it is too large
Load Diff
@ -1,225 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% 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">FSH Converter</h1>
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
Convert FHIR JSON or XML resources to FHIR Shorthand (FSH) using GoFSH.
|
||||
</p>
|
||||
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
|
||||
<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">View Downloaded IGs</a>
|
||||
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary btn-lg px-4">Validate Sample</a>
|
||||
<a href="{{ url_for('fhir_ui_operations') }}" class="btn btn-outline-secondary btn-lg px-4">FHIR Operations</a>
|
||||
</div>
|
||||
-----------------------------------------------------------------remove the buttons----------------------------------------------------->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spinner Overlay -->
|
||||
<div id="spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50 d-flex align-items-center justify-content-center" style="z-index: 1050;">
|
||||
<div class="text-center">
|
||||
<div id="spinner-animation" style="width: 200px; height: 200px;"></div>
|
||||
<p class="text-white mt-3">Waiting, don't leave this page...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<h2><i class="bi bi-file-code me-2"></i>Convert FHIR to FSH</h2>
|
||||
<div id="fsh-output">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="fsh-converter-form" method="POST" enctype="multipart/form-data" class="form">
|
||||
{{ form.hidden_tag() }}
|
||||
{{ render_field(form.package) }}
|
||||
{{ render_field(form.input_mode) }}
|
||||
<div id="file-upload" style="display: none;">
|
||||
{{ render_field(form.fhir_file) }}
|
||||
</div>
|
||||
<div id="text-input" style="display: none;">
|
||||
{{ render_field(form.fhir_text) }}
|
||||
</div>
|
||||
{{ render_field(form.output_style) }}
|
||||
{{ render_field(form.log_level) }}
|
||||
{{ render_field(form.fhir_version) }}
|
||||
{{ render_field(form.fishing_trip) }}
|
||||
{{ render_field(form.dependencies, placeholder="One per line, e.g., hl7.fhir.us.core@6.1.0") }}
|
||||
{{ render_field(form.indent_rules) }}
|
||||
{{ render_field(form.meta_profile) }}
|
||||
{{ render_field(form.alias_file) }}
|
||||
{{ render_field(form.no_alias) }}
|
||||
<div class="d-grid gap-2 d-sm-flex">
|
||||
{{ form.submit(class="btn btn-success", id="submit-btn") }}
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/lottie.min.js') }}"></script>
|
||||
<script>
|
||||
// --- Global Variables and Helper Functions (Defined *outside* DOMContentLoaded) ---
|
||||
let currentLottieAnimation = null;
|
||||
|
||||
function loadLottieAnimation(theme) {
|
||||
// console.log(`loadLottieAnimation called with theme: ${theme}`); // Optional debug log
|
||||
const spinnerContainer = document.getElementById('spinner-animation');
|
||||
// console.log(`Found spinnerContainer: ${!!spinnerContainer}`); // Optional debug log
|
||||
if (!spinnerContainer) return;
|
||||
if (currentLottieAnimation) {
|
||||
// console.log("Destroying previous Lottie animation."); // Optional debug log
|
||||
currentLottieAnimation.destroy();
|
||||
currentLottieAnimation = null;
|
||||
}
|
||||
const animationPath = (theme === 'dark')
|
||||
? '{{ url_for('static', filename='animations/loading-dark.json') }}'
|
||||
: '{{ url_for('static', filename='animations/loading-light.json') }}';
|
||||
// console.log(`Determined animation path: ${animationPath}`); // Optional debug log
|
||||
try {
|
||||
currentLottieAnimation = lottie.loadAnimation({ container: spinnerContainer, renderer: 'svg', loop: true, autoplay: true, path: animationPath });
|
||||
// console.log("New Lottie animation loaded."); // Optional debug log
|
||||
} catch(lottieError) {
|
||||
console.error("Error loading Lottie animation:", lottieError);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Function to handle input mode changes (for SELECT element) ---
|
||||
function handleInputModeChange(selectElement) {
|
||||
if (!selectElement) { console.error("handleInputModeChange called with no selectElement"); return; }
|
||||
const selectedValue = selectElement.value; // Get value from the select element
|
||||
console.log(`handleInputModeChange called for value: ${selectedValue}`); // Keep this log for now
|
||||
|
||||
const form = selectElement.closest('form');
|
||||
if (!form) { console.error('Could not find parent form for input mode toggle.'); return; }
|
||||
|
||||
const fileUploadDiv = form.querySelector('#file-upload');
|
||||
const textInputDiv = form.querySelector('#text-input');
|
||||
// console.log(`Found fileUploadDiv: ${!!fileUploadDiv}, Found textInputDiv: ${!!textInputDiv}`); // Optional debug log
|
||||
|
||||
if (fileUploadDiv && textInputDiv) {
|
||||
// Show/hide based on the SELECTED VALUE
|
||||
if (selectedValue === 'file') {
|
||||
fileUploadDiv.style.display = 'block';
|
||||
textInputDiv.style.display = 'none';
|
||||
} else { // Assuming 'text' is the other value
|
||||
fileUploadDiv.style.display = 'none';
|
||||
textInputDiv.style.display = 'block';
|
||||
}
|
||||
// console.log(`Set display - file: ${fileUploadDiv.style.display}, text: ${textInputDiv.style.display}`); // Optional debug log
|
||||
} else {
|
||||
console.error('Could not find file-upload or text-input divs within the form.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Function to initialize UI elements within a container (for SELECT element) ---
|
||||
function initializeFshConverterUI(containerElement) {
|
||||
console.log("Initializing FSH Converter UI within:", containerElement === document ? "document" : "outputContainer"); // Keep this log for now
|
||||
// --- Input Mode Toggle Initialization ---
|
||||
// Select the dropdown using its ID
|
||||
const inputModeSelect = containerElement.querySelector('#input_mode'); // Use ID selector for the select
|
||||
|
||||
if (inputModeSelect) {
|
||||
// Attach the listener directly to the select element
|
||||
inputModeSelect.addEventListener('change', (event) => {
|
||||
// Pass the select element itself to the handler
|
||||
handleInputModeChange(event.target);
|
||||
});
|
||||
|
||||
// Set initial state based on the select element's current value
|
||||
console.log(`Initial input mode detected: ${inputModeSelect.value}`); // Keep this log
|
||||
handleInputModeChange(inputModeSelect); // Call handler directly with the select element
|
||||
|
||||
} else {
|
||||
// This error should hopefully not appear now
|
||||
console.error('Input mode select element (#input_mode) not found within container:', containerElement);
|
||||
}
|
||||
|
||||
// Only load initial Lottie when initializing the whole document
|
||||
if (containerElement === document) {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
// Make sure loadLottieAnimation function exists before calling
|
||||
if (typeof loadLottieAnimation === 'function') {
|
||||
loadLottieAnimation(currentTheme);
|
||||
} else {
|
||||
console.error("loadLottieAnimation function not defined.");
|
||||
}
|
||||
}
|
||||
} // End initializeFshConverterUI
|
||||
|
||||
// --- Main Execution on DOMContentLoaded ---
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// --- Initial UI Setup ---
|
||||
// Use setTimeout for the initial call to ensure form elements are ready
|
||||
setTimeout(() => {
|
||||
initializeFshConverterUI(document);
|
||||
}, 50); // Using a slightly longer delay just in case 0 isn't enough
|
||||
|
||||
// --- Form submission with AJAX (Event Delegation) ---
|
||||
const outputContainer = document.getElementById('fsh-output');
|
||||
if (outputContainer) {
|
||||
outputContainer.addEventListener('submit', async function(event) {
|
||||
// Check if the event originated from our specific form
|
||||
if (event.target && event.target.id === 'fsh-converter-form') {
|
||||
event.preventDefault(); // Prevent default only if it's our form
|
||||
|
||||
const form = event.target;
|
||||
const submitBtn = form.querySelector('#submit-btn');
|
||||
const spinnerOverlay = document.getElementById('spinner-overlay'); // Spinner is outside the form
|
||||
|
||||
if (!submitBtn || !spinnerOverlay) {
|
||||
console.error("Submit button or spinner overlay not found.");
|
||||
return; // Stop if critical elements missing
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
spinnerOverlay.classList.remove('d-none'); // Show spinner
|
||||
|
||||
const formData = new FormData(form);
|
||||
try {
|
||||
const response = await fetch('{{ url_for('fsh_converter') }}', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: formData });
|
||||
if (!response.ok) { let errorText = response.statusText; try { const errorData = await response.json(); errorText = errorData.error || errorText; } catch (e) {} throw new Error(`Network response was not ok: ${response.status} ${errorText}`); }
|
||||
const html = await response.text();
|
||||
|
||||
const themeNow = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
// Check function exists before calling, just in case
|
||||
if (typeof loadLottieAnimation === 'function') {
|
||||
loadLottieAnimation(themeNow); // Ensure animation theme is updated before showing result
|
||||
}
|
||||
|
||||
outputContainer.innerHTML = html; // Replace content
|
||||
|
||||
initializeFshConverterUI(outputContainer); // Re-initialize controls for new content
|
||||
|
||||
// Hide spinner AFTER content replaced and potentially re-initialized
|
||||
spinnerOverlay.classList.add('d-none');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during FSH conversion AJAX:', error);
|
||||
if(spinnerOverlay) spinnerOverlay.classList.add('d-none');
|
||||
// No need to re-enable submitBtn as the form was replaced
|
||||
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-danger mt-4';
|
||||
alertDiv.textContent = `An error occurred during conversion: ${error.message}. Please check input and try again.`;
|
||||
outputContainer.innerHTML = ''; // Clear previous content before showing only error
|
||||
outputContainer.appendChild(alertDiv);
|
||||
}
|
||||
} // End if event target is form
|
||||
}); // End submit listener
|
||||
} else {
|
||||
console.error("#fsh-output container not found for attaching delegated event listener.");
|
||||
}
|
||||
}); // End DOMContentLoaded Listener
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,145 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% 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">Import FHIR Implementation Guides</h1>
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
Import new FHIR Implementation Guides to the system for viewing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fire-loading-overlay" class="fire-loading-overlay d-none">
|
||||
<div class="campfire">
|
||||
<div class="campfire-scaler">
|
||||
<div class="sparks">
|
||||
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
|
||||
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
|
||||
</div>
|
||||
<div class="logs">
|
||||
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||
</div>
|
||||
<div class="sticks">
|
||||
<div class="stick"></div><div class="stick"></div><div class="stick"></div><div class="stick"></div>
|
||||
</div>
|
||||
<div class="fire">
|
||||
<div class="fire__red"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||
<div class="fire__orange"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||
<div class="fire__yellow"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||
<div class="fire__white"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||
</div>
|
||||
</div> </div> <div class="loading-text text-center">
|
||||
<p class="text-white mt-3">Importing Implementation Guide... Please wait.</p>
|
||||
<p id="log-display" class="text-white mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<h2><i class="bi bi-download me-2"></i>Import a New IG</h2>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="import-ig-form" method="POST" class="form">
|
||||
{{ form.hidden_tag() }}
|
||||
{{ render_field(form.package_name) }}
|
||||
{{ render_field(form.package_version) }}
|
||||
{{ render_field(form.dependency_mode) }}
|
||||
<div class="d-grid gap-2 d-sm-flex">
|
||||
{{ form.submit(class="btn btn-success", id="submit-btn") }}
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Your existing JS here (exactly as provided before)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('import-ig-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const loadingOverlay = document.getElementById('fire-loading-overlay');
|
||||
const logDisplay = document.getElementById('log-display');
|
||||
|
||||
if (form && submitBtn && loadingOverlay && logDisplay) {
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
submitBtn.disabled = true;
|
||||
logDisplay.textContent = 'Starting import...';
|
||||
loadingOverlay.classList.remove('d-none'); // Show the overlay
|
||||
|
||||
let eventSource;
|
||||
try {
|
||||
eventSource = new EventSource('{{ url_for('stream_import_logs') }}');
|
||||
eventSource.onmessage = function(event) {
|
||||
if (event.data === '[DONE]') {
|
||||
eventSource.close();
|
||||
return;
|
||||
}
|
||||
logDisplay.textContent = event.data;
|
||||
};
|
||||
eventSource.onerror = function() {
|
||||
console.error('SSE connection error');
|
||||
if(eventSource) eventSource.close();
|
||||
logDisplay.textContent = 'Log streaming interrupted.';
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize SSE:', e);
|
||||
logDisplay.textContent = 'Unable to stream logs: ' + e.message;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
try {
|
||||
const response = await fetch('{{ url_for('import_ig') }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: `HTTP error: ${response.status}` }));
|
||||
throw new Error(errorData.message || `HTTP error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
// Optional: display success message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||
eventSource.close();
|
||||
}
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-danger mt-3';
|
||||
alertDiv.textContent = `Import failed: ${error.message}`;
|
||||
form.closest('.card-body').appendChild(alertDiv);
|
||||
} finally {
|
||||
loadingOverlay.classList.add('d-none'); // Hide overlay when done
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Required elements missing for import script.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,168 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block body_class %}fire-animation-page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-5 my-5 text-center">
|
||||
<div id="logo-container" class="mb-4">
|
||||
<img class="d-block mx-auto" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE IG Toolkit" width="256" height="256">
|
||||
</div>
|
||||
<div id="animation-container" class="mb-4 d-none">
|
||||
<div class="section-center">
|
||||
<div class="moon">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="shooting-star"></div>
|
||||
<div class="shooting-star-2"></div>
|
||||
<div class="star"></div>
|
||||
<div class="star snd"></div>
|
||||
<div class="star trd"></div>
|
||||
<div class="star fth"></div>
|
||||
<div class="star fith"></div>
|
||||
<div class="circle"></div>
|
||||
<div class="wood-circle"></div>
|
||||
<div class="wood"></div>
|
||||
<div class="tree-1"></div>
|
||||
<div class="tree-2"></div>
|
||||
<div class="fire">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="smoke">
|
||||
<span class="s-0"></span>
|
||||
<span class="s-1"></span>
|
||||
<span class="s-2"></span>
|
||||
<span class="s-3"></span>
|
||||
<span class="s-4"></span>
|
||||
<span class="s-5"></span>
|
||||
<span class="s-6"></span>
|
||||
<span class="s-7"></span>
|
||||
<span class="s-8"></span>
|
||||
<span class="s-9"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="display-5 fw-bold text-body-emphasis">Welcome to {{ site_name }}</h1>
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<p class="lead mb-2">Simple tool for importing, viewing, and validating FHIR Implementation Guides.</p>
|
||||
<p class="text-muted">Streamline Your FHIR Workflow</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="row g-4">
|
||||
<!-- IG Management -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">IG Management</h5>
|
||||
<p class="card-text">Import and manage FHIR Implementation Guides.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<!-- <a href="{{ url_for('import_ig') }}" class="btn btn-primary">Import FHIR IG</a> -->
|
||||
<a href="{{ url_for('search_and_import') }}" class="btn btn-primary">Search & Import IGs</a>
|
||||
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary">Manage FHIR Packages</a>
|
||||
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary">Push IGs</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Validation & Testing -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Validation & Testing</h5>
|
||||
<p class="card-text">Validate and test FHIR resources.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('upload_test_data') }}" class="btn btn-outline-secondary">Upload Test Data</a>
|
||||
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary">Validate FHIR Sample</a>
|
||||
<a href="{{ url_for('retrieve_split_data') }}" class="btn btn-outline-secondary">Retrieve & Split Data</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- API & Tools -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">API & Tools</h5>
|
||||
<p class="card-text">Explore FHIR APIs and convert resources.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('fhir_ui') }}" class="btn btn-outline-secondary">FHIR API Explorer</a>
|
||||
<a href="{{ url_for('fhir_ui_operations') }}" class="btn btn-outline-secondary">FHIR UI Operations</a>
|
||||
<a href="{{ url_for('fsh_converter') }}" class="btn btn-outline-secondary">FSH Converter</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-4">
|
||||
<a href="{{ url_for('about') }}" class="btn btn-outline-info">About FHIRFLARE</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#logo-container {
|
||||
animation: fadeOut 30s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% { opacity: 1; }
|
||||
95% { opacity: 1; }
|
||||
100% { opacity: 0; display: none; }
|
||||
}
|
||||
|
||||
#animation-container {
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const logoContainer = document.getElementById('logo-container');
|
||||
const animationContainer = document.getElementById('animation-container');
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
// After 30 seconds, fade out logo and show animation
|
||||
setTimeout(() => {
|
||||
logoContainer.style.display = 'none';
|
||||
animationContainer.classList.remove('d-none');
|
||||
animationContainer.style.opacity = '1';
|
||||
}, 30000);
|
||||
|
||||
// Sync animation with theme (reversed: dark = fire-on, light = fire-off)
|
||||
const updateAnimationState = () => {
|
||||
const savedTheme = localStorage.getItem('theme') || (document.cookie.includes('theme=dark') ? 'dark' : 'light');
|
||||
const isDark = savedTheme === 'dark';
|
||||
document.body.classList.toggle('fire-on', isDark); // Dark theme: fire-on, Light theme: fire-off
|
||||
};
|
||||
|
||||
// Initial state
|
||||
updateAnimationState();
|
||||
|
||||
// Override toggleTheme to ensure animation updates
|
||||
const originalToggleTheme = window.toggleTheme || function() {};
|
||||
window.toggleTheme = function() {
|
||||
originalToggleTheme();
|
||||
updateAnimationState();
|
||||
};
|
||||
|
||||
// Observe changes to data-theme attribute
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'data-theme') {
|
||||
updateAnimationState();
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(htmlElement, { attributes: true });
|
||||
|
||||
// Clean up observer on page unload
|
||||
window.addEventListener('unload', () => {
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,194 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
{% include "_flash_messages.html" %}
|
||||
|
||||
<h1 class="mt-4 mb-3">Import FHIR Implementation Guides</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p class="lead">Import new FHIR Implementation Guides to the system via file or URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h2>Import a New IG</h2>
|
||||
<form id="manual-import-ig-form" method="POST" action="{{ url_for('manual_import_ig') }}" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ render_field(form.import_mode, class="form-select") }}
|
||||
</div>
|
||||
<div id="file-field" class="{% if form.import_mode.data != 'file' %}d-none{% endif %}">
|
||||
<div class="mb-3">
|
||||
<label for="tgz_file" class="form-label">IG Package File (.tgz)</label>
|
||||
<div class="input-group">
|
||||
<input type="file" name="tgz_file" id="tgz_file" accept=".tgz" class="form-control custom-file-input">
|
||||
<span id="file-label" class="custom-file-label">No file selected</span>
|
||||
</div>
|
||||
{% if form.tgz_file.errors %}
|
||||
<div class="form-error">
|
||||
{% for error in form.tgz_file.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="url-field" class="{% if form.import_mode.data != 'url' %}d-none{% endif %}">
|
||||
<div class="mb-3">
|
||||
{{ render_field(form.tgz_url, class="form-control url-input") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ render_field(form.dependency_mode, class="form-select") }}
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
{{ form.resolve_dependencies(type="checkbox", class="form-check-input") }}
|
||||
<label class="form-check-label" for="resolve_dependencies">Resolve Dependencies</label>
|
||||
{% if form.resolve_dependencies.errors %}
|
||||
<div class="form-error">
|
||||
{% for error in form.resolve_dependencies.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-sm-flex mt-3">
|
||||
{{ form.submit(class="btn btn-success", id="submit-btn") }}
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-error {
|
||||
color: #dc3545;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.custom-file-input {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.custom-file-input::-webkit-file-upload-button {
|
||||
display: none;
|
||||
}
|
||||
.custom-file-input::before {
|
||||
content: 'Choose File';
|
||||
display: inline-block;
|
||||
background: #007bff;
|
||||
color: #fff;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.custom-file-input:hover::before {
|
||||
background: #0056b3;
|
||||
}
|
||||
.custom-file-label {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
max-width: 60%;
|
||||
}
|
||||
#file-field, #url-field {
|
||||
display: none;
|
||||
}
|
||||
#file-field:not(.d-none), #url-field:not(.d-none) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.debug('DOMContentLoaded fired for manual_import_ig.html');
|
||||
|
||||
const importForm = document.getElementById('manual-import-ig-form');
|
||||
const importModeSelect = document.querySelector('#import_mode');
|
||||
const fileField = document.querySelector('#file-field');
|
||||
const urlField = document.querySelector('#url-field');
|
||||
const fileInput = document.querySelector('#tgz_file');
|
||||
const fileLabel = document.querySelector('#file-label');
|
||||
const urlInput = document.querySelector('#tgz_url');
|
||||
|
||||
// Debug DOM elements
|
||||
console.debug('importForm:', !!importForm);
|
||||
console.debug('importModeSelect:', !!importModeSelect);
|
||||
console.debug('fileField:', !!fileField);
|
||||
console.debug('urlField:', !!urlField);
|
||||
console.debug('fileInput:', !!fileInput);
|
||||
console.debug('fileLabel:', !!fileLabel);
|
||||
console.debug('urlInput:', !!urlInput);
|
||||
|
||||
function toggleFields() {
|
||||
if (!importModeSelect || !fileField || !urlField) {
|
||||
console.error('Required elements for toggling not found');
|
||||
return;
|
||||
}
|
||||
const mode = importModeSelect.value;
|
||||
console.debug(`Toggling fields for mode: ${mode}`);
|
||||
fileField.classList.toggle('d-none', mode !== 'file');
|
||||
urlField.classList.toggle('d-none', mode !== 'url');
|
||||
console.debug(`file-field d-none: ${fileField.classList.contains('d-none')}, url-field d-none: ${urlField.classList.contains('d-none')}`);
|
||||
}
|
||||
|
||||
function updateFileLabel() {
|
||||
if (fileInput && fileLabel) {
|
||||
const fileName = fileInput.files.length > 0 ? fileInput.files[0].name : 'No file selected';
|
||||
fileLabel.textContent = fileName;
|
||||
console.debug(`Updated file label to: ${fileName}`);
|
||||
} else {
|
||||
console.warn('File input or label not found');
|
||||
}
|
||||
}
|
||||
|
||||
if (importModeSelect) {
|
||||
importModeSelect.addEventListener('change', function() {
|
||||
console.debug('Import mode changed to:', importModeSelect.value);
|
||||
toggleFields();
|
||||
});
|
||||
toggleFields();
|
||||
} else {
|
||||
console.error('Import mode select not found');
|
||||
}
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function() {
|
||||
console.debug('File input changed');
|
||||
updateFileLabel();
|
||||
});
|
||||
updateFileLabel();
|
||||
}
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.addEventListener('input', function() {
|
||||
console.debug(`URL input value: ${urlInput.value}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (importForm) {
|
||||
importForm.addEventListener('submit', function(evt) {
|
||||
console.debug('Form submission triggered');
|
||||
if (!importForm.checkValidity()) {
|
||||
evt.preventDefault();
|
||||
console.error('Form validation failed');
|
||||
importForm.reportValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,7 +0,0 @@
|
||||
<ul class="list-group">
|
||||
{% for canonical in canonicals %}
|
||||
<li class="list-group-item">
|
||||
<a href="{{ canonical }}" target="_blank">{{ canonical }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
@ -1,31 +0,0 @@
|
||||
{% if dependents %}
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Package</th>
|
||||
<th scope="col">Latest</th>
|
||||
<th scope="col">Author</th>
|
||||
<th scope="col">FHIR</th>
|
||||
<th scope="col">Versions</th>
|
||||
<th scope="col">Canonical</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dep in dependents %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-box-seam me-2"></i>
|
||||
<a class="text-primary" href="{{ url_for('package_details_view', name=dep.name) }}">{{ dep.name }}</a>
|
||||
</td>
|
||||
<td>{{ dep.version }}</td>
|
||||
<td>{{ dep.author }}</td>
|
||||
<td>{{ dep.fhir_version }}</td>
|
||||
<td>{{ dep.version_count }}</td>
|
||||
<td>{{ dep.canonical }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">No dependent packages found for this package.</p>
|
||||
{% endif %}
|
@ -1,22 +0,0 @@
|
||||
{% if logs %}
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Version</th>
|
||||
<th scope="col">Publication Date</th>
|
||||
<th scope="col">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.version }}</td>
|
||||
<td>{{ log.pubDate }}</td>
|
||||
<td>{{ log.when }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">No version history found for this package.</p>
|
||||
{% endif %}
|
@ -1,16 +0,0 @@
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package</th>
|
||||
<th>Dependency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for problem in problems %}
|
||||
<tr>
|
||||
<td>{{ problem.package }}</td>
|
||||
<td class="text-danger">{{ problem.dependency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
@ -1,124 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{# Package Header #}
|
||||
<h1 class="display-5 fw-bold mb-1">{{ package_json.name }} <span class="text-muted">v{{ package_json.version }}</span></h1>
|
||||
<p class="text-muted mb-3">
|
||||
Latest Version: {{ package_json.version }}
|
||||
{% if latest_official_version and latest_official_version != package_json.version %}
|
||||
| Latest Official: {{ latest_official_version }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{# Install Commands #}
|
||||
<div class="mb-3">
|
||||
<label for="npmInstall" class="form-label small">Install package</label>
|
||||
<div class="input-group input-group-sm">
|
||||
{% set registry_base = package_json.registry | default('https://packages.simplifier.net') %}
|
||||
{% set registry_url = registry_base | replace('/rssfeed', '') %}
|
||||
<input type="text" class="form-control form-control-sm" id="npmInstall" readonly
|
||||
value="npm --registry {{ registry_url }} install {{ package_json.name }}@{{ package_json.version }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="copyToClipboard('npmInstall')" aria-label="Copy NPM command">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="curlCommand" class="form-label small">Get resources</label>
|
||||
<div class="input-group input-group-sm">
|
||||
{% set registry_base = package_json.registry | default('https://packages.simplifier.net') %}
|
||||
{% set registry_url = registry_base | replace('/rssfeed', '') %}
|
||||
<input type="text" class="form-control form-control-sm" id="curlCommand" readonly
|
||||
value="curl {{ registry_url }}/{{ package_json.name }}/{{ package_json.version }}/package.tgz | gunzip | tar xf -">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="copyToClipboard('curlCommand')" aria-label="Copy curl command">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="packageURL" class="form-label small">Straight Package Download</label>
|
||||
<div class="input-group input-group-sm">
|
||||
{% set registry_base = package_json.registry | default('https://packages.simplifier.net') %}
|
||||
{% set registry_url = registry_base | replace('/rssfeed', '') %}
|
||||
<input type="text" class="form-control form-control-sm" id="packageURL" readonly
|
||||
value="{{ package_json.canonical }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="copyToClipboard('packageURL')" aria-label="Copy curl command">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
<h5 class="mt-4">Description</h5>
|
||||
<p>{{ package_json.description | default('No description provided.', true) }}</p>
|
||||
|
||||
{# Dependencies #}
|
||||
<h5 class="mt-4">Dependencies</h5>
|
||||
{% if dependencies %}
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Package</th>
|
||||
<th scope="col">Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dep in dependencies %}
|
||||
<tr>
|
||||
<td>{{ dep.name }}</td>
|
||||
<td>{{ dep.version }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">No dependencies found for this package.</p>
|
||||
{% endif %}
|
||||
|
||||
{# Dependents #}
|
||||
<h5 class="mt-4">Dependents</h5>
|
||||
<div id="dependents-content" hx-get="{{ url_for('package.dependents', name=package_name) }}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading...
|
||||
</div>
|
||||
|
||||
{# Logs #}
|
||||
<h5 class="mt-4">Logs</h5>
|
||||
<div id="logs-content" hx-get="{{ url_for('package.logs', name=package_name) }}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<h3 class="mb-3">Versions ({{ versions | length }})</h3>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for version in versions %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>{{ version }}</span>
|
||||
{% if version == latest_official_version %}
|
||||
<i class="fas fa-star text-warning" title="Latest Official Release"></i>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">No versions found.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(elementId) {
|
||||
var copyText = document.getElementById(elementId);
|
||||
copyText.select();
|
||||
copyText.setSelectionRange(0, 99999); /* For mobile devices */
|
||||
navigator.clipboard.writeText(copyText.value).then(function() {
|
||||
// Optional: Show a temporary tooltip or change button text
|
||||
}, function(err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,690 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-5 my-5 text-center">
|
||||
<h1 class="display-5 fw-bold text-body-emphasis">Retrieve & Split Data</h1>
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
Retrieve FHIR bundles from a server, then download as a ZIP file. Split uploaded or retrieved bundle ZIPs into individual resources, downloaded as a ZIP file.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<h4 class="my-0 fw-normal"><i class="bi bi-cloud-download me-2"></i>Retrieve Bundles</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<p><strong>Please correct the following errors:</strong></p>
|
||||
<ul>
|
||||
{% for field, errors in form.errors.items() %}
|
||||
<li>{{ form[field].label.text }}: {{ errors|join(', ') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form id="retrieveForm" method="POST" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">FHIR Server</label>
|
||||
<div class="input-group">
|
||||
<button type="button" class="btn btn-outline-primary" id="toggleServer">
|
||||
<span id="toggleLabel">Use Local HAPI</span>
|
||||
</button>
|
||||
{{ form.fhir_server_url(class="form-control", id="fhirServerUrl", style="display: none;", placeholder="e.g., https://fhir.hl7.org.au/aucore/fhir/DEFAULT", **{'aria-describedby': 'fhirServerHelp'}) }}
|
||||
</div>
|
||||
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (/fhir proxy) or enter a custom FHIR server URL.</small>
|
||||
</div>
|
||||
|
||||
{# Authentication Section (Shown for Custom URL) #}
|
||||
<div class="mb-3" id="authSection" style="display: none;">
|
||||
<label class="form-label fw-bold">Authentication</label>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="basic">Basic Authentication</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
{# Bearer Token Input #}
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
<label for="bearerToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
{# Basic Auth Inputs #}
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
|
||||
</div>
|
||||
|
||||
{# Checkbox Row #}
|
||||
<div class="row g-3 mb-3 align-items-center">
|
||||
<div class="col-md-6">
|
||||
{{ render_field(form.validate_references, id='validate_references_checkbox') }}
|
||||
</div>
|
||||
<div class="col-md-6" id="fetchReferenceBundlesGroup" style="display: none;">
|
||||
{{ render_field(form.fetch_reference_bundles, id='fetch_reference_bundles_checkbox') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary mb-3" id="fetchMetadata">Fetch Metadata</button>
|
||||
<div class="banner3 mt-3" id="resourceTypes" style="display: none;">
|
||||
<h5>Resource Types</h5>
|
||||
<div class="d-flex flex-wrap gap-2" id="resourceButtons"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="retrieveButton" name="submit_retrieve">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<i class="bi bi-arrow-down-circle me-2"></i>{{ form.submit_retrieve.label.text }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-success btn-lg w-100 mt-2" id="downloadRetrieveButton" style="display: none;">
|
||||
<i class="bi bi-download me-2"></i>Download Retrieved Bundles
|
||||
</button>
|
||||
</form>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">Retrieval Log</div>
|
||||
<div class="card-body">
|
||||
<div id="retrieveConsole" class="border p-3 rounded bg-dark text-light" style="height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
|
||||
<span class="text-muted">Retrieval output will appear here...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h4 class="my-0 fw-normal"><i class="bi bi-scissors me-2"></i>Split Bundles</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="splitForm" method="POST" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Bundle Source</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="bundle_source" id="uploadBundles" value="upload" checked>
|
||||
<label class="form-check-label" for="uploadBundles">Upload Bundle ZIP</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="bundle_source" id="useRetrievedBundles" value="retrieved" {% if not session.get('retrieve_params', {}).get('bundle_zip_path', None) %} disabled {% endif %}>
|
||||
<label class="form-check-label" for="useRetrievedBundles">Use Retrieved Bundles{% if not session.get('retrieve_params', {}).get('bundle_zip_path', None) %} (No bundles retrieved in this session){% endif %}</label>
|
||||
</div>
|
||||
</div>
|
||||
{{ render_field(form.split_bundle_zip, class="form-control") }}
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="splitButton" name="submit_split">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<i class="bi bi-arrow-right-circle me-2"></i>{{ form.submit_split.label.text }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-success btn-lg w-100 mt-2" id="downloadSplitButton" style="display: none;">
|
||||
<i class="bi bi-download me-2"></i>Download Split Resources
|
||||
</button>
|
||||
</form>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">Splitting Log</div>
|
||||
<div class="card-body">
|
||||
<div id="splitConsole" class="border p-3 rounded bg-dark text-light" style="height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
|
||||
<span class="text-muted">Splitting output will appear here...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- DOM Element References ---
|
||||
const retrieveForm = document.getElementById('retrieveForm');
|
||||
const splitForm = document.getElementById('splitForm');
|
||||
const retrieveButton = document.getElementById('retrieveButton');
|
||||
const splitButton = document.getElementById('splitButton');
|
||||
const downloadRetrieveButton = document.getElementById('downloadRetrieveButton');
|
||||
const downloadSplitButton = document.getElementById('downloadSplitButton');
|
||||
const fetchMetadataButton = document.getElementById('fetchMetadata');
|
||||
const toggleServerButton = document.getElementById('toggleServer');
|
||||
const toggleLabel = document.getElementById('toggleLabel');
|
||||
const fhirServerUrlInput = document.getElementById('fhirServerUrl');
|
||||
const resourceTypesDiv = document.getElementById('resourceTypes');
|
||||
const resourceButtonsContainer = document.getElementById('resourceButtons');
|
||||
const retrieveConsole = document.getElementById('retrieveConsole');
|
||||
const splitConsole = document.getElementById('splitConsole');
|
||||
const bundleSourceRadios = document.getElementsByName('bundle_source');
|
||||
const splitBundleZipInput = document.getElementById('split_bundle_zip');
|
||||
const splitBundleZipGroup = splitBundleZipInput ? splitBundleZipInput.closest('.form-group') : null;
|
||||
const validateReferencesCheckbox = document.getElementById('validate_references_checkbox');
|
||||
const fetchReferenceBundlesGroup = document.getElementById('fetchReferenceBundlesGroup');
|
||||
const fetchReferenceBundlesCheckbox = document.getElementById('fetch_reference_bundles_checkbox');
|
||||
const authSection = document.getElementById('authSection');
|
||||
const authTypeSelect = document.getElementById('authType');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerToken');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
// --- State & Config Variables ---
|
||||
const appMode = '{{ app_mode | default("standalone") | lower }}';
|
||||
const apiKey = '{{ api_key }}';
|
||||
const sessionZipPath = '{{ session.get("retrieve_params", {}).get("bundle_zip_path", "") }}';
|
||||
let useLocalHapi = (appMode !== 'lite');
|
||||
let retrieveZipPath = null;
|
||||
let splitZipPath = null;
|
||||
let fetchedMetadataCache = null;
|
||||
|
||||
// --- Helper Functions ---
|
||||
const sanitizeText = (str) => str ? String(str).replace(/</g, "<").replace(/>/g, ">") : "";
|
||||
|
||||
// --- UI Update Functions ---
|
||||
function updateBundleSourceUI() {
|
||||
const selectedSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
|
||||
if (splitBundleZipGroup) {
|
||||
splitBundleZipGroup.style.display = selectedSource === 'upload' ? 'block' : 'none';
|
||||
}
|
||||
if (splitBundleZipInput) {
|
||||
splitBundleZipInput.required = selectedSource === 'upload';
|
||||
}
|
||||
}
|
||||
|
||||
function updateServerToggleUI() {
|
||||
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton || !authSection) return;
|
||||
|
||||
if (appMode === 'lite') {
|
||||
useLocalHapi = false;
|
||||
toggleServerButton.disabled = true;
|
||||
toggleServerButton.classList.add('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'none';
|
||||
toggleServerButton.setAttribute('aria-disabled', 'true');
|
||||
toggleServerButton.title = "Local HAPI server is unavailable in Lite mode";
|
||||
toggleLabel.textContent = 'Use Custom URL';
|
||||
fhirServerUrlInput.style.display = 'block';
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
||||
fhirServerUrlInput.required = true;
|
||||
authSection.style.display = 'block';
|
||||
} else {
|
||||
toggleServerButton.disabled = false;
|
||||
toggleServerButton.classList.remove('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'auto';
|
||||
toggleServerButton.removeAttribute('aria-disabled');
|
||||
toggleServerButton.title = "Toggle between Local HAPI and Custom URL";
|
||||
toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL';
|
||||
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
|
||||
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
|
||||
fhirServerUrlInput.required = !useLocalHapi;
|
||||
authSection.style.display = useLocalHapi ? 'none' : 'block';
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
updateAuthInputsUI();
|
||||
console.log(`Server toggle UI updated: useLocalHapi=${useLocalHapi}, customUrl=${fhirServerUrlInput.value}`);
|
||||
}
|
||||
|
||||
function updateAuthInputsUI() {
|
||||
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) return;
|
||||
const authType = authTypeSelect.value;
|
||||
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
|
||||
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
|
||||
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
}
|
||||
|
||||
function toggleFetchReferenceBundles() {
|
||||
if (validateReferencesCheckbox && fetchReferenceBundlesGroup) {
|
||||
fetchReferenceBundlesGroup.style.display = validateReferencesCheckbox.checked ? 'block' : 'none';
|
||||
if (!validateReferencesCheckbox.checked && fetchReferenceBundlesCheckbox) {
|
||||
fetchReferenceBundlesCheckbox.checked = false;
|
||||
}
|
||||
} else {
|
||||
console.warn("Could not find checkbox elements needed for toggling.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
bundleSourceRadios.forEach(radio => {
|
||||
radio.addEventListener('change', updateBundleSourceUI);
|
||||
});
|
||||
|
||||
toggleServerButton.addEventListener('click', () => {
|
||||
if (appMode === 'lite') return;
|
||||
useLocalHapi = !useLocalHapi;
|
||||
if (useLocalHapi) fhirServerUrlInput.value = '';
|
||||
updateServerToggleUI();
|
||||
resourceTypesDiv.style.display = 'none';
|
||||
fetchedMetadataCache = null;
|
||||
console.log(`Server toggled: useLocalHapi=${useLocalHapi}`);
|
||||
});
|
||||
|
||||
if (authTypeSelect) {
|
||||
authTypeSelect.addEventListener('change', updateAuthInputsUI);
|
||||
}
|
||||
|
||||
if (validateReferencesCheckbox) {
|
||||
validateReferencesCheckbox.addEventListener('change', toggleFetchReferenceBundles);
|
||||
} else {
|
||||
console.warn("Validate references checkbox not found.");
|
||||
}
|
||||
|
||||
fetchMetadataButton.addEventListener('click', async () => {
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>';
|
||||
resourceTypesDiv.style.display = 'block';
|
||||
const customUrl = useLocalHapi ? null : fhirServerUrlInput.value.trim().replace(/\/+$/, '');
|
||||
|
||||
if (!useLocalHapi && !customUrl) {
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Please enter a valid FHIR server URL.');
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Custom URL required.</span>';
|
||||
return;
|
||||
}
|
||||
if (!useLocalHapi) {
|
||||
try { new URL(customUrl); } catch (_) {
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Invalid custom URL format.');
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Invalid URL format.</span>';
|
||||
return;
|
||||
}
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
|
||||
fetchMetadataButton.disabled = true;
|
||||
fetchMetadataButton.textContent = 'Fetching...';
|
||||
|
||||
try {
|
||||
const fetchUrl = '/fhir/metadata';
|
||||
const headers = { 'Accept': 'application/fhir+json' };
|
||||
if (!useLocalHapi && customUrl) {
|
||||
headers['X-Target-FHIR-Server'] = customUrl;
|
||||
if (authTypeSelect && authTypeSelect.value !== 'none') {
|
||||
const authType = authTypeSelect.value;
|
||||
if (authType === 'bearer' && bearerTokenInput && bearerTokenInput.value) {
|
||||
headers['Authorization'] = `Bearer ${bearerTokenInput.value}`;
|
||||
} else if (authType === 'basic' && usernameInput && passwordInput && usernameInput.value && passwordInput.value) {
|
||||
const credentials = btoa(`${usernameInput.value}:${passwordInput.value}`);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
}
|
||||
console.log(`Fetching metadata via proxy with X-Target-FHIR-Server: ${customUrl}`);
|
||||
} else {
|
||||
console.log("Fetching metadata via proxy for local HAPI server");
|
||||
}
|
||||
console.log(`Proxy Fetch URL: ${fetchUrl}`);
|
||||
console.log(`Request Headers sent TO PROXY: ${JSON.stringify(headers)}`);
|
||||
|
||||
const response = await fetch(fetchUrl, { method: 'GET', headers: headers });
|
||||
|
||||
if (!response.ok) {
|
||||
let errorText = `Request failed with status ${response.status} ${response.statusText}`;
|
||||
try { const errorBody = await response.text(); errorText += ` - Body: ${errorBody.substring(0, 200)}...`; } catch (e) {}
|
||||
console.error(`Metadata fetch failed: ${errorText}`);
|
||||
throw new Error(errorText);
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log("Metadata received:", JSON.stringify(data, null, 2).substring(0, 500));
|
||||
fetchedMetadataCache = data;
|
||||
|
||||
const resourceTypes = data.rest?.[0]?.resource?.map(r => r.type)?.filter(Boolean) || [];
|
||||
resourceButtonsContainer.innerHTML = '';
|
||||
if (!resourceTypes.length) {
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-warning">No resource types found in metadata.</span>';
|
||||
} else {
|
||||
resourceTypes.sort().forEach(type => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'btn btn-outline-primary btn-sm me-2 mb-2';
|
||||
button.textContent = type;
|
||||
button.dataset.resource = type;
|
||||
button.addEventListener('click', () => {
|
||||
button.classList.toggle('active');
|
||||
button.classList.toggle('btn-primary');
|
||||
button.classList.toggle('btn-outline-primary');
|
||||
});
|
||||
resourceButtonsContainer.appendChild(button);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Metadata fetch error:', e);
|
||||
resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: ${e.message}</span>`;
|
||||
resourceTypesDiv.style.display = 'block';
|
||||
alert(`Error fetching metadata: ${e.message}`);
|
||||
fetchedMetadataCache = null;
|
||||
} finally {
|
||||
fetchMetadataButton.disabled = false;
|
||||
fetchMetadataButton.textContent = 'Fetch Metadata';
|
||||
}
|
||||
});
|
||||
|
||||
retrieveForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const spinner = retrieveButton.querySelector('.spinner-border');
|
||||
const icon = retrieveButton.querySelector('i');
|
||||
retrieveButton.disabled = true;
|
||||
if (spinner) spinner.style.display = 'inline-block';
|
||||
if (icon) icon.style.display = 'none';
|
||||
downloadRetrieveButton.style.display = 'none';
|
||||
retrieveZipPath = null;
|
||||
retrieveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting bundle retrieval...</div>`;
|
||||
|
||||
const selectedResources = Array.from(resourceButtonsContainer.querySelectorAll('.btn.active')).map(btn => btn.dataset.resource);
|
||||
if (!selectedResources.length) {
|
||||
alert('Please select at least one resource type.');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const csrfTokenInput = retrieveForm.querySelector('input[name="csrf_token"]');
|
||||
if (csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
|
||||
selectedResources.forEach(res => formData.append('resources', res));
|
||||
|
||||
if (validateReferencesCheckbox) {
|
||||
formData.append('validate_references', validateReferencesCheckbox.checked ? 'true' : 'false');
|
||||
}
|
||||
if (fetchReferenceBundlesCheckbox) {
|
||||
if (validateReferencesCheckbox && validateReferencesCheckbox.checked) {
|
||||
formData.append('fetch_reference_bundles', fetchReferenceBundlesCheckbox.checked ? 'true' : 'false');
|
||||
} else {
|
||||
formData.append('fetch_reference_bundles', 'false');
|
||||
}
|
||||
} else {
|
||||
formData.append('fetch_reference_bundles', 'false');
|
||||
}
|
||||
|
||||
const currentFhirServerUrl = useLocalHapi ? '/fhir' : fhirServerUrlInput.value.trim();
|
||||
if (!useLocalHapi && !currentFhirServerUrl) {
|
||||
alert('Custom FHIR Server URL is required.');
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('fhir_server_url', currentFhirServerUrl);
|
||||
|
||||
// Add authentication fields for custom URL
|
||||
if (!useLocalHapi && authTypeSelect) {
|
||||
const authType = authTypeSelect.value;
|
||||
formData.append('auth_type', authType);
|
||||
if (authType === 'bearer' && bearerTokenInput) {
|
||||
if (!bearerTokenInput.value) {
|
||||
alert('Please enter a Bearer Token.');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('bearer_token', bearerTokenInput.value);
|
||||
} else if (authType === 'basic' && usernameInput && passwordInput) {
|
||||
if (!usernameInput.value || !passwordInput.value) {
|
||||
alert('Please enter both Username and Password for Basic Authentication.');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('username', usernameInput.value);
|
||||
formData.append('password', passwordInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/x-ndjson',
|
||||
'X-CSRFToken': csrfTokenInput ? csrfTokenInput.value : '',
|
||||
'X-API-Key': apiKey
|
||||
};
|
||||
console.log(`Submitting retrieve request. Server: ${currentFhirServerUrl}, ValidateRefs: ${formData.get('validate_references')}, FetchRefBundles: ${formData.get('fetch_reference_bundles')}, AuthType: ${formData.get('auth_type')}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/retrieve-bundles', { method: 'POST', headers: headers, body: formData });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response.' }));
|
||||
throw new Error(`HTTP ${response.status}: ${errorData.message || 'Unknown error during retrieval'}`);
|
||||
}
|
||||
retrieveZipPath = response.headers.get('X-Zip-Path');
|
||||
console.log(`Received X-Zip-Path: ${retrieveZipPath}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info';
|
||||
let prefix = '[INFO]';
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
|
||||
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`;
|
||||
retrieveConsole.appendChild(messageDiv);
|
||||
retrieveConsole.scrollTop = retrieveConsole.scrollHeight;
|
||||
|
||||
if (data.type === 'complete' && retrieveZipPath) {
|
||||
const completeData = data.data || {};
|
||||
const bundlesFound = (completeData.total_initial_bundles > 0) || (completeData.fetched_individual_references > 0) || (completeData.fetched_type_bundles > 0);
|
||||
if (!bundlesFound) {
|
||||
retrieveConsole.innerHTML += `<div class="text-warning">${timestamp} [WARNING] No bundles were retrieved. Check server data or resource selection.</div>`;
|
||||
} else {
|
||||
downloadRetrieveButton.style.display = 'block';
|
||||
const useRetrievedRadio = document.getElementById('useRetrievedBundles');
|
||||
const useRetrievedLabel = useRetrievedRadio?.nextElementSibling;
|
||||
if (useRetrievedRadio) useRetrievedRadio.disabled = false;
|
||||
if (useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles';
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) { /* Handle final buffer */ }
|
||||
|
||||
} catch (e) {
|
||||
console.error('Retrieval error:', e);
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
retrieveConsole.innerHTML += `<div class="text-danger">${ts} [ERROR] ${sanitizeText(e.message)}</div>`;
|
||||
retrieveConsole.scrollTop = retrieveConsole.scrollHeight;
|
||||
} finally {
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
}
|
||||
});
|
||||
|
||||
downloadRetrieveButton.addEventListener('click', () => {
|
||||
if (retrieveZipPath) {
|
||||
const filename = retrieveZipPath.split('/').pop() || 'retrieved_bundles.zip';
|
||||
const downloadUrl = `/tmp/${filename}`;
|
||||
console.log(`Attempting to download ZIP from Flask endpoint: ${downloadUrl}`);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = 'retrieved_bundles.zip';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
setTimeout(() => {
|
||||
const csrfToken = retrieveForm.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
fetch('/clear-session', { method: 'POST', headers: { 'X-CSRFToken': csrfToken }})
|
||||
.then(() => console.log("Session clear requested."))
|
||||
.catch(err => console.error("Session clear failed:", err));
|
||||
downloadRetrieveButton.style.display = 'none';
|
||||
retrieveZipPath = null;
|
||||
const useRetrievedRadio = document.getElementById('useRetrievedBundles');
|
||||
const useRetrievedLabel = useRetrievedRadio?.nextElementSibling;
|
||||
if (useRetrievedRadio) useRetrievedRadio.disabled = true;
|
||||
if (useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles (No bundles retrieved in this session)';
|
||||
}, 500);
|
||||
} else {
|
||||
console.error("No retrieve ZIP path available for download");
|
||||
alert("Download error: No file path available.");
|
||||
}
|
||||
});
|
||||
|
||||
splitForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const spinner = splitButton.querySelector('.spinner-border');
|
||||
const icon = splitButton.querySelector('i');
|
||||
splitButton.disabled = true;
|
||||
if (spinner) spinner.style.display = 'inline-block';
|
||||
if (icon) icon.style.display = 'none';
|
||||
downloadSplitButton.style.display = 'none';
|
||||
splitZipPath = null;
|
||||
splitConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting bundle splitting...</div>`;
|
||||
|
||||
const formData = new FormData();
|
||||
const csrfTokenInput = splitForm.querySelector('input[name="csrf_token"]');
|
||||
if (csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
|
||||
|
||||
const bundleSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
|
||||
|
||||
if (bundleSource === 'upload') {
|
||||
if (!splitBundleZipInput || splitBundleZipInput.files.length === 0) {
|
||||
alert('Please select a ZIP file to upload for splitting.');
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('split_bundle_zip', splitBundleZipInput.files[0]);
|
||||
console.log("Splitting uploaded file:", splitBundleZipInput.files[0].name);
|
||||
} else if (bundleSource === 'retrieved' && sessionZipPath) {
|
||||
formData.append('split_bundle_zip_path', sessionZipPath);
|
||||
console.log("Splitting retrieved bundle path:", sessionZipPath);
|
||||
} else if (bundleSource === 'retrieved' && !sessionZipPath) {
|
||||
if (retrieveZipPath) {
|
||||
formData.append('split_bundle_zip_path', retrieveZipPath);
|
||||
console.log("Splitting retrieve bundle path from current run:", retrieveZipPath);
|
||||
} else {
|
||||
alert('No bundle source available. Please retrieve bundles first or upload a ZIP.');
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
alert('No bundle source selected.');
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/x-ndjson',
|
||||
'X-CSRFToken': csrfTokenInput ? csrfTokenInput.value : '',
|
||||
'X-API-Key': apiKey
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/split-bundles', { method: 'POST', headers: headers, body: formData });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
let detail = errorMsg;
|
||||
try { const errorJson = JSON.parse(errorMsg); detail = errorJson.message || errorJson.error || JSON.stringify(errorJson); } catch (e) {}
|
||||
throw new Error(`HTTP ${response.status}: ${detail}`);
|
||||
}
|
||||
splitZipPath = response.headers.get('X-Zip-Path');
|
||||
console.log(`Received X-Zip-Path for split: ${splitZipPath}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info';
|
||||
let prefix = '[INFO]';
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
|
||||
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`;
|
||||
splitConsole.appendChild(messageDiv);
|
||||
splitConsole.scrollTop = splitConsole.scrollHeight;
|
||||
if (data.type === 'complete' && splitZipPath) {
|
||||
downloadSplitButton.style.display = 'block';
|
||||
}
|
||||
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) { /* Handle final buffer */ }
|
||||
|
||||
} catch (e) {
|
||||
console.error('Splitting error:', e);
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
splitConsole.innerHTML += `<div class="text-danger">${ts} [ERROR] ${sanitizeText(e.message)}</div>`;
|
||||
splitConsole.scrollTop = splitConsole.scrollHeight;
|
||||
} finally {
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
}
|
||||
});
|
||||
|
||||
downloadSplitButton.addEventListener('click', () => {
|
||||
if (splitZipPath) {
|
||||
const filename = splitZipPath.split('/').pop() || 'split_resources.zip';
|
||||
const downloadUrl = `/tmp/${filename}`;
|
||||
console.log(`Attempting to download split ZIP from Flask endpoint: ${downloadUrl}`);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = 'split_resources.zip';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => {
|
||||
const csrfToken = splitForm.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
fetch('/clear-session', { method: 'POST', headers: { 'X-CSRFToken': csrfToken }})
|
||||
.then(() => console.log("Session clear requested after split download."))
|
||||
.catch(err => console.error("Session clear failed:", err));
|
||||
downloadSplitButton.style.display = 'none';
|
||||
splitZipPath = null;
|
||||
}, 500);
|
||||
} else {
|
||||
console.error("No split ZIP path available for download");
|
||||
alert("Download error: No file path available.");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Initial Setup Calls ---
|
||||
updateBundleSourceUI();
|
||||
updateServerToggleUI();
|
||||
toggleFetchReferenceBundles();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,461 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block extra_head %} {# Assuming base.html has an 'extra_head' block for additional head elements #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Main page content for searching and importing packages #}
|
||||
|
||||
<div class="container">
|
||||
{# Flash messages area - Ensure _flash_messages.html exists and is included in base.html or rendered here #}
|
||||
{% include "_flash_messages.html" %}
|
||||
|
||||
{# Display warning if package fetching failed on initial load #}
|
||||
{% if fetch_failed %}
|
||||
<div class="alert alert-warning">
|
||||
Unable to fetch packages from registries. Showing a fallback list. Please try again later or contact support.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{# Left Column: Search Packages Area #}
|
||||
<div class="col-md-9 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{# Header with Refresh Button #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="bi bi-search me-2"></i>Search Packages</h2>
|
||||
<button class="btn btn-sm btn-outline-warning" id="clear-cache-btn"
|
||||
title="Clear package cache and fetch fresh list"
|
||||
hx-post="{{ url_for('refresh_cache_task') }}"
|
||||
hx-indicator="#cache-spinner"
|
||||
hx-swap="none"
|
||||
hx-target="this">
|
||||
<i class="bi bi-arrow-clockwise"></i> Clear & Refresh Cache
|
||||
<span id="cache-spinner" class="spinner-border spinner-border-sm ms-1" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Cache Status Timestamp #}
|
||||
<div class="mb-3 text-muted" style="font-size: 0.85em;">
|
||||
{% if last_cached_timestamp %}
|
||||
Package list last fetched: {{ last_cached_timestamp.strftime('%Y-%m-%d %H:%M:%S %Z') if last_cached_timestamp else 'Never' }}
|
||||
{% if fetch_failed %} (Fetch Failed){% endif %}
|
||||
{% elif is_fetching %} {# Show text spinner specifically during initial fetch state triggered by backend #}
|
||||
Fetching package list... <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
{% else %}
|
||||
Never fetched or cache cleared.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Search Input with HTMX #}
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search packages..." name="search" autofocus
|
||||
hx-get="{{ url_for('api_search_packages') }}" hx-indicator=".htmx-indicator" hx-target="#search-results" hx-trigger="input changed delay:500ms, search">
|
||||
<span class="input-group-text htmx-indicator"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
|
||||
</div>
|
||||
<div id="search-error" class="alert alert-danger d-none"></div>
|
||||
|
||||
{# Search Results Area (populated by HTMX) #}
|
||||
<div id="search-results">
|
||||
{% include '_search_results_table.html' %} {# Includes the initial table state or updated results #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Right Column: Import Form, Log Window, Animation, Warning #}
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{# Import Form #}
|
||||
<h2><i class="bi bi-download me-2"></i>Import a New IG</h2>
|
||||
<form id="import-ig-form"> {# Form ID used by JS #}
|
||||
{{ form.hidden_tag() }} {# Include CSRF token if using Flask-WTF #}
|
||||
{{ render_field(form.package_name, class="form-control") }}
|
||||
{{ render_field(form.package_version, class="form-control") }}
|
||||
{{ render_field(form.dependency_mode, class="form-select") }}
|
||||
<div class="d-grid gap-2 d-sm-flex mt-3">
|
||||
{# Import Button triggers HTMX POST #}
|
||||
<button class="btn btn-success" id="submit-btn"
|
||||
hx-post="{{ url_for('import_ig') }}"
|
||||
hx-indicator="#import-spinner"
|
||||
hx-include="closest form"
|
||||
hx-swap="none">
|
||||
Import
|
||||
</button>
|
||||
<span id="import-spinner" class="spinner-border spinner-border-sm align-middle ms-2" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a> {# Simple link back #}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Live Log Output Window #}
|
||||
<div class="log-window mt-4">
|
||||
<h5><i class="bi bi-terminal me-2"></i>Live Log Output</h5>
|
||||
<div id="log-output" class="log-output">
|
||||
<p class="text-muted small">Logs from caching or import actions will appear here.</p>
|
||||
</div>
|
||||
{# Indicator shown while connecting to SSE #}
|
||||
<div id="log-loading-indicator" class="mt-2 text-muted small" style="display: none;">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Connecting to log stream...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Animation Window (Hidden by default) #}
|
||||
<div id="log-animation-window" class="minifire-container" style="display: none;">
|
||||
{# Status Text Overlay #}
|
||||
<div id="animation-status-text" class="minifire-status-text"></div>
|
||||
{# Campfire Animation HTML Structure #}
|
||||
<div class="minifire-campfire">
|
||||
<div class="minifire-sparks">
|
||||
<div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div>
|
||||
<div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div>
|
||||
</div>
|
||||
<div class="minifire-logs">
|
||||
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
|
||||
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
|
||||
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
|
||||
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
|
||||
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
|
||||
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
|
||||
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
|
||||
</div>
|
||||
<div class="minifire-sticks">
|
||||
<div class="minifire-stick"></div> <div class="minifire-stick"></div> <div class="minifire-stick"></div> <div class="minifire-stick"></div>
|
||||
</div>
|
||||
<div class="minifire-fire">
|
||||
<div class="minifire-fire__red"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
|
||||
<div class="minifire-fire__orange"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
|
||||
<div class="minifire-fire__yellow"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
|
||||
<div class="minifire-fire__white"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Warning Text (Hidden by default) #}
|
||||
<div id="process-warning-text" class="import-warning-text" style="display: none; margin-top: 1rem;">
|
||||
DO NOT LEAVE PAGE<br>
|
||||
UNTIL COMPLETED<br>
|
||||
!ANIMATION DISAPEARS!
|
||||
</div>
|
||||
|
||||
</div> {# End card-body #}
|
||||
</div> {# End card #}
|
||||
</div> {# End col-md-3 #}
|
||||
</div> {# End row #}
|
||||
</div> {# End container #}
|
||||
|
||||
<style>
|
||||
/* Internal CSS Styles */
|
||||
.log-window { margin-top: 1.5rem; }
|
||||
.log-output {
|
||||
background-color: #1e2228; border: 1px solid #444; border-radius: 4px; padding: 10px;
|
||||
height: 250px; overflow-y: auto; font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.85rem; color: #e0e0e0; white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
.log-output p { margin: 0; padding: 1px 0; border-bottom: 1px solid #333; line-height: 1.3; }
|
||||
.log-output p:last-child { border-bottom: none; }
|
||||
.log-output .log-timestamp { color: #777; margin-right: 8px; }
|
||||
.log-output .text-muted.small { color: #888 !important; font-style: italic; border-bottom: none; }
|
||||
|
||||
/* Animation Status Text (from fire-animation.css or here) */
|
||||
.minifire-status-text {
|
||||
position: absolute; top: 8px; left: 0; width: 100%; text-align: center;
|
||||
color: #f0f0f0; font-size: 0.85em; font-weight: bold; z-index: 60;
|
||||
pointer-events: none; text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8); line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Warning Text Style */
|
||||
.import-warning-text {
|
||||
color: #dc3545; /* Bootstrap danger color */
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Other general styles */
|
||||
.text-muted { color: #ccc !important; font-size: 1.1rem; margin-top: 1rem; }
|
||||
.table, .card { background-color: #2a2e34; color: #fff; }
|
||||
.table th, .table td { border-color: #444; }
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-block; opacity: 1; }
|
||||
#cache-spinner, #import-spinner { vertical-align: text-bottom; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if HTMX is loaded (essential for button actions)
|
||||
if (typeof htmx === 'undefined') {
|
||||
console.error('HTMX is not loaded. Button functionality will be disabled.');
|
||||
const searchErrorDiv = document.getElementById('search-error');
|
||||
if(searchErrorDiv) {
|
||||
searchErrorDiv.textContent = 'Error: Core page functionality failed to load. Refresh or contact support.';
|
||||
searchErrorDiv.classList.remove('d-none');
|
||||
}
|
||||
// Disable interactive elements that rely on HTMX
|
||||
document.getElementById('clear-cache-btn')?.setAttribute('disabled', 'true');
|
||||
document.getElementById('submit-btn')?.setAttribute('disabled', 'true');
|
||||
document.querySelector('input[name="search"]')?.setAttribute('disabled', 'true');
|
||||
return; // Stop script execution
|
||||
}
|
||||
|
||||
// Get references to frequently used DOM elements
|
||||
const logOutput = document.getElementById('log-output');
|
||||
const logLoadingIndicator = document.getElementById('log-loading-indicator');
|
||||
const logAnimationWindow = document.getElementById('log-animation-window');
|
||||
const animationStatusText = document.getElementById('animation-status-text');
|
||||
const processWarningText = document.getElementById('process-warning-text'); // Get warning text element
|
||||
let currentEventSource = null; // Variable to hold the active Server-Sent Events connection
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
// Appends a formatted log message to the log output window
|
||||
function appendLogMessage(message) {
|
||||
if (!logOutput) return;
|
||||
const initialMsg = logOutput.querySelector('.text-muted.small'); // Find placeholder
|
||||
if (initialMsg) initialMsg.remove(); // Remove placeholder on first message
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString(); // Get current time for timestamp
|
||||
const logEntry = document.createElement('p'); // Create paragraph for the log line
|
||||
const messageText = String(message).replace(/^INFO:services:/, '').replace(/^INFO:app:/, '').trim(); // Clean message
|
||||
|
||||
logEntry.innerHTML = `<span class="log-timestamp">[${timestamp}]</span>`; // Add timestamp
|
||||
logEntry.appendChild(document.createTextNode(messageText)); // Add log message text safely
|
||||
|
||||
// Style error/warning messages
|
||||
if (messageText.startsWith("ERROR:") || messageText.startsWith("CRITICAL ERROR:")) {
|
||||
logEntry.style.color = "#f8d7da"; logEntry.style.backgroundColor = "#49272a";
|
||||
} else if (messageText.startsWith("WARNING:")) {
|
||||
logEntry.style.color = "#fff3cd";
|
||||
}
|
||||
|
||||
logOutput.appendChild(logEntry); // Add the log line to the window
|
||||
logOutput.scrollTop = logOutput.scrollHeight; // Scroll to the bottom
|
||||
}
|
||||
|
||||
// Clears the log window and resets the placeholder text
|
||||
function clearLogWindow() {
|
||||
if (logOutput) logOutput.innerHTML = '<p class="text-muted small">Waiting for logs...</p>';
|
||||
}
|
||||
|
||||
// Shows the animation window and sets the status text
|
||||
function showAnimation(statusText = '') {
|
||||
if (logAnimationWindow) logAnimationWindow.style.display = 'flex'; // Use flex to utilize justify/align center for campfire
|
||||
if (animationStatusText) animationStatusText.textContent = statusText; // Update status text
|
||||
if (processWarningText) processWarningText.style.display = 'block'; // Show the warning text
|
||||
}
|
||||
|
||||
// Hides the animation window and clears status/warning text
|
||||
function hideAnimation() {
|
||||
if (logAnimationWindow) logAnimationWindow.style.display = 'none';
|
||||
if (animationStatusText) animationStatusText.textContent = '';
|
||||
if (processWarningText) processWarningText.style.display = 'none'; // Hide the warning text
|
||||
}
|
||||
|
||||
// --- Server-Sent Events (SSE) Setup Function ---
|
||||
// Connects to the log stream and handles incoming messages
|
||||
function setupLogStream(onDoneCallback) {
|
||||
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close existing connection
|
||||
clearLogWindow(); // Clear previous logs
|
||||
if (logLoadingIndicator) logLoadingIndicator.style.display = 'block'; // Show "Connecting..."
|
||||
// Note: showAnimation() is now called by the function initiating the action
|
||||
|
||||
try {
|
||||
currentEventSource = new EventSource("{{ url_for('stream_import_logs') }}"); // Establish connection
|
||||
|
||||
// On successful connection opening
|
||||
currentEventSource.onopen = function() {
|
||||
console.log("SSE connection opened.");
|
||||
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none'; // Hide "Connecting..."
|
||||
// Animation should already be visible here if started correctly
|
||||
};
|
||||
|
||||
// On receiving a message from the server
|
||||
currentEventSource.onmessage = function(event) {
|
||||
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none'; // Hide indicator if still visible
|
||||
// Keep animation visible
|
||||
|
||||
// Check for the special [DONE] signal
|
||||
if (event.data === '[DONE]') {
|
||||
console.log("SSE stream finished ([DONE] received).");
|
||||
hideAnimation(); // Hide animation and warning text
|
||||
const lastLog = logOutput.lastElementChild?.textContent || ""; // Check last log for errors
|
||||
const hadError = lastLog.includes("ERROR:") || lastLog.includes("CRITICAL ERROR:");
|
||||
if (!hadError) { appendLogMessage("Operation complete."); } // Log completion if no errors seen
|
||||
|
||||
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close SSE
|
||||
if (onDoneCallback) onDoneCallback(true, hadError); // Trigger callback (success=true)
|
||||
} else {
|
||||
appendLogMessage(event.data); // Log the received message
|
||||
}
|
||||
};
|
||||
|
||||
// On SSE connection error
|
||||
currentEventSource.onerror = function(err) {
|
||||
console.error('SSE connection error:', err);
|
||||
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none';
|
||||
hideAnimation(); // Hide animation and warning text on error
|
||||
appendLogMessage("ERROR: Log streaming connection error or closed prematurely.");
|
||||
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; }
|
||||
if (onDoneCallback) onDoneCallback(false, true); // Trigger callback (success=false, error=true)
|
||||
};
|
||||
|
||||
} catch (e) { // On error initializing EventSource
|
||||
console.error('Failed to initialize SSE:', e);
|
||||
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none';
|
||||
hideAnimation(); // Hide animation and warning text on failure
|
||||
appendLogMessage("ERROR: Unable to establish log stream: " + (e.message || 'Unknown Error'));
|
||||
if (onDoneCallback) onDoneCallback(false, true); // Trigger callback (success=false, error=true)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page Load / Initial State Logic ---
|
||||
|
||||
// Check if the backend flagged that an initial fetch is happening
|
||||
const isFetchingInitial = {{ is_fetching | tojson }};
|
||||
if (isFetchingInitial) {
|
||||
console.log("Initial fetch detected, setting up log stream.");
|
||||
showAnimation('Refreshing cache...'); // Show animation and "Refreshing" text immediately
|
||||
setupLogStream(function(success, streamHadError) {
|
||||
console.log(`Initial fetch log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
|
||||
hideAnimation(); // Hide animation when stream ends
|
||||
// Backend handles the page update after its fetch finishes
|
||||
});
|
||||
}
|
||||
|
||||
// --- Event Listeners for Buttons ---
|
||||
|
||||
// Listener for "Clear & Refresh Cache" Button (HTMX Triggered)
|
||||
const clearCacheBtn = document.getElementById('clear-cache-btn');
|
||||
const cacheSpinner = document.getElementById('cache-spinner');
|
||||
if (clearCacheBtn) {
|
||||
// Before HTMX sends the request to start the cache refresh task
|
||||
clearCacheBtn.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
console.log("HTMX Clear Cache request starting.");
|
||||
clearCacheBtn.disabled = true; if(cacheSpinner) cacheSpinner.style.display = 'inline-block';
|
||||
showAnimation('Refreshing cache...'); // Show animation and set text
|
||||
// Setup the log stream, providing the callback for when SSE finishes
|
||||
setupLogStream(function(success, streamHadError) {
|
||||
// This runs ONLY when the SSE stream sends [DONE] or errors out
|
||||
console.log(`Clear cache log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
|
||||
hideAnimation(); // Hide animation and warning text
|
||||
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none'; // Re-enable button/hide spinner
|
||||
|
||||
// Determine message and reload page
|
||||
if (success && !streamHadError) { appendLogMessage("Cache refresh complete. Reloading page..."); }
|
||||
else if (success && streamHadError) { appendLogMessage("Cache refresh finished with errors. Reloading page..."); }
|
||||
else { appendLogMessage("ERROR: Cache refresh failed or log stream error. Reloading page..."); }
|
||||
setTimeout(() => window.location.reload(), 750); // Reload after slight delay
|
||||
});
|
||||
});
|
||||
|
||||
// After HTMX gets the *initial* response from the API (should be 202 Accepted)
|
||||
clearCacheBtn.addEventListener('htmx:afterRequest', function(evt) {
|
||||
console.log('HTMX Clear Cache API response received. Status:', evt.detail.xhr.status);
|
||||
if (evt.detail.failed || evt.detail.xhr.status >= 400) {
|
||||
// The API call itself failed to start the task
|
||||
hideAnimation(); // Hide animation/warning immediately
|
||||
console.error("Failed to trigger cache refresh API:", evt.detail);
|
||||
appendLogMessage(`ERROR: Could not start cache refresh (Server status: ${evt.detail.xhr.status})`);
|
||||
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none'; // Reset button
|
||||
// Close SSE stream if it was prematurely opened
|
||||
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; appendLogMessage("Log stream closed."); }
|
||||
} else if (evt.detail.xhr.status === 202) {
|
||||
// Success: Task started in background
|
||||
appendLogMessage("Cache refresh process started in background...");
|
||||
// Keep animation and warning text visible, waiting for SSE logs
|
||||
} else {
|
||||
// Unexpected success status from API
|
||||
hideAnimation(); // Hide animation/warning
|
||||
appendLogMessage(`Warning: Unexpected server response ${evt.detail.xhr.status}. Refresh may not have started.`);
|
||||
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none';
|
||||
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close stream
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listener for Import Button (HTMX Triggered)
|
||||
const importForm = document.getElementById('import-ig-form');
|
||||
const importSubmitBtn = document.getElementById('submit-btn');
|
||||
const importSpinner = document.getElementById('import-spinner');
|
||||
if (importForm && importSubmitBtn) {
|
||||
// Before HTMX sends the import request
|
||||
importSubmitBtn.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
console.log('HTMX import request starting.');
|
||||
importSubmitBtn.disabled = true; if(importSpinner) importSpinner.style.display = 'inline-block';
|
||||
showAnimation('Importing...'); // Show animation and set "Importing..." text
|
||||
// Setup log stream, callback runs when SSE part finishes
|
||||
setupLogStream(function(success, streamHadError) {
|
||||
console.log(`Import log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
|
||||
hideAnimation(); // Hide animation/warning when SSE finishes
|
||||
// Note: Redirect or final status is handled in 'afterRequest' below
|
||||
if (!success || streamHadError) { appendLogMessage("Warning: Log stream for import finished with errors or failed."); }
|
||||
});
|
||||
});
|
||||
|
||||
// After HTMX gets the response from the import API call
|
||||
importSubmitBtn.addEventListener('htmx:afterRequest', function(evt) {
|
||||
console.log('HTMX import request finished. Status:', evt.detail.xhr.status);
|
||||
importSubmitBtn.disabled = false; if(importSpinner) importSpinner.style.display = 'none'; // Reset button/spinner
|
||||
|
||||
// Ensure animation/warning are hidden if the main request fails quickly
|
||||
if (evt.detail.failed) hideAnimation();
|
||||
|
||||
// Check SSE state (though it should manage its own closure)
|
||||
if (currentEventSource) console.warn("SSE connection might still be open after import HTMX request completed.");
|
||||
|
||||
// Handle failed import API request
|
||||
if (evt.detail.failed) {
|
||||
appendLogMessage(`ERROR: Import request failed (Status: ${evt.detail.xhr.status})`);
|
||||
try { const errorData = JSON.parse(evt.detail.xhr.responseText); if (errorData?.message) appendLogMessage(` -> ${errorData.message}`); } catch(e) {}
|
||||
} else {
|
||||
// Handle successful import API request (parse JSON response for status/redirect)
|
||||
try {
|
||||
const data = JSON.parse(evt.detail.xhr.responseText);
|
||||
console.log("Import response data:", data);
|
||||
|
||||
// --- REDIRECT/STATUS LOGIC ---
|
||||
if (data?.status === 'success' && data.redirect) {
|
||||
appendLogMessage("Import successful. Redirecting...");
|
||||
setTimeout(() => { window.location.href = data.redirect; }, 1000); // Redirect after 1s
|
||||
} else if (data?.status === 'warning') {
|
||||
appendLogMessage(`Warning: ${data.message || 'Import partially successful.'}`); htmx.process(document.body); // Process flash messages if swapped
|
||||
} else if (data?.status === 'error') {
|
||||
appendLogMessage(`ERROR: ${data.message || 'Import failed.'}`); htmx.process(document.body); // Process flash messages if swapped
|
||||
} else {
|
||||
appendLogMessage("Import request completed (unexpected response format)."); htmx.process(document.body); // Process flash messages if swapped
|
||||
}
|
||||
// --- END REDIRECT/STATUS LOGIC ---
|
||||
|
||||
} catch (e) { // Handle JSON parsing error
|
||||
console.error("Error parsing import response JSON:", e);
|
||||
appendLogMessage("ERROR: Could not process server response after import.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- HTMX Search Event Listener (No changes needed) ---
|
||||
htmx.on('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.requestConfig?.path?.includes('api/search-packages')) { /* ... existing search error handling ... */ }
|
||||
});
|
||||
htmx.on('htmx:afterSwap', function(evt) { console.log("HTMX content swapped for target:", evt.detail.target.id); });
|
||||
|
||||
// --- Page Unload Cleanup ---
|
||||
// Close SSE connection if user navigates away or closes tab
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (currentEventSource) {
|
||||
console.log("Page unloading, closing SSE connection.");
|
||||
currentEventSource.close();
|
||||
currentEventSource = null;
|
||||
}
|
||||
});
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,302 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% 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">{{ title }}</h1>
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
Upload FHIR test data (JSON, XML, or ZIP containing JSON/XML) to a target server. The tool will attempt to parse resources, determine dependencies based on references, and upload them in the correct order. Optionally validate resources before uploading.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<h4 class="my-0 fw-normal"><i class="bi bi-cloud-upload me-2"></i>Upload Configuration</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<p><strong>Please correct the following errors:</strong></p>
|
||||
<ul>
|
||||
{% for field, errors in form.errors.items() %}
|
||||
<li>{{ form[field].label.text }}: {{ errors|join(', ') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="uploadTestDataForm" method="POST" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
{{ render_field(form.fhir_server_url, class="form-control form-control-lg") }}
|
||||
|
||||
<div class="row g-3 mb-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
{{ render_field(form.auth_type, class="form-select") }}
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
{{ render_field(form.auth_token, class="form-control") }}
|
||||
</div>
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
{{ render_field(form.username, class="form-control mb-2", placeholder="Username") }}
|
||||
{{ render_field(form.password, class="form-control", placeholder="Password", type="password") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ render_field(form.test_data_file, class="form-control") }}
|
||||
<small class="form-text text-muted">Select one or more .json, .xml files, or a single .zip file containing them.</small>
|
||||
|
||||
<div class="row g-3 mt-3 mb-3 align-items-center">
|
||||
<div class="col-md-6">
|
||||
{{ render_field(form.validate_before_upload) }}
|
||||
<small class="form-text text-muted">It is suggested to not validate against more than 500 files</small>
|
||||
</div>
|
||||
<div class="col-md-6" id="validationPackageGroup" style="display: none;">
|
||||
{{ render_field(form.validation_package_id, class="form-select") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
{{ render_field(form.upload_mode, class="form-select") }}
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
{{ render_field(form.use_conditional_uploads) }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ render_field(form.error_handling, class="form-select") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="uploadButton">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<i class="bi bi-arrow-up-circle me-2"></i>Upload and Process
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h4 class="my-0 fw-normal"><i class="bi bi-terminal me-2"></i>Processing Log & Results</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="liveConsole" class="border p-3 rounded bg-dark text-light mb-3" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
|
||||
<span class="text-muted">Processing output will appear here...</span>
|
||||
</div>
|
||||
<div id="uploadResponse" class="mt-3">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('uploadTestDataForm');
|
||||
const uploadButton = document.getElementById('uploadButton');
|
||||
const spinner = uploadButton ? uploadButton.querySelector('.spinner-border') : null;
|
||||
const liveConsole = document.getElementById('liveConsole');
|
||||
const responseDiv = document.getElementById('uploadResponse');
|
||||
const authTypeSelect = document.getElementById('auth_type');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerTokenInput');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const authTokenInput = document.getElementById('auth_token');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const fileInput = document.getElementById('test_data_file');
|
||||
const validateCheckbox = document.getElementById('validate_before_upload');
|
||||
const validationPackageGroup = document.getElementById('validationPackageGroup');
|
||||
const validationPackageSelect = document.getElementById('validation_package_id');
|
||||
const uploadModeSelect = document.getElementById('upload_mode');
|
||||
const conditionalUploadCheckbox = document.getElementById('use_conditional_uploads');
|
||||
|
||||
const sanitizeText = (str) => str ? String(str).replace(/</g, "<").replace(/>/g, ">") : "";
|
||||
|
||||
if (authTypeSelect && authInputsGroup && bearerTokenInput && basicAuthInputs) {
|
||||
authTypeSelect.addEventListener('change', function() {
|
||||
authInputsGroup.style.display = (this.value === 'bearerToken' || this.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = this.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = this.value === 'basic' ? 'block' : 'none';
|
||||
if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = '';
|
||||
if (this.value !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (this.value !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
});
|
||||
authInputsGroup.style.display = (authTypeSelect.value === 'bearerToken' || authTypeSelect.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authTypeSelect.value === 'basic' ? 'block' : 'none';
|
||||
} else {
|
||||
console.error("Auth elements not found.");
|
||||
}
|
||||
|
||||
if (validateCheckbox && validationPackageGroup) {
|
||||
const toggleValidationPackage = () => {
|
||||
validationPackageGroup.style.display = validateCheckbox.checked ? 'block' : 'none';
|
||||
};
|
||||
validateCheckbox.addEventListener('change', toggleValidationPackage);
|
||||
toggleValidationPackage();
|
||||
} else {
|
||||
console.error("Validation checkbox or package group not found.");
|
||||
}
|
||||
|
||||
if (uploadModeSelect && conditionalUploadCheckbox) {
|
||||
const toggleConditionalCheckbox = () => {
|
||||
conditionalUploadCheckbox.disabled = (uploadModeSelect.value !== 'individual');
|
||||
if (conditionalUploadCheckbox.disabled) {
|
||||
conditionalUploadCheckbox.checked = false;
|
||||
}
|
||||
};
|
||||
uploadModeSelect.addEventListener('change', toggleConditionalCheckbox);
|
||||
toggleConditionalCheckbox();
|
||||
} else {
|
||||
console.error("Upload mode select or conditional upload checkbox not found.");
|
||||
}
|
||||
|
||||
if (form && uploadButton && spinner && liveConsole && responseDiv && fileInput && validateCheckbox && validationPackageSelect && conditionalUploadCheckbox) {
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
console.log("Form submitted");
|
||||
|
||||
if (!fileInput.files || fileInput.files.length === 0) { alert('Please select at least one file.'); return; }
|
||||
const fhirServerUrl = document.getElementById('fhir_server_url').value.trim();
|
||||
if (!fhirServerUrl) { alert('Please enter the Target FHIR Server URL.'); return; }
|
||||
if (validateCheckbox.checked && !validationPackageSelect.value) { alert('Please select a package for validation.'); return; }
|
||||
if (authTypeSelect.value === 'basic') {
|
||||
if (!usernameInput.value.trim()) { alert('Please enter a username for Basic Authentication.'); return; }
|
||||
if (!passwordInput.value) { alert('Please enter a password for Basic Authentication.'); return; }
|
||||
}
|
||||
|
||||
uploadButton.disabled = true;
|
||||
if(spinner) spinner.style.display = 'inline-block';
|
||||
const uploadIcon = uploadButton.querySelector('i');
|
||||
if (uploadIcon) uploadIcon.style.display = 'none';
|
||||
liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting upload process...</div>`;
|
||||
responseDiv.innerHTML = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fhir_server_url', fhirServerUrl);
|
||||
formData.append('auth_type', authTypeSelect.value);
|
||||
if (authTypeSelect.value === 'bearerToken' && authTokenInput) {
|
||||
formData.append('auth_token', authTokenInput.value);
|
||||
} else if (authTypeSelect.value === 'basic') {
|
||||
formData.append('username', usernameInput.value);
|
||||
formData.append('password', passwordInput.value);
|
||||
}
|
||||
formData.append('upload_mode', uploadModeSelect.value);
|
||||
formData.append('error_handling', document.getElementById('error_handling').value);
|
||||
formData.append('validate_before_upload', validateCheckbox.checked ? 'true' : 'false');
|
||||
if (validateCheckbox.checked) { formData.append('validation_package_id', validationPackageSelect.value); }
|
||||
formData.append('use_conditional_uploads', conditionalUploadCheckbox.checked ? 'true' : 'false');
|
||||
|
||||
for (let i = 0; i < fileInput.files.length; i++) {
|
||||
formData.append('test_data_files', fileInput.files[i]);
|
||||
}
|
||||
|
||||
const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfTokenInput ? csrfTokenInput.value : "";
|
||||
const internalApiKey = {{ api_key | default("") | tojson }};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload-test-data', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Upload failed: ${response.status} ${response.statusText}`;
|
||||
try { const errorData = await response.json(); errorMsg = errorData.message || JSON.stringify(errorData); } catch (e) {}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
if (!response.body) { throw new Error("Response body missing."); }
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n'); buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info'; let prefix = '[INFO]';
|
||||
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; }
|
||||
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
|
||||
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
|
||||
else if (data.type === 'validation_info') { prefix = '[VALIDATION]'; messageClass = 'text-info'; }
|
||||
else if (data.type === 'validation_warning') { prefix = '[VALIDATION]'; messageClass = 'text-warning'; }
|
||||
else if (data.type === 'validation_error') { prefix = '[VALIDATION]'; messageClass = 'text-danger'; }
|
||||
|
||||
const messageDiv = document.createElement('div'); messageDiv.className = messageClass;
|
||||
let messageText = sanitizeText(data.message) || '...';
|
||||
if (data.details) { messageText += ` <small>(${sanitizeText(data.details)})</small>`; }
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${messageText}`;
|
||||
liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
|
||||
if (data.type === 'complete' && data.data) {
|
||||
const summary = data.data;
|
||||
let alertClass = 'alert-secondary';
|
||||
if (summary.status === 'success') alertClass = 'alert-success';
|
||||
else if (summary.status === 'partial') alertClass = 'alert-warning';
|
||||
else if (summary.status === 'failure') alertClass = 'alert-danger';
|
||||
|
||||
responseDiv.innerHTML = `
|
||||
<div class="alert ${alertClass} mt-3">
|
||||
<strong>Status: ${sanitizeText(summary.status)}</strong><br>
|
||||
${sanitizeText(summary.message)}<hr>
|
||||
Files Processed: ${summary.files_processed ?? 'N/A'}<br>
|
||||
Resources Parsed: ${summary.resources_parsed ?? 'N/A'}<br>
|
||||
Validation Errors: ${summary.validation_errors ?? 'N/A'}<br>
|
||||
Validation Warnings: ${summary.validation_warnings ?? 'N/A'}<br>
|
||||
Resources Uploaded: ${summary.resources_uploaded ?? 'N/A'}<br>
|
||||
Upload Errors: ${summary.error_count ?? 'N/A'}
|
||||
${summary.errors?.length > 0 ? '<br><strong>Details:</strong><ul>' + summary.errors.map(e => `<li>${sanitizeText(e)}</li>`).join('') + '</ul>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
} catch (parseError) { console.error('Stream parse error:', parseError, 'Line:', line); }
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim());
|
||||
if (data.type === 'complete' && data.data) { /* ... update summary ... */ }
|
||||
} catch (parseError) { console.error('Final buffer parse error:', parseError); }
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message)}</div>`;
|
||||
const ts = new Date().toLocaleTimeString(); const errDiv = document.createElement('div'); errDiv.className = 'text-danger'; errDiv.textContent = `${ts} [CLIENT_ERROR] ${sanitizeText(error.message)}`; liveConsole.appendChild(errDiv);
|
||||
} finally {
|
||||
uploadButton.disabled = false;
|
||||
if(spinner) spinner.style.display = 'none';
|
||||
const uploadIcon = uploadButton.querySelector('i');
|
||||
if (uploadIcon) uploadIcon.style.display = 'inline-block';
|
||||
}
|
||||
});
|
||||
} else { console.error("Could not find all required elements for form submission."); }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,376 +0,0 @@
|
||||
<!-- templates/validate_sample.html -->
|
||||
{% 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>
|
||||
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
|
||||
<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>
|
||||
----------------------------------------------------------------remove the buttons----------------------------------------------------->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">Validation Form</div>
|
||||
<div class="card-body">
|
||||
<form 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">
|
||||
<option value="">-- Select a Package --</option>
|
||||
{% if packages %}
|
||||
{% for pkg in packages %}
|
||||
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</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>
|
||||
{{ form.package_name(class="form-control", id=form.package_name.id) }}
|
||||
{% 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", id=form.version.id) }}
|
||||
{% 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>
|
||||
<button type="button" class="btn btn-primary" id="validateButton">Validate</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" id="validationResult" style="display: none;">
|
||||
<div class="card-header">Validation Report</div>
|
||||
<div class="card-body" id="validationContent">
|
||||
<!-- Results will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const packageSelect = document.getElementById('packageSelect');
|
||||
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
|
||||
const versionInput = document.getElementById('{{ form.version.id }}');
|
||||
const validateButton = document.getElementById('validateButton');
|
||||
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
|
||||
const validationResult = document.getElementById('validationResult');
|
||||
const validationContent = document.getElementById('validationContent');
|
||||
|
||||
// Debug DOM elements
|
||||
console.log('Package Select:', packageSelect ? 'Found' : 'Missing');
|
||||
console.log('Package Name Input:', packageNameInput ? 'Found' : 'Missing');
|
||||
console.log('Version Input:', versionInput ? 'Found' : 'Missing');
|
||||
|
||||
// Update package_name and version fields from dropdown
|
||||
function updatePackageFields(select) {
|
||||
const value = select.value;
|
||||
console.log('Selected package:', value);
|
||||
if (!packageNameInput || !versionInput) {
|
||||
console.error('Input fields not found');
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
const [name, version] = value.split('#');
|
||||
if (name && version) {
|
||||
packageNameInput.value = name;
|
||||
versionInput.value = version;
|
||||
console.log('Updated fields:', { packageName: name, version });
|
||||
} else {
|
||||
console.warn('Invalid package format:', value);
|
||||
packageNameInput.value = '';
|
||||
versionInput.value = '';
|
||||
}
|
||||
} else {
|
||||
packageNameInput.value = '';
|
||||
versionInput.value = '';
|
||||
console.log('Cleared fields: no package selected');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dropdown based on form values
|
||||
if (packageNameInput && versionInput && packageNameInput.value && versionInput.value) {
|
||||
const currentValue = `${packageNameInput.value}#${versionInput.value}`;
|
||||
if (packageSelect.querySelector(`option[value="${currentValue}"]`)) {
|
||||
packageSelect.value = currentValue;
|
||||
console.log('Initialized dropdown with:', currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug dropdown options
|
||||
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => opt.value));
|
||||
|
||||
// Attach event listener for package selection
|
||||
if (packageSelect) {
|
||||
packageSelect.addEventListener('change', function() {
|
||||
updatePackageFields(this);
|
||||
});
|
||||
}
|
||||
|
||||
// Client-side JSON validation preview
|
||||
if (sampleInput) {
|
||||
sampleInput.addEventListener('input', function() {
|
||||
try {
|
||||
if (this.value.trim()) {
|
||||
JSON.parse(this.value);
|
||||
this.classList.remove('is-invalid');
|
||||
this.classList.add('is-valid');
|
||||
} else {
|
||||
this.classList.remove('is-valid', 'is-invalid');
|
||||
}
|
||||
} catch (e) {
|
||||
this.classList.remove('is-valid');
|
||||
this.classList.add('is-invalid');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check HAPI server status
|
||||
function checkHapiStatus() {
|
||||
return fetch('/fhir/metadata', { method: 'GET' })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('HAPI server unavailable');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('HAPI status check failed:', error.message);
|
||||
validationContent.innerHTML = '<div class="alert alert-warning">HAPI server unavailable; using local validation.</div>';
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize text to prevent XSS
|
||||
function sanitizeText(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Validate button handler
|
||||
if (validateButton) {
|
||||
validateButton.addEventListener('click', function() {
|
||||
const packageName = packageNameInput.value;
|
||||
const version = versionInput.value;
|
||||
const sampleData = sampleInput.value.trim();
|
||||
const includeDependencies = document.getElementById('{{ form.include_dependencies.id }}').checked;
|
||||
|
||||
if (!packageName || !version) {
|
||||
validationResult.style.display = 'block';
|
||||
validationContent.innerHTML = '<div class="alert alert-danger">Please select a package and version.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sampleData) {
|
||||
validationResult.style.display = 'block';
|
||||
validationContent.innerHTML = '<div class="alert alert-danger">Please provide FHIR sample JSON.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(sampleData);
|
||||
} catch (e) {
|
||||
validationResult.style.display = 'block';
|
||||
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + sanitizeText(e.message) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
validationResult.style.display = 'block';
|
||||
validationContent.innerHTML = '<div class="text-center py-3"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Validating...</span></div></div>';
|
||||
|
||||
// Check HAPI status before validation
|
||||
checkHapiStatus().then(() => {
|
||||
console.log('Sending request to /api/validate-sample with:', {
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
include_dependencies: includeDependencies,
|
||||
sample_data_length: sampleData.length
|
||||
});
|
||||
|
||||
fetch('/api/validate-sample', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ form.csrf_token._value() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
include_dependencies: includeDependencies,
|
||||
sample_data: sampleData
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
console.log('Server response status:', response.status, response.statusText);
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
console.error('Server response text:', text.substring(0, 200) + (text.length > 200 ? '...' : ''));
|
||||
throw new Error(`HTTP error ${response.status}: ${text.substring(0, 100)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Validation response:', data);
|
||||
validationContent.innerHTML = '';
|
||||
let hasContent = false;
|
||||
|
||||
// Display profile information
|
||||
if (data.results) {
|
||||
const profileDiv = document.createElement('div');
|
||||
profileDiv.className = 'mb-3';
|
||||
const profiles = new Set();
|
||||
Object.values(data.results).forEach(result => {
|
||||
if (result.profile) profiles.add(result.profile);
|
||||
});
|
||||
if (profiles.size > 0) {
|
||||
profileDiv.innerHTML = `
|
||||
<h5>Validated Against Profile${profiles.size > 1 ? 's' : ''}</h5>
|
||||
<ul>
|
||||
${Array.from(profiles).map(p => `<li><a href="${sanitizeText(p)}" target="_blank">${sanitizeText(p.split('/').pop())}</a></li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
validationContent.appendChild(profileDiv);
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Display errors
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
hasContent = true;
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger';
|
||||
errorDiv.innerHTML = `
|
||||
<h5>Errors</h5>
|
||||
<ul>
|
||||
${data.errors.map(e => `<li>${sanitizeText(typeof e === 'string' ? e : e.message)}</li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
validationContent.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
// Display warnings
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
hasContent = true;
|
||||
const warningDiv = document.createElement('div');
|
||||
warningDiv.className = 'alert alert-warning';
|
||||
warningDiv.innerHTML = `
|
||||
<h5>Warnings</h5>
|
||||
<ul>
|
||||
${data.warnings.map(w => {
|
||||
const isMustSupport = w.includes('Must Support');
|
||||
return `<li>${sanitizeText(typeof w === 'string' ? w : w.message)}${isMustSupport ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
`;
|
||||
validationContent.appendChild(warningDiv);
|
||||
}
|
||||
|
||||
// Detailed results with collapsible sections
|
||||
if (data.results) {
|
||||
hasContent = true;
|
||||
const resultsDiv = document.createElement('div');
|
||||
resultsDiv.innerHTML = '<h5>Detailed Results</h5>';
|
||||
let index = 0;
|
||||
for (const [resourceId, result] of Object.entries(data.results)) {
|
||||
const collapseId = `result-collapse-${index++}`;
|
||||
resultsDiv.innerHTML += `
|
||||
<div class="card mb-2">
|
||||
<div class="card-header" role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}">
|
||||
<h6 class="mb-0">${sanitizeText(resourceId)}</h6>
|
||||
<p class="mb-0"><strong>Valid:</strong> ${result.valid ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div id="${collapseId}" class="collapse">
|
||||
<div class="card-body">
|
||||
${result.details && result.details.length > 0 ? `
|
||||
<ul>
|
||||
${result.details.map(d => `
|
||||
<li class="${d.severity === 'error' ? 'text-danger' : d.severity === 'warning' ? 'text-warning' : 'text-info'}">
|
||||
${sanitizeText(d.issue)}
|
||||
${d.description && d.description !== d.issue ? `<br><small class="text-muted">${sanitizeText(d.description)}</small>` : ''}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
` : '<p>No additional details.</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
validationContent.appendChild(resultsDiv);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const summaryDiv = document.createElement('div');
|
||||
summaryDiv.className = 'alert alert-info';
|
||||
summaryDiv.innerHTML = `
|
||||
<h5>Summary</h5>
|
||||
<p>Valid: ${data.valid ? 'Yes' : 'No'}</p>
|
||||
<p>Errors: ${data.summary.error_count || 0}, Warnings: ${data.summary.warning_count || 0}</p>
|
||||
`;
|
||||
validationContent.appendChild(summaryDiv);
|
||||
hasContent = true;
|
||||
|
||||
if (!hasContent && data.valid) {
|
||||
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
|
||||
}
|
||||
|
||||
// Initialize Bootstrap collapse and tooltips
|
||||
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const target = document.querySelector(el.getAttribute('data-bs-target'));
|
||||
target.classList.toggle('show');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
new bootstrap.Tooltip(el);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Validation error:', error);
|
||||
validationContent.innerHTML = `<div class="alert alert-danger">Error during validation: ${sanitizeText(error.message)}</div>`;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,648 +0,0 @@
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import tarfile
|
||||
import shutil
|
||||
import io
|
||||
import requests
|
||||
import time
|
||||
import subprocess
|
||||
from unittest.mock import patch, MagicMock, mock_open, call
|
||||
from flask import Flask, session
|
||||
from flask.testing import FlaskClient
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add the parent directory (/app) to sys.path
|
||||
APP_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
if APP_DIR not in sys.path:
|
||||
sys.path.insert(0, APP_DIR)
|
||||
|
||||
from app import app, db, ProcessedIg
|
||||
import services
|
||||
|
||||
# Helper function to parse NDJSON stream
|
||||
def parse_ndjson(byte_stream):
|
||||
decoded_stream = byte_stream.decode('utf-8').strip()
|
||||
if not decoded_stream:
|
||||
return []
|
||||
lines = decoded_stream.split('\n')
|
||||
return [json.loads(line) for line in lines if line.strip()]
|
||||
|
||||
class DockerComposeContainer:
|
||||
"""
|
||||
A class that follows the Testcontainers pattern for managing Docker Compose environments.
|
||||
This implementation uses subprocess to call docker-compose directly since we're not
|
||||
installing the testcontainers-python package.
|
||||
"""
|
||||
|
||||
def __init__(self, compose_file_path):
|
||||
"""
|
||||
Initialize with the path to the docker-compose.yml file
|
||||
|
||||
Args:
|
||||
compose_file_path: Path to the docker-compose.yml file
|
||||
"""
|
||||
self.compose_file = compose_file_path
|
||||
self.compose_dir = os.path.dirname(os.path.abspath(compose_file_path))
|
||||
self.containers_up = False
|
||||
self.service_ports = {}
|
||||
self._container_ids = {}
|
||||
|
||||
def __enter__(self):
|
||||
"""Start containers when entering context"""
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Stop containers when exiting context"""
|
||||
self.stop()
|
||||
|
||||
def with_service_port(self, service_name, port):
|
||||
"""
|
||||
Map a service port (following the testcontainers builder pattern)
|
||||
|
||||
Args:
|
||||
service_name: Name of the service in docker-compose.yml
|
||||
port: Port number to expose
|
||||
|
||||
Returns:
|
||||
self for chaining
|
||||
"""
|
||||
self.service_ports[service_name] = port
|
||||
return self
|
||||
|
||||
def start(self):
|
||||
"""Start the Docker Compose environment"""
|
||||
if self.containers_up:
|
||||
return self
|
||||
|
||||
print("Starting Docker Compose environment...")
|
||||
result = subprocess.run(
|
||||
['docker-compose', '-f', self.compose_file, 'up', '-d'],
|
||||
cwd=self.compose_dir,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f"Failed to start Docker Compose environment: {result.stderr}"
|
||||
print(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Store container IDs for later use
|
||||
self._get_container_ids()
|
||||
|
||||
self.containers_up = True
|
||||
self._wait_for_services()
|
||||
return self
|
||||
|
||||
def _get_container_ids(self):
|
||||
"""Get the container IDs for all services"""
|
||||
result = subprocess.run(
|
||||
['docker-compose', '-f', self.compose_file, 'ps', '-q'],
|
||||
cwd=self.compose_dir,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
container_ids = result.stdout.strip().split('\n')
|
||||
if not container_ids:
|
||||
return
|
||||
|
||||
# Get service names for each container
|
||||
for container_id in container_ids:
|
||||
if not container_id:
|
||||
continue
|
||||
|
||||
inspect_result = subprocess.run(
|
||||
['docker', 'inspect', '--format', '{{index .Config.Labels "com.docker.compose.service"}}', container_id],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if inspect_result.returncode == 0:
|
||||
service_name = inspect_result.stdout.strip()
|
||||
self._container_ids[service_name] = container_id
|
||||
|
||||
def get_container_id(self, service_name):
|
||||
"""
|
||||
Get the container ID for a specific service
|
||||
|
||||
Args:
|
||||
service_name: Name of the service in docker-compose.yml
|
||||
|
||||
Returns:
|
||||
Container ID as string or None if not found
|
||||
"""
|
||||
return self._container_ids.get(service_name)
|
||||
|
||||
def get_service_host(self, service_name):
|
||||
"""
|
||||
Get the host for a specific service - for Docker Compose we just use localhost
|
||||
|
||||
Args:
|
||||
service_name: Name of the service in docker-compose.yml
|
||||
|
||||
Returns:
|
||||
Host as string (usually localhost)
|
||||
"""
|
||||
return "localhost"
|
||||
|
||||
def get_service_url(self, service_name, path=""):
|
||||
"""
|
||||
Get the URL for a specific service
|
||||
|
||||
Args:
|
||||
service_name: Name of the service in docker-compose.yml
|
||||
path: Optional path to append to the URL
|
||||
|
||||
Returns:
|
||||
URL as string
|
||||
"""
|
||||
port = self.service_ports.get(service_name)
|
||||
if not port:
|
||||
raise ValueError(f"No port mapping defined for service {service_name}")
|
||||
|
||||
url = f"http://{self.get_service_host(service_name)}:{port}"
|
||||
if path:
|
||||
# Ensure path starts with /
|
||||
if not path.startswith('/'):
|
||||
path = f"/{path}"
|
||||
url = f"{url}{path}"
|
||||
|
||||
return url
|
||||
|
||||
def get_logs(self, service_name):
|
||||
"""
|
||||
Get logs for a specific service
|
||||
|
||||
Args:
|
||||
service_name: Name of the service in docker-compose.yml
|
||||
|
||||
Returns:
|
||||
Logs as string
|
||||
"""
|
||||
container_id = self.get_container_id(service_name)
|
||||
if not container_id:
|
||||
return f"No container found for service {service_name}"
|
||||
|
||||
result = subprocess.run(
|
||||
['docker', 'logs', container_id],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
return result.stdout
|
||||
|
||||
def stop(self):
|
||||
"""Stop the Docker Compose environment"""
|
||||
if not self.containers_up:
|
||||
return
|
||||
|
||||
print("Stopping Docker Compose environment...")
|
||||
result = subprocess.run(
|
||||
['docker-compose', '-f', self.compose_file, 'down'],
|
||||
cwd=self.compose_dir,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: Error stopping Docker Compose: {result.stderr}")
|
||||
|
||||
self.containers_up = False
|
||||
|
||||
def _wait_for_services(self):
|
||||
"""Wait for all services to be ready"""
|
||||
print("Waiting for services to be ready...")
|
||||
|
||||
# Wait for HAPI FHIR server
|
||||
if 'fhir' in self.service_ports:
|
||||
self._wait_for_http_service(
|
||||
self.get_service_url('fhir', 'fhir/metadata'),
|
||||
"HAPI FHIR server"
|
||||
)
|
||||
|
||||
# Wait for FHIRFLARE application
|
||||
if 'fhirflare' in self.service_ports:
|
||||
self._wait_for_http_service(
|
||||
self.get_service_url('fhirflare'),
|
||||
"FHIRFLARE application"
|
||||
)
|
||||
|
||||
# Give additional time for services to stabilize
|
||||
time.sleep(5)
|
||||
|
||||
def _wait_for_http_service(self, url, service_name, max_retries=30, retry_interval=2):
|
||||
"""
|
||||
Wait for an HTTP service to be ready
|
||||
|
||||
Args:
|
||||
url: URL to check
|
||||
service_name: Name of the service for logging
|
||||
max_retries: Maximum number of retries
|
||||
retry_interval: Interval between retries in seconds
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
print(f"{service_name} is ready after {attempt + 1} attempts")
|
||||
return True
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
print(f"Waiting for {service_name} (attempt {attempt + 1}/{max_retries})...")
|
||||
time.sleep(retry_interval)
|
||||
|
||||
print(f"Warning: {service_name} did not become ready in time")
|
||||
return False
|
||||
|
||||
class TestFHIRFlareIGToolkit(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Define the Docker Compose container
|
||||
compose_file_path = os.path.join(os.path.dirname(__file__), 'docker-compose.yml')
|
||||
cls.container = DockerComposeContainer(compose_file_path) \
|
||||
.with_service_port('fhir', 8080) \
|
||||
.with_service_port('fhirflare', 5000)
|
||||
|
||||
# Start the containers
|
||||
cls.container.start()
|
||||
|
||||
# Configure app for testing
|
||||
app.config['TESTING'] = True
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
cls.test_packages_dir = os.path.join(os.path.dirname(__file__), 'test_fhir_packages_temp')
|
||||
app.config['FHIR_PACKAGES_DIR'] = cls.test_packages_dir
|
||||
app.config['SECRET_KEY'] = 'test-secret-key'
|
||||
app.config['API_KEY'] = 'test-api-key'
|
||||
app.config['VALIDATE_IMPOSED_PROFILES'] = True
|
||||
app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = True
|
||||
app.config['HAPI_FHIR_URL'] = cls.container.get_service_url('fhir', 'fhir') # Point to containerized HAPI FHIR
|
||||
|
||||
cls.app_context = app.app_context()
|
||||
cls.app_context.push()
|
||||
db.create_all()
|
||||
cls.client = app.test_client()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.app_context.pop()
|
||||
if os.path.exists(cls.test_packages_dir):
|
||||
shutil.rmtree(cls.test_packages_dir)
|
||||
|
||||
# Stop Docker Compose environment
|
||||
cls.container.stop()
|
||||
|
||||
def setUp(self):
|
||||
if os.path.exists(self.test_packages_dir):
|
||||
shutil.rmtree(self.test_packages_dir)
|
||||
os.makedirs(self.test_packages_dir, exist_ok=True)
|
||||
with self.app_context:
|
||||
for item in db.session.query(ProcessedIg).all():
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
# Helper Method
|
||||
def create_mock_tgz(self, filename, files_content):
|
||||
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
|
||||
with tarfile.open(tgz_path, "w:gz") as tar:
|
||||
for name, content in files_content.items():
|
||||
if isinstance(content, (dict, list)):
|
||||
data_bytes = json.dumps(content).encode('utf-8')
|
||||
elif isinstance(content, str):
|
||||
data_bytes = content.encode('utf-8')
|
||||
else:
|
||||
raise TypeError(f"Unsupported type for mock file '{name}': {type(content)}")
|
||||
file_io = io.BytesIO(data_bytes)
|
||||
tarinfo = tarfile.TarInfo(name=name)
|
||||
tarinfo.size = len(data_bytes)
|
||||
tarinfo.mtime = int(datetime.now(timezone.utc).timestamp())
|
||||
tar.addfile(tarinfo, file_io)
|
||||
return tgz_path
|
||||
|
||||
# --- Phase 1 Tests ---
|
||||
|
||||
def test_01_navigate_fhir_path(self):
|
||||
resource = {
|
||||
"resourceType": "Patient",
|
||||
"name": [{"given": ["John"]}],
|
||||
"identifier": [{"system": "http://hl7.org/fhir/sid/us-ssn", "sliceName": "us-ssn"}],
|
||||
"extension": [{"url": "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", "valueAddress": {"city": "Boston"}}]
|
||||
}
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.name[0].given"), ["John"])
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.identifier:us-ssn.system"), "http://hl7.org/fhir/sid/us-ssn")
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.extension", extension_url="http://hl7.org/fhir/StructureDefinition/patient-birthPlace")["valueAddress"]["city"], "Boston")
|
||||
with patch('fhirpath.evaluate', side_effect=Exception("fhirpath error")):
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.name[0].given"), ["John"])
|
||||
|
||||
# --- Basic Page Rendering Tests ---
|
||||
|
||||
def test_03_homepage(self):
|
||||
# Connect to the containerized application
|
||||
response = requests.get(self.container.get_service_url('fhirflare'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('FHIRFLARE IG Toolkit', response.text)
|
||||
|
||||
def test_04_import_ig_page(self):
|
||||
response = requests.get(self.container.get_service_url('fhirflare', 'import-ig'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Import IG', response.text)
|
||||
self.assertIn('Package Name', response.text)
|
||||
self.assertIn('Package Version', response.text)
|
||||
self.assertIn('name="dependency_mode"', response.text)
|
||||
|
||||
# --- API Integration Tests ---
|
||||
|
||||
def test_30_load_ig_to_hapi_integration(self):
|
||||
"""Test loading an IG to the containerized HAPI FHIR server"""
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
self.create_mock_tgz(filename, {
|
||||
'package/package.json': {'name': pkg_name, 'version': pkg_version},
|
||||
'package/StructureDefinition-us-core-patient.json': {
|
||||
'resourceType': 'StructureDefinition',
|
||||
'id': 'us-core-patient',
|
||||
'url': 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient',
|
||||
'name': 'USCorePatientProfile',
|
||||
'type': 'Patient',
|
||||
'status': 'active'
|
||||
}
|
||||
})
|
||||
|
||||
# Load IG to HAPI
|
||||
response = self.client.post(
|
||||
'/api/load-ig-to-hapi',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['status'], 'success')
|
||||
|
||||
# Verify the resource was loaded by querying the HAPI FHIR server directly
|
||||
hapi_response = requests.get(self.container.get_service_url('fhir', 'fhir/StructureDefinition/us-core-patient'))
|
||||
self.assertEqual(hapi_response.status_code, 200)
|
||||
resource = hapi_response.json()
|
||||
self.assertEqual(resource['resourceType'], 'StructureDefinition')
|
||||
self.assertEqual(resource['id'], 'us-core-patient')
|
||||
|
||||
def test_31_validate_sample_with_hapi_integration(self):
|
||||
"""Test validating a sample against the containerized HAPI FHIR server"""
|
||||
# First, load the necessary StructureDefinition
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
self.create_mock_tgz(filename, {
|
||||
'package/package.json': {'name': pkg_name, 'version': pkg_version},
|
||||
'package/StructureDefinition-us-core-patient.json': {
|
||||
'resourceType': 'StructureDefinition',
|
||||
'id': 'us-core-patient',
|
||||
'url': 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient',
|
||||
'name': 'USCorePatientProfile',
|
||||
'type': 'Patient',
|
||||
'status': 'active',
|
||||
'snapshot': {
|
||||
'element': [
|
||||
{'path': 'Patient', 'min': 1, 'max': '1'},
|
||||
{'path': 'Patient.name', 'min': 1, 'max': '*'},
|
||||
{'path': 'Patient.identifier', 'min': 0, 'max': '*', 'mustSupport': True}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Load IG to HAPI
|
||||
self.client.post(
|
||||
'/api/load-ig-to-hapi',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
|
||||
# Validate a sample that's missing a required element
|
||||
sample_resource = {
|
||||
'resourceType': 'Patient',
|
||||
'id': 'test-patient',
|
||||
'meta': {'profile': ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient']}
|
||||
# Missing required 'name' element
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
'/api/validate-sample',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'sample_data': json.dumps(sample_resource),
|
||||
'mode': 'single',
|
||||
'include_dependencies': True
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['valid'])
|
||||
# Check for validation error related to missing name
|
||||
found_name_error = any('name' in error for error in data['errors'])
|
||||
self.assertTrue(found_name_error, f"Expected error about missing name element, got: {data['errors']}")
|
||||
|
||||
def test_32_push_ig_to_hapi_integration(self):
|
||||
"""Test pushing multiple resources from an IG to the containerized HAPI FHIR server"""
|
||||
pkg_name = 'test.push.pkg'
|
||||
pkg_version = '1.0.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
|
||||
# Create a test package with multiple resources
|
||||
self.create_mock_tgz(filename, {
|
||||
'package/package.json': {'name': pkg_name, 'version': pkg_version},
|
||||
'package/Patient-test1.json': {
|
||||
'resourceType': 'Patient',
|
||||
'id': 'test1',
|
||||
'name': [{'family': 'Test', 'given': ['Patient']}]
|
||||
},
|
||||
'package/Observation-test1.json': {
|
||||
'resourceType': 'Observation',
|
||||
'id': 'test1',
|
||||
'status': 'final',
|
||||
'code': {'coding': [{'system': 'http://loinc.org', 'code': '12345-6'}]}
|
||||
}
|
||||
})
|
||||
|
||||
# Push the IG to HAPI
|
||||
response = self.client.post(
|
||||
'/api/push-ig',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'fhir_server_url': self.container.get_service_url('fhir', 'fhir'),
|
||||
'include_dependencies': False
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
streamed_data = parse_ndjson(response.data)
|
||||
complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None)
|
||||
self.assertIsNotNone(complete_msg, "Complete message not found in streamed response")
|
||||
summary = complete_msg.get('data', {})
|
||||
self.assertTrue(summary.get('success_count') >= 2, f"Expected at least 2 successful resources, got {summary.get('success_count')}")
|
||||
|
||||
# Verify resources were loaded by querying the HAPI FHIR server directly
|
||||
patient_response = requests.get(self.container.get_service_url('fhir', 'fhir/Patient/test1'))
|
||||
self.assertEqual(patient_response.status_code, 200)
|
||||
patient = patient_response.json()
|
||||
self.assertEqual(patient['resourceType'], 'Patient')
|
||||
self.assertEqual(patient['id'], 'test1')
|
||||
|
||||
observation_response = requests.get(self.container.get_service_url('fhir', 'fhir/Observation/test1'))
|
||||
self.assertEqual(observation_response.status_code, 200)
|
||||
observation = observation_response.json()
|
||||
self.assertEqual(observation['resourceType'], 'Observation')
|
||||
self.assertEqual(observation['id'], 'test1')
|
||||
|
||||
# --- Existing API Tests ---
|
||||
|
||||
@patch('app.list_downloaded_packages')
|
||||
@patch('app.services.process_package_file')
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
@patch('os.path.exists')
|
||||
def test_40_api_import_ig_success(self, mock_os_exists, mock_import, mock_process, mock_list_pkgs):
|
||||
pkg_name = 'api.test.pkg'
|
||||
pkg_version = '1.2.3'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
pkg_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
|
||||
mock_import.return_value = {'requested': (pkg_name, pkg_version), 'processed': {(pkg_name, pkg_version)}, 'downloaded': {(pkg_name, pkg_version): pkg_path}, 'all_dependencies': {}, 'dependencies': [], 'errors': []}
|
||||
mock_process.return_value = {'resource_types_info': [], 'must_support_elements': {}, 'examples': {}, 'complies_with_profiles': ['http://prof.com/a'], 'imposed_profiles': [], 'errors': []}
|
||||
mock_os_exists.return_value = True
|
||||
mock_list_pkgs.return_value = ([{'name': pkg_name, 'version': pkg_version, 'filename': filename}], [], {})
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'dependency_mode': 'direct', 'api_key': 'test-api-key'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['status'], 'success')
|
||||
self.assertEqual(data['complies_with_profiles'], ['http://prof.com/a'])
|
||||
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
def test_41_api_import_ig_failure(self, mock_import):
|
||||
mock_import.return_value = {'requested': ('bad.pkg', '1.0'), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'dependencies': [], 'errors': ['HTTP error: 404 Not Found']}
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': 'bad.pkg', 'version': '1.0', 'api_key': 'test-api-key'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('Failed to import bad.pkg#1.0: HTTP error: 404 Not Found', data['message'])
|
||||
|
||||
def test_42_api_import_ig_invalid_key(self):
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': 'a', 'version': '1', 'api_key': 'wrong'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_43_api_import_ig_missing_key(self):
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': 'a', 'version': '1'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
# --- API Push Tests ---
|
||||
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch('app.services.get_package_metadata')
|
||||
@patch('tarfile.open')
|
||||
@patch('requests.Session')
|
||||
def test_50_api_push_ig_success(self, mock_session, mock_tarfile_open, mock_get_metadata, mock_os_exists):
|
||||
pkg_name = 'push.test.pkg'
|
||||
pkg_version = '1.0.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
fhir_server_url = self.container.get_service_url('fhir', 'fhir')
|
||||
mock_get_metadata.return_value = {'imported_dependencies': []}
|
||||
mock_tar = MagicMock()
|
||||
mock_patient = {'resourceType': 'Patient', 'id': 'pat1'}
|
||||
mock_obs = {'resourceType': 'Observation', 'id': 'obs1', 'status': 'final'}
|
||||
patient_member = MagicMock(spec=tarfile.TarInfo)
|
||||
patient_member.name = 'package/Patient-pat1.json'
|
||||
patient_member.isfile.return_value = True
|
||||
obs_member = MagicMock(spec=tarfile.TarInfo)
|
||||
obs_member.name = 'package/Observation-obs1.json'
|
||||
obs_member.isfile.return_value = True
|
||||
mock_tar.getmembers.return_value = [patient_member, obs_member]
|
||||
def mock_extractfile(member):
|
||||
if member.name == 'package/Patient-pat1.json':
|
||||
return io.BytesIO(json.dumps(mock_patient).encode('utf-8'))
|
||||
if member.name == 'package/Observation-obs1.json':
|
||||
return io.BytesIO(json.dumps(mock_obs).encode('utf-8'))
|
||||
return None
|
||||
mock_tar.extractfile.side_effect = mock_extractfile
|
||||
mock_tarfile_open.return_value.__enter__.return_value = mock_tar
|
||||
mock_session_instance = MagicMock()
|
||||
mock_put_response = MagicMock(status_code=200)
|
||||
mock_put_response.raise_for_status.return_value = None
|
||||
mock_session_instance.put.return_value = mock_put_response
|
||||
mock_session.return_value = mock_session_instance
|
||||
self.create_mock_tgz(filename, {'package/dummy.txt': 'content'})
|
||||
response = self.client.post(
|
||||
'/api/push-ig',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'fhir_server_url': fhir_server_url,
|
||||
'include_dependencies': False,
|
||||
'api_key': 'test-api-key'
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, 'application/x-ndjson')
|
||||
streamed_data = parse_ndjson(response.data)
|
||||
complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None)
|
||||
self.assertIsNotNone(complete_msg)
|
||||
summary = complete_msg.get('data', {})
|
||||
self.assertEqual(summary.get('status'), 'success')
|
||||
self.assertEqual(summary.get('success_count'), 2)
|
||||
self.assertEqual(len(summary.get('failed_details')), 0)
|
||||
mock_os_exists.assert_called_with(os.path.join(self.test_packages_dir, filename))
|
||||
|
||||
# --- Helper method to debug container issues ---
|
||||
|
||||
def test_99_print_container_logs_on_failure(self):
|
||||
"""Helper test that prints container logs in case of failures"""
|
||||
# This test should always pass but will print logs if other tests fail
|
||||
try:
|
||||
if hasattr(self, 'container') and self.container.containers_up:
|
||||
for service_name in ['fhir', 'db', 'fhirflare']:
|
||||
if service_name in self.container._container_ids:
|
||||
print(f"\n=== Logs for {service_name} ===")
|
||||
print(self.container.get_logs(service_name))
|
||||
except Exception as e:
|
||||
print(f"Error getting container logs: {e}")
|
||||
|
||||
# This assertion always passes - this test is just for debug info
|
||||
self.assertTrue(True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
@ -1,431 +0,0 @@
|
||||
{
|
||||
"resourceType" : "Bundle",
|
||||
"id" : "transaction-ex",
|
||||
"type" : "transaction",
|
||||
"entry" : [{
|
||||
"fullUrl" : "urn:uuid:64eb2d39-8da6-4c1d-b4c7-a6d3e916cd5b",
|
||||
"resource" : {
|
||||
"resourceType" : "Patient",
|
||||
"id" : "example-patient",
|
||||
"meta" : {
|
||||
"profile" : ["urn://example.com/ph-core/fhir/StructureDefinition/ph-core-patient"]
|
||||
},
|
||||
"text" : {
|
||||
"status" : "generated",
|
||||
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><a name=\"Patient_example-patient\"> </a>Juan Dela Cruz is a male patient born on 1 January 1980, residing in Manila, NCR, Philippines.</div>"
|
||||
},
|
||||
"extension" : [{
|
||||
"extension" : [{
|
||||
"url" : "code",
|
||||
"valueCodeableConcept" : {
|
||||
"coding" : [{
|
||||
"system" : "urn:iso:std:iso:3166",
|
||||
"code" : "PH",
|
||||
"display" : "Philippines"
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "period",
|
||||
"valuePeriod" : {
|
||||
"start" : "2020-01-01",
|
||||
"end" : "2023-01-01"
|
||||
}
|
||||
}],
|
||||
"url" : "http://hl7.org/fhir/StructureDefinition/patient-nationality"
|
||||
},
|
||||
{
|
||||
"url" : "http://hl7.org/fhir/StructureDefinition/patient-religion",
|
||||
"valueCodeableConcept" : {
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/v3-ReligiousAffiliation",
|
||||
"code" : "1007",
|
||||
"display" : "Atheism"
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/indigenous-people",
|
||||
"valueBoolean" : true
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/indigenous-group",
|
||||
"valueCodeableConcept" : {
|
||||
"coding" : [{
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/indigenous-groups",
|
||||
"code" : "Ilongots",
|
||||
"display" : "Ilongots"
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/race",
|
||||
"valueCodeableConcept" : {
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/v3-Race",
|
||||
"code" : "2036-2",
|
||||
"display" : "Filipino"
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"identifier" : [{
|
||||
"system" : "http://philhealth.gov.ph/fhir/Identifier/philhealth-id",
|
||||
"value" : "63-584789845-5"
|
||||
}],
|
||||
"active" : true,
|
||||
"name" : [{
|
||||
"family" : "Dela Cruz",
|
||||
"given" : ["Juan Jane",
|
||||
"Dela Fuente"]
|
||||
}],
|
||||
"gender" : "male",
|
||||
"birthDate" : "1985-06-15",
|
||||
"address" : [{
|
||||
"extension" : [{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/city-municipality",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "1380200000",
|
||||
"display" : "City of Las Piñas"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/city-municipality",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "1380100000",
|
||||
"display" : "City of Caloocan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/province",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "0402100000",
|
||||
"display" : "Cavite"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/province",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "0403400000",
|
||||
"display" : "Laguna"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/province",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "0405800000",
|
||||
"display" : "Rizal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/province",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "1704000000",
|
||||
"display" : "Marinduque"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/province",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "0402100000",
|
||||
"display" : "Cavite"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url" : "urn://example.com/ph-core/fhir/StructureDefinition/province",
|
||||
"valueCoding" : {
|
||||
"system" : "urn://example.com/ph-core/fhir/CodeSystem/PSGC",
|
||||
"code" : "1705100000",
|
||||
"display" : "Occidental Mindoro"
|
||||
}
|
||||
}],
|
||||
"line" : ["123 Mabini Street",
|
||||
"Barangay Malinis"],
|
||||
"city" : "Quezon City",
|
||||
"district" : "NCR",
|
||||
"postalCode" : "1100",
|
||||
"country" : "PH"
|
||||
}]
|
||||
},
|
||||
"request" : {
|
||||
"method" : "POST",
|
||||
"url" : "Patient"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl" : "urn:uuid:60b7132e-7cfd-44bc-83c2-de140dc8aaae",
|
||||
"resource" : {
|
||||
"resourceType" : "Encounter",
|
||||
"id" : "example-encounter",
|
||||
"meta" : {
|
||||
"profile" : ["urn://example.com/ph-core/fhir/StructureDefinition/ph-core-encounter"]
|
||||
},
|
||||
"text" : {
|
||||
"status" : "generated",
|
||||
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><a name=\"Encounter_example-encounter\"> </a>An ambulatory encounter for Juan Dela Cruz that has been completed.</div>"
|
||||
},
|
||||
"status" : "finished",
|
||||
"class" : {
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/v3-ActCode",
|
||||
"code" : "AMB",
|
||||
"display" : "ambulatory"
|
||||
},
|
||||
"subject" : {
|
||||
"reference" : "urn:uuid:64eb2d39-8da6-4c1d-b4c7-a6d3e916cd5b"
|
||||
}
|
||||
},
|
||||
"request" : {
|
||||
"method" : "POST",
|
||||
"url" : "Encounter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl" : "urn:uuid:1a391d1e-a068-479a-88e3-e3d52c3a6f64",
|
||||
"resource" : {
|
||||
"resourceType" : "Condition",
|
||||
"id" : "example-condition",
|
||||
"text" : {
|
||||
"status" : "generated",
|
||||
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><a name=\"Condition_example-condition\"> </a>Juan Dela Cruz has an active diagnosis of Type 2 Diabetes Mellitus.</div>"
|
||||
},
|
||||
"clinicalStatus" : {
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/condition-clinical",
|
||||
"code" : "active",
|
||||
"display" : "Active"
|
||||
}]
|
||||
},
|
||||
"code" : {
|
||||
"coding" : [{
|
||||
"system" : "http://snomed.info/sct",
|
||||
"code" : "44054006",
|
||||
"display" : "Diabetes mellitus type 2"
|
||||
}]
|
||||
},
|
||||
"subject" : {
|
||||
"reference" : "urn:uuid:64eb2d39-8da6-4c1d-b4c7-a6d3e916cd5b"
|
||||
},
|
||||
"encounter" : {
|
||||
"reference" : "urn:uuid:60b7132e-7cfd-44bc-83c2-de140dc8aaae"
|
||||
}
|
||||
},
|
||||
"request" : {
|
||||
"method" : "POST",
|
||||
"url" : "Condition"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl" : "urn:uuid:024dcb47-cc23-407a-839b-b4634e95abae",
|
||||
"resource" : {
|
||||
"resourceType" : "Medication",
|
||||
"id" : "example-medication",
|
||||
"meta" : {
|
||||
"profile" : ["urn://example.com/ph-core/fhir/StructureDefinition/ph-core-medication"]
|
||||
},
|
||||
"text" : {
|
||||
"status" : "generated",
|
||||
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><a name=\"Medication_example-medication\"> </a>A medication resource has been created, but no specific details are provided.</div>"
|
||||
}
|
||||
},
|
||||
"request" : {
|
||||
"method" : "POST",
|
||||
"url" : "Medication"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl" : "urn:uuid:013f46df-f245-4a2f-beaf-9eb2c47fb1a3",
|
||||
"resource" : {
|
||||
"resourceType" : "Observation",
|
||||
"id" : "blood-pressure",
|
||||
"meta" : {
|
||||
"profile" : ["urn://example.com/ph-core/fhir/StructureDefinition/ph-core-observation",
|
||||
"http://hl7.org/fhir/StructureDefinition/vitalsigns",
|
||||
"http://hl7.org/fhir/StructureDefinition/bp"]
|
||||
},
|
||||
"text" : {
|
||||
"status" : "generated",
|
||||
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><a name=\"Observation_blood-pressure\"> </a>On 17 September 2012, a blood pressure observation was recorded for Juan Dela Cruz. The systolic pressure was 107 mmHg (Normal), and the diastolic pressure was 60 mmHg (Below low normal). The measurement was taken from the right arm and performed by a practitioner.</div>"
|
||||
},
|
||||
"identifier" : [{
|
||||
"system" : "urn:ietf:rfc:3986",
|
||||
"value" : "urn:uuid:187e0c12-8dd2-67e2-99b2-bf273c878281"
|
||||
}],
|
||||
"basedOn" : [{
|
||||
"identifier" : {
|
||||
"system" : "https://acme.org/identifiers",
|
||||
"value" : "1234"
|
||||
}
|
||||
}],
|
||||
"status" : "final",
|
||||
"category" : [{
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/observation-category",
|
||||
"code" : "vital-signs",
|
||||
"display" : "Vital Signs"
|
||||
}]
|
||||
}],
|
||||
"code" : {
|
||||
"coding" : [{
|
||||
"system" : "http://loinc.org",
|
||||
"code" : "85354-9",
|
||||
"display" : "Blood pressure panel with all children optional"
|
||||
}],
|
||||
"text" : "Blood pressure systolic & diastolic"
|
||||
},
|
||||
"subject" : {
|
||||
"reference" : "urn:uuid:64eb2d39-8da6-4c1d-b4c7-a6d3e916cd5b"
|
||||
},
|
||||
"effectiveDateTime" : "2012-09-17",
|
||||
"performer" : [{
|
||||
"reference" : "urn:uuid:a036fd4c-c950-497b-8905-0d2c5ec6f1d4"
|
||||
}],
|
||||
"interpretation" : [{
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
|
||||
"code" : "L",
|
||||
"display" : "Low"
|
||||
}],
|
||||
"text" : "Below low normal"
|
||||
}],
|
||||
"bodySite" : {
|
||||
"coding" : [{
|
||||
"system" : "http://snomed.info/sct",
|
||||
"code" : "85050009",
|
||||
"display" : "Bone structure of humerus"
|
||||
}]
|
||||
},
|
||||
"component" : [{
|
||||
"code" : {
|
||||
"coding" : [{
|
||||
"system" : "http://loinc.org",
|
||||
"code" : "8480-6",
|
||||
"display" : "Systolic blood pressure"
|
||||
}]
|
||||
},
|
||||
"valueQuantity" : {
|
||||
"value" : 107,
|
||||
"unit" : "mmHg",
|
||||
"system" : "http://unitsofmeasure.org",
|
||||
"code" : "mm[Hg]"
|
||||
},
|
||||
"interpretation" : [{
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
|
||||
"code" : "N",
|
||||
"display" : "Normal"
|
||||
}],
|
||||
"text" : "Normal"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"code" : {
|
||||
"coding" : [{
|
||||
"system" : "http://loinc.org",
|
||||
"code" : "8462-4",
|
||||
"display" : "Diastolic blood pressure"
|
||||
}]
|
||||
},
|
||||
"valueQuantity" : {
|
||||
"value" : 60,
|
||||
"unit" : "mmHg",
|
||||
"system" : "http://unitsofmeasure.org",
|
||||
"code" : "mm[Hg]"
|
||||
},
|
||||
"interpretation" : [{
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
|
||||
"code" : "L",
|
||||
"display" : "Low"
|
||||
}],
|
||||
"text" : "Below low normal"
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"request" : {
|
||||
"method" : "POST",
|
||||
"url" : "Observation"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl" : "urn:uuid:b43c67e7-d9c4-48bb-a1b4-55769eeb9066",
|
||||
"resource" : {
|
||||
"resourceType" : "AllergyIntolerance",
|
||||
"id" : "example-allergy",
|
||||
"text" : {
|
||||
"status" : "generated",
|
||||
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><a name=\"AllergyIntolerance_example-allergy\"> </a>Juan Dela Cruz has a high criticality, active allergy to Benethamine penicillin.</div>"
|
||||
},
|
||||
"clinicalStatus" : {
|
||||
"coding" : [{
|
||||
"system" : "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
|
||||
"code" : "active",
|
||||
"display" : "Active"
|
||||
}]
|
||||
},
|
||||
"criticality" : "high",
|
||||
"code" : {
|
||||
"coding" : [{
|
||||
"system" : "http://snomed.info/sct",
|
||||
"code" : "294494002",
|
||||
"display" : "Benethamine penicillin allergy"
|
||||
}]
|
||||
},
|
||||
"patient" : {
|
||||
"reference" : "urn:uuid:64eb2d39-8da6-4c1d-b4c7-a6d3e916cd5b"
|
||||
}
|
||||
},
|
||||
"request" : {
|
||||
"method" : "POST",
|
||||
"url" : "AllergyIntolerance"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl" : "urn:uuid:a036fd4c-c950-497b-8905-0d2c5ec6f1d4",
|
||||
"resource" : {
|
||||
"resourceType" : "Practitioner",
|
||||
"id" : "example-practitioner",
|
||||
"meta" : {
|
||||
"profile" : ["urn://example.com/ph-core/fhir/StructureDefinition/ph-core-practitioner"]
|
||||
},
|
||||
"text" : {
|
||||
"status" : "generated",
|
||||
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><a name=\"Practitioner_example-practitioner\"> </a>Dr. Maria Clara Santos is a female practitioner born on May 15, 1985. She resides at 1234 Mabini Street, Manila, NCR, 1000, Philippines. She can be contacted via mobile at +63-912-345-6789 or by email at maria.santos@example.ph.</div>"
|
||||
},
|
||||
"name" : [{
|
||||
"family" : "Santos",
|
||||
"given" : ["Maria",
|
||||
"Clara"]
|
||||
}],
|
||||
"telecom" : [{
|
||||
"system" : "phone",
|
||||
"value" : "+63-912-345-6789",
|
||||
"use" : "mobile"
|
||||
},
|
||||
{
|
||||
"system" : "email",
|
||||
"value" : "maria.santos@example.ph",
|
||||
"use" : "work"
|
||||
}],
|
||||
"address" : [{
|
||||
"use" : "home",
|
||||
"line" : ["1234 Mabini Street"],
|
||||
"city" : "Manila",
|
||||
"state" : "NCR",
|
||||
"postalCode" : "1000",
|
||||
"country" : "PH"
|
||||
}],
|
||||
"gender" : "female",
|
||||
"birthDate" : "1985-05-15"
|
||||
},
|
||||
"request" : {
|
||||
"method" : "POST",
|
||||
"url" : "Practitioner"
|
||||
}
|
||||
}]
|
||||
}
|
Binary file not shown.
Binary file not shown.
@ -1,25 +0,0 @@
|
||||
2025-07-31 12:34:21,943 - services - DEBUG - Received validate-sample request
|
||||
2025-07-31 12:34:21,944 - services - DEBUG - Request params: package_name=example.fhir.ph.core.r4, version=0.1.0, sample_data_length=713
|
||||
2025-07-31 12:34:21,944 - services - DEBUG - Using FHIR_PACKAGES_DIR from current_app config: /app/instance/fhir_packages
|
||||
2025-07-31 12:34:21,944 - services - DEBUG - Checking package file: /app/instance/fhir_packages/example.fhir.ph.core.r4-0.1.0.tgz
|
||||
2025-07-31 12:34:21,944 - services - DEBUG - Validating AllergyIntolerance against example.fhir.ph.core.r4#0.1.0
|
||||
2025-07-31 12:34:21,944 - services - DEBUG - Using FHIR_PACKAGES_DIR from current_app config: /app/instance/fhir_packages
|
||||
2025-07-31 12:34:21,945 - services - DEBUG - Searching for SD matching 'AllergyIntolerance' with profile 'None' in example.fhir.ph.core.r4-0.1.0.tgz
|
||||
2025-07-31 12:34:21,956 - services - INFO - SD matching identifier 'AllergyIntolerance' or profile 'None' not found within archive example.fhir.ph.core.r4-0.1.0.tgz
|
||||
2025-07-31 12:34:21,956 - services - INFO - Validation result for AllergyIntolerance against example.fhir.ph.core.r4#0.1.0: valid=False, errors=1, warnings=0
|
||||
2025-07-31 12:34:21,957 - werkzeug - INFO - 10.0.0.102 - - [31/Jul/2025 12:34:21] "POST /api/validate-sample HTTP/1.1" 200 -
|
||||
2025-07-31 12:34:24,510 - werkzeug - INFO - 10.0.2.245 - - [31/Jul/2025 12:34:24] "GET / HTTP/1.1" 200 -
|
||||
2025-07-31 12:34:27,378 - werkzeug - INFO - 10.0.2.245 - - [31/Jul/2025 12:34:27] "GET / HTTP/1.1" 200 -
|
||||
2025-07-31 12:34:34,510 - werkzeug - INFO - 10.0.2.245 - - [31/Jul/2025 12:34:34] "GET / HTTP/1.1" 200 -
|
||||
2025-07-31 12:34:36,799 - __main__ - DEBUG - Scanning packages directory: /app/instance/fhir_packages
|
||||
2025-07-31 12:34:36,800 - __main__ - DEBUG - Found 8 .tgz files: ['PHCDI.r4-0.1.0.tgz', 'hl7.fhir.uv.ips-1.1.0.tgz', 'hl7.fhir.r4.core-4.0.1.tgz', 'fhir.dicom-2022.4.20221006.tgz', 'hl7.terminology.r4-5.0.0.tgz', 'example.fhir.ph.core.r4-0.1.0.tgz', 'hl7.terminology.r4-6.4.0.tgz', 'hl7.fhir.uv.extensions.r4-5.2.0.tgz']
|
||||
2025-07-31 12:34:36,813 - __main__ - DEBUG - Added package: PHCDI.r4#0.1.0
|
||||
2025-07-31 12:34:36,837 - __main__ - DEBUG - Added package: hl7.fhir.uv.ips#1.1.0
|
||||
2025-07-31 12:34:37,378 - werkzeug - INFO - 10.0.2.245 - - [31/Jul/2025 12:34:37] "GET / HTTP/1.1" 200 -
|
||||
2025-07-31 12:34:37,514 - __main__ - DEBUG - Added package: hl7.fhir.r4.core#4.0.1
|
||||
2025-07-31 12:34:37,622 - __main__ - DEBUG - Added package: fhir.dicom#2022.4.20221006
|
||||
2025-07-31 12:34:38,008 - __main__ - DEBUG - Added package: hl7.terminology.r4#5.0.0
|
||||
2025-07-31 12:34:38,015 - __main__ - DEBUG - Added package: example.fhir.ph.core.r4#0.1.0
|
||||
2025-07-31 12:34:38,413 - __main__ - DEBUG - Added package: hl7.terminology.r4#6.4.0
|
||||
2025-07-31 12:34:38,524 - __main__ - DEBUG - Added package: hl7.fhir.uv.extensions.r4#5.2.0
|
||||
2025-07-31 12:34:38,525 - __main__ - DEBUG - Set package choices: [('', 'None'), ('PHCDI.r4#0.1.0', 'PHCDI.r4#0.1.0'), ('example.fhir.ph.core.r4#0.1.0', 'example.fhir.ph.core.r4#0.1.0'), ('fhir.dicom#2022.4.20221006', 'fhir.dicom#2022.4.20221006'), ('hl7.fhir.r4.core#4.0.1', 'hl7.fhir.r4.core#4.0.1'), ('hl7.fhir.uv.extensions.r4#5.2.0', 'hl7.fhir.uv.extensions.r4#5.2.0'), ('hl7.fhir.uv.ips#1.1.0', 'hl7.fhir.uv.ips#1.1.0'), ('hl7.terminology.r4#5.0.0', 'hl7.terminology.r4#5.0.0'), ('hl7.terminology.r4#6.4.0', 'hl7.terminology.r4#6.4.0')]
|
Loading…
x
Reference in New Issue
Block a user