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
« prev ^ index » next coverage.py v7.8.0, created at 2025-08-27 10:34 -0500
1"""
2crate_anon/crateweb/consent/utils.py
4===============================================================================
6 Copyright (C) 2015, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CRATE.
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.
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.
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/>.
24===============================================================================
26**Utility functions for the consent-to-contact system.**
28"""
30import datetime
32# from functools import lru_cache
33import os
34import re
35from typing import Any, Dict, Optional, Union
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
43# =============================================================================
44# Read files
45# =============================================================================
48def read_static_file_contents(filename: str) -> str:
49 """
50 Returns the text contents of a static file.
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()
61# =============================================================================
62# CSS, plus assistance for PDF/e-mail rendering to HTML
63# =============================================================================
66def pdf_css(patient: bool = True) -> str:
67 """
68 Returns CSS for use in PDF letters etc.
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
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.
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 }
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.
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")?
124 Returns:
125 HTML
126 """
127 context = context or {}
128 context.update(pdf_template_dict(patient))
129 return render_to_string(template, context)
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
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 }
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.
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)
165 Returns:
166 HTML
167 """
168 context = context or {}
169 context.update(email_template_dict())
170 return render_to_string(template, context)
173# =============================================================================
174# E-mail addresses
175# =============================================================================
178def get_domain_from_email(email: str) -> str:
179 """
180 Extracts the domain part from an e-mail address.
182 Args:
183 email: the e-mail address, e.g. "someone@cam.ac.uk"
185 Returns:
186 the domain part, e.g. "cam.ac.uk"
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")
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.
203 We validate the e-mail domain against
204 ``settings.VALID_RESEARCHER_EMAIL_DOMAINS``, if set.
206 Args:
207 email: an e-mail address
209 Raises:
210 :class:`django.core.exceptions.ValidationError` on failure
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")
223APPROX_EMAIL_REGEX = re.compile( # http://emailregex.com/
224 r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)"
225)
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.
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
241 Returns:
242 e-mail address (or ``default``)
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
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.
274 Args:
275 forename: forename
276 surname: surname
277 default: value to return if something looks wrong
279 Returns:
280 e-mail address: ``forename.surname@cpft.nhs.uk``, or ``default``
282 """
283 return make_forename_surname_email_address(
284 forename, surname, "cpft.nhs.uk", default
285 )
288# =============================================================================
289# Date/time
290# =============================================================================
293def days_to_years(days: int, dp: int = 1) -> str:
294 """
295 Converts days to years, in string form.
297 Args:
298 days: number of days
299 dp: number of decimal places
301 Returns:
302 str: number of years
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 "?"
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
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