diff --git a/.env.jinja b/.env.jinja index 4c11fe6..764d21f 100644 --- a/.env.jinja +++ b/.env.jinja @@ -56,5 +56,5 @@ APPLE_TOPIC={{ app_id }} # compose configuration - files and project name: COMPOSE_PATH_SEPARATOR=; -COMPOSE_FILE=compose.yaml{% if use_ofelia %};compose.ofelia.yaml{% endif %}{% if db_use_adminer %};compose.adminer.yaml{% endif %} +COMPOSE_FILE=compose.yaml{% if use_ofelia %};compose.ofelia.yaml{% endif %}{% if use_orms %};compose.orms.yaml{% endif %}{% if db_use_adminer %};compose.adminer.yaml{% endif %} # COMPOSE_PROJECT_NAME=opal_${ENVIRONMENT} diff --git a/.envs/orms.env.jinja b/.envs/orms.env.jinja new file mode 100644 index 0000000..8c582ae --- /dev/null +++ b/.envs/orms.env.jinja @@ -0,0 +1,101 @@ +# WEB SERVER SETTINGS +BASE_PATH=/var/www/orms +BASE_URL=https://${APP_HOST}:${HTTPS_PORT}/orms +IMAGE_PATH=/var/www/orms/images +IMAGE_URL=https://${APP_HOST}/orms/images +LOG_PATH=/var/www/orms/logs + +# DATABASE CONNECTION SETTINGS +# ORMS database settings +ORMS_DATABASE_USER=${DB_USER} +ORMS_DATABASE_PASSWORD=${DB_PASSWORD} +ORMS_DATABASE_HOST=${DB_HOST} +ORMS_DATABASE_NAME=OrmsDatabase +ORMS_DATABASE_PORT=${DB_PORT} + +# Log database settings +LOG_DATABASE_USER=${DB_USER} +LOG_DATABASE_PASSWORD=${DB_PASSWORD} +LOG_DATABASE_HOST=${DB_HOST} +LOG_DATABASE_NAME=OrmsLog +LOG_DATABASE_PORT=${DB_PORT} + +# FIREBASE SETTINGS +# URL of the Firebase realtime database (NOTE! The slash is required after .com) +FIREBASE_CONFIG_PATH=/config/firebase-config.json +FIREBASE_BRANCH=/dev3/${INSTITUTION_CODE}/orms/ + +# OPAL INTERFACE ENGINE (OIE) SETTINGS +# Setting to enable/disable OIE communication +OIE_ENABLED=0 +#OIE_URL=https://${OIE_HOST}:${OIE_PORT} +OIE_URL= +OIE_USERNAME= +OIE_PASSWORD= + +# SMS SETTINGS +# Setting to enable/disable SMS messages +SMS_ENABLED=0 +SMS_PROVIDER=twilio +SMS_LICENCE_KEY= +SMS_TOKEN= +SMS_REMINDER_CRON_ENABLED=0 +SMS_INCOMING_SMS_CRON_ENABLED=0 + +# NEW OPAL ADMIN SETTINGS +NEW_OPAL_ADMIN_HOST_INTERNAL=http://admin:8000 +NEW_OPAL_ADMIN_HOST_EXTERNAL=https://${APP_HOST}:${HTTPS_PORT} +NEW_OPAL_ADMIN_TOKEN={{ orms_token }} + + +LEGACY_OPAL_ADMIN_HOST_INTERNAL=http://admin-legacy:8080 +# API settings for direct requests from orms to legacy OA endpoints +LEGACY_OPAL_ADMIN_API_USERNAME=orms +LEGACY_OPAL_ADMIN_API_PASSWORD={{ orms_password }} +LEGACY_OPAL_ADMIN_HOST_EXTERNAL=https://${APP_HOST}:${HTTPS_PORT}/opalAdmin + +# SETTINGS FOR THE WEIGHTS +# Setting to enable/disable sending weights PDF to the OIE +SEND_WEIGHTS=0 + +# SETTINGS FOR THE VIRTUAL WAITING ROOM +# Setting for enabling cron job +VWR_CRON_ENABLED=0 + +# MESSAGE TRANSLATIONS +FAILED_CHECK_IN_MESSAGE_EN = "There is a problem checking in for your appointment(s). If you have an appointment today, please go to the reception to complete the check-in process." +FAILED_CHECK_IN_MESSAGE_FR = "Une erreur s'est produite lors de l'enregistrement pour votre/vos rendez-vous. Si vous avez un rendez-vous aujourd'hui, veuillez vous rendre à la réception pour terminer le processus d'enregistrement." +UNKNOWN_COMMAND_MESSAGE_EN = "You have not been checked-in. To check-in for an appointment, please reply with the word \"arrive\". No other messages are accepted." +UNKNOWN_COMMAND_MESSAGE_FR = "Vous n\'avez pas été enregistré(e). Pour vous enregister pour votre rendez-vous, svp repondez \"arrive\". Aucun autre message ne sera accepté." + +# List of the email addresses (a.k.a., recipients) who should get an email when new appointment type detected +# The list should be separated by comma (e.g., ",") with no space +EMAIL= + +# List of long codes +# TODO: investigate what list values we need to provide +# The list should be separated by comma (e.g., ",") with no space +# REGISTERED_LONG_CODES= + +# SMTP MAILER SETTINGS +# ORMS sends emails in 2 cases: +# - An error occurs in a cron +# - A new appointment code / clinic code combination is detected. +# This was supposed to be used to notify the staff that there's +# a new appointment type that should be enabled for SMS +RECIPIENT_EMAILS= +EMAIL_HOST= +EMAIL_USER= +EMAIL_PASSWORD= +EMAIL_SENT_FROM_ADDRESS= +EMAIL_PORT=587 + +DATABASE_USE_SSL=${DB_USE_TLS} +{% if db_use_tls -%} +# Path to your CA public key file; used for DB connections if DATABASE_USE_SSL is enabled +SSL_CA=${DB_CERTS} +{% endif -%} + +# Source System host for appointment location updates from orms (kiosk/vwr/sms patient checkins) +SOURCE_SYSTEM_SUPPORTS_CHECKIN=0 +SOURCE_SYSTEM_HOST_EXTERNAL= diff --git a/README.md b/README.md index 50155b2..47f0402 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Prepare a directory for these files with the following contents: - `firebase-admin-key.json`: The private key of the service account used by the [Firebase Admin SDK](https://firebase.google.com/docs/database/admin/start#admin-sdk-authentication) - `apn.crt` and `apn.key`: The public and private certificates for the Apple Push Notification service. - The private key cannot be password-protected + The private key cannot be password-protected. ### Details @@ -226,6 +226,13 @@ Some questions are conditional based on your answer to a previous question. It is an alternative to using the cron daemon on the host. *Ofelia* sends emails for any failing job. +1. **Do you want to use the room management component (ORMS)?** + + [ORMS](https://github.com/opalmedapps/#:~:text=opal%2Drms) provides a virtual waiting room. + Patients can check-in for an appointment via a kiosk. + Clinical staff can see checked in patients and call them to a room. + The called patients can be displayed on a TV screen. + 1. **The Firebase project name** The name of the Firebase project that is used for communication with the mobile app. diff --git a/compose.orms.yaml b/compose.orms.yaml new file mode 100644 index 0000000..1b6dc20 --- /dev/null +++ b/compose.orms.yaml @@ -0,0 +1,49 @@ +services: + memcached: + image: memcached:1.6.40-alpine3.23 + environment: + - TZ=${TIMEZONE} + orms: + image: ghcr.io/opalmedapps/opal-rms:main + restart: unless-stopped + depends_on: + - memcached + - db + env_file: + - $PWD/.envs/orms.env + environment: + - PHP_MEMORY_LIMIT=512M + - TZ=${TIMEZONE} + volumes: + - type: bind + source: $PWD/config/firebase/web-config.json + target: /config/firebase-config.json + read_only: true + labels: + - "ofelia.enabled=true" + - "ofelia.job-exec.vwr-appointments.schedule=@every 3s" + - "ofelia.job-exec.vwr-appointments.command=php php/cron/generateVwrAppointments.php" + - "ofelia.job-exec.vwr-appointments.no-overlap=true" + - "ofelia.job-exec.vwr-appointments.user=www-data" + # - "ofelia.job-exec.incoming-sms.schedule=@every 5s" + # - "ofelia.job-exec.incoming-sms.command=php php/cron/processIncomingSmsMessages.php" + # - "ofelia.job-exec.incoming-sms.no-overlap=true" + - "ofelia.job-exec.appointment-reminder.schedule=0 0 18 * * *" + - "ofelia.job-exec.appointment-reminder.command=php php/cron/generateAppointmentReminders.php" + - "ofelia.job-exec.checkout.schedule=0 30 23 * * *" + - "ofelia.job-exec.checkout.command=php php/cron/endOfDayCheckout.php" + admin: + environment: + - ORMS_ENABLED=True + - ORMS_HOST=https://${APP_HOST}/orms + admin-legacy: + environment: + - ORMS_ENABLED=1 + - ORMS_HOST=https://${APP_HOST}/orms + listener: + environment: + - ORMS_ENABLED=1 + - CHECKIN_ROOM=OPAL PHONE APP + # URL notified when a questionnaire is completed + - QUESTIONNAIRE_COMPLETED_URL=http://orms:8080/orms/php/api/public/v1/patient/notifyNewQuestionnaireResponse + - ORMS_CHECKIN_URL=http://orms:8080/orms/php/api/public/v1/patient/checkInToLocation diff --git a/config/traefik/dynamic-config/services.yaml.jinja b/config/traefik/dynamic-config/services.yaml.jinja index 81bed78..f07cf48 100644 --- a/config/traefik/dynamic-config/services.yaml.jinja +++ b/config/traefik/dynamic-config/services.yaml.jinja @@ -56,6 +56,17 @@ http: tls: {} {%- endif %} + {% if use_orms -%} + orms: + rule: {% raw %}'Host(`{{ env "APP_HOST" }}`) && PathPrefix(`/orms`)'{% endraw %} + entryPoints: + - web-secure + middlewares: + - redirect-missing-slash + service: orms + tls: {} + {%- endif %} + middlewares: redirect-root: redirectRegex: @@ -113,3 +124,10 @@ http: servers: - url: http://adminer:8080 {%- endif %} + + {% if use_orms -%} + orms: + loadBalancer: + servers: + - url: http://orms:8080 + {%- endif %} diff --git a/copier.yaml b/copier.yaml index b98b419..1e591ae 100644 --- a/copier.yaml +++ b/copier.yaml @@ -175,6 +175,11 @@ use_ofelia: help: Do you want to use Ofelia as a job scheduler to run period jobs? default: true +use_orms: + type: bool + help: Do you want to use the room management component (ORMS)? + default: false + firebase_project_name: type: str help: The Firebase project name @@ -223,6 +228,12 @@ interface_engine_token: default: "{% if run_setup %}{{ random_token(20) }}{% endif %}" when: false +orms_token: + type: str + default: "{{ existing_secret('.envs/orms.env', 'NEW_OPAL_ADMIN_TOKEN') or random_token(20) }}" + when: false + + admin_token: type: str default: "{{ existing_secret('.envs/admin-legacy.env', 'NEW_OPALADMIN_TOKEN') or random_token(20) }}" @@ -238,6 +249,11 @@ labs_password: default: "{% if run_setup %}{{ random_password(20) }}{% endif %}" when: false +orms_password: + type: str + default: "{% if run_setup %}{{ random_password(20) }}{% endif %}" + when: false + _message_after_copy: | Your project "{{ project_name }}" has been created successfully! @@ -354,11 +370,19 @@ _tasks: - command: | echo "Running init_db script to initialize DB..." ENVIRONMENT="{{ environment }}" DB_ROOT_USER="{{ db_root_user }}" DB_ROOT_PASSWORD="{{ db_root_password }}" DB_HOST="{{ db_host }}" DB_PORT="{{ db_port }}" DB_USER="{{ db_user }}" DB_PASSWORD="{{ db_password }}" DB_NAME=admin bash ./scripts/init_db.sh + - command: | + echo "Running init_orms script to initialize ORMS DB..." + ENVIRONMENT="{{ environment }}" DB_ROOT_USER="{{ db_root_user }}" DB_ROOT_PASSWORD="{{ db_root_password }}" DB_HOST="{{ db_host }}" DB_PORT="{{ db_port }}" DB_USER="{{ db_user }}" DB_PASSWORD="{{ db_password }}" DB_NAME=admin bash ./scripts/init_orms.sh + when: "{{ use_orms }}" # run admin - docker compose up -d admin # Migrate schemas and initialize data - command: | - LISTENER_TOKEN="{{ listener_token }}" LISTENER_REGISTRATION_TOKEN="{{ listener_registration_token }}" INTERFACE_ENGINE_TOKEN="{{ interface_engine_token }}" INTERFACE_ENGINE_PASSWORD="{{ labs_password }}" ADMIN_TOKEN="{{ admin_token }}" ADMIN_PASSWORD={{ admin_password }} ./scripts/initialize.sh + LISTENER_TOKEN="{{ listener_token }}" LISTENER_REGISTRATION_TOKEN="{{ listener_registration_token }}" INTERFACE_ENGINE_TOKEN="{{ interface_engine_token }}" INTERFACE_ENGINE_PASSWORD="{{ labs_password }}" ADMIN_TOKEN="{{ admin_token }}" ADMIN_PASSWORD="{{ admin_password }}" ./scripts/initialize.sh + # explicitly set a password for the orms user to allow logins on admin-legacy + - command: | + docker compose exec admin python manage.py shell -c 'user = User.objects.get(username="orms"); user.set_password("{{ orms_password }}"); user.save();' + when: "{{ use_orms }}" # run the remaining services - command: | docker compose up -d @@ -394,7 +418,11 @@ _exclude: - "renovate.json5" - "ruff.toml" - "zizmor.yml" + - "LICENSE" # - "README.md" - extensions + - "{% if not db_use_adminer %}compose.adminer.yaml{% endif %}" + - "{% if not use_orms %}compose.orms.yaml{% endif %}" + - "{% if not use_ofelia %}compose.ofelia.yaml{% endif %}" - "{% if not is_test %}scripts/cleanup_db.sh{% endif %}" - "{% if not is_test %}tests{% endif %}" diff --git a/ctt.toml b/ctt.toml index 10cceed..49605ca 100644 --- a/ctt.toml +++ b/ctt.toml @@ -17,8 +17,9 @@ _extra_tasks = [ [output.".ctt/defaults"] _extra_tasks = [ + "! ls compose.orms.yaml", "uv run tests/admin_login.py '{{ admin_password }}'", - "uv run tests/system_user_login.py '{{ labs_password }}'", + "uv run tests/system_user_login.py interface-engine '{{ labs_password }}'", "uv run tests/labs_basic_auth.py '{{ labs_password }}'", "uv run tests/validate_token.py admin_token {{ admin_token }}", "uv run tests/validate_token.py listener_token {{ listener_token }}", @@ -64,3 +65,12 @@ db_root_password = "root-password" # TODO: add test case with TLS enabled db_use_tls = 0 use_custom_certs = false + +# orms enabled +[output.".ctt/orms_enabled"] +use_orms = true +_extra_tasks = [ + "ls compose.orms.yaml", + "uv run tests/system_user_login.py orms {{ orms_password }}", + "docker compose down", +] diff --git a/scripts/init_orms.sh b/scripts/init_orms.sh new file mode 100644 index 0000000..535b150 --- /dev/null +++ b/scripts/init_orms.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +# renovate: datasource=docker depName=alpine +ALPINE_VERSION="3.23.2" +WAIT_FOR_IT_VERSION="latest" + +echo "Waiting for DB container to be ready..." +docker run --rm --interactive --network opal-${ENVIRONMENT} chainguard/wait-for-it:${WAIT_FOR_IT_VERSION} --host="$DB_HOST" --port="$DB_PORT" --timeout=20 + +echo "Running container for mysql-client..." +docker run --rm --interactive \ + --env DB_ROOT_USER=${DB_ROOT_USER} \ + --env DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} \ + --env DB_HOST=${DB_HOST} \ + --env DB_USER=${DB_USER} \ + --env DB_PASSWORD=${DB_PASSWORD} \ + --network opal-${ENVIRONMENT} \ + alpine:${ALPINE_VERSION} sh -s << EOF +set -euo pipefail +apk add --no-cache mysql-client +echo "Connecting to DB server on ${DB_HOST}:${DB_PORT}..." +echo "Creating ORMS DBs..." +MYSQL_PWD=${DB_ROOT_PASSWORD} mariadb --protocol tcp --skip-ssl --user ${DB_ROOT_USER} --host ${DB_HOST} --port ${DB_PORT} <<'EOIF' +CREATE DATABASE IF NOT EXISTS \`OrmsDatabase\` /*!40100 DEFAULT CHARACTER SET latin1 */; +CREATE DATABASE IF NOT EXISTS \`OrmsLog\` /*!40100 DEFAULT CHARACTER SET latin1 */; +EOIF +echo "Successfully created ORMS DBs" +echo "Granting privileges to DB user..." +MYSQL_PWD=${DB_ROOT_PASSWORD} mariadb --protocol tcp --skip-ssl --user ${DB_ROOT_USER} --host ${DB_HOST} --port ${DB_PORT} <<'EOIF' +GRANT ALL PRIVILEGES ON \`OrmsDatabase\`.* TO '$DB_USER'@'%'; +GRANT ALL PRIVILEGES ON \`OrmsLog\`.* TO '$DB_USER'@'%'; +FLUSH PRIVILEGES; +EOIF +echo "Successfully granted privileges to DB user" +echo "Done!" +EOF diff --git a/scripts/refresh_data_orms.sh b/scripts/refresh_data_orms.sh new file mode 100644 index 0000000..69419e4 --- /dev/null +++ b/scripts/refresh_data_orms.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Description: Refresh data in a non-production environment + +set -euo pipefail + +echo "Beginning ORMS test data reset..." + +# Check for required arguments +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + echo "Valid institutions: OMI, OHIGPH" + exit 1 +fi + +institution=$1 +institution_lower=$(echo $1 | tr '[:upper:]' '[:lower:]') + +# Validate the institution +if [[ "$institution" != "OMI" && "$institution" != "OHIGPH" ]]; then + echo "Invalid institution: $institution" + echo "Valid institutions: OMI, OHIGPH" + exit 1 +fi + +set -euxo pipefail + +echo "Upgrading OrmsDB..." +docker compose run --rm db-management alembic --name ormsdb upgrade head +echo "Upgrading OrmsLogDB..." +docker compose run --rm db-management alembic --name ormslogdb upgrade head + +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsDB db_management/ormsdb/data/truncate/ +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsLogDB db_management/ormslogdb/data/truncate/ + +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsDB db_management/ormsdb/data/initial/ +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsDB db_management/ormsdb/data/test/ +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsDB db_management/ormsdb/data/test/$institution_lower/ +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsLogDB db_management/ormslogdb/data/initial/ +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsLogDB db_management/ormslogdb/data/test/ +docker compose run --rm db-management python -m db_management.run_sql_scripts OrmsLogDB db_management/ormslogdb/data/test/$institution_lower/ + +docker compose exec admin python manage.py update_orms_patients + +echo "ORMS test data successfully reset." diff --git a/tests/admin_login.py b/tests/admin_login.py index cbf9dff..7d68e34 100644 --- a/tests/admin_login.py +++ b/tests/admin_login.py @@ -8,7 +8,10 @@ import requests import typer +app = typer.Typer() + +@app.command(context_settings={'ignore_unknown_options': True}) def main(admin_password: str): response = requests.post( 'https://localhost/opalAdmin/user/validate-login', @@ -30,4 +33,4 @@ def main(admin_password: str): if __name__ == '__main__': - typer.run(main) + app() diff --git a/tests/labs_basic_auth.py b/tests/labs_basic_auth.py index d0435f3..6d3c711 100644 --- a/tests/labs_basic_auth.py +++ b/tests/labs_basic_auth.py @@ -11,7 +11,10 @@ import requests import typer +app = typer.Typer() + +@app.command(context_settings={'ignore_unknown_options': True}) def main(labs_password: str): response = requests.post( 'https://localhost/opalAdmin/labs/api/processLabForPatient.php', @@ -38,4 +41,4 @@ def main(labs_password: str): if __name__ == '__main__': - typer.run(main) + app() diff --git a/tests/system_user_login.py b/tests/system_user_login.py index c81f589..4576fcb 100644 --- a/tests/system_user_login.py +++ b/tests/system_user_login.py @@ -8,13 +8,18 @@ import requests import typer +app = typer.Typer() + + +@app.command(context_settings={'ignore_unknown_options': True}) +def main(username: str, password: str): + print(f'Attempting system user login for user: {username}') -def main(labs_password: str): response = requests.post( 'https://localhost/opalAdmin/user/system-login', data={ - 'username': 'interface-engine', - 'password': labs_password, + 'username': username, + 'password': password, }, timeout=5, allow_redirects=False, @@ -26,8 +31,8 @@ def main(labs_password: str): print(f'System user login failed: {response.status_code} {response.text}') raise typer.Exit(code=1) - print('System user login succeeded') + print(f'System user login succeeded for user: {username}') if __name__ == '__main__': - typer.run(main) + app()