Coverage for cc_modules/cc_config.py: 62%

1# noinspection HttpUrlsUsage

2"""

3 camcops_server/cc_modules/cc_config.py

4

5===============================================================================

6

7 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

8 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

9

10 This file is part of CamCOPS.

11

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.

16

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.

21

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/>.

24

25===============================================================================

26

27**Read and represent a CamCOPS config file.**

28

29 Also contains various types of demonstration config file (CamCOPS, but also

30``supervisord``, Apache, etc.) and demonstration helper scripts (e.g. MySQL).

31

32 There are CONDITIONAL AND IN-FUNCTION IMPORTS HERE; see below. This is to

33 minimize the number of modules loaded when this is used in the context of the

34 client-side database script, rather than the webview.

35

36 Moreover, it should not use SQLAlchemy objects directly; see ``celery.py``.

37

38 In particular, I tried hard to use a "database-unaware" (unbound) SQLAlchemy

39 ExportRecipient object. However, when the backend re-calls the config to get

40 its recipients, we get errors like:

41

42.. code-block:: none

43

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)

67

68""" # noqa

69

70 import codecs

71 import collections

72 import configparser

73 import contextlib

74 import datetime

75 import os

76 import logging

77 import re

78 from subprocess import run, PIPE

79 from typing import Any, Dict, Generator, List, Optional, Union

80

81 from cardinal_pythonlib.classes import class_attribute_values

82 from cardinal_pythonlib.configfiles import (

83 get_config_parameter,

84 get_config_parameter_boolean,

85 get_config_parameter_loglevel,

86 get_config_parameter_multiline,

87)

88 from cardinal_pythonlib.docker import running_under_docker

89 from cardinal_pythonlib.fileops import relative_filename_within_dir

90 from cardinal_pythonlib.logs import BraceStyleAdapter

91 from cardinal_pythonlib.randomness import create_base64encoded_randomness

92 from cardinal_pythonlib.reprfunc import auto_repr

93 from cardinal_pythonlib.sqlalchemy.alembic_func import (

94 get_current_and_head_revision,

95)

96 from cardinal_pythonlib.sqlalchemy.engine_func import (

97 is_sqlserver,

98 is_sqlserver_2008_or_later,

99)

100 from cardinal_pythonlib.sqlalchemy.logs import (

101 pre_disable_sqlalchemy_extra_echo_log,

102)

103 from cardinal_pythonlib.sqlalchemy.schema import get_table_names

104 from cardinal_pythonlib.sqlalchemy.session import get_safe_url_from_engine

105 from cardinal_pythonlib.wsgi.reverse_proxied_mw import ReverseProxiedMiddleware

106 import celery.schedules

107 from sqlalchemy.engine import create_engine

108 from sqlalchemy.engine.base import Engine

109 from sqlalchemy.orm import sessionmaker

110 from sqlalchemy.orm import Session as SqlASession

111

112 from 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)

120 from camcops_server.cc_modules.cc_cache import cache_region_static, fkg

121 from 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)

135 from camcops_server.cc_modules.cc_exportrecipientinfo import (

136 ExportRecipientInfo,

137)

138 from camcops_server.cc_modules.cc_exception import raise_runtime_error

139 from camcops_server.cc_modules.cc_filename import PatientSpecElementForFilename

140 from camcops_server.cc_modules.cc_language import POSSIBLE_LOCALES

141 from camcops_server.cc_modules.cc_pyramid import MASTER_ROUTE_CLIENT_API

142 from camcops_server.cc_modules.cc_sms import (

143 get_sms_backend,

144 KapowSmsBackend,

145 TwilioSmsBackend,

146)

147 from 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)

153 from camcops_server.cc_modules.cc_validators import (

154 validate_export_recipient_name,

155 validate_group_name,

156)

157 from camcops_server.cc_modules.cc_version_string import (

158 CAMCOPS_SERVER_VERSION_STRING,

159)

160

161 log = BraceStyleAdapter(logging.getLogger(__name__))

162

163 pre_disable_sqlalchemy_extra_echo_log()

164

165

166# =============================================================================

167# Constants

168# =============================================================================

169

170 VALID_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/

174

175# Windows paths: irrelevant, as Windows doesn't run supervisord

176 DEFAULT_LINUX_CAMCOPS_CONFIG = "/etc/camcops/camcops.conf"

177 DEFAULT_LINUX_CAMCOPS_BASE_DIR = "/usr/share/camcops"

178 DEFAULT_LINUX_CAMCOPS_VENV_DIR = os.path.join(

179 DEFAULT_LINUX_CAMCOPS_BASE_DIR, "venv"

180)

181 DEFAULT_LINUX_CAMCOPS_VENV_BIN_DIR = os.path.join(

182 DEFAULT_LINUX_CAMCOPS_VENV_DIR, "bin"

183)

184 DEFAULT_LINUX_CAMCOPS_EXECUTABLE = os.path.join(

185 DEFAULT_LINUX_CAMCOPS_VENV_BIN_DIR, "camcops_server"

186)

187 DEFAULT_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)

195 DEFAULT_LINUX_LOGDIR = "/var/log/supervisor"

196 DEFAULT_LINUX_USER = "www-data" # Ubuntu default

197

198

199# =============================================================================

200# Helper functions

201# =============================================================================

202

203

204 def 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.

216

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 )

248

249

250 def 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.

256

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 )

271

272

273 def 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.

277

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 )

289

290

291 def 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)

302

303

304# =============================================================================

305# Demo config

306# =============================================================================

307

308# Cosmetic demonstration constants:

309 DEFAULT_DB_READONLY_USER = "QQQ_USERNAME_REPLACE_ME"

310 DEFAULT_DB_READONLY_PASSWORD = "PPP_PASSWORD_REPLACE_ME"

311 DUMMY_INSTITUTION_URL = "https://www.mydomain/"

312

313

314 def get_demo_config(for_docker: bool = False) -> str:

315 """

316 Returns a demonstration config file based on the specified parameters.

317

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)

326

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}

335

336# =============================================================================

337# CamCOPS site

338# =============================================================================

339

340[{CONFIG_FILE_SITE_SECTION}]

341

342# -----------------------------------------------------------------------------

343# Database connection

344# -----------------------------------------------------------------------------

345

346{ConfigParamSite.DB_URL} = {cd.demo_db_url}

347{ConfigParamSite.DB_ECHO} = {cd.DB_ECHO}

348

349# -----------------------------------------------------------------------------

350# URLs and paths

351# -----------------------------------------------------------------------------

352

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}

356

357{ConfigParamSite.EXTRA_STRING_FILES} = {cd.EXTRA_STRING_FILES}

358{ConfigParamSite.RESTRICTED_TASKS} =

359{ConfigParamSite.LANGUAGE} = {cd.LANGUAGE}

360

361{ConfigParamSite.SNOMED_TASK_XML_FILENAME} =

362{ConfigParamSite.SNOMED_ICD9_XML_FILENAME} =

363{ConfigParamSite.SNOMED_ICD10_XML_FILENAME} =

364

365{ConfigParamSite.WKHTMLTOPDF_FILENAME} =

366

367# -----------------------------------------------------------------------------

368# Server geographical location

369# -----------------------------------------------------------------------------

370

371{ConfigParamSite.REGION_CODE} = {cd.REGION_CODE}

372

373# -----------------------------------------------------------------------------

374# Login and session configuration

375# -----------------------------------------------------------------------------

376

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}

386

387# -----------------------------------------------------------------------------

388# Suggested filenames for saving PDFs from the web view

389# -----------------------------------------------------------------------------

390

391{ConfigParamSite.PATIENT_SPEC_IF_ANONYMOUS} = {cd.PATIENT_SPEC_IF_ANONYMOUS}

392{ConfigParamSite.PATIENT_SPEC} = {{{PatientSpecElementForFilename.SURNAME}}}_{{{PatientSpecElementForFilename.FORENAME}}}_{{{PatientSpecElementForFilename.ALLIDNUMS}}}

393

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}}

397

398# -----------------------------------------------------------------------------

399# E-mail options

400# -----------------------------------------------------------------------------

401

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>

410

411# -----------------------------------------------------------------------------

412# SMS options

413# -----------------------------------------------------------------------------

414

415{ConfigParamSite.SMS_BACKEND} = {cd.SMS_BACKEND}

416

417# -----------------------------------------------------------------------------

418# User download options

419# -----------------------------------------------------------------------------

420

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}

425

426# -----------------------------------------------------------------------------

427# Debugging options

428# -----------------------------------------------------------------------------

429

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}

433

434

435# =============================================================================

436# Web server options

437# =============================================================================

438

439[{CONFIG_FILE_SERVER_SECTION}]

440

441# -----------------------------------------------------------------------------

442# Common web server options

443# -----------------------------------------------------------------------------

444

445{ConfigParamServer.HOST} = {cd.HOST}

446{ConfigParamServer.PORT} = {cd.PORT}

447{ConfigParamServer.UNIX_DOMAIN_SOCKET} =

448

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}

455

456# -----------------------------------------------------------------------------

457# WSGI options

458# -----------------------------------------------------------------------------

459

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

480

481# -----------------------------------------------------------------------------

482# Determining the externally accessible CamCOPS URL for back-end work

483# -----------------------------------------------------------------------------

484

485{ConfigParamServer.EXTERNAL_URL_SCHEME} =

486{ConfigParamServer.EXTERNAL_SERVER_NAME} =

487{ConfigParamServer.EXTERNAL_SERVER_PORT} =

488{ConfigParamServer.EXTERNAL_SCRIPT_NAME} =

489

490# -----------------------------------------------------------------------------

491# CherryPy options

492# -----------------------------------------------------------------------------

493

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}

499

500# -----------------------------------------------------------------------------

501# Gunicorn options

502# -----------------------------------------------------------------------------

503

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}

508

509

510# =============================================================================

511# Export options

512# =============================================================================

513

514[{CONFIG_FILE_EXPORT_SECTION}]

515

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}

527

528{ConfigParamExportGeneral.RECIPIENTS} =

529

530{ConfigParamExportGeneral.SCHEDULE_TIMEZONE} = {cd.SCHEDULE_TIMEZONE}

531{ConfigParamExportGeneral.SCHEDULE} =

532

533

534# =============================================================================

535# Details for each export recipient

536# =============================================================================

537

538# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

539# Example recipient

540# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

541 # Example (disabled because it's not in the {ConfigParamExportGeneral.RECIPIENTS} list above)

542

543[recipient:recipient_A]

544

545 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

546 # How to export

547 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

548

549{ConfigParamExportRecipient.TRANSMISSION_METHOD} = hl7

550{ConfigParamExportRecipient.PUSH} = true

551{ConfigParamExportRecipient.TASK_FORMAT} = pdf

552{ConfigParamExportRecipient.XML_FIELD_COMMENTS} = {cd.XML_FIELD_COMMENTS}

553

554 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

555 # What to export

556 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

557

558{ConfigParamExportRecipient.ALL_GROUPS} = false

559{ConfigParamExportRecipient.GROUPS} =

560 myfirstgroup

561 mysecondgroup

562{ConfigParamExportRecipient.TASKS} =

563

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}

570

571 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

572 # Options applicable to database exports

573 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

574

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}

580

581 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

582 # Options applicable to e-mail exports

583 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

584

585{ConfigParamExportRecipient.EMAIL_TO} =

586 Perinatal Psychiatry Admin <perinatal@myinstitution.mydomain>

587

588{ConfigParamExportRecipient.EMAIL_CC} =

589 Dr Alice Bradford <alice.bradford@myinstitution.mydomain>

590 Dr Charles Dogfoot <charles.dogfoot@myinstitution.mydomain>

591

592{ConfigParamExportRecipient.EMAIL_BCC} =

593 superuser <root@myinstitution.mydomain>

594

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

602

603 {{patient}}

604

605 Task type: {{tasktype}}

606 Created: {{created}}

607 CamCOPS server primary key: {{serverpk}}

608

609 Yours faithfully,

610

611 The CamCOPS computer.

612

613{ConfigParamExportRecipient.EMAIL_KEEP_MESSAGE} = {cd.HL7_KEEP_MESSAGE}

614

615 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

616 # Options applicable to FHIR

617 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

618

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} =

624

625 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

626 # Options applicable to HL7 (v2) exports

627 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

628

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}

637

638 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

639 # Options applicable to file transfers/attachments

640 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

641

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} =

649

650 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

651 # Extra options for RiO metadata for file-based export

652 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

653

654{ConfigParamExportRecipient.RIO_IDNUM} = 2

655{ConfigParamExportRecipient.RIO_UPLOADING_USER} = CamCOPS

656{ConfigParamExportRecipient.RIO_DOCUMENT_TYPE} = CC

657

658 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

659 # Extra options for REDCap export

660 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

661

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

665

666# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

667# Example SMS Backends. No configuration needed for '{SmsBackendNames.CONSOLE}' (testing only).

668# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

669

670[{CONFIG_FILE_SMS_BACKEND_PREFIX}:{SmsBackendNames.KAPOW}]

671

672{KapowSmsBackend.PARAM_USERNAME} = myusername

673{KapowSmsBackend.PARAM_PASSWORD} = mypassword

674

675[{CONFIG_FILE_SMS_BACKEND_PREFIX}:{SmsBackendNames.TWILIO}]

676

677{TwilioSmsBackend.PARAM_SID.upper()} = mysid

678{TwilioSmsBackend.PARAM_TOKEN.upper()} = mytoken

679{TwilioSmsBackend.PARAM_FROM_PHONE_NUMBER.upper()} = myphonenumber

680

681 """.strip() # noqa

682

683

684# =============================================================================

685# Demo configuration files, other than the CamCOPS config file itself

686# =============================================================================

687

688 DEFAULT_SOCKET_FILENAME = "/run/camcops/camcops.socket"

689

690

691 def 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

709

710[program:camcops_server]

711

712 command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} serve_gunicorn

713 --config {DEFAULT_LINUX_CAMCOPS_CONFIG}

714

715 directory = {DEFAULT_LINUX_CAMCOPS_BASE_DIR}

716 environment = MPLCONFIGDIR="{LINUX_DEFAULT_MATPLOTLIB_CACHE_DIR}"

717 user = {DEFAULT_LINUX_USER}

718 stdout_logfile = {DEFAULT_LINUX_LOGDIR}/camcops_server.log

719 redirect_stderr = {redirect_stderr}

720 autostart = {autostart}

721 autorestart = {autorestart}

722 startsecs = {startsecs}

723 stopwaitsecs = {stopwaitsecs}

724

725[program:camcops_workers]

726

727 command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} launch_workers

728 --config {DEFAULT_LINUX_CAMCOPS_CONFIG}

729

730 directory = {DEFAULT_LINUX_CAMCOPS_BASE_DIR}

731 environment = MPLCONFIGDIR="{LINUX_DEFAULT_MATPLOTLIB_CACHE_DIR}"

732 user = {DEFAULT_LINUX_USER}

733 stdout_logfile = {DEFAULT_LINUX_LOGDIR}/camcops_workers.log

734 redirect_stderr = {redirect_stderr}

735 autostart = {autostart}

736 autorestart = {autorestart}

737 startsecs = {startsecs}

738 stopwaitsecs = {stopwaitsecs}

739 startretries = {startretries}

740 stopasgroup = {stopasgroup}

741

742[program:camcops_scheduler]

743

744 command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} launch_scheduler

745 --config {DEFAULT_LINUX_CAMCOPS_CONFIG}

746

747 directory = {DEFAULT_LINUX_CAMCOPS_BASE_DIR}

748 environment = MPLCONFIGDIR="{LINUX_DEFAULT_MATPLOTLIB_CACHE_DIR}"

749 user = {DEFAULT_LINUX_USER}

750 stdout_logfile = {DEFAULT_LINUX_LOGDIR}/camcops_scheduler.log

751 redirect_stderr = {redirect_stderr}

752 autostart = {autostart}

753 autorestart = {autorestart}

754 startsecs = {startsecs}

755 stopwaitsecs = {stopwaitsecs}

756 startretries = {startretries}

757

758[group:camcops]

759

760 programs = camcops_server, camcops_workers, camcops_scheduler

761

762 """.strip() # noqa

763

764

765 def 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

776

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 #"""

796

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 = ""

806

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.

817

818<VirtualHost *:443>

819 # ...

820

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.

827

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

834

835 # CHANGE THIS: aim the alias at your own institutional logo.

836

837 Alias {urlbaseslash}static/logo_local.png {DEFAULT_LINUX_CAMCOPS_STATIC_DIR}/logo_local.png

838

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.)

842

843 Alias {urlbaseslash}static/ {DEFAULT_LINUX_CAMCOPS_STATIC_DIR}/

844

845 <Directory {DEFAULT_LINUX_CAMCOPS_STATIC_DIR}>

846 Require all granted

847

848 # ... for old Apache versions (e.g. 2.2), use instead:

849 # Order allow,deny

850 # Allow from all

851 </Directory>

852

853 # Don't ProxyPass the static files; we'll serve them via Apache.

854

855 ProxyPassMatch ^{urlbaseslash}static/ !

856

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).

908

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}

911

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

972

973 ProxyPass {urlbase} unix:{specimen_socket_file}|http://dummy1 retry=0 timeout=300

974 ProxyPassReverse {urlbase} unix:{specimen_socket_file}|http://dummy1

975

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)

982

983 SSLProxyEngine on

984

985 <Location {urlbase}>

986

987 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

988 # (c) Allow access

989 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

990

991 Require all granted

992

993 # ... for old Apache versions (e.g. 2.2), use instead:

994 #

995 # Order allow,deny

996 # Allow from all

997

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:

1005

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.)

1019

1020 </Location>

1021

1022 #==========================================================================

1023 # SSL security (for HTTPS)

1024 #==========================================================================

1025

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:

1038

1039 # SSLEngine on

1040

1041 # SSLCertificateKeyFile /etc/ssl/private/my.private.key

1042

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).

1046

1047 # SSLCertificateFile /etc/ssl/certs/my.public.cert

1048

1049 # ... signed and supplied to you by the certificate authority (CA),

1050 # from the public certificate you sent to them.

1051

1052 # SSLCertificateChainFile /etc/ssl/certs/my-institution.ca-bundle

1053

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

1059

1060</VirtualHost>

1061

1062 """.strip() # noqa

1063

1064

1065# =============================================================================

1066# Helper functions

1067# =============================================================================

1068

1069

1070 def 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)

1076

1077

1078# =============================================================================

1079# CrontabEntry

1080# =============================================================================

1081

1082

1083 class CrontabEntry(object):

1084 """

1085 Class to represent a ``crontab``-style entry.

1086 """

1087

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

1114

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:])

1146

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

1153

1154 def __repr__(self) -> str:

1155 return auto_repr(self, sort_attrs=False)

1156

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 )

1162

1163 def get_celery_schedule(self) -> celery.schedules.crontab:

1164 """

1165 Returns the corresponding Celery schedule.

1166

1167 Returns:

1168 a :class:`celery.schedules.crontab`

1169

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 )

1180

1181

1182# =============================================================================

1183# Configuration class. (It gets cached on a per-process basis.)

1184# =============================================================================

1185

1186

1187 class CamcopsConfig(object):

1188 """

1189 Class representing the CamCOPS configuration.

1190 """

1191

1192 def __init__(self, config_filename: str, config_text: str = None) -> None:

1193 """

1194 Initialize by reading the config file.

1195

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 """

1203

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

1214

1215 def _get_bool(section: str, paramname: str, default: bool) -> bool:

1216 return get_config_parameter_boolean(

1217 parser, section, paramname, default

1218 )

1219

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 )

1226

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 )

1232

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 )

1242

1243 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1244 # Learn something about our environment

1245 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1246 self.running_under_docker = running_under_docker()

1247

1248 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1249 # Open config file

1250 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1251 self.camcops_config_filename = config_filename

1252 parser = configparser.ConfigParser()

1253

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)

1266

1267 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1268 # Main section (in alphabetical order)

1269 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1270 s = CONFIG_FILE_SITE_SECTION

1271 cs = ConfigParamSite

1272 cd = ConfigDefaults()

1273

1274 self.allow_insecure_cookies = _get_bool(

1275 s, cs.ALLOW_INSECURE_COOKIES, cd.ALLOW_INSECURE_COOKIES

1276 )

1277

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)

1282

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

1293

1294 self.disable_password_autocomplete = _get_bool(

1295 s,

1296 cs.DISABLE_PASSWORD_AUTOCOMPLETE,

1297 cd.DISABLE_PASSWORD_AUTOCOMPLETE,

1298 )

1299

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, "")

1305

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]

1313

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, "")

1317

1318 self.extra_string_files = _get_multiline(s, cs.EXTRA_STRING_FILES)

1319

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 )

1341

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}")

1352

1353 self.mfa_timeout_s = _get_int(s, cs.MFA_TIMEOUT_S, cd.MFA_TIMEOUT_S)

1354

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

1369

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

1397

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 )

1420

1421 self.task_filename_spec = _get_str(s, cs.TASK_FILENAME_SPEC)

1422 self.tracker_filename_spec = _get_str(s, cs.TRACKER_FILENAME_SPEC)

1423

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 )

1433

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)

1440

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)

1454

1455 # To prevent errors:

1456 del s

1457 del cs

1458

1459 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1460 # Web server/WSGI section

1461 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1462 ws = CONFIG_FILE_SERVER_SECTION

1463 cw = ConfigParamServer

1464

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)

1532

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, "")

1544

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 )

1552

1553 del ws

1554 del cw

1555

1556 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1557 # Export section

1558 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1559 es = CONFIG_FILE_EXPORT_SECTION

1560 ce = ConfigParamExportGeneral

1561

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 )

1579

1580 self.export_lockdir = _get_str(es, ce.EXPORT_LOCKDIR)

1581 if not self.export_lockdir:

1582 raise_missing(es, ConfigParamExportGeneral.EXPORT_LOCKDIR)

1583

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)

1610

1611 self.schedule_timezone = _get_str(

1612 es, ce.SCHEDULE_TIMEZONE, cd.SCHEDULE_TIMEZONE

1613 )

1614

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)

1629

1630 del es

1631 del ce

1632

1633 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1634 # Other attributes

1635 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1636 self._sqla_engine: Optional[Engine] = None

1637

1638 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1639 # Docker checks

1640 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1641 if self.running_under_docker:

1642 log.info("Docker environment detected")

1643

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 )

1655

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 # )

1670

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 )

1720

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 )

1738

1739 # -------------------------------------------------------------------------

1740 # Database functions

1741 # -------------------------------------------------------------------------

1742

1743 def get_sqla_engine(self) -> Engine:

1744 """

1745 Returns an SQLAlchemy :class:`Engine`.

1746

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.

1752

1753 "The appropriate scope is once per [database] URL per application,

1754 at the module level."

1755

1756 - https://groups.google.com/forum/#!topic/sqlalchemy/ZtCo2DsHhS4

1757 - https://stackoverflow.com/questions/8645250/how-to-close-sqlalchemy-connection-in-mysql

1758

1759 Now, our CamcopsConfig instance is cached, so there should be one of

1760 them overall. See get_config() below.

1761

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

1777

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)

1787

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

1797

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()

1814

1815 def _assert_valid_database_engine(self) -> None:

1816 """

1817 Assert that our backend database is a valid type.

1818

1819 Specifically, we prohibit:

1820

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 )

1832

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 )

1854

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()

1862

1863 # -------------------------------------------------------------------------

1864 # SNOMED-CT functions

1865 # -------------------------------------------------------------------------

1866

1867 def get_task_snomed_concepts(self) -> Dict[str, SnomedConcept]:

1868 """

1869 Returns all SNOMED-CT concepts for tasks.

1870

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)

1877

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.

1881

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)

1888

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.

1893

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 )

1902

1903 # -------------------------------------------------------------------------

1904 # Export functions

1905 # -------------------------------------------------------------------------

1906

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``.

1915

1916 Note that these objects are **not** associated with a database session.

1917

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)

1931

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.

1936

1937 Returns:

1938 list: of

1939 :class:`camcops_server.cc_modules.cc_exportrecipientinfo.ExportRecipientInfo`

1940 """

1941 return self._export_recipients

1942

1943 # -------------------------------------------------------------------------

1944 # File-based locks

1945 # -------------------------------------------------------------------------

1946

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.

1951

1952 Args:

1953 recipient_name: name of the recipient

1954

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)

1961

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.

1968

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.)

1972

1973 Args:

1974 recipient_name: name of the recipient

1975

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)

1982

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.

1989

1990 Args:

1991 recipient_name: name of the recipient

1992 basetable: task base table name

1993 pk: server PK of the task

1994

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)

2001

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.

2009

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)

2016

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)

2023

2024 # -------------------------------------------------------------------------

2025 # SMS backend

2026 # -------------------------------------------------------------------------

2027

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 {}

2038

2039 sms_config = {}

2040 section = parser[section_name]

2041 for key in section:

2042 sms_config[key.lower()] = section[key]

2043 return sms_config

2044

2045

2046# =============================================================================

2047# Get config filename from an appropriate environment (WSGI or OS)

2048# =============================================================================

2049

2050

2051 def get_config_filename_from_os_env() -> str:

2052 """

2053 Returns the config filename to use, from our operating system environment

2054 variable.

2055

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

2065

2066

2067# =============================================================================

2068# Cached instances

2069# =============================================================================

2070

2071

2072@cache_region_static.cache_on_arguments(function_key_generator=fkg)

2073 def get_config(config_filename: str) -> CamcopsConfig:

2074 """

2075 Returns a :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` from

2076 the specified config filename.

2077

2078 Cached.

2079 """

2080 return CamcopsConfig(config_filename)

2081

2082

2083# =============================================================================

2084# Get default config

2085# =============================================================================

2086

2087

2088 def 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())