"""
crate_anon/crateweb/consent/utils.py
===============================================================================
Copyright (C) 2015, University of Cambridge, Department of Psychiatry.
Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
This file is part of CRATE.
CRATE is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CRATE is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CRATE. If not, see <https://www.gnu.org/licenses/>.
===============================================================================
**Utility functions for the consent-to-contact system.**
"""
import datetime
# from functools import lru_cache
import os
import re
from typing import Any, Dict, Optional, Union
from cardinal_pythonlib.django.function_cache import django_cache_function
from django.conf import settings
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
# =============================================================================
# Read files
# =============================================================================
[docs]def read_static_file_contents(filename: str) -> str:
"""
Returns the text contents of a static file.
Args:
filename:
filename (within the local static directory as determined by
``settings.LOCAL_STATIC_DIR``
"""
with open(os.path.join(settings.LOCAL_STATIC_DIR, filename)) as f:
return f.read()
# =============================================================================
# CSS, plus assistance for PDF/e-mail rendering to HTML
# =============================================================================
[docs]def pdf_css(patient: bool = True) -> str:
"""
Returns CSS for use in PDF letters etc.
Args:
patient:
patient settings (e.g. "large print"), rather than researcher
settings ("cram it in")?
"""
contents = read_static_file_contents("base.css")
context = {
"fontsize": (
settings.PATIENT_FONTSIZE
if patient
else settings.RESEARCHER_FONTSIZE
),
}
contents += render_to_string("pdf.css", context)
return contents
@django_cache_function(timeout=None)
# @lru_cache(maxsize=None)
def pdf_template_dict(patient: bool = True) -> Dict[str, str]:
"""
Returns a template dictionary for use in generating PDF letters etc.
Args:
patient:
patient CSS settings (e.g. "large print"), rather than researcher
CSS settings ("cram it in")?
"""
return {
"css": pdf_css(patient),
"PDF_LOGO_ABS_URL": settings.PDF_LOGO_ABS_URL,
"PDF_LOGO_WIDTH": settings.PDF_LOGO_WIDTH,
"TRAFFIC_LIGHT_RED_ABS_URL": settings.TRAFFIC_LIGHT_RED_ABS_URL,
"TRAFFIC_LIGHT_YELLOW_ABS_URL": settings.TRAFFIC_LIGHT_YELLOW_ABS_URL,
"TRAFFIC_LIGHT_GREEN_ABS_URL": settings.TRAFFIC_LIGHT_GREEN_ABS_URL,
}
[docs]def render_pdf_html_to_string(
template: str, context: Dict[str, Any] = None, patient: bool = True
) -> str:
"""
Renders a template into HTML that can be used for making PDFs.
Args:
template:
filename of the Django template
context:
template context dictionary (which will be augmented with
PDF-specific content)
patient:
patient CSS settings (e.g. "large print"), rather than researcher
CSS settings ("cram it in")?
Returns:
HTML
"""
context = context or {}
context.update(pdf_template_dict(patient))
return render_to_string(template, context)
[docs]def email_css() -> str:
"""
Returns CSS for use in e-mails to clinicians.
"""
contents = read_static_file_contents("base.css")
contents += render_to_string("email.css")
return contents
@django_cache_function(timeout=None)
# @lru_cache(maxsize=None)
def email_template_dict() -> Dict[str, str]:
"""
Returns a template dictionary for use in generating e-mails.
"""
return {
"css": email_css(),
}
[docs]def render_email_html_to_string(
template: str, context: Dict[str, Any] = None
) -> str:
"""
Renders a template into HTML that can be used for making PDFs.
Args:
template:
filename of the Django template
context:
template context dictionary (which will be augmented with
email-specific content)
Returns:
HTML
"""
context = context or {}
context.update(email_template_dict())
return render_to_string(template, context)
# =============================================================================
# E-mail addresses
# =============================================================================
[docs]def get_domain_from_email(email: str) -> str:
"""
Extracts the domain part from an e-mail address.
Args:
email: the e-mail address, e.g. "someone@cam.ac.uk"
Returns:
the domain part, e.g. "cam.ac.uk"
Very simple algorithm...
"""
try:
return email.split("@")[1]
except (AttributeError, IndexError):
raise ValidationError("Bad e-mail address: no domain")
[docs]def validate_researcher_email_domain(email: str) -> None:
"""
Ensures that an e-mail address is acceptable as a researcher e-mail
address. We may be sending patient-identifiable information (with consent)
via this method, so we want to be sure that nobody's put dodgy researcher
e-mails in our system.
We validate the e-mail domain against
``settings.VALID_RESEARCHER_EMAIL_DOMAINS``, if set.
Args:
email: an e-mail address
Raises:
:class:`django.core.exceptions.ValidationError` on failure
"""
if not settings.VALID_RESEARCHER_EMAIL_DOMAINS:
# Anything goes.
return
domain = get_domain_from_email(email)
for valid_domain in settings.VALID_RESEARCHER_EMAIL_DOMAINS:
if domain.lower() == valid_domain.lower():
return
raise ValidationError("Invalid researcher e-mail domain")
APPROX_EMAIL_REGEX = re.compile( # http://emailregex.com/
r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)"
)
[docs]def make_forename_surname_email_address(
forename: str, surname: str, domain: str, default: str = ""
) -> str:
"""
Converts a forename and surname into an e-mail address of the form
``forename.surname@domain``. Not guaranteed to work.
Args:
forename: forename
surname: surname
domain: domain, e.g. "cpft.nhs.uk"
default: value to return if something looks wrong
Returns:
e-mail address (or ``default``)
"""
if not forename or not surname: # in case one is None
return default
forename = forename.replace(" ", "")
surname = surname.replace(" ", "")
if not forename or not surname: # in case one is empty
return default
if len(forename) == 1:
# Initial only; that won't do.
return default
# Other duff things we see: John Smith (CALT), where "Smith (CALT)" is the
# surname and CALT is Cambridge Adult Locality Team. This can map to
# something unpredictable, like JohnSmithOT@cpft.nhs.uk, so we can't use
# it.
# Formal definition is at https://stackoverflow.com/questions/2049502/what-characters-are-allowed-in-email-address # noqa
# See also: http://emailregex.com/
attempt = f"{forename}.{surname}@{domain}"
if APPROX_EMAIL_REGEX.match(attempt):
return attempt
else:
return default
[docs]def make_cpft_email_address(
forename: str, surname: str, default: str = ""
) -> str:
"""
Make a CPFT e-mail address. Not guaranteed to work.
Args:
forename: forename
surname: surname
default: value to return if something looks wrong
Returns:
e-mail address: ``forename.surname@cpft.nhs.uk``, or ``default``
"""
return make_forename_surname_email_address(
forename, surname, "cpft.nhs.uk", default
)
# =============================================================================
# Date/time
# =============================================================================
[docs]def days_to_years(days: int, dp: int = 1) -> str:
"""
Converts days to years, in string form.
Args:
days: number of days
dp: number of decimal places
Returns:
str: number of years
- For "consent after discharge", primarily.
- Assumes 365 days/year, not 365.24.
"""
try:
years = days / 365
if years % 1: # needs decimals
return f"{years:.{dp}f}"
else:
return str(int(years))
except (TypeError, ValueError):
return "?"
[docs]def latest_date(*args) -> Optional[datetime.date]:
"""
Returns the latest of a bunch of dates, or ``None`` if there are no dates
specified at all.
"""
latest = None
for d in args:
if d is None:
continue
if latest is None:
latest = d
else:
latest = max(d, latest)
return latest
[docs]def to_date(
d: Optional[Union[datetime.date, datetime.datetime]]
) -> Optional[datetime.date]:
"""
Converts any of various date-like things to ``datetime.date`` objects.
"""
if isinstance(d, datetime.datetime):
return d.date()
return d # datetime.date, or None