Coverage for crateweb/consent/utils.py: 30%

84 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-08-27 10:34 -0500

1""" 

2crate_anon/crateweb/consent/utils.py 

3 

4=============================================================================== 

5 

6 Copyright (C) 2015, University of Cambridge, Department of Psychiatry. 

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

8 

9 This file is part of CRATE. 

10 

11 CRATE is free software: you can redistribute it and/or modify 

12 it under the terms of the GNU General Public License as published by 

13 the Free Software Foundation, either version 3 of the License, or 

14 (at your option) any later version. 

15 

16 CRATE is distributed in the hope that it will be useful, 

17 but WITHOUT ANY WARRANTY; without even the implied warranty of 

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

21 You should have received a copy of the GNU General Public License 

22 along with CRATE. If not, see <https://www.gnu.org/licenses/>. 

23 

24=============================================================================== 

25 

26**Utility functions for the consent-to-contact system.** 

27 

28""" 

29 

30import datetime 

31 

32# from functools import lru_cache 

33import os 

34import re 

35from typing import Any, Dict, Optional, Union 

36 

37from cardinal_pythonlib.django.function_cache import django_cache_function 

38from django.conf import settings 

39from django.core.exceptions import ValidationError 

40from django.template.loader import render_to_string 

41 

42 

43# ============================================================================= 

44# Read files 

45# ============================================================================= 

46 

47 

48def read_static_file_contents(filename: str) -> str: 

49 """ 

50 Returns the text contents of a static file. 

51 

52 Args: 

53 filename: 

54 filename (within the local static directory as determined by 

55 ``settings.LOCAL_STATIC_DIR`` 

56 """ 

57 with open(os.path.join(settings.LOCAL_STATIC_DIR, filename)) as f: 

58 return f.read() 

59 

60 

61# ============================================================================= 

62# CSS, plus assistance for PDF/e-mail rendering to HTML 

63# ============================================================================= 

64 

65 

66def pdf_css(patient: bool = True) -> str: 

67 """ 

68 Returns CSS for use in PDF letters etc. 

69 

70 Args: 

71 patient: 

72 patient settings (e.g. "large print"), rather than researcher 

73 settings ("cram it in")? 

74 """ 

75 contents = read_static_file_contents("base.css") 

76 context = { 

77 "fontsize": ( 

78 settings.PATIENT_FONTSIZE 

79 if patient 

80 else settings.RESEARCHER_FONTSIZE 

81 ), 

82 } 

83 contents += render_to_string("pdf.css", context) 

84 return contents 

85 

86 

87@django_cache_function(timeout=None) 

88# @lru_cache(maxsize=None) 

89def pdf_template_dict(patient: bool = True) -> Dict[str, str]: 

90 """ 

91 Returns a template dictionary for use in generating PDF letters etc. 

92 

93 Args: 

94 patient: 

95 patient CSS settings (e.g. "large print"), rather than researcher 

96 CSS settings ("cram it in")? 

97 """ 

98 return { 

99 "css": pdf_css(patient), 

100 "PDF_LOGO_ABS_URL": settings.PDF_LOGO_ABS_URL, 

101 "PDF_LOGO_WIDTH": settings.PDF_LOGO_WIDTH, 

102 "TRAFFIC_LIGHT_RED_ABS_URL": settings.TRAFFIC_LIGHT_RED_ABS_URL, 

103 "TRAFFIC_LIGHT_YELLOW_ABS_URL": settings.TRAFFIC_LIGHT_YELLOW_ABS_URL, 

104 "TRAFFIC_LIGHT_GREEN_ABS_URL": settings.TRAFFIC_LIGHT_GREEN_ABS_URL, 

105 } 

106 

107 

108def render_pdf_html_to_string( 

109 template: str, context: Dict[str, Any] = None, patient: bool = True 

110) -> str: 

111 """ 

112 Renders a template into HTML that can be used for making PDFs. 

113 

114 Args: 

115 template: 

116 filename of the Django template 

117 context: 

118 template context dictionary (which will be augmented with 

119 PDF-specific content) 

120 patient: 

121 patient CSS settings (e.g. "large print"), rather than researcher 

122 CSS settings ("cram it in")? 

123 

124 Returns: 

125 HTML 

126 """ 

127 context = context or {} 

128 context.update(pdf_template_dict(patient)) 

129 return render_to_string(template, context) 

130 

131 

132def email_css() -> str: 

133 """ 

134 Returns CSS for use in e-mails to clinicians. 

135 """ 

136 contents = read_static_file_contents("base.css") 

137 contents += render_to_string("email.css") 

138 return contents 

139 

140 

141@django_cache_function(timeout=None) 

142# @lru_cache(maxsize=None) 

143def email_template_dict() -> Dict[str, str]: 

144 """ 

145 Returns a template dictionary for use in generating e-mails. 

146 """ 

147 return { 

148 "css": email_css(), 

149 } 

150 

151 

152def render_email_html_to_string( 

153 template: str, context: Dict[str, Any] = None 

154) -> str: 

155 """ 

156 Renders a template into HTML that can be used for making PDFs. 

157 

158 Args: 

159 template: 

160 filename of the Django template 

161 context: 

162 template context dictionary (which will be augmented with 

163 email-specific content) 

164 

165 Returns: 

166 HTML 

167 """ 

168 context = context or {} 

169 context.update(email_template_dict()) 

170 return render_to_string(template, context) 

171 

172 

173# ============================================================================= 

174# E-mail addresses 

175# ============================================================================= 

176 

177 

178def get_domain_from_email(email: str) -> str: 

179 """ 

180 Extracts the domain part from an e-mail address. 

181 

182 Args: 

183 email: the e-mail address, e.g. "someone@cam.ac.uk" 

184 

185 Returns: 

186 the domain part, e.g. "cam.ac.uk" 

187 

188 Very simple algorithm... 

189 """ 

190 try: 

191 return email.split("@")[1] 

192 except (AttributeError, IndexError): 

193 raise ValidationError("Bad e-mail address: no domain") 

194 

195 

196def validate_researcher_email_domain(email: str) -> None: 

197 """ 

198 Ensures that an e-mail address is acceptable as a researcher e-mail 

199 address. We may be sending patient-identifiable information (with consent) 

200 via this method, so we want to be sure that nobody's put dodgy researcher 

201 e-mails in our system. 

202 

203 We validate the e-mail domain against 

204 ``settings.VALID_RESEARCHER_EMAIL_DOMAINS``, if set. 

205 

206 Args: 

207 email: an e-mail address 

208 

209 Raises: 

210 :class:`django.core.exceptions.ValidationError` on failure 

211 

212 """ 

213 if not settings.VALID_RESEARCHER_EMAIL_DOMAINS: 

214 # Anything goes. 

215 return 

216 domain = get_domain_from_email(email) 

217 for valid_domain in settings.VALID_RESEARCHER_EMAIL_DOMAINS: 

218 if domain.lower() == valid_domain.lower(): 

219 return 

220 raise ValidationError("Invalid researcher e-mail domain") 

221 

222 

223APPROX_EMAIL_REGEX = re.compile( # http://emailregex.com/ 

224 r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" 

225) 

226 

227 

228def make_forename_surname_email_address( 

229 forename: str, surname: str, domain: str, default: str = "" 

230) -> str: 

231 """ 

232 Converts a forename and surname into an e-mail address of the form 

233 ``forename.surname@domain``. Not guaranteed to work. 

234 

235 Args: 

236 forename: forename 

237 surname: surname 

238 domain: domain, e.g. "cpft.nhs.uk" 

239 default: value to return if something looks wrong 

240 

241 Returns: 

242 e-mail address (or ``default``) 

243 

244 """ 

245 if not forename or not surname: # in case one is None 

246 return default 

247 forename = forename.replace(" ", "") 

248 surname = surname.replace(" ", "") 

249 if not forename or not surname: # in case one is empty 

250 return default 

251 if len(forename) == 1: 

252 # Initial only; that won't do. 

253 return default 

254 # Other duff things we see: John Smith (CALT), where "Smith (CALT)" is the 

255 # surname and CALT is Cambridge Adult Locality Team. This can map to 

256 # something unpredictable, like JohnSmithOT@cpft.nhs.uk, so we can't use 

257 # it. 

258 # Formal definition is at 

259 # https://stackoverflow.com/questions/2049502/what-characters-are-allowed-in-email-address # noqa: E501 

260 # See also: http://emailregex.com/ 

261 attempt = f"{forename}.{surname}@{domain}" 

262 if APPROX_EMAIL_REGEX.match(attempt): 

263 return attempt 

264 else: 

265 return default 

266 

267 

268def make_cpft_email_address( 

269 forename: str, surname: str, default: str = "" 

270) -> str: 

271 """ 

272 Make a CPFT e-mail address. Not guaranteed to work. 

273 

274 Args: 

275 forename: forename 

276 surname: surname 

277 default: value to return if something looks wrong 

278 

279 Returns: 

280 e-mail address: ``forename.surname@cpft.nhs.uk``, or ``default`` 

281 

282 """ 

283 return make_forename_surname_email_address( 

284 forename, surname, "cpft.nhs.uk", default 

285 ) 

286 

287 

288# ============================================================================= 

289# Date/time 

290# ============================================================================= 

291 

292 

293def days_to_years(days: int, dp: int = 1) -> str: 

294 """ 

295 Converts days to years, in string form. 

296 

297 Args: 

298 days: number of days 

299 dp: number of decimal places 

300 

301 Returns: 

302 str: number of years 

303 

304 - For "consent after discharge", primarily. 

305 - Assumes 365 days/year, not 365.24. 

306 """ 

307 try: 

308 years = days / 365 

309 if years % 1: # needs decimals 

310 return f"{years:.{dp}f}" 

311 else: 

312 return str(int(years)) 

313 except (TypeError, ValueError): 

314 return "?" 

315 

316 

317def latest_date(*args) -> Optional[datetime.date]: 

318 """ 

319 Returns the latest of a bunch of dates, or ``None`` if there are no dates 

320 specified at all. 

321 """ 

322 latest = None 

323 for d in args: 

324 if d is None: 

325 continue 

326 if latest is None: 

327 latest = d 

328 else: 

329 latest = max(d, latest) 

330 return latest 

331 

332 

333def to_date( 

334 d: Optional[Union[datetime.date, datetime.datetime]] 

335) -> Optional[datetime.date]: 

336 """ 

337 Converts any of various date-like things to ``datetime.date`` objects. 

338 """ 

339 if isinstance(d, datetime.datetime): 

340 return d.date() 

341 return d # datetime.date, or None