Source code for crate_anon.crateweb.core.utils

"""
crate_anon/crateweb/core/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/>.

===============================================================================

**Core utility functions for the web interface.**

"""

from abc import ABC, abstractmethod
import datetime
import logging
import mimetypes
import re
import urllib.parse
from typing import Any, Generator, List, Optional, Union

from cardinal_pythonlib.reprfunc import auto_repr
from django.conf import settings
from django.core.paginator import Paginator, EmptyPage, Page, PageNotAnInteger
from django.db.models import QuerySet
from django.http import QueryDict
from django.http.request import HttpRequest
from django.utils import timezone

from crate_anon.crateweb.userprofile.models import get_per_page

log = logging.getLogger(__name__)


# =============================================================================
# User tests/user profile
# =============================================================================


[docs]def is_superuser(user: settings.AUTH_USER_MODEL) -> bool: """ Is the user a superuser? Function for use with a decorator, e.g. .. code-block:: python @user_passes_test(is_superuser) def some_view(request: HttpRequest) -> HttpResponse: pass Superuser equates to Research Database Manager. """ # https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test # noqa return user.is_superuser
[docs]def is_developer(user: settings.AUTH_USER_MODEL) -> bool: """ Is the user a developer? (Developers are a subset of superusers.) """ if not user.is_authenticated: return False # won't have a profile return user.profile.is_developer
[docs]def is_clinician(user: settings.AUTH_USER_MODEL) -> bool: """ Is the user a clinician? """ if not user.is_authenticated: return False # won't have a profile return user.profile.is_clinician
# ============================================================================= # Forms # =============================================================================
[docs]def paginate( request: HttpRequest, all_items: Union[QuerySet, List[Any]], per_page: int = None, ) -> Page: """ Paginate a list or a Django QuerySet. Args: request: the :class:`django.http.request.HttpRequest` all_items: a list or a :class:`django.db.models.QuerySet` per_page: number of items per page Returns: a :class:`django.core.paginator.Page` """ if per_page is None: per_page = get_per_page(request) paginator = Paginator(all_items, per_page) # noinspection PyCallByClass,PyArgumentList requested_page = request.GET.get("page") try: return paginator.page(requested_page) except PageNotAnInteger: return paginator.page(1) except EmptyPage: return paginator.page(paginator.num_pages)
# ============================================================================= # URL creation # =============================================================================
[docs]def url_with_querystring( path: str, querydict: QueryDict = None, **kwargs: Any ) -> str: """ Add GET arguments to a URL from named arguments or a QueryDict. Args: path: a base URL path querydict: a :class:`django.http.QueryDict` **kwargs: as an alternative to the ``querydict``, we can use ``kwargs`` as a dictionary of query attribute-value pairs Returns: the URL with query parameters Note: This does not currently sort query parameters. Doing that might be slightly advantageous for caching, i.e. to ensure that "path?a=1&b=2" is treated as identical to "path?b=2&a=1". However, it is legal for servers to treat them as ordered. See https://stackoverflow.com/questions/43893853/http-cache-control-and-params-order. """ # Get initial query parameters, if any. # log.debug(f"IN: path={path!r}, querydict={querydict!r}, " # f"kwargs={kwargs!r}") pr = urllib.parse.urlparse(path) # type: urllib.parse.ParseResult qd = QueryDict(mutable=True) if pr.query: for k, values in urllib.parse.parse_qs(pr.query).items(): for v in values: qd[k] = v # Update with the new parameters if querydict is not None: if not isinstance(querydict, QueryDict): raise ValueError("Bad querydict value") qd.update(querydict) if kwargs: qd.update(kwargs) # Calculate the query string if qd: querystring = qd.urlencode() # for kwargs: querystring = urllib.parse.urlencode(kwargs) else: querystring = "" # Return the final rebuilt URL. # You can't write to a urllib.parse.ParseResult. So, as per # https://stackoverflow.com/questions/26221669/how-do-i-replace-a-query-with-a-new-value-in-urlparse # noqa # we have do to this: components = list(pr) components[4] = querystring url = urllib.parse.urlunparse(components) # log.debug(f"OUT: {url}") return url
[docs]def site_absolute_url(path: str) -> str: """ Returns an absolute URL for the site, given a relative part. Use like: .. code-block:: python url = site_absolute_url(static('red.png')) # ... determined in part by STATIC_URL. url = site_absolute_url(reverse(UrlNames.CLINICIAN_RESPONSE, args=[self.id])) # ... determined by SCRIPT_NAME or FORCE_SCRIPT_NAME # ... which is context-dependent: see below We need to generate links to our site outside the request environment, e.g. for inclusion in e-mails, even when we're generating the e-mails offline via Celery. There's no easy way to do this automatically (site path information comes in only via requests), so we put it in the settings. See also: - https://stackoverflow.com/questions/4150258/django-obtaining-the-absolute-url-without-access-to-a-request-object - https://fragmentsofcode.wordpress.com/2009/02/24/django-fully-qualified-url/ **IMPORTANT** BEWARE: :func:`reverse` will produce something different inside a request and outside it. - https://stackoverflow.com/questions/32340806/django-reverse-returns-different-values-when-called-from-wsgi-or-shell So the only moderately clean way of doing this is to do this in the Celery backend jobs, for anything that uses Django URLs (e.g. :func:`reverse`) -- NOT necessary for anything using only static URLs (e.g. pictures in PDFs). .. code-block:: python from django.conf import settings from django.urls import set_script_prefix set_script_prefix(settings.FORCE_SCRIPT_NAME) But that does at least mean we can use the same method for static and Django URLs. """ # noqa url = settings.DJANGO_SITE_ROOT_ABSOLUTE_URL + path log.debug(f"site_absolute_url: {path} -> {url}") return url
# ============================================================================= # Formatting # =============================================================================
[docs]def get_friendly_date(date: datetime.datetime) -> str: """ Returns a string form of a date/datetime. """ if date is None: return "" try: return date.strftime("%d %B %Y") # e.g. 03 December 2013 except Exception as e: raise type(e)(str(e) + f" [value was {date!r}]")
# ============================================================================= # Date/time # =============================================================================
[docs]def string_time_now() -> str: """ Returns the current time in short-form ISO-8601 UTC, for filenames. """ return timezone.now().strftime("%Y%m%dT%H%M%SZ")
# ============================================================================= # HTTP Content-Type and MIME types # =============================================================================
[docs]def guess_mimetype(filename: str, default: str = None) -> Optional[str]: """ Guesses a file's MIME type (HTTP Content-Type) from its filename. Args: filename: filename default: value to return if guessing fails """ return mimetypes.guess_type(filename)[0] or default
# ============================================================================= # Javascript help # ============================================================================= HTML_WHITESPACE = re.compile("[ \n\t]+")
[docs]def javascript_quoted_string_from_html(html: str) -> str: """ Takes some HTML, which may be multiline, and makes it into a single quoted Javascript string, for when we want to muck around with the DOM. We elect to use double-quotes. """ # Remove extra whitespace/newlines: x = " ".join(HTML_WHITESPACE.split(html)) x = x.replace('"', r"\"") # Escape double quotes x = f'"{x}"' # Enclose string in double quotes return x
# ============================================================================= # Javascript tree # =============================================================================
[docs]class JavascriptTreeNode(ABC): """ Represents a node of a :class:`JavascriptTree`. """
[docs] def __init__( self, text: str = "", node_id: str = "", children: List["JavascriptTreeNode"] = None, ) -> None: """ Args: text: text to display node_id: CSS node ID (only the root node will use this mechanism; the rest will be autoset by the root node) children: child nodes, if any """ self.text = text self.node_id = node_id self.children = children or [] # type: List[JavascriptTreeNode]
def __repr__(self) -> str: return auto_repr(self)
[docs] def set_node_id(self, node_id: str) -> None: """ Sets the node's ID. """ self.node_id = node_id
[docs] def gen_descendants(self) -> Generator["JavascriptTreeNode", None, None]: """ Yields all descendants, recursively. """ for child in self.children: yield child for descendant in child.gen_descendants(): yield descendant
[docs] @abstractmethod def html(self) -> str: """ Returns HTML for this node. """ pass
[docs]class JavascriptLeafNode(JavascriptTreeNode): """ Represents a leaf node of a :class:`JavascriptTree`, i.e. one that launches some action. """
[docs] def __init__(self, text: str, action_html: str) -> None: """ Args: text: text to display action_html: HTML associated with the action (e.g. to attach to part of the page, in order to load other content) """ super().__init__(text=text) self.action_html = action_html
[docs] def html(self) -> str: return f'<li id="{self.node_id}">{self.text}</li>'
[docs] def js_action_dict_key_value(self) -> str: """ Returns a Javascript snippet for incorporating into a dictionary: ``node_id: action_html``. """ js_action_html = javascript_quoted_string_from_html(self.action_html) return f'"{self.node_id}":{js_action_html}'
[docs]class JavascriptBranchNode(JavascriptTreeNode): """ Represents a leaf node of a :class:`JavascriptTree`, i.e. one that has children but does not itself perform an action. """
[docs] def __init__( self, text: str, children: List[JavascriptTreeNode] = None, branch_class: str = "caret", child_ul_class: str = "nested", ) -> None: """ Args: text: text to display children: children of this node branch_class: CSS class for the branch with caret/indicator child_ul_class: CSS class for the sublist with the children """ super().__init__(text=text, children=children) self.branch_class = branch_class self.child_ul_class = child_ul_class
[docs] def html(self) -> str: child_html = "".join(node.html() for node in self.children) return ( f"<li>" f'<span class="{self.branch_class}">{self.text}</span>' f'<ul class="{self.child_ul_class}">' f"{child_html}" f"</ul>" f"</li>" )
[docs] def add_child(self, child: JavascriptTreeNode) -> None: """ Adds a child at the end of our list. """ self.children.append(child)
[docs]class JavascriptTree(JavascriptTreeNode): """ Represents the root node of an expanding tree implemented via Javascript. Demo: .. code-block:: Python # Django debugging preamble import os import django os.environ['DJANGO_SETTINGS_MODULE'] = 'crate_anon.crateweb.config.settings' django.setup() from crate_anon.crateweb.core.utils import ( JavascriptBranchNode, JavascriptLeafNode, JavascriptTree, ) t = JavascriptTree( tree_id="my_tree", child_id_prefix="my_tree_child_", children=[ JavascriptBranchNode("RiO", [ JavascriptLeafNode("Clinical Documents", "<p>Clinical docs</p>"), JavascriptLeafNode("Progress Notes", "<p>Prog notes</p>"), ]), JavascriptLeafNode("Test PDF", "<p>Test a PDF</p>"), ] ) print(t.html()) print(t.js_str_html()) print(t.js_data()) """ # noqa
[docs] def __init__( self, tree_id: str, child_id_prefix: str, children: List[JavascriptTreeNode] = None, tree_class: str = "tree", ) -> None: """ Args: tree_id: CSS ID for this tree child_id_prefix: CSS ID prefix for children children: child nodes tree_class: CSS class for this tree """ super().__init__(children=children, node_id=tree_id) self.node_id_prefix = child_id_prefix self.tree_class = tree_class self._node_ids_set = False
[docs] def add_child(self, child: JavascriptTreeNode) -> None: """ Adds a child at the end of our list. """ self.children.append(child) self._node_ids_set = False
def _write_child_ids(self) -> None: """ Sets the node IDs for all our children. """ if self._node_ids_set: return for i, descendant in enumerate(self.gen_descendants()): descendant.set_node_id(f"{self.node_id_prefix}{i}") self._node_ids_set = True
[docs] def html(self) -> str: """ Returns HTML for this tree. """ self._write_child_ids() child_html = "".join(node.html() for node in self.children) return ( f'<ul class="{self.tree_class}" id="{self.node_id}">' f"{child_html}" f"</ul>" )
[docs] def js_str_html(self) -> str: """ Returns HTML for this tree, as a quoted Javascript string, for embedding in Javascript code directly. """ return javascript_quoted_string_from_html(self.html())
[docs] def js_data(self) -> str: """ Returns Javascript code for a dictionary mapping node IDs to action HTML. """ self._write_child_ids() content = ",".join( child.js_action_dict_key_value() for child in self.gen_descendants() if isinstance(child, JavascriptLeafNode) ) return f"{{{content}}}"
@property def tree_id(self) -> str: """ Synonym for ``node_id`` for the root node. """ return self.node_id