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

1# noinspection HttpUrlsUsage 

2""" 

3camcops_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 

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

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

31 

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. 

35 

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

37 

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: 

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 

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 

80 

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 

111 

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) 

160 

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

162 

163pre_disable_sqlalchemy_extra_echo_log() 

164 

165 

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

167# Constants 

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

169 

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/ 

174 

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 

197 

198 

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

200# Helper functions 

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

202 

203 

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. 

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 

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. 

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 

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. 

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 

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) 

302 

303 

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

305# Demo config 

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

307 

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

312 

313 

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

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

689 

690 

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 

709 

710[program:camcops_server] 

711 

712command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} serve_gunicorn 

713 --config {DEFAULT_LINUX_CAMCOPS_CONFIG} 

714 

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} 

724 

725[program:camcops_workers] 

726 

727command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} launch_workers 

728 --config {DEFAULT_LINUX_CAMCOPS_CONFIG} 

729 

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} 

741 

742[program:camcops_scheduler] 

743 

744command = {DEFAULT_LINUX_CAMCOPS_EXECUTABLE} launch_scheduler 

745 --config {DEFAULT_LINUX_CAMCOPS_CONFIG} 

746 

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} 

757 

758[group:camcops] 

759 

760programs = camcops_server, camcops_workers, camcops_scheduler 

761 

762 """.strip() # noqa 

763 

764 

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 

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 

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) 

1076 

1077 

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

1079# CrontabEntry 

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

1081 

1082 

1083class 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 

1187class 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 

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

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. 

2077 

2078 Cached. 

2079 """ 

2080 return CamcopsConfig(config_filename) 

2081 

2082 

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

2084# Get default config 

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

2086 

2087 

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