Coverage for cc_modules/cc_config.py: 62%
457 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
1# noinspection HttpUrlsUsage
2"""
3camcops_server/cc_modules/cc_config.py
5===============================================================================
7 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
8 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27**Read and represent a CamCOPS config file.**
29Also contains various types of demonstration config file (CamCOPS, but also
30``supervisord``, Apache, etc.) and demonstration helper scripts (e.g. MySQL).
32There are CONDITIONAL AND IN-FUNCTION IMPORTS HERE; see below. This is to
33minimize the number of modules loaded when this is used in the context of the
34client-side database script, rather than the webview.
36Moreover, it should not use SQLAlchemy objects directly; see ``celery.py``.
38In particular, I tried hard to use a "database-unaware" (unbound) SQLAlchemy
39ExportRecipient object. However, when the backend re-calls the config to get
40its recipients, we get errors like:
42.. code-block:: none
44 [2018-12-25 00:56:00,118: ERROR/ForkPoolWorker-7] Task camcops_server.cc_modules.celery_tasks.export_to_recipient_backend[ab2e2691-c2fa-4821-b8cd-2cbeb86ddc8f] raised unexpected: DetachedInstanceError('Instance <ExportRecipient at 0x7febbeeea7b8> is not bound to a Session; attribute refresh operation cannot proceed',)
45 Traceback (most recent call last):
46 File "/home/rudolf/dev/venvs/camcops/lib/python3.6/site-packages/celery/app/trace.py", line 382, in trace_task
47 R = retval = fun(*args, **kwargs)
48 File "/home/rudolf/dev/venvs/camcops/lib/python3.6/site-packages/celery/app/trace.py", line 641, in __protected_call__
49 return self.run(*args, **kwargs)
50 File "/home/rudolf/Documents/code/camcops/server/camcops_server/cc_modules/celery_tasks.py", line 103, in export_to_recipient_backend
51 schedule_via_backend=False)
52 File "/home/rudolf/Documents/code/camcops/server/camcops_server/cc_modules/cc_export.py", line 255, in export
53 req, recipient_names=recipient_names, all_recipients=all_recipients)
54 File "/home/rudolf/Documents/code/camcops/server/camcops_server/cc_modules/cc_config.py", line 1460, in get_export_recipients
55 valid_names = set(r.recipient_name for r in recipients)
56 File "/home/rudolf/Documents/code/camcops/server/camcops_server/cc_modules/cc_config.py", line 1460, in <genexpr>
57 valid_names = set(r.recipient_name for r in recipients)
58 File "/home/rudolf/dev/venvs/camcops/lib/python3.6/site-packages/sqlalchemy/orm/attributes.py", line 242, in __get__
59 return self.impl.get(instance_state(instance), dict_)
60 File "/home/rudolf/dev/venvs/camcops/lib/python3.6/site-packages/sqlalchemy/orm/attributes.py", line 594, in get
61 value = state._load_expired(state, passive)
62 File "/home/rudolf/dev/venvs/camcops/lib/python3.6/site-packages/sqlalchemy/orm/state.py", line 608, in _load_expired
63 self.manager.deferred_scalar_loader(self, toload)
64 File "/home/rudolf/dev/venvs/camcops/lib/python3.6/site-packages/sqlalchemy/orm/loading.py", line 813, in load_scalar_attributes
65 (state_str(state)))
66 sqlalchemy.orm.exc.DetachedInstanceError: Instance <ExportRecipient at 0x7febbeeea7b8> is not bound to a Session; attribute refresh operation cannot proceed (Background on this error at: http://sqlalche.me/e/bhk3)
68""" # noqa
70import codecs
71import collections
72import configparser
73import contextlib
74import datetime
75import os
76import logging
77import re
78from subprocess import run, PIPE
79from typing import Any, Dict, Generator, List, Optional, Union
81from cardinal_pythonlib.classes import class_attribute_values
82from cardinal_pythonlib.configfiles import (
83 get_config_parameter,
84 get_config_parameter_boolean,
85 get_config_parameter_loglevel,
86 get_config_parameter_multiline,
87)
88from cardinal_pythonlib.docker import running_under_docker
89from cardinal_pythonlib.fileops import relative_filename_within_dir
90from cardinal_pythonlib.logs import BraceStyleAdapter
91from cardinal_pythonlib.randomness import create_base64encoded_randomness
92from cardinal_pythonlib.reprfunc import auto_repr
93from cardinal_pythonlib.sqlalchemy.alembic_func import (
94 get_current_and_head_revision,
95)
96from cardinal_pythonlib.sqlalchemy.engine_func import (
97 is_sqlserver,
98 is_sqlserver_2008_or_later,
99)
100from cardinal_pythonlib.sqlalchemy.logs import (
101 pre_disable_sqlalchemy_extra_echo_log,
102)
103from cardinal_pythonlib.sqlalchemy.schema import get_table_names
104from cardinal_pythonlib.sqlalchemy.session import get_safe_url_from_engine
105from cardinal_pythonlib.wsgi.reverse_proxied_mw import ReverseProxiedMiddleware
106import celery.schedules
107from sqlalchemy.engine import create_engine
108from sqlalchemy.engine.base import Engine
109from sqlalchemy.orm import sessionmaker
110from sqlalchemy.orm import Session as SqlASession
112from camcops_server.cc_modules.cc_baseconstants import (
113 ALEMBIC_BASE_DIR,
114 ALEMBIC_CONFIG_FILENAME,
115 ALEMBIC_VERSION_TABLE,
116 ENVVAR_CONFIG_FILE,
117 LINUX_DEFAULT_MATPLOTLIB_CACHE_DIR,
118 ON_READTHEDOCS,
119)
120from camcops_server.cc_modules.cc_cache import cache_region_static, fkg
121from camcops_server.cc_modules.cc_constants import (
122 CONFIG_FILE_EXPORT_SECTION,
123 CONFIG_FILE_SERVER_SECTION,
124 CONFIG_FILE_SITE_SECTION,
125 CONFIG_FILE_SMS_BACKEND_PREFIX,
126 ConfigDefaults,
127 ConfigParamExportGeneral,
128 ConfigParamExportRecipient,
129 ConfigParamServer,
130 ConfigParamSite,
131 DockerConstants,
132 MfaMethod,
133 SmsBackendNames,
134)
135from camcops_server.cc_modules.cc_exportrecipientinfo import (
136 ExportRecipientInfo,
137)
138from camcops_server.cc_modules.cc_exception import raise_runtime_error
139from camcops_server.cc_modules.cc_filename import PatientSpecElementForFilename
140from camcops_server.cc_modules.cc_language import POSSIBLE_LOCALES
141from camcops_server.cc_modules.cc_pyramid import MASTER_ROUTE_CLIENT_API
142from camcops_server.cc_modules.cc_sms import (
143 get_sms_backend,
144 KapowSmsBackend,
145 TwilioSmsBackend,
146)
147from camcops_server.cc_modules.cc_snomed import (
148 get_all_task_snomed_concepts,
149 get_icd9_snomed_concepts_from_xml,
150 get_icd10_snomed_concepts_from_xml,
151 SnomedConcept,
152)
153from camcops_server.cc_modules.cc_validators import (
154 validate_export_recipient_name,
155 validate_group_name,
156)
157from camcops_server.cc_modules.cc_version_string import (
158 CAMCOPS_SERVER_VERSION_STRING,
159)
161log = BraceStyleAdapter(logging.getLogger(__name__))
163pre_disable_sqlalchemy_extra_echo_log()
166# =============================================================================
167# Constants
168# =============================================================================
170VALID_RECIPIENT_NAME_REGEX = r"^[\w_-]+$"
171# ... because we'll use them for filenames, amongst other things
172# https://stackoverflow.com/questions/10944438/
173# https://regexr.com/
175# Windows paths: irrelevant, as Windows doesn't run supervisord
176DEFAULT_LINUX_CAMCOPS_CONFIG = "/etc/camcops/camcops.conf"
177DEFAULT_LINUX_CAMCOPS_BASE_DIR = "/usr/share/camcops"
178DEFAULT_LINUX_CAMCOPS_VENV_DIR = os.path.join(
179 DEFAULT_LINUX_CAMCOPS_BASE_DIR, "venv"
180)
181DEFAULT_LINUX_CAMCOPS_VENV_BIN_DIR = os.path.join(
182 DEFAULT_LINUX_CAMCOPS_VENV_DIR, "bin"
183)
184DEFAULT_LINUX_CAMCOPS_EXECUTABLE = os.path.join(
185 DEFAULT_LINUX_CAMCOPS_VENV_BIN_DIR, "camcops_server"
186)
187DEFAULT_LINUX_CAMCOPS_STATIC_DIR = os.path.join(
188 DEFAULT_LINUX_CAMCOPS_VENV_DIR,
189 "lib",
190 "python3.9",
191 "site-packages",
192 "camcops_server",
193 "static",
194)
195DEFAULT_LINUX_LOGDIR = "/var/log/supervisor"
196DEFAULT_LINUX_USER = "www-data" # Ubuntu default
199# =============================================================================
200# Helper functions
201# =============================================================================
204def warn_if_not_within_docker_dir(
205 param_name: str,
206 filespec: str,
207 permit_cfg: bool = False,
208 permit_venv: bool = False,
209 permit_tmp: bool = False,
210 param_contains_not_is: bool = False,
211) -> None:
212 """
213 If the specified filename isn't within a relevant directory that will be
214 used by CamCOPS when operating within a Docker Compose application, warn
215 the user.
217 Args:
218 param_name:
219 Name of the parameter in the CamCOPS config file.
220 filespec:
221 Filename (or filename-like thing) to check.
222 permit_cfg:
223 Permit the file to be in the configuration directory.
224 permit_venv:
225 Permit the file to be in the virtual environment directory.
226 permit_tmp:
227 Permit the file to be in the shared temporary space.
228 param_contains_not_is:
229 The parameter "contains", not "is", the filename.
230 """
231 if not filespec:
232 return
233 is_phrase = "contains" if param_contains_not_is else "is"
234 permitted_dirs = [] # type: List[str]
235 if permit_cfg:
236 permitted_dirs.append(DockerConstants.CONFIG_DIR)
237 if permit_venv:
238 permitted_dirs.append(DockerConstants.VENV_DIR)
239 if permit_tmp:
240 permitted_dirs.append(DockerConstants.TMP_DIR)
241 ok = any(relative_filename_within_dir(filespec, d) for d in permitted_dirs)
242 if not ok:
243 log.warning(
244 f"Config parameter {param_name} {is_phrase} {filespec!r}, "
245 f"which is not within the permitted Docker directories "
246 f"{permitted_dirs!r}"
247 )
250def warn_if_not_docker_value(
251 param_name: str, actual_value: Any, required_value: Any
252) -> None:
253 """
254 Warn the user if a parameter does not match the specific value required
255 when operating under Docker.
257 Args:
258 param_name:
259 Name of the parameter in the CamCOPS config file.
260 actual_value:
261 Value in the config file.
262 required_value:
263 Value that should be used.
264 """
265 if actual_value != required_value:
266 log.warning(
267 f"Config parameter {param_name} is {actual_value!r}, "
268 f"but should be {required_value!r} when running inside "
269 f"Docker"
270 )
273def warn_if_not_present(param_name: str, value: Any) -> None:
274 """
275 Warn the user if a parameter is not set (None, or an empty string), for
276 when operating under Docker.
278 Args:
279 param_name:
280 Name of the parameter in the CamCOPS config file.
281 value:
282 Value in the config file.
283 """
284 if value is None or value == "":
285 log.warning(
286 f"Config parameter {param_name} is not specified, "
287 f"but should be specified when running inside Docker"
288 )
291def list_to_multiline_string(values: List[Any]) -> str:
292 """
293 Converts a Python list to a multiline string suitable for use as a config
294 file default (in a pretty way).
295 """
296 spacer = "\n "
297 gen_values = (str(x) for x in values)
298 if len(values) <= 1:
299 return spacer.join(gen_values)
300 else:
301 return spacer + spacer.join(gen_values)
304# =============================================================================
305# Demo config
306# =============================================================================
308# Cosmetic demonstration constants:
309DEFAULT_DB_READONLY_USER = "QQQ_USERNAME_REPLACE_ME"
310DEFAULT_DB_READONLY_PASSWORD = "PPP_PASSWORD_REPLACE_ME"
311DUMMY_INSTITUTION_URL = "https://www.mydomain/"
314def get_demo_config(for_docker: bool = False) -> str:
315 """
316 Returns a demonstration config file based on the specified parameters.
318 Args:
319 for_docker:
320 Adjust defaults for the Docker environment.
321 """
322 # ...
323 # http://www.debian.org/doc/debian-policy/ch-opersys.html#s-writing-init
324 # https://people.canonical.com/~cjwatson/ubuntu-policy/policy.html/ch-opersys.html # noqa
325 session_cookie_secret = create_base64encoded_randomness(num_bytes=64)
327 cd = ConfigDefaults(docker=for_docker)
328 return f"""
329# Demonstration CamCOPS server configuration file.
330#
331# Created by CamCOPS server version {CAMCOPS_SERVER_VERSION_STRING}.
332# See help at https://camcops.readthedocs.io/.
333#
334# Using defaults for Docker environment: {for_docker}
336# =============================================================================
337# CamCOPS site
338# =============================================================================
340[{CONFIG_FILE_SITE_SECTION}]
342# -----------------------------------------------------------------------------
343# Database connection
344# -----------------------------------------------------------------------------
346{ConfigParamSite.DB_URL} = {cd.demo_db_url}
347{ConfigParamSite.DB_ECHO} = {cd.DB_ECHO}
349# -----------------------------------------------------------------------------
350# URLs and paths
351# -----------------------------------------------------------------------------
353{ConfigParamSite.LOCAL_INSTITUTION_URL} = {DUMMY_INSTITUTION_URL}
354{ConfigParamSite.LOCAL_LOGO_FILE_ABSOLUTE} = {cd.LOCAL_LOGO_FILE_ABSOLUTE}
355{ConfigParamSite.CAMCOPS_LOGO_FILE_ABSOLUTE} = {cd.CAMCOPS_LOGO_FILE_ABSOLUTE}
357{ConfigParamSite.EXTRA_STRING_FILES} = {cd.EXTRA_STRING_FILES}
358{ConfigParamSite.RESTRICTED_TASKS} =
359{ConfigParamSite.LANGUAGE} = {cd.LANGUAGE}
361{ConfigParamSite.SNOMED_TASK_XML_FILENAME} =
362{ConfigParamSite.SNOMED_ICD9_XML_FILENAME} =
363{ConfigParamSite.SNOMED_ICD10_XML_FILENAME} =
365{ConfigParamSite.WKHTMLTOPDF_FILENAME} =
367# -----------------------------------------------------------------------------
368# Server geographical location
369# -----------------------------------------------------------------------------
371{ConfigParamSite.REGION_CODE} = {cd.REGION_CODE}
373# -----------------------------------------------------------------------------
374# Login and session configuration
375# -----------------------------------------------------------------------------
377{ConfigParamSite.MFA_METHODS} = {list_to_multiline_string(cd.MFA_METHODS)}
378{ConfigParamSite.MFA_TIMEOUT_S} = {cd.MFA_TIMEOUT_S}
379{ConfigParamSite.SESSION_COOKIE_SECRET} = camcops_autogenerated_secret_{session_cookie_secret}
380{ConfigParamSite.SESSION_TIMEOUT_MINUTES} = {cd.SESSION_TIMEOUT_MINUTES}
381{ConfigParamSite.SESSION_CHECK_USER_IP} = {cd.SESSION_CHECK_USER_IP}
382{ConfigParamSite.PASSWORD_CHANGE_FREQUENCY_DAYS} = {cd.PASSWORD_CHANGE_FREQUENCY_DAYS}
383{ConfigParamSite.LOCKOUT_THRESHOLD} = {cd.LOCKOUT_THRESHOLD}
384{ConfigParamSite.LOCKOUT_DURATION_INCREMENT_MINUTES} = {cd.LOCKOUT_DURATION_INCREMENT_MINUTES}
385{ConfigParamSite.DISABLE_PASSWORD_AUTOCOMPLETE} = {cd.DISABLE_PASSWORD_AUTOCOMPLETE}
387# -----------------------------------------------------------------------------
388# Suggested filenames for saving PDFs from the web view
389# -----------------------------------------------------------------------------
391{ConfigParamSite.PATIENT_SPEC_IF_ANONYMOUS} = {cd.PATIENT_SPEC_IF_ANONYMOUS}
392{ConfigParamSite.PATIENT_SPEC} = {{{PatientSpecElementForFilename.SURNAME}}}_{{{PatientSpecElementForFilename.FORENAME}}}_{{{PatientSpecElementForFilename.ALLIDNUMS}}}
394{ConfigParamSite.TASK_FILENAME_SPEC} = CamCOPS_{{patient}}_{{created}}_{{tasktype}}-{{serverpk}}.{{filetype}}
395{ConfigParamSite.TRACKER_FILENAME_SPEC} = CamCOPS_{{patient}}_{{now}}_tracker.{{filetype}}
396{ConfigParamSite.CTV_FILENAME_SPEC} = CamCOPS_{{patient}}_{{now}}_clinicaltextview.{{filetype}}
398# -----------------------------------------------------------------------------
399# E-mail options
400# -----------------------------------------------------------------------------
402{ConfigParamSite.EMAIL_HOST} = mysmtpserver.mydomain
403{ConfigParamSite.EMAIL_PORT} = {cd.EMAIL_PORT}
404{ConfigParamSite.EMAIL_USE_TLS} = {cd.EMAIL_USE_TLS}
405{ConfigParamSite.EMAIL_HOST_USERNAME} = myusername
406{ConfigParamSite.EMAIL_HOST_PASSWORD} = mypassword
407{ConfigParamSite.EMAIL_FROM} = CamCOPS computer <noreply@myinstitution.mydomain>
408{ConfigParamSite.EMAIL_SENDER} =
409{ConfigParamSite.EMAIL_REPLY_TO} = CamCOPS clinical administrator <admin@myinstitution.mydomain>
411# -----------------------------------------------------------------------------
412# SMS options
413# -----------------------------------------------------------------------------
415{ConfigParamSite.SMS_BACKEND} = {cd.SMS_BACKEND}
417# -----------------------------------------------------------------------------
418# User download options
419# -----------------------------------------------------------------------------
421{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS} = {cd.PERMIT_IMMEDIATE_DOWNLOADS}
422{ConfigParamSite.USER_DOWNLOAD_DIR} = {cd.USER_DOWNLOAD_DIR}
423{ConfigParamSite.USER_DOWNLOAD_FILE_LIFETIME_MIN} = {cd.USER_DOWNLOAD_FILE_LIFETIME_MIN}
424{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB} = {cd.USER_DOWNLOAD_MAX_SPACE_MB}
426# -----------------------------------------------------------------------------
427# Debugging options
428# -----------------------------------------------------------------------------
430{ConfigParamSite.WEBVIEW_LOGLEVEL} = {cd.WEBVIEW_LOGLEVEL_TEXTFORMAT}
431{ConfigParamSite.CLIENT_API_LOGLEVEL} = {cd.CLIENT_API_LOGLEVEL_TEXTFORMAT}
432{ConfigParamSite.ALLOW_INSECURE_COOKIES} = {cd.ALLOW_INSECURE_COOKIES}
435# =============================================================================
436# Web server options
437# =============================================================================
439[{CONFIG_FILE_SERVER_SECTION}]
441# -----------------------------------------------------------------------------
442# Common web server options
443# -----------------------------------------------------------------------------
445{ConfigParamServer.HOST} = {cd.HOST}
446{ConfigParamServer.PORT} = {cd.PORT}
447{ConfigParamServer.UNIX_DOMAIN_SOCKET} =
449# If you host CamCOPS behind Apache, it’s likely that you’ll want Apache to
450# handle HTTPS and CamCOPS to operate unencrypted behind a reverse proxy, in
451# which case don’t set SSL_CERTIFICATE or SSL_PRIVATE_KEY.
452{ConfigParamServer.SSL_CERTIFICATE} = {cd.SSL_CERTIFICATE}
453{ConfigParamServer.SSL_PRIVATE_KEY} = {cd.SSL_PRIVATE_KEY}
454{ConfigParamServer.STATIC_CACHE_DURATION_S} = {cd.STATIC_CACHE_DURATION_S}
456# -----------------------------------------------------------------------------
457# WSGI options
458# -----------------------------------------------------------------------------
460{ConfigParamServer.DEBUG_REVERSE_PROXY} = {cd.DEBUG_REVERSE_PROXY}
461{ConfigParamServer.DEBUG_TOOLBAR} = {cd.DEBUG_TOOLBAR}
462{ConfigParamServer.SHOW_REQUESTS} = {cd.SHOW_REQUESTS}
463{ConfigParamServer.SHOW_REQUEST_IMMEDIATELY} = {cd.SHOW_REQUEST_IMMEDIATELY}
464{ConfigParamServer.SHOW_RESPONSE} = {cd.SHOW_RESPONSE}
465{ConfigParamServer.SHOW_TIMING} = {cd.SHOW_TIMING}
466{ConfigParamServer.PROXY_HTTP_HOST} =
467{ConfigParamServer.PROXY_REMOTE_ADDR} =
468{ConfigParamServer.PROXY_REWRITE_PATH_INFO} = {cd.PROXY_REWRITE_PATH_INFO}
469{ConfigParamServer.PROXY_SCRIPT_NAME} =
470{ConfigParamServer.PROXY_SERVER_NAME} =
471{ConfigParamServer.PROXY_SERVER_PORT} =
472{ConfigParamServer.PROXY_URL_SCHEME} =
473{ConfigParamServer.TRUSTED_PROXY_HEADERS} =
474 HTTP_X_FORWARDED_HOST
475 HTTP_X_FORWARDED_SERVER
476 HTTP_X_FORWARDED_PORT
477 HTTP_X_FORWARDED_PROTO
478 HTTP_X_FORWARDED_FOR
479 HTTP_X_SCRIPT_NAME
481# -----------------------------------------------------------------------------
482# Determining the externally accessible CamCOPS URL for back-end work
483# -----------------------------------------------------------------------------
485{ConfigParamServer.EXTERNAL_URL_SCHEME} =
486{ConfigParamServer.EXTERNAL_SERVER_NAME} =
487{ConfigParamServer.EXTERNAL_SERVER_PORT} =
488{ConfigParamServer.EXTERNAL_SCRIPT_NAME} =
490# -----------------------------------------------------------------------------
491# CherryPy options
492# -----------------------------------------------------------------------------
494{ConfigParamServer.CHERRYPY_SERVER_NAME} = {cd.CHERRYPY_SERVER_NAME}
495{ConfigParamServer.CHERRYPY_THREADS_START} = {cd.CHERRYPY_THREADS_START}
496{ConfigParamServer.CHERRYPY_THREADS_MAX} = {cd.CHERRYPY_THREADS_MAX}
497{ConfigParamServer.CHERRYPY_LOG_SCREEN} = {cd.CHERRYPY_LOG_SCREEN}
498{ConfigParamServer.CHERRYPY_ROOT_PATH} = {cd.CHERRYPY_ROOT_PATH}
500# -----------------------------------------------------------------------------
501# Gunicorn options
502# -----------------------------------------------------------------------------
504{ConfigParamServer.GUNICORN_NUM_WORKERS} = {cd.GUNICORN_NUM_WORKERS}
505{ConfigParamServer.GUNICORN_DEBUG_RELOAD} = {cd.GUNICORN_DEBUG_RELOAD}
506{ConfigParamServer.GUNICORN_TIMEOUT_S} = {cd.GUNICORN_TIMEOUT_S}
507{ConfigParamServer.DEBUG_SHOW_GUNICORN_OPTIONS} = {cd.DEBUG_SHOW_GUNICORN_OPTIONS}
510# =============================================================================
511# Export options
512# =============================================================================
514[{CONFIG_FILE_EXPORT_SECTION}]
516{ConfigParamExportGeneral.CELERY_BEAT_EXTRA_ARGS} =
517{ConfigParamExportGeneral.CELERY_BEAT_SCHEDULE_DATABASE} = {cd.CELERY_BEAT_SCHEDULE_DATABASE}
518{ConfigParamExportGeneral.CELERY_BROKER_URL} = {cd.CELERY_BROKER_URL}
519# Celery max memory per child set to be
520# celery_max_mem_kilobytes / worker_concurrency (by default number of cpus/cores available)
521# This example: 4GB / 4 = 1GB = 1000000
522{ConfigParamExportGeneral.CELERY_WORKER_EXTRA_ARGS} =
523 --max-tasks-per-child=1000
524 --max-memory-per-child=100000
525{ConfigParamExportGeneral.CELERY_EXPORT_TASK_RATE_LIMIT} = 100/m
526{ConfigParamExportGeneral.EXPORT_LOCKDIR} = {cd.EXPORT_LOCKDIR}
528{ConfigParamExportGeneral.RECIPIENTS} =
530{ConfigParamExportGeneral.SCHEDULE_TIMEZONE} = {cd.SCHEDULE_TIMEZONE}
531{ConfigParamExportGeneral.SCHEDULE} =
534# =============================================================================
535# Details for each export recipient
536# =============================================================================
538# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
539# Example recipient
540# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
541 # Example (disabled because it's not in the {ConfigParamExportGeneral.RECIPIENTS} list above)
543[recipient:recipient_A]
545 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
546 # How to export
547 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
549{ConfigParamExportRecipient.TRANSMISSION_METHOD} = hl7
550{ConfigParamExportRecipient.PUSH} = true
551{ConfigParamExportRecipient.TASK_FORMAT} = pdf
552{ConfigParamExportRecipient.XML_FIELD_COMMENTS} = {cd.XML_FIELD_COMMENTS}
554 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
555 # What to export
556 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
558{ConfigParamExportRecipient.ALL_GROUPS} = false
559{ConfigParamExportRecipient.GROUPS} =
560 myfirstgroup
561 mysecondgroup
562{ConfigParamExportRecipient.TASKS} =
564{ConfigParamExportRecipient.START_DATETIME_UTC} =
565{ConfigParamExportRecipient.END_DATETIME_UTC} =
566{ConfigParamExportRecipient.FINALIZED_ONLY} = {cd.FINALIZED_ONLY}
567{ConfigParamExportRecipient.INCLUDE_ANONYMOUS} = {cd.INCLUDE_ANONYMOUS}
568{ConfigParamExportRecipient.PRIMARY_IDNUM} = 1
569{ConfigParamExportRecipient.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY} = {cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY}
571 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
572 # Options applicable to database exports
573 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
575{ConfigParamExportRecipient.DB_URL} = some_sqlalchemy_url
576{ConfigParamExportRecipient.DB_ECHO} = {cd.DB_ECHO}
577{ConfigParamExportRecipient.DB_INCLUDE_BLOBS} = {cd.DB_INCLUDE_BLOBS}
578{ConfigParamExportRecipient.DB_ADD_SUMMARIES} = {cd.DB_ADD_SUMMARIES}
579{ConfigParamExportRecipient.DB_PATIENT_ID_PER_ROW} = {cd.DB_PATIENT_ID_PER_ROW}
581 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
582 # Options applicable to e-mail exports
583 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
585{ConfigParamExportRecipient.EMAIL_TO} =
586 Perinatal Psychiatry Admin <perinatal@myinstitution.mydomain>
588{ConfigParamExportRecipient.EMAIL_CC} =
589 Dr Alice Bradford <alice.bradford@myinstitution.mydomain>
590 Dr Charles Dogfoot <charles.dogfoot@myinstitution.mydomain>
592{ConfigParamExportRecipient.EMAIL_BCC} =
593 superuser <root@myinstitution.mydomain>
595{ConfigParamExportRecipient.EMAIL_PATIENT_SPEC_IF_ANONYMOUS} = anonymous
596{ConfigParamExportRecipient.EMAIL_PATIENT_SPEC} = {{{PatientSpecElementForFilename.SURNAME}}}, {{{PatientSpecElementForFilename.FORENAME}}}, {{{PatientSpecElementForFilename.ALLIDNUMS}}}
597{ConfigParamExportRecipient.EMAIL_SUBJECT} = CamCOPS task for {{patient}}, created {{created}}: {{tasktype}}, PK {{serverpk}}
598{ConfigParamExportRecipient.EMAIL_BODY_IS_HTML} = false
599{ConfigParamExportRecipient.EMAIL_BODY} =
600 Please find attached a new CamCOPS task for manual filing to the electronic
601 patient record of
603 {{patient}}
605 Task type: {{tasktype}}
606 Created: {{created}}
607 CamCOPS server primary key: {{serverpk}}
609 Yours faithfully,
611 The CamCOPS computer.
613{ConfigParamExportRecipient.EMAIL_KEEP_MESSAGE} = {cd.HL7_KEEP_MESSAGE}
615 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
616 # Options applicable to FHIR
617 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
619{ConfigParamExportRecipient.FHIR_API_URL} = https://my.fhir.server/api
620{ConfigParamExportRecipient.FHIR_APP_ID} = {cd.FHIR_APP_ID}
621{ConfigParamExportRecipient.FHIR_APP_SECRET} = my_fhir_secret_abc
622{ConfigParamExportRecipient.FHIR_LAUNCH_TOKEN} =
623{ConfigParamExportRecipient.FHIR_CONCURRENT} =
625 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
626 # Options applicable to HL7 (v2) exports
627 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
629{ConfigParamExportRecipient.HL7_HOST} = myhl7server.mydomain
630{ConfigParamExportRecipient.HL7_PORT} = {cd.HL7_PORT}
631{ConfigParamExportRecipient.HL7_PING_FIRST} = {cd.HL7_PING_FIRST}
632{ConfigParamExportRecipient.HL7_NETWORK_TIMEOUT_MS} = {cd.HL7_NETWORK_TIMEOUT_MS}
633{ConfigParamExportRecipient.HL7_KEEP_MESSAGE} = {cd.HL7_KEEP_MESSAGE}
634{ConfigParamExportRecipient.HL7_KEEP_REPLY} = {cd.HL7_KEEP_REPLY}
635{ConfigParamExportRecipient.HL7_DEBUG_DIVERT_TO_FILE} = {cd.HL7_DEBUG_DIVERT_TO_FILE}
636{ConfigParamExportRecipient.HL7_DEBUG_TREAT_DIVERTED_AS_SENT} = {cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT}
638 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
639 # Options applicable to file transfers/attachments
640 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
642{ConfigParamExportRecipient.FILE_PATIENT_SPEC} = {{surname}}_{{forename}}_{{idshortdesc1}}{{idnum1}}
643{ConfigParamExportRecipient.FILE_PATIENT_SPEC_IF_ANONYMOUS} = {cd.FILE_PATIENT_SPEC_IF_ANONYMOUS}
644{ConfigParamExportRecipient.FILE_FILENAME_SPEC} = /my_nfs_mount/mypath/CamCOPS_{{patient}}_{{created}}_{{tasktype}}-{{serverpk}}.{{filetype}}
645{ConfigParamExportRecipient.FILE_MAKE_DIRECTORY} = {cd.FILE_MAKE_DIRECTORY}
646{ConfigParamExportRecipient.FILE_OVERWRITE_FILES} = {cd.FILE_OVERWRITE_FILES}
647{ConfigParamExportRecipient.FILE_EXPORT_RIO_METADATA} = {cd.FILE_EXPORT_RIO_METADATA}
648{ConfigParamExportRecipient.FILE_SCRIPT_AFTER_EXPORT} =
650 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
651 # Extra options for RiO metadata for file-based export
652 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
654{ConfigParamExportRecipient.RIO_IDNUM} = 2
655{ConfigParamExportRecipient.RIO_UPLOADING_USER} = CamCOPS
656{ConfigParamExportRecipient.RIO_DOCUMENT_TYPE} = CC
658 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
659 # Extra options for REDCap export
660 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
662{ConfigParamExportRecipient.REDCAP_API_URL} = https://domain.of.redcap.server/api/
663{ConfigParamExportRecipient.REDCAP_API_KEY} = myapikey
664{ConfigParamExportRecipient.REDCAP_FIELDMAP_FILENAME} = /location/of/fieldmap.xml
666# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
667# Example SMS Backends. No configuration needed for '{SmsBackendNames.CONSOLE}' (testing only).
668# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
670[{CONFIG_FILE_SMS_BACKEND_PREFIX}:{SmsBackendNames.KAPOW}]
672{KapowSmsBackend.PARAM_USERNAME} = myusername
673{KapowSmsBackend.PARAM_PASSWORD} = mypassword
675[{CONFIG_FILE_SMS_BACKEND_PREFIX}:{SmsBackendNames.TWILIO}]
677{TwilioSmsBackend.PARAM_SID.upper()} = mysid
678{TwilioSmsBackend.PARAM_TOKEN.upper()} = mytoken
679{TwilioSmsBackend.PARAM_FROM_PHONE_NUMBER.upper()} = myphonenumber
681 """.strip() # noqa
684# =============================================================================
685# Demo configuration files, other than the CamCOPS config file itself
686# =============================================================================
688DEFAULT_SOCKET_FILENAME = "/run/camcops/camcops.socket"
691def get_demo_supervisor_config() -> str:
692 """
693 Returns a demonstration ``supervisord`` config file based on the
694 specified parameters.
695 """
696 redirect_stderr = "true"
697 autostart = "true"
698 autorestart = "true"
699 startsecs = "30"
700 stopwaitsecs = "60"
701 startretries = "10"
702 stopasgroup = "true"
703 return f"""
704# =============================================================================
705# Demonstration 'supervisor' (supervisord) config file for CamCOPS.
706# Created by CamCOPS version {CAMCOPS_SERVER_VERSION_STRING}.
707# =============================================================================
708# See https://camcops.readthedocs.io/en/latest/administrator/server_configuration.html#start-camcops
710[program:camcops_server]
712command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} serve_gunicorn
713 --config {DEFAULT_LINUX_CAMCOPS_CONFIG}
715directory = {DEFAULT_LINUX_CAMCOPS_BASE_DIR}
716environment = MPLCONFIGDIR="{LINUX_DEFAULT_MATPLOTLIB_CACHE_DIR}"
717user = {DEFAULT_LINUX_USER}
718stdout_logfile = {DEFAULT_LINUX_LOGDIR}/camcops_server.log
719redirect_stderr = {redirect_stderr}
720autostart = {autostart}
721autorestart = {autorestart}
722startsecs = {startsecs}
723stopwaitsecs = {stopwaitsecs}
725[program:camcops_workers]
727command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} launch_workers
728 --config {DEFAULT_LINUX_CAMCOPS_CONFIG}
730directory = {DEFAULT_LINUX_CAMCOPS_BASE_DIR}
731environment = MPLCONFIGDIR="{LINUX_DEFAULT_MATPLOTLIB_CACHE_DIR}"
732user = {DEFAULT_LINUX_USER}
733stdout_logfile = {DEFAULT_LINUX_LOGDIR}/camcops_workers.log
734redirect_stderr = {redirect_stderr}
735autostart = {autostart}
736autorestart = {autorestart}
737startsecs = {startsecs}
738stopwaitsecs = {stopwaitsecs}
739startretries = {startretries}
740stopasgroup = {stopasgroup}
742[program:camcops_scheduler]
744command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} launch_scheduler
745 --config {DEFAULT_LINUX_CAMCOPS_CONFIG}
747directory = {DEFAULT_LINUX_CAMCOPS_BASE_DIR}
748environment = MPLCONFIGDIR="{LINUX_DEFAULT_MATPLOTLIB_CACHE_DIR}"
749user = {DEFAULT_LINUX_USER}
750stdout_logfile = {DEFAULT_LINUX_LOGDIR}/camcops_scheduler.log
751redirect_stderr = {redirect_stderr}
752autostart = {autostart}
753autorestart = {autorestart}
754startsecs = {startsecs}
755stopwaitsecs = {stopwaitsecs}
756startretries = {startretries}
758[group:camcops]
760programs = camcops_server, camcops_workers, camcops_scheduler
762 """.strip() # noqa
765def get_demo_apache_config(
766 rootpath: str = "", # no slash
767 specimen_internal_port: int = None,
768 specimen_socket_file: str = DEFAULT_SOCKET_FILENAME,
769) -> str:
770 """
771 Returns a demo Apache HTTPD config file section applicable to CamCOPS.
772 """
773 cd = ConfigDefaults()
774 specimen_internal_port = specimen_internal_port or cd.PORT
775 indent_8 = " " * 8
777 if rootpath:
778 urlbase = f"/{rootpath}"
779 urlbaseslash = f"{urlbase}/"
780 api_path = f"{urlbase}{MASTER_ROUTE_CLIENT_API}"
781 trailing_slash_notes = f"""{indent_8}#
782 # - Don't specify trailing slashes for the ProxyPass and
783 # ProxyPassReverse directives.
784 # If you do, http://camcops.example.com{urlbase} will fail though
785 # http://camcops.example.com{urlbaseslash} will succeed.
786 #
787 # - An alternative fix is to enable mod_rewrite (e.g. sudo a2enmod
788 # rewrite), then add these commands:
789 #
790 # RewriteEngine on
791 # RewriteRule ^/{rootpath}$ {rootpath}/ [L,R=301]
792 #
793 # which will redirect requests without the trailing slash to a
794 # version with the trailing slash.
795 #"""
797 x_script_name = (
798 f"{indent_8}RequestHeader set X-Script-Name {urlbase}\n"
799 )
800 else:
801 urlbase = "/"
802 urlbaseslash = "/"
803 api_path = MASTER_ROUTE_CLIENT_API
804 trailing_slash_notes = " " * 8 + "#"
805 x_script_name = ""
807 # noinspection HttpUrlsUsage
808 return f"""
809# Demonstration Apache config file section for CamCOPS.
810# Created by CamCOPS version {CAMCOPS_SERVER_VERSION_STRING}.
811#
812# Under Ubuntu, the Apache config will be somewhere in /etc/apache2/
813# Under CentOS, the Apache config will be somewhere in /etc/httpd/
814#
815# This section should go within the <VirtualHost> directive for the secure
816# (SSL, HTTPS) part of the web site.
818<VirtualHost *:443>
819 # ...
821 # =========================================================================
822 # CamCOPS
823 # =========================================================================
824 # Apache operates on the principle that the first match wins. So, if we
825 # want to serve CamCOPS but then override some of its URLs to serve static
826 # files faster, we define the static stuff first.
828 # ---------------------------------------------------------------------
829 # 1. Serve static files
830 # ---------------------------------------------------------------------
831 # a) offer them at the appropriate URL
832 # b) provide permission
833 # c) disable ProxyPass for static files
835 # CHANGE THIS: aim the alias at your own institutional logo.
837 Alias {urlbaseslash}static/logo_local.png {DEFAULT_LINUX_CAMCOPS_STATIC_DIR}/logo_local.png
839 # We move from more specific to less specific aliases; the first match
840 # takes precedence. (Apache will warn about conflicting aliases if
841 # specified in a wrong, less-to-more-specific, order.)
843 Alias {urlbaseslash}static/ {DEFAULT_LINUX_CAMCOPS_STATIC_DIR}/
845 <Directory {DEFAULT_LINUX_CAMCOPS_STATIC_DIR}>
846 Require all granted
848 # ... for old Apache versions (e.g. 2.2), use instead:
849 # Order allow,deny
850 # Allow from all
851 </Directory>
853 # Don't ProxyPass the static files; we'll serve them via Apache.
855 ProxyPassMatch ^{urlbaseslash}static/ !
857 # ---------------------------------------------------------------------
858 # 2. Proxy requests to the CamCOPS web server and back; allow access
859 # ---------------------------------------------------------------------
860 # ... either via an internal TCP/IP port (e.g. 1024 or higher, and NOT
861 # accessible to users);
862 # ... or, better, via a Unix socket, e.g. {specimen_socket_file}
863 #
864 # NOTES
865 #
866 # - When you ProxyPass {urlbase}, you should browse to (e.g.)
867 #
868 # https://camcops.example.com{urlbase}
869 #
870 # and point your tablet devices to
871 #
872 # https://camcops.example.com{api_path}
873{trailing_slash_notes}
874 # - Ensure that you put the CORRECT PROTOCOL (http, https) in the rules
875 # below.
876 #
877 # - For ProxyPass options, see https://httpd.apache.org/docs/2.2/mod/mod_proxy.html#proxypass
878 #
879 # - Include "retry=0" to stop Apache disabling the connection for
880 # while on failure.
881 # - Consider adding a "timeout=<seconds>" option if the back-end is
882 # slow and causing timeouts.
883 #
884 # - CamCOPS MUST BE TOLD about its location and protocol, because that
885 # information is critical for synthesizing URLs, but is stripped out
886 # by the reverse proxy system. There are two ways:
887 #
888 # (i) specifying headers or WSGI environment variables, such as
889 # the HTTP(S) headers X-Forwarded-Proto and X-Script-Name below
890 # (and telling CamCOPS to trust them via its
891 # TRUSTED_PROXY_HEADERS setting);
892 #
893 # (ii) specifying other options to "camcops_server", including
894 # PROXY_SCRIPT_NAME, PROXY_URL_SCHEME; see the help for the
895 # CamCOPS config.
896 #
897 # So:
898 #
899 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
900 # (a) Reverse proxy
901 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
902 #
903 # #####################################################################
904 # PORT METHOD
905 # #####################################################################
906 # Note the use of "http" (reflecting the backend), not https (like the
907 # front end).
909 # ProxyPass {urlbase} http://127.0.0.1:{specimen_internal_port} retry=0 timeout=300
910 # ProxyPassReverse {urlbase} http://127.0.0.1:{specimen_internal_port}
912 # #####################################################################
913 # UNIX SOCKET METHOD (Apache 2.4.9 and higher)
914 # #####################################################################
915 # This requires Apache 2.4.9, and passes after the '|' character a URL
916 # that determines the Host: value of the request; see
917 # ://httpd.apache.org/docs/trunk/mod/mod_proxy.html#proxypass
918 #
919 # The general syntax is:
920 #
921 # ProxyPass /URL_USER_SEES unix:SOCKETFILE|PROTOCOL://HOST/EXTRA_URL_FOR_BACKEND retry=0
922 #
923 # Note that:
924 #
925 # - the protocol should be http, not https (Apache deals with the
926 # HTTPS part and passes HTTP on)
927 # - the EXTRA_URL_FOR_BACKEND needs to be (a) unique for each
928 # instance or Apache will use a single worker for multiple
929 # instances, and (b) blank for the backend's benefit. Since those
930 # two conflict when there's >1 instance, there's a problem.
931 # - Normally, HOST is given as localhost. It may be that this problem
932 # is solved by using a dummy unique value for HOST:
933 # https://bz.apache.org/bugzilla/show_bug.cgi?id=54101#c1
934 #
935 # If your Apache version is too old, you will get the error
936 #
937 # "AH00526: Syntax error on line 56 of /etc/apache2/sites-enabled/SOMETHING:
938 # ProxyPass URL must be absolute!"
939 #
940 # If you get this error:
941 #
942 # AH01146: Ignoring parameter 'retry=0' for worker 'unix:/tmp/.camcops_gunicorn.sock|https://localhost' because of worker sharing
943 # https://wiki.apache.org/httpd/ListOfErrors
944 #
945 # ... then your URLs are overlapping and should be redone or sorted;
946 # see http://httpd.apache.org/docs/2.4/mod/mod_proxy.html#workers
947 #
948 # The part that must be unique for each instance, with no part a
949 # leading substring of any other, is THIS_BIT in:
950 #
951 # ProxyPass /URL_USER_SEES unix:SOCKETFILE|http://localhost/THIS_BIT retry=0
952 #
953 # If you get an error like this:
954 #
955 # AH01144: No protocol handler was valid for the URL /SOMEWHERE. If you are using a DSO version of mod_proxy, make sure the proxy submodules are included in the configuration using LoadModule.
956 #
957 # Then do this:
958 #
959 # sudo a2enmod proxy proxy_http
960 # sudo apache2ctl restart
961 #
962 # If you get an error like this:
963 #
964 # ... [proxy_http:error] [pid 32747] (103)Software caused connection abort: [client 109.151.49.173:56898] AH01102: error reading status line from remote server httpd-UDS:0
965 # [proxy:error] [pid 32747] [client 109.151.49.173:56898] AH00898: Error reading from remote server returned by /camcops_bruhl/webview
966 #
967 # then check you are specifying http://, not https://, in the ProxyPass
968 #
969 # Other information sources:
970 #
971 # - https://emptyhammock.com/projects/info/pyweb/webconfig.html
973 ProxyPass {urlbase} unix:{specimen_socket_file}|http://dummy1 retry=0 timeout=300
974 ProxyPassReverse {urlbase} unix:{specimen_socket_file}|http://dummy1
976 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
977 # (b) Allow proxy over SSL.
978 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
979 # Without this, you will get errors like:
980 # ... SSL Proxy requested for wombat:443 but not enabled [Hint: SSLProxyEngine]
981 # ... failed to enable ssl support for 0.0.0.0:0 (httpd-UDS)
983 SSLProxyEngine on
985 <Location {urlbase}>
987 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
988 # (c) Allow access
989 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
991 Require all granted
993 # ... for old Apache versions (e.g. 2.2), use instead:
994 #
995 # Order allow,deny
996 # Allow from all
998 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
999 # (d) Tell the proxied application that we are using HTTPS, and
1000 # where the application is installed
1001 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1002 # ... https://stackoverflow.com/questions/16042647
1003 #
1004 # Enable mod_headers (e.g. "sudo a2enmod headers") and set:
1006 RequestHeader set X-Forwarded-Proto https
1007{x_script_name}
1008 # ... then ensure the TRUSTED_PROXY_HEADERS setting in the CamCOPS
1009 # config file includes:
1010 #
1011 # HTTP_X_FORWARDED_HOST
1012 # HTTP_X_FORWARDED_SERVER
1013 # HTTP_X_FORWARDED_PORT
1014 # HTTP_X_FORWARDED_PROTO
1015 # HTTP_X_SCRIPT_NAME
1016 #
1017 # (X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Server are
1018 # supplied by Apache automatically.)
1020 </Location>
1022 #==========================================================================
1023 # SSL security (for HTTPS)
1024 #==========================================================================
1026 # You will also need to install your SSL certificate; see the
1027 # instructions that came with it. You get a certificate by creating a
1028 # certificate signing request (CSR). You enter some details about your
1029 # site, and a software tool makes (1) a private key, which you keep
1030 # utterly private, and (2) a CSR, which you send to a Certificate
1031 # Authority (CA) for signing. They send back a signed certificate, and
1032 # a chain of certificates leading from yours to a trusted root CA.
1033 #
1034 # You can create your own (a 'snake-oil' certificate), but your tablets
1035 # and browsers will not trust it, so this is a bad idea.
1036 #
1037 # Once you have your certificate: edit and uncomment these lines:
1039 # SSLEngine on
1041 # SSLCertificateKeyFile /etc/ssl/private/my.private.key
1043 # ... a private file that you made before creating the certificate
1044 # request, and NEVER GAVE TO ANYBODY, and NEVER WILL (or your
1045 # security is broken and you need a new certificate).
1047 # SSLCertificateFile /etc/ssl/certs/my.public.cert
1049 # ... signed and supplied to you by the certificate authority (CA),
1050 # from the public certificate you sent to them.
1052 # SSLCertificateChainFile /etc/ssl/certs/my-institution.ca-bundle
1054 # ... made from additional certificates in a chain, supplied to you by
1055 # the CA. For example, mine is univcam.ca-bundle, made with the
1056 # command:
1057 #
1058 # cat TERENASSLCA.crt UTNAddTrustServer_CA.crt AddTrustExternalCARoot.crt > univcam.ca-bundle
1060</VirtualHost>
1062 """.strip() # noqa
1065# =============================================================================
1066# Helper functions
1067# =============================================================================
1070def raise_missing(section: str, parameter: str) -> None:
1071 msg = (
1072 f"Config file: missing/blank parameter {parameter} "
1073 f"in section [{section}]"
1074 )
1075 raise_runtime_error(msg)
1078# =============================================================================
1079# CrontabEntry
1080# =============================================================================
1083class CrontabEntry(object):
1084 """
1085 Class to represent a ``crontab``-style entry.
1086 """
1088 def __init__(
1089 self,
1090 line: str = None,
1091 minute: Union[str, int, List[int]] = "*",
1092 hour: Union[str, int, List[int]] = "*",
1093 day_of_week: Union[str, int, List[int]] = "*",
1094 day_of_month: Union[str, int, List[int]] = "*",
1095 month_of_year: Union[str, int, List[int]] = "*",
1096 content: str = None,
1097 ) -> None:
1098 """
1099 Args:
1100 line:
1101 line of the form ``m h dow dom moy content content content``.
1102 minute:
1103 crontab "minute" entry
1104 hour:
1105 crontab "hour" entry
1106 day_of_week:
1107 crontab "day_of_week" entry
1108 day_of_month:
1109 crontab "day_of_month" entry
1110 month_of_year:
1111 crontab "month_of_year" entry
1112 content:
1113 crontab "thing to run" entry
1115 If ``line`` is specified, it is used. Otherwise, the components are
1116 used; the default for each of them is ``"*"``, meaning "all". Thus, for
1117 example, you can specify ``minute="*/5"`` and that is sufficient to
1118 mean "every 5 minutes".
1119 """
1120 has_line = line is not None
1121 has_components = bool(
1122 minute
1123 and hour
1124 and day_of_week
1125 and day_of_month
1126 and month_of_year
1127 and content
1128 )
1129 assert (
1130 has_line or has_components
1131 ), "Specify either a crontab line or all the time components"
1132 if has_line:
1133 line = line.split("#")[0].strip() # everything before a '#'
1134 components = line.split() # split on whitespace
1135 assert (
1136 len(components) >= 6
1137 ), "Must specify 5 time components and then contents"
1138 (
1139 minute,
1140 hour,
1141 day_of_week,
1142 day_of_month,
1143 month_of_year,
1144 ) = components[0:5]
1145 content = " ".join(components[5:])
1147 self.minute = minute
1148 self.hour = hour
1149 self.day_of_week = day_of_week
1150 self.day_of_month = day_of_month
1151 self.month_of_year = month_of_year
1152 self.content = content
1154 def __repr__(self) -> str:
1155 return auto_repr(self, sort_attrs=False)
1157 def __str__(self) -> str:
1158 return (
1159 f"{self.minute} {self.hour} {self.day_of_week} "
1160 f"{self.day_of_month} {self.month_of_year} {self.content}"
1161 )
1163 def get_celery_schedule(self) -> celery.schedules.crontab:
1164 """
1165 Returns the corresponding Celery schedule.
1167 Returns:
1168 a :class:`celery.schedules.crontab`
1170 Raises:
1171 :exc:`celery.schedules.ParseException` if the input can't be parsed
1172 """
1173 return celery.schedules.crontab(
1174 minute=self.minute,
1175 hour=self.hour,
1176 day_of_week=self.day_of_week,
1177 day_of_month=self.day_of_month,
1178 month_of_year=self.month_of_year,
1179 )
1182# =============================================================================
1183# Configuration class. (It gets cached on a per-process basis.)
1184# =============================================================================
1187class CamcopsConfig(object):
1188 """
1189 Class representing the CamCOPS configuration.
1190 """
1192 def __init__(self, config_filename: str, config_text: str = None) -> None:
1193 """
1194 Initialize by reading the config file.
1196 Args:
1197 config_filename:
1198 Filename of the config file (usual method)
1199 config_text:
1200 Text contents of the config file (alternative method for
1201 special circumstances); overrides ``config_filename``
1202 """
1204 def _get_str(
1205 section: str,
1206 paramname: str,
1207 default: str = None,
1208 replace_empty_strings_with_default: bool = True,
1209 ) -> Optional[str]:
1210 p = get_config_parameter(parser, section, paramname, str, default)
1211 if p == "" and replace_empty_strings_with_default:
1212 return default
1213 return p
1215 def _get_bool(section: str, paramname: str, default: bool) -> bool:
1216 return get_config_parameter_boolean(
1217 parser, section, paramname, default
1218 )
1220 def _get_int(
1221 section: str, paramname: str, default: int = None
1222 ) -> Optional[int]:
1223 return get_config_parameter(
1224 parser, section, paramname, int, default
1225 )
1227 def _get_multiline(section: str, paramname: str) -> List[str]:
1228 # http://stackoverflow.com/questions/335695/lists-in-configparser
1229 return get_config_parameter_multiline(
1230 parser, section, paramname, []
1231 )
1233 def _get_multiline_ignoring_comments(
1234 section: str, paramname: str
1235 ) -> List[str]:
1236 # Returns lines with any trailing comments removed, and any
1237 # comment-only lines removed.
1238 lines = _get_multiline(section, paramname)
1239 return list(
1240 filter(None, (x.split("#")[0].strip() for x in lines if x))
1241 )
1243 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1244 # Learn something about our environment
1245 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1246 self.running_under_docker = running_under_docker()
1248 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1249 # Open config file
1250 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1251 self.camcops_config_filename = config_filename
1252 parser = configparser.ConfigParser()
1254 if config_text:
1255 log.info("Reading config from supplied string")
1256 parser.read_string(config_text)
1257 else:
1258 if not config_filename:
1259 raise AssertionError(
1260 f"Environment variable {ENVVAR_CONFIG_FILE} not specified "
1261 f"(and no command-line alternative given)"
1262 )
1263 log.info("Reading from config file: {}", config_filename)
1264 with codecs.open(config_filename, "r", "utf8") as file:
1265 parser.read_file(file)
1267 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1268 # Main section (in alphabetical order)
1269 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1270 s = CONFIG_FILE_SITE_SECTION
1271 cs = ConfigParamSite
1272 cd = ConfigDefaults()
1274 self.allow_insecure_cookies = _get_bool(
1275 s, cs.ALLOW_INSECURE_COOKIES, cd.ALLOW_INSECURE_COOKIES
1276 )
1278 self.camcops_logo_file_absolute = _get_str(
1279 s, cs.CAMCOPS_LOGO_FILE_ABSOLUTE, cd.CAMCOPS_LOGO_FILE_ABSOLUTE
1280 )
1281 self.ctv_filename_spec = _get_str(s, cs.CTV_FILENAME_SPEC)
1283 self.db_url = parser.get(s, cs.DB_URL)
1284 # ... no default: will fail if not provided
1285 self.db_echo = _get_bool(s, cs.DB_ECHO, cd.DB_ECHO)
1286 self.client_api_loglevel = get_config_parameter_loglevel(
1287 parser, s, cs.CLIENT_API_LOGLEVEL, cd.CLIENT_API_LOGLEVEL
1288 )
1289 logging.getLogger("camcops_server.cc_modules.client_api").setLevel(
1290 self.client_api_loglevel
1291 )
1292 # ... MUTABLE GLOBAL STATE (if relatively unimportant); todo: fix
1294 self.disable_password_autocomplete = _get_bool(
1295 s,
1296 cs.DISABLE_PASSWORD_AUTOCOMPLETE,
1297 cd.DISABLE_PASSWORD_AUTOCOMPLETE,
1298 )
1300 self.email_host = _get_str(s, cs.EMAIL_HOST, "")
1301 self.email_port = _get_int(s, cs.EMAIL_PORT, cd.EMAIL_PORT)
1302 self.email_use_tls = _get_bool(s, cs.EMAIL_USE_TLS, cd.EMAIL_USE_TLS)
1303 self.email_host_username = _get_str(s, cs.EMAIL_HOST_USERNAME, "")
1304 self.email_host_password = _get_str(s, cs.EMAIL_HOST_PASSWORD, "")
1306 # Development only: Read password from safe using GNU Pass
1307 gnu_pass_lookup = _get_str(
1308 s, cs.EMAIL_HOST_PASSWORD_GNU_PASS_LOOKUP, ""
1309 )
1310 if gnu_pass_lookup:
1311 output = run(["pass", gnu_pass_lookup], stdout=PIPE)
1312 self.email_host_password = output.stdout.decode("utf-8").split()[0]
1314 self.email_from = _get_str(s, cs.EMAIL_FROM, "")
1315 self.email_sender = _get_str(s, cs.EMAIL_SENDER, "")
1316 self.email_reply_to = _get_str(s, cs.EMAIL_REPLY_TO, "")
1318 self.extra_string_files = _get_multiline(s, cs.EXTRA_STRING_FILES)
1320 self.language = _get_str(s, cs.LANGUAGE, cd.LANGUAGE)
1321 if self.language not in POSSIBLE_LOCALES:
1322 log.warning(
1323 f"Invalid language {self.language!r}, "
1324 f"switching to {cd.LANGUAGE!r}"
1325 )
1326 self.language = cd.LANGUAGE
1327 self.local_institution_url = _get_str(
1328 s, cs.LOCAL_INSTITUTION_URL, cd.LOCAL_INSTITUTION_URL
1329 )
1330 self.local_logo_file_absolute = _get_str(
1331 s, cs.LOCAL_LOGO_FILE_ABSOLUTE, cd.LOCAL_LOGO_FILE_ABSOLUTE
1332 )
1333 self.lockout_threshold = _get_int(
1334 s, cs.LOCKOUT_THRESHOLD, cd.LOCKOUT_THRESHOLD
1335 )
1336 self.lockout_duration_increment_minutes = _get_int(
1337 s,
1338 cs.LOCKOUT_DURATION_INCREMENT_MINUTES,
1339 cd.LOCKOUT_DURATION_INCREMENT_MINUTES,
1340 )
1342 self.mfa_methods = _get_multiline(s, cs.MFA_METHODS)
1343 if not self.mfa_methods:
1344 self.mfa_methods = cd.MFA_METHODS
1345 log.warning(f"MFA_METHODS not specified. Using {self.mfa_methods}")
1346 self.mfa_methods = [x.lower() for x in self.mfa_methods]
1347 assert self.mfa_methods, "Bug: missing MFA_METHODS"
1348 _valid_mfa_methods = class_attribute_values(MfaMethod)
1349 for _mfa_method in self.mfa_methods:
1350 if _mfa_method not in _valid_mfa_methods:
1351 raise ValueError(f"Bad MFA_METHOD item: {_mfa_method!r}")
1353 self.mfa_timeout_s = _get_int(s, cs.MFA_TIMEOUT_S, cd.MFA_TIMEOUT_S)
1355 self.password_change_frequency_days = _get_int(
1356 s,
1357 cs.PASSWORD_CHANGE_FREQUENCY_DAYS,
1358 cd.PASSWORD_CHANGE_FREQUENCY_DAYS,
1359 )
1360 self.patient_spec_if_anonymous = _get_str(
1361 s, cs.PATIENT_SPEC_IF_ANONYMOUS, cd.PATIENT_SPEC_IF_ANONYMOUS
1362 )
1363 self.patient_spec = _get_str(s, cs.PATIENT_SPEC)
1364 self.permit_immediate_downloads = _get_bool(
1365 s, cs.PERMIT_IMMEDIATE_DOWNLOADS, cd.PERMIT_IMMEDIATE_DOWNLOADS
1366 )
1367 # currently not configurable, but easy to add in the future:
1368 self.plot_fontsize = cd.PLOT_FONTSIZE
1370 self.region_code = _get_str(s, cs.REGION_CODE, cd.REGION_CODE)
1371 self.restricted_tasks = {} # type: Dict[str, List[str]]
1372 # ... maps XML task names to lists of authorized group names
1373 restricted_tasks = _get_multiline(s, cs.RESTRICTED_TASKS)
1374 for rt_line in restricted_tasks:
1375 rt_line = rt_line.split("#")[0].strip()
1376 # ... everything before a '#'
1377 if not rt_line: # comment line
1378 continue
1379 try:
1380 xml_taskname, groupnames = rt_line.split(":")
1381 except ValueError:
1382 raise ValueError(
1383 f"Restricted tasks line not in the format "
1384 f"'xml_taskname: groupname1, groupname2, ...'. Line was:\n"
1385 f"{rt_line!r}"
1386 )
1387 xml_taskname = xml_taskname.strip()
1388 if xml_taskname in self.restricted_tasks:
1389 raise ValueError(
1390 f"Duplicate restricted task specification "
1391 f"for {xml_taskname!r}"
1392 )
1393 groupnames_list = [x.strip() for x in groupnames.split(",")]
1394 for gn in groupnames_list:
1395 validate_group_name(gn)
1396 self.restricted_tasks[xml_taskname] = groupnames_list
1398 self.session_timeout_minutes = _get_int(
1399 s, cs.SESSION_TIMEOUT_MINUTES, cd.SESSION_TIMEOUT_MINUTES
1400 )
1401 self.session_cookie_secret = _get_str(s, cs.SESSION_COOKIE_SECRET)
1402 self.session_timeout = datetime.timedelta(
1403 minutes=self.session_timeout_minutes
1404 )
1405 self.session_check_user_ip = _get_bool(
1406 s, cs.SESSION_CHECK_USER_IP, cd.SESSION_CHECK_USER_IP
1407 )
1408 sms_label = _get_str(s, cs.SMS_BACKEND, cd.SMS_BACKEND)
1409 sms_config = self._read_sms_config(parser, sms_label)
1410 self.sms_backend = get_sms_backend(sms_label, sms_config)
1411 self.snomed_task_xml_filename = _get_str(
1412 s, cs.SNOMED_TASK_XML_FILENAME
1413 )
1414 self.snomed_icd9_xml_filename = _get_str(
1415 s, cs.SNOMED_ICD9_XML_FILENAME
1416 )
1417 self.snomed_icd10_xml_filename = _get_str(
1418 s, cs.SNOMED_ICD10_XML_FILENAME
1419 )
1421 self.task_filename_spec = _get_str(s, cs.TASK_FILENAME_SPEC)
1422 self.tracker_filename_spec = _get_str(s, cs.TRACKER_FILENAME_SPEC)
1424 self.user_download_dir = _get_str(s, cs.USER_DOWNLOAD_DIR, "")
1425 self.user_download_file_lifetime_min = _get_int(
1426 s,
1427 cs.USER_DOWNLOAD_FILE_LIFETIME_MIN,
1428 cd.USER_DOWNLOAD_FILE_LIFETIME_MIN,
1429 )
1430 self.user_download_max_space_mb = _get_int(
1431 s, cs.USER_DOWNLOAD_MAX_SPACE_MB, cd.USER_DOWNLOAD_MAX_SPACE_MB
1432 )
1434 self.webview_loglevel = get_config_parameter_loglevel(
1435 parser, s, cs.WEBVIEW_LOGLEVEL, cd.WEBVIEW_LOGLEVEL
1436 )
1437 logging.getLogger().setLevel(self.webview_loglevel) # root logger
1438 # ... MUTABLE GLOBAL STATE (if relatively unimportant); todo: fix
1439 self.wkhtmltopdf_filename = _get_str(s, cs.WKHTMLTOPDF_FILENAME)
1441 # More validity checks for the main section:
1442 if not self.patient_spec_if_anonymous:
1443 raise_missing(s, cs.PATIENT_SPEC_IF_ANONYMOUS)
1444 if not self.patient_spec:
1445 raise_missing(s, cs.PATIENT_SPEC)
1446 if not self.session_cookie_secret:
1447 raise_missing(s, cs.SESSION_COOKIE_SECRET)
1448 if not self.task_filename_spec:
1449 raise_missing(s, cs.TASK_FILENAME_SPEC)
1450 if not self.tracker_filename_spec:
1451 raise_missing(s, cs.TRACKER_FILENAME_SPEC)
1452 if not self.ctv_filename_spec:
1453 raise_missing(s, cs.CTV_FILENAME_SPEC)
1455 # To prevent errors:
1456 del s
1457 del cs
1459 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1460 # Web server/WSGI section
1461 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1462 ws = CONFIG_FILE_SERVER_SECTION
1463 cw = ConfigParamServer
1465 self.cherrypy_log_screen = _get_bool(
1466 ws, cw.CHERRYPY_LOG_SCREEN, cd.CHERRYPY_LOG_SCREEN
1467 )
1468 self.cherrypy_root_path = _get_str(
1469 ws, cw.CHERRYPY_ROOT_PATH, cd.CHERRYPY_ROOT_PATH
1470 )
1471 self.cherrypy_server_name = _get_str(
1472 ws, cw.CHERRYPY_SERVER_NAME, cd.CHERRYPY_SERVER_NAME
1473 )
1474 self.cherrypy_threads_max = _get_int(
1475 ws, cw.CHERRYPY_THREADS_MAX, cd.CHERRYPY_THREADS_MAX
1476 )
1477 self.cherrypy_threads_start = _get_int(
1478 ws, cw.CHERRYPY_THREADS_START, cd.CHERRYPY_THREADS_START
1479 )
1480 self.debug_reverse_proxy = _get_bool(
1481 ws, cw.DEBUG_REVERSE_PROXY, cd.DEBUG_REVERSE_PROXY
1482 )
1483 self.debug_show_gunicorn_options = _get_bool(
1484 ws, cw.DEBUG_SHOW_GUNICORN_OPTIONS, cd.DEBUG_SHOW_GUNICORN_OPTIONS
1485 )
1486 self.debug_toolbar = _get_bool(ws, cw.DEBUG_TOOLBAR, cd.DEBUG_TOOLBAR)
1487 self.gunicorn_debug_reload = _get_bool(
1488 ws, cw.GUNICORN_DEBUG_RELOAD, cd.GUNICORN_DEBUG_RELOAD
1489 )
1490 self.gunicorn_num_workers = _get_int(
1491 ws, cw.GUNICORN_NUM_WORKERS, cd.GUNICORN_NUM_WORKERS
1492 )
1493 self.gunicorn_timeout_s = _get_int(
1494 ws, cw.GUNICORN_TIMEOUT_S, cd.GUNICORN_TIMEOUT_S
1495 )
1496 self.host = _get_str(ws, cw.HOST, cd.HOST)
1497 self.port = _get_int(ws, cw.PORT, cd.PORT)
1498 self.proxy_http_host = _get_str(ws, cw.PROXY_HTTP_HOST)
1499 self.proxy_remote_addr = _get_str(ws, cw.PROXY_REMOTE_ADDR)
1500 self.proxy_rewrite_path_info = _get_bool(
1501 ws, cw.PROXY_REWRITE_PATH_INFO, cd.PROXY_REWRITE_PATH_INFO
1502 )
1503 self.proxy_script_name = _get_str(ws, cw.PROXY_SCRIPT_NAME)
1504 self.proxy_server_name = _get_str(ws, cw.PROXY_SERVER_NAME)
1505 self.proxy_server_port = _get_int(ws, cw.PROXY_SERVER_PORT)
1506 self.proxy_url_scheme = _get_str(ws, cw.PROXY_URL_SCHEME)
1507 self.show_request_immediately = _get_bool(
1508 ws, cw.SHOW_REQUEST_IMMEDIATELY, cd.SHOW_REQUEST_IMMEDIATELY
1509 )
1510 self.show_requests = _get_bool(ws, cw.SHOW_REQUESTS, cd.SHOW_REQUESTS)
1511 self.show_response = _get_bool(ws, cw.SHOW_RESPONSE, cd.SHOW_RESPONSE)
1512 self.show_timing = _get_bool(ws, cw.SHOW_TIMING, cd.SHOW_TIMING)
1513 self.ssl_certificate = _get_str(ws, cw.SSL_CERTIFICATE)
1514 if self.ssl_certificate and not os.path.isfile(self.ssl_certificate):
1515 raise ValueError(
1516 f"Invalid {cw.SSL_CERTIFICATE}: {self.ssl_certificate!r} is "
1517 f"not a file"
1518 )
1519 self.ssl_private_key = _get_str(ws, cw.SSL_PRIVATE_KEY)
1520 if self.ssl_private_key and not os.path.isfile(self.ssl_private_key):
1521 raise ValueError(
1522 f"Invalid {cw.SSL_PRIVATE_KEY}: {self.ssl_private_key!r} is "
1523 f"not a file"
1524 )
1525 self.static_cache_duration_s = _get_int(
1526 ws, cw.STATIC_CACHE_DURATION_S, cd.STATIC_CACHE_DURATION_S
1527 )
1528 self.trusted_proxy_headers = _get_multiline(
1529 ws, cw.TRUSTED_PROXY_HEADERS
1530 )
1531 self.unix_domain_socket = _get_str(ws, cw.UNIX_DOMAIN_SOCKET)
1533 # The defaults here depend on values above:
1534 self.external_url_scheme = _get_str(
1535 ws, cw.EXTERNAL_URL_SCHEME, cd.EXTERNAL_URL_SCHEME
1536 )
1537 self.external_server_name = _get_str(
1538 ws, cw.EXTERNAL_SERVER_NAME, self.host
1539 )
1540 self.external_server_port = _get_int(
1541 ws, cw.EXTERNAL_SERVER_PORT, self.port
1542 )
1543 self.external_script_name = _get_str(ws, cw.EXTERNAL_SCRIPT_NAME, "")
1545 for tph in self.trusted_proxy_headers:
1546 if tph not in ReverseProxiedMiddleware.ALL_CANDIDATES:
1547 raise ValueError(
1548 f"Invalid {cw.TRUSTED_PROXY_HEADERS} value specified: "
1549 f"was {tph!r}, options are "
1550 f"{ReverseProxiedMiddleware.ALL_CANDIDATES}"
1551 )
1553 del ws
1554 del cw
1556 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1557 # Export section
1558 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1559 es = CONFIG_FILE_EXPORT_SECTION
1560 ce = ConfigParamExportGeneral
1562 self.celery_beat_extra_args = _get_multiline(
1563 es, ce.CELERY_BEAT_EXTRA_ARGS
1564 )
1565 self.celery_beat_schedule_database = _get_str(
1566 es, ce.CELERY_BEAT_SCHEDULE_DATABASE
1567 )
1568 if not self.celery_beat_schedule_database:
1569 raise_missing(es, ce.CELERY_BEAT_SCHEDULE_DATABASE)
1570 self.celery_broker_url = _get_str(
1571 es, ce.CELERY_BROKER_URL, cd.CELERY_BROKER_URL
1572 )
1573 self.celery_worker_extra_args = _get_multiline(
1574 es, ce.CELERY_WORKER_EXTRA_ARGS
1575 )
1576 self.celery_export_task_rate_limit = _get_str(
1577 es, ce.CELERY_EXPORT_TASK_RATE_LIMIT
1578 )
1580 self.export_lockdir = _get_str(es, ce.EXPORT_LOCKDIR)
1581 if not self.export_lockdir:
1582 raise_missing(es, ConfigParamExportGeneral.EXPORT_LOCKDIR)
1584 self.export_recipient_names = _get_multiline_ignoring_comments(
1585 CONFIG_FILE_EXPORT_SECTION, ce.RECIPIENTS
1586 )
1587 duplicates = [
1588 name
1589 for name, count in collections.Counter(
1590 self.export_recipient_names
1591 ).items()
1592 if count > 1
1593 ]
1594 if duplicates:
1595 raise ValueError(
1596 f"Duplicate export recipients specified: {duplicates!r}"
1597 )
1598 for recip_name in self.export_recipient_names:
1599 if re.match(VALID_RECIPIENT_NAME_REGEX, recip_name) is None:
1600 raise ValueError(
1601 f"Recipient names must be alphanumeric or _- only; was "
1602 f"{recip_name!r}"
1603 )
1604 if len(set(self.export_recipient_names)) != len(
1605 self.export_recipient_names
1606 ):
1607 raise ValueError("Recipient names contain duplicates")
1608 self._export_recipients = [] # type: List[ExportRecipientInfo]
1609 self._read_export_recipients(parser)
1611 self.schedule_timezone = _get_str(
1612 es, ce.SCHEDULE_TIMEZONE, cd.SCHEDULE_TIMEZONE
1613 )
1615 self.crontab_entries = [] # type: List[CrontabEntry]
1616 crontab_lines = _get_multiline(es, ce.SCHEDULE)
1617 for crontab_line in crontab_lines:
1618 crontab_line = crontab_line.split("#")[0].strip()
1619 # ... everything before a '#'
1620 if not crontab_line: # comment line
1621 continue
1622 crontab_entry = CrontabEntry(line=crontab_line)
1623 if crontab_entry.content not in self.export_recipient_names:
1624 raise ValueError(
1625 f"{ce.SCHEDULE} setting exists for non-existent recipient "
1626 f"{crontab_entry.content}"
1627 )
1628 self.crontab_entries.append(crontab_entry)
1630 del es
1631 del ce
1633 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1634 # Other attributes
1635 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1636 self._sqla_engine: Optional[Engine] = None
1638 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1639 # Docker checks
1640 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1641 if self.running_under_docker:
1642 log.info("Docker environment detected")
1644 # Values expected to be fixed
1645 warn_if_not_docker_value(
1646 param_name=ConfigParamExportGeneral.CELERY_BROKER_URL,
1647 actual_value=self.celery_broker_url,
1648 required_value=DockerConstants.CELERY_BROKER_URL,
1649 )
1650 warn_if_not_docker_value(
1651 param_name=ConfigParamServer.HOST,
1652 actual_value=self.host,
1653 required_value=DockerConstants.HOST,
1654 )
1656 # Values expected to be present
1657 #
1658 # - Re SSL certificates: reconsidered. People may want to run
1659 # internal plain HTTP but then an Apache front end, and they
1660 # wouldn't appreciate the warnings.
1661 #
1662 # warn_if_not_present(
1663 # param_name=ConfigParamServer.SSL_CERTIFICATE,
1664 # value=self.ssl_certificate
1665 # )
1666 # warn_if_not_present(
1667 # param_name=ConfigParamServer.SSL_PRIVATE_KEY,
1668 # value=self.ssl_private_key
1669 # )
1671 # Config-related files
1672 warn_if_not_within_docker_dir(
1673 param_name=ConfigParamServer.SSL_CERTIFICATE,
1674 filespec=self.ssl_certificate,
1675 permit_cfg=True,
1676 )
1677 warn_if_not_within_docker_dir(
1678 param_name=ConfigParamServer.SSL_PRIVATE_KEY,
1679 filespec=self.ssl_private_key,
1680 permit_cfg=True,
1681 )
1682 warn_if_not_within_docker_dir(
1683 param_name=ConfigParamSite.LOCAL_LOGO_FILE_ABSOLUTE,
1684 filespec=self.local_logo_file_absolute,
1685 permit_cfg=True,
1686 permit_venv=True,
1687 )
1688 warn_if_not_within_docker_dir(
1689 param_name=ConfigParamSite.CAMCOPS_LOGO_FILE_ABSOLUTE,
1690 filespec=self.camcops_logo_file_absolute,
1691 permit_cfg=True,
1692 permit_venv=True,
1693 )
1694 for esf in self.extra_string_files:
1695 warn_if_not_within_docker_dir(
1696 param_name=ConfigParamSite.EXTRA_STRING_FILES,
1697 filespec=esf,
1698 permit_cfg=True,
1699 permit_venv=True,
1700 param_contains_not_is=True,
1701 )
1702 warn_if_not_within_docker_dir(
1703 param_name=ConfigParamSite.SNOMED_ICD9_XML_FILENAME,
1704 filespec=self.snomed_icd9_xml_filename,
1705 permit_cfg=True,
1706 permit_venv=True,
1707 )
1708 warn_if_not_within_docker_dir(
1709 param_name=ConfigParamSite.SNOMED_ICD10_XML_FILENAME,
1710 filespec=self.snomed_icd10_xml_filename,
1711 permit_cfg=True,
1712 permit_venv=True,
1713 )
1714 warn_if_not_within_docker_dir(
1715 param_name=ConfigParamSite.SNOMED_TASK_XML_FILENAME,
1716 filespec=self.snomed_task_xml_filename,
1717 permit_cfg=True,
1718 permit_venv=True,
1719 )
1721 # Temporary/scratch space that needs to be shared between Docker
1722 # containers
1723 warn_if_not_within_docker_dir(
1724 param_name=ConfigParamSite.USER_DOWNLOAD_DIR,
1725 filespec=self.user_download_dir,
1726 permit_tmp=True,
1727 )
1728 warn_if_not_within_docker_dir(
1729 param_name=ConfigParamExportGeneral.CELERY_BEAT_SCHEDULE_DATABASE, # noqa
1730 filespec=self.celery_beat_schedule_database,
1731 permit_tmp=True,
1732 )
1733 warn_if_not_within_docker_dir(
1734 param_name=ConfigParamExportGeneral.EXPORT_LOCKDIR,
1735 filespec=self.export_lockdir,
1736 permit_tmp=True,
1737 )
1739 # -------------------------------------------------------------------------
1740 # Database functions
1741 # -------------------------------------------------------------------------
1743 def get_sqla_engine(self) -> Engine:
1744 """
1745 Returns an SQLAlchemy :class:`Engine`.
1747 I was previously misinterpreting the appropriate scope of an Engine.
1748 I thought: create one per request.
1749 But the Engine represents the connection *pool*.
1750 So if you create them all the time, you get e.g. a
1751 'Too many connections' error.
1753 "The appropriate scope is once per [database] URL per application,
1754 at the module level."
1756 - https://groups.google.com/forum/#!topic/sqlalchemy/ZtCo2DsHhS4
1757 - https://stackoverflow.com/questions/8645250/how-to-close-sqlalchemy-connection-in-mysql
1759 Now, our CamcopsConfig instance is cached, so there should be one of
1760 them overall. See get_config() below.
1762 Therefore, making the engine a member of this class should do the
1763 trick, whilst avoiding global variables.
1764 """ # noqa
1765 if self._sqla_engine is None:
1766 self._sqla_engine = create_engine(
1767 self.db_url,
1768 echo=self.db_echo,
1769 pool_pre_ping=True,
1770 # pool_size=0, # no limit (for parallel testing, which failed)
1771 )
1772 log.debug(
1773 "Created SQLAlchemy engine for URL {}",
1774 get_safe_url_from_engine(self._sqla_engine),
1775 )
1776 return self._sqla_engine
1778 @property
1779 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
1780 def get_all_table_names(self) -> List[str]:
1781 """
1782 Returns all table names from the database.
1783 """
1784 log.debug("Fetching database table names")
1785 engine = self.get_sqla_engine()
1786 return get_table_names(engine=engine)
1788 def get_dbsession_raw(self) -> SqlASession:
1789 """
1790 Returns a raw SQLAlchemy Session.
1791 Avoid this -- use :func:`get_dbsession_context` instead.
1792 """
1793 engine = self.get_sqla_engine()
1794 maker = sessionmaker(bind=engine)
1795 dbsession = maker() # type: SqlASession
1796 return dbsession
1798 @contextlib.contextmanager
1799 def get_dbsession_context(self) -> Generator[SqlASession, None, None]:
1800 """
1801 Context manager to provide an SQLAlchemy session that will COMMIT
1802 once we've finished, or perform a ROLLBACK if there was an exception.
1803 """
1804 dbsession = self.get_dbsession_raw()
1805 # noinspection PyBroadException
1806 try:
1807 yield dbsession
1808 dbsession.commit()
1809 except Exception:
1810 dbsession.rollback()
1811 raise
1812 finally:
1813 dbsession.close()
1815 def _assert_valid_database_engine(self) -> None:
1816 """
1817 Assert that our backend database is a valid type.
1819 Specifically, we prohibit:
1821 - SQL Server versions before 2008: they don't support timezones
1822 and we need that.
1823 """
1824 engine = self.get_sqla_engine()
1825 if not is_sqlserver(engine):
1826 return
1827 assert is_sqlserver_2008_or_later(engine), (
1828 "If you use Microsoft SQL Server as the back-end database for a "
1829 "CamCOPS server, it must be at least SQL Server 2008. Older "
1830 "versions do not have time zone awareness."
1831 )
1833 def _assert_database_is_at_head(self) -> None:
1834 """
1835 Assert that the current database is at its head (most recent) revision,
1836 by comparing its Alembic version number (written into the Alembic
1837 version table of the database) to the most recent Alembic revision in
1838 our ``camcops_server/alembic/versions`` directory.
1839 """
1840 current, head = get_current_and_head_revision(
1841 database_url=self.db_url,
1842 alembic_config_filename=ALEMBIC_CONFIG_FILENAME,
1843 alembic_base_dir=ALEMBIC_BASE_DIR,
1844 version_table=ALEMBIC_VERSION_TABLE,
1845 )
1846 if current == head:
1847 log.debug("Database is at correct (head) revision of {}", current)
1848 else:
1849 raise_runtime_error(
1850 f"Database structure is at version {current} but should be at "
1851 f"version {head}. CamCOPS will not start. Please use the "
1852 f"'upgrade_db' command to fix this."
1853 )
1855 def assert_database_ok(self) -> None:
1856 """
1857 Asserts that our database engine is OK and our database structure is
1858 correct.
1859 """
1860 self._assert_valid_database_engine()
1861 self._assert_database_is_at_head()
1863 # -------------------------------------------------------------------------
1864 # SNOMED-CT functions
1865 # -------------------------------------------------------------------------
1867 def get_task_snomed_concepts(self) -> Dict[str, SnomedConcept]:
1868 """
1869 Returns all SNOMED-CT concepts for tasks.
1871 Returns:
1872 dict: maps lookup strings to :class:`SnomedConcept` objects
1873 """
1874 if not self.snomed_task_xml_filename:
1875 return {}
1876 return get_all_task_snomed_concepts(self.snomed_task_xml_filename)
1878 def get_icd9cm_snomed_concepts(self) -> Dict[str, List[SnomedConcept]]:
1879 """
1880 Returns all SNOMED-CT concepts for ICD-9-CM codes supported by CamCOPS.
1882 Returns:
1883 dict: maps ICD-9-CM codes to :class:`SnomedConcept` objects
1884 """
1885 if not self.snomed_icd9_xml_filename:
1886 return {}
1887 return get_icd9_snomed_concepts_from_xml(self.snomed_icd9_xml_filename)
1889 def get_icd10_snomed_concepts(self) -> Dict[str, List[SnomedConcept]]:
1890 """
1891 Returns all SNOMED-CT concepts for ICD-10-CM codes supported by
1892 CamCOPS.
1894 Returns:
1895 dict: maps ICD-10 codes to :class:`SnomedConcept` objects
1896 """
1897 if not self.snomed_icd10_xml_filename:
1898 return {}
1899 return get_icd10_snomed_concepts_from_xml(
1900 self.snomed_icd10_xml_filename
1901 )
1903 # -------------------------------------------------------------------------
1904 # Export functions
1905 # -------------------------------------------------------------------------
1907 def _read_export_recipients(
1908 self, parser: configparser.ConfigParser = None
1909 ) -> None:
1910 """
1911 Loads
1912 :class:`camcops_server.cc_modules.cc_exportrecipientinfo.ExportRecipientInfo`
1913 objects from the config file. Stores them in
1914 ``self._export_recipients``.
1916 Note that these objects are **not** associated with a database session.
1918 Args:
1919 parser: optional :class:`configparser.ConfigParser` object.
1920 """
1921 for recip_name in self.export_recipient_names:
1922 log.debug("Loading export config for recipient {!r}", recip_name)
1923 try:
1924 validate_export_recipient_name(recip_name)
1925 except ValueError as e:
1926 raise ValueError(f"Bad recipient name {recip_name!r}: {e}")
1927 recipient = ExportRecipientInfo.read_from_config(
1928 self, parser=parser, recipient_name=recip_name
1929 )
1930 self._export_recipients.append(recipient)
1932 def get_all_export_recipient_info(self) -> List["ExportRecipientInfo"]:
1933 """
1934 Returns all export recipients (in their "database unaware" form)
1935 specified in the config.
1937 Returns:
1938 list: of
1939 :class:`camcops_server.cc_modules.cc_exportrecipientinfo.ExportRecipientInfo`
1940 """
1941 return self._export_recipients
1943 # -------------------------------------------------------------------------
1944 # File-based locks
1945 # -------------------------------------------------------------------------
1947 def get_export_lockfilename_recipient_db(self, recipient_name: str) -> str:
1948 """
1949 Returns a full path to a lockfile suitable for locking for a
1950 whole-database export to a particular export recipient.
1952 Args:
1953 recipient_name: name of the recipient
1955 Returns:
1956 a filename
1957 """
1958 filename = f"camcops_export_db_{recipient_name}"
1959 # ".lock" is appended automatically by the lockfile package
1960 return os.path.join(self.export_lockdir, filename)
1962 def get_export_lockfilename_recipient_fhir(
1963 self, recipient_name: str
1964 ) -> str:
1965 """
1966 Returns a full path to a lockfile suitable for locking for a
1967 FHIR export to a particular export recipient.
1969 (This must be different from
1970 :meth:`get_export_lockfilename_recipient_db`, because of what we assume
1971 about someone else holding the same lock.)
1973 Args:
1974 recipient_name: name of the recipient
1976 Returns:
1977 a filename
1978 """
1979 filename = f"camcops_export_fhir_{recipient_name}"
1980 # ".lock" is appended automatically by the lockfile package
1981 return os.path.join(self.export_lockdir, filename)
1983 def get_export_lockfilename_recipient_task(
1984 self, recipient_name: str, basetable: str, pk: int
1985 ) -> str:
1986 """
1987 Returns a full path to a lockfile suitable for locking for a
1988 single-task export to a particular export recipient.
1990 Args:
1991 recipient_name: name of the recipient
1992 basetable: task base table name
1993 pk: server PK of the task
1995 Returns:
1996 a filename
1997 """
1998 filename = f"camcops_export_task_{recipient_name}_{basetable}_{pk}"
1999 # ".lock" is appended automatically by the lockfile package
2000 return os.path.join(self.export_lockdir, filename)
2002 def get_master_export_recipient_lockfilename(self) -> str:
2003 """
2004 When we are modifying export recipients, we check "is this information
2005 the same as the current version in the database", and if not, we write
2006 fresh information to the database. If lots of processes do that at the
2007 same time, we have a problem (usually a database deadlock) -- hence
2008 this lock.
2010 Returns:
2011 a filename
2012 """
2013 filename = "camcops_master_export_recipient"
2014 # ".lock" is appended automatically by the lockfile package
2015 return os.path.join(self.export_lockdir, filename)
2017 def get_celery_beat_pidfilename(self) -> str:
2018 """
2019 Process ID file (pidfile) used by ``celery beat --pidfile ...``.
2020 """
2021 filename = "camcops_celerybeat.pid"
2022 return os.path.join(self.export_lockdir, filename)
2024 # -------------------------------------------------------------------------
2025 # SMS backend
2026 # -------------------------------------------------------------------------
2028 @staticmethod
2029 def _read_sms_config(
2030 parser: configparser.ConfigParser, sms_label: str
2031 ) -> Dict[str, str]:
2032 """
2033 Read a config section for a specific SMS backend.
2034 """
2035 section_name = f"{CONFIG_FILE_SMS_BACKEND_PREFIX}:{sms_label}"
2036 if not parser.has_section(section_name):
2037 return {}
2039 sms_config = {}
2040 section = parser[section_name]
2041 for key in section:
2042 sms_config[key.lower()] = section[key]
2043 return sms_config
2046# =============================================================================
2047# Get config filename from an appropriate environment (WSGI or OS)
2048# =============================================================================
2051def get_config_filename_from_os_env() -> str:
2052 """
2053 Returns the config filename to use, from our operating system environment
2054 variable.
2056 (We do NOT trust the WSGI environment for this.)
2057 """
2058 config_filename = os.environ.get(ENVVAR_CONFIG_FILE)
2059 if not config_filename:
2060 raise AssertionError(
2061 f"OS environment did not provide the required "
2062 f"environment variable {ENVVAR_CONFIG_FILE}"
2063 )
2064 return config_filename
2067# =============================================================================
2068# Cached instances
2069# =============================================================================
2072@cache_region_static.cache_on_arguments(function_key_generator=fkg)
2073def get_config(config_filename: str) -> CamcopsConfig:
2074 """
2075 Returns a :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` from
2076 the specified config filename.
2078 Cached.
2079 """
2080 return CamcopsConfig(config_filename)
2083# =============================================================================
2084# Get default config
2085# =============================================================================
2088def get_default_config_from_os_env() -> CamcopsConfig:
2089 """
2090 Returns the :class:`camcops_server.cc_modules.cc_config.CamcopsConfig`
2091 representing the config filename that we read from our operating system
2092 environment variable.
2093 """
2094 if ON_READTHEDOCS:
2095 return CamcopsConfig(config_filename="", config_text=get_demo_config())
2096 else:
2097 return get_config(get_config_filename_from_os_env())