Coverage for crateweb/core/utils.py: 36%
142 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/core/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**Core utility functions for the web interface.**
28"""
30from abc import ABC, abstractmethod
31import datetime
32import logging
33import mimetypes
34import re
35import urllib.parse
36from typing import Any, Generator, List, Optional, Union
38from cardinal_pythonlib.reprfunc import auto_repr
39from django.conf import settings
40from django.core.paginator import Paginator, EmptyPage, Page, PageNotAnInteger
41from django.db.models import QuerySet
42from django.http import QueryDict
43from django.http.request import HttpRequest
44from django.utils import timezone
46from crate_anon.crateweb.userprofile.models import get_per_page
48log = logging.getLogger(__name__)
51# =============================================================================
52# User tests/user profile
53# =============================================================================
56def is_superuser(user: settings.AUTH_USER_MODEL) -> bool:
57 """
58 Is the user a superuser?
60 Function for use with a decorator, e.g.
62 .. code-block:: python
64 @user_passes_test(is_superuser)
65 def some_view(request: HttpRequest) -> HttpResponse:
66 pass
68 Superuser equates to Research Database Manager.
69 """
70 # https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test # noqa: E501
71 return user.is_superuser
74def is_developer(user: settings.AUTH_USER_MODEL) -> bool:
75 """
76 Is the user a developer?
78 (Developers are a subset of superusers.)
79 """
80 if not user.is_authenticated:
81 return False # won't have a profile
82 return user.profile.is_developer
85def is_clinician(user: settings.AUTH_USER_MODEL) -> bool:
86 """
87 Is the user a clinician?
88 """
89 if not user.is_authenticated:
90 return False # won't have a profile
91 return user.profile.is_clinician
94# =============================================================================
95# Forms
96# =============================================================================
99def paginate(
100 request: HttpRequest,
101 all_items: Union[QuerySet, List[Any]],
102 per_page: int = None,
103) -> Page:
104 """
105 Paginate a list or a Django QuerySet.
107 Args:
108 request: the :class:`django.http.request.HttpRequest`
109 all_items: a list or a :class:`django.db.models.QuerySet`
110 per_page: number of items per page
112 Returns:
113 a :class:`django.core.paginator.Page`
115 """
116 if per_page is None:
117 per_page = get_per_page(request)
118 paginator = Paginator(all_items, per_page)
119 # noinspection PyCallByClass,PyArgumentList
120 requested_page = request.GET.get("page")
121 try:
122 return paginator.page(requested_page)
123 except PageNotAnInteger:
124 return paginator.page(1)
125 except EmptyPage:
126 return paginator.page(paginator.num_pages)
129# =============================================================================
130# URL creation
131# =============================================================================
134def url_with_querystring(
135 path: str, querydict: QueryDict = None, **kwargs: Any
136) -> str:
137 """
138 Add GET arguments to a URL from named arguments or a QueryDict.
140 Args:
141 path:
142 a base URL path
143 querydict:
144 a :class:`django.http.QueryDict`
145 **kwargs:
146 as an alternative to the ``querydict``, we can use ``kwargs`` as a
147 dictionary of query attribute-value pairs
149 Returns:
150 the URL with query parameters
152 Note:
154 This does not currently sort query parameters. Doing that might be
155 slightly advantageous for caching, i.e. to ensure that
156 "path?a=1&b=2" is treated as identical to "path?b=2&a=1". However, it
157 is legal for servers to treat them as ordered. See
158 https://stackoverflow.com/questions/43893853/http-cache-control-and-params-order.
159 """
160 # Get initial query parameters, if any.
161 # log.debug(f"IN: path={path!r}, querydict={querydict!r}, "
162 # f"kwargs={kwargs!r}")
163 pr = urllib.parse.urlparse(path) # type: urllib.parse.ParseResult
164 qd = QueryDict(mutable=True)
165 if pr.query:
166 for k, values in urllib.parse.parse_qs(pr.query).items():
167 for v in values:
168 qd[k] = v
170 # Update with the new parameters
171 if querydict is not None:
172 if not isinstance(querydict, QueryDict):
173 raise ValueError("Bad querydict value")
174 qd.update(querydict)
175 if kwargs:
176 qd.update(kwargs)
178 # Calculate the query string
179 if qd:
180 querystring = qd.urlencode()
181 # for kwargs: querystring = urllib.parse.urlencode(kwargs)
182 else:
183 querystring = ""
185 # Return the final rebuilt URL.
186 # You can't write to a urllib.parse.ParseResult. So, as per
187 # https://stackoverflow.com/questions/26221669/how-do-i-replace-a-query-with-a-new-value-in-urlparse # noqa: E501
188 # we have do to this:
190 components = list(pr)
191 components[4] = querystring
192 url = urllib.parse.urlunparse(components)
193 # log.debug(f"OUT: {url}")
194 return url
197def site_absolute_url(path: str) -> str:
198 """
199 Returns an absolute URL for the site, given a relative part.
200 Use like:
202 .. code-block:: python
204 url = site_absolute_url(static('red.png'))
205 # ... determined in part by STATIC_URL.
206 url = site_absolute_url(reverse(UrlNames.CLINICIAN_RESPONSE, args=[self.id]))
207 # ... determined by SCRIPT_NAME or FORCE_SCRIPT_NAME
208 # ... which is context-dependent: see below
210 We need to generate links to our site outside the request environment, e.g.
211 for inclusion in e-mails, even when we're generating the e-mails offline
212 via Celery. There's no easy way to do this automatically (site path
213 information comes in only via requests), so we put it in the settings.
215 See also:
217 - https://stackoverflow.com/questions/4150258/django-obtaining-the-absolute-url-without-access-to-a-request-object
218 - https://fragmentsofcode.wordpress.com/2009/02/24/django-fully-qualified-url/
220 **IMPORTANT**
222 BEWARE: :func:`reverse` will produce something different inside a request
223 and outside it.
225 - https://stackoverflow.com/questions/32340806/django-reverse-returns-different-values-when-called-from-wsgi-or-shell
227 So the only moderately clean way of doing this is to do this in the Celery
228 backend jobs, for anything that uses Django URLs (e.g. :func:`reverse`) --
229 NOT necessary for anything using only static URLs (e.g. pictures in PDFs).
231 .. code-block:: python
233 from django.conf import settings
234 from django.urls import set_script_prefix
236 set_script_prefix(settings.FORCE_SCRIPT_NAME)
238 But that does at least mean we can use the same method for static and
239 Django URLs.
240 """ # noqa: E501
241 url = settings.DJANGO_SITE_ROOT_ABSOLUTE_URL + path
242 log.debug(f"site_absolute_url: {path} -> {url}")
243 return url
246# =============================================================================
247# Formatting
248# =============================================================================
251def get_friendly_date(date: datetime.datetime) -> str:
252 """
253 Returns a string form of a date/datetime.
254 """
255 if date is None:
256 return ""
257 try:
258 return date.strftime("%d %B %Y") # e.g. 03 December 2013
259 except Exception as e:
260 raise type(e)(str(e) + f" [value was {date!r}]")
263# =============================================================================
264# Date/time
265# =============================================================================
268def string_time_now() -> str:
269 """
270 Returns the current time in short-form ISO-8601 UTC, for filenames.
271 """
272 return timezone.now().strftime("%Y%m%dT%H%M%SZ")
275# =============================================================================
276# HTTP Content-Type and MIME types
277# =============================================================================
280def guess_mimetype(filename: str, default: str = None) -> Optional[str]:
281 """
282 Guesses a file's MIME type (HTTP Content-Type) from its filename.
284 Args:
285 filename: filename
286 default: value to return if guessing fails
287 """
288 return mimetypes.guess_type(filename)[0] or default
291# =============================================================================
292# Javascript help
293# =============================================================================
295HTML_WHITESPACE = re.compile("[ \n\t]+")
298def javascript_quoted_string_from_html(html: str) -> str:
299 """
300 Takes some HTML, which may be multiline, and makes it into a single quoted
301 Javascript string, for when we want to muck around with the DOM.
303 We elect to use double-quotes.
304 """
305 # Remove extra whitespace/newlines:
306 x = " ".join(HTML_WHITESPACE.split(html))
307 x = x.replace('"', r"\"") # Escape double quotes
308 x = f'"{x}"' # Enclose string in double quotes
309 return x
312# =============================================================================
313# Javascript tree
314# =============================================================================
317class JavascriptTreeNode(ABC):
318 """
319 Represents a node of a :class:`JavascriptTree`.
320 """
322 def __init__(
323 self,
324 text: str = "",
325 node_id: str = "",
326 children: List["JavascriptTreeNode"] = None,
327 ) -> None:
328 """
329 Args:
330 text: text to display
331 node_id: CSS node ID (only the root node will use this mechanism;
332 the rest will be autoset by the root node)
333 children: child nodes, if any
334 """
335 self.text = text
336 self.node_id = node_id
337 self.children = children or [] # type: List[JavascriptTreeNode]
339 def __repr__(self) -> str:
340 return auto_repr(self)
342 def set_node_id(self, node_id: str) -> None:
343 """
344 Sets the node's ID.
345 """
346 self.node_id = node_id
348 def gen_descendants(self) -> Generator["JavascriptTreeNode", None, None]:
349 """
350 Yields all descendants, recursively.
351 """
352 for child in self.children:
353 yield child
354 for descendant in child.gen_descendants():
355 yield descendant
357 @abstractmethod
358 def html(self) -> str:
359 """
360 Returns HTML for this node.
361 """
362 pass
365class JavascriptLeafNode(JavascriptTreeNode):
366 """
367 Represents a leaf node of a :class:`JavascriptTree`, i.e. one that launches
368 some action.
369 """
371 def __init__(self, text: str, action_html: str) -> None:
372 """
373 Args:
374 text: text to display
375 action_html: HTML associated with the action (e.g. to attach to
376 part of the page, in order to load other content)
377 """
378 super().__init__(text=text)
379 self.action_html = action_html
381 def html(self) -> str:
382 return f'<li id="{self.node_id}">{self.text}</li>'
384 def js_action_dict_key_value(self) -> str:
385 """
386 Returns a Javascript snippet for incorporating into a dictionary:
387 ``node_id: action_html``.
388 """
389 js_action_html = javascript_quoted_string_from_html(self.action_html)
390 return f'"{self.node_id}":{js_action_html}'
393class JavascriptBranchNode(JavascriptTreeNode):
394 """
395 Represents a leaf node of a :class:`JavascriptTree`, i.e. one that has
396 children but does not itself perform an action.
397 """
399 def __init__(
400 self,
401 text: str,
402 children: List[JavascriptTreeNode] = None,
403 branch_class: str = "caret",
404 child_ul_class: str = "nested",
405 ) -> None:
406 """
407 Args:
408 text: text to display
409 children: children of this node
410 branch_class: CSS class for the branch with caret/indicator
411 child_ul_class: CSS class for the sublist with the children
412 """
413 super().__init__(text=text, children=children)
414 self.branch_class = branch_class
415 self.child_ul_class = child_ul_class
417 def html(self) -> str:
418 child_html = "".join(node.html() for node in self.children)
419 return (
420 f"<li>"
421 f'<span class="{self.branch_class}">{self.text}</span>'
422 f'<ul class="{self.child_ul_class}">'
423 f"{child_html}"
424 f"</ul>"
425 f"</li>"
426 )
428 def add_child(self, child: JavascriptTreeNode) -> None:
429 """
430 Adds a child at the end of our list.
431 """
432 self.children.append(child)
435class JavascriptTree(JavascriptTreeNode):
436 """
437 Represents the root node of an expanding tree implemented via Javascript.
439 Demo:
441 .. code-block:: Python
443 # Django debugging preamble
444 import os
445 import django
446 os.environ['DJANGO_SETTINGS_MODULE'] = 'crate_anon.crateweb.config.settings'
447 django.setup()
449 from crate_anon.crateweb.core.utils import (
450 JavascriptBranchNode,
451 JavascriptLeafNode,
452 JavascriptTree,
453 )
455 t = JavascriptTree(
456 tree_id="my_tree",
457 child_id_prefix="my_tree_child_",
458 children=[
459 JavascriptBranchNode("RiO", [
460 JavascriptLeafNode("Clinical Documents", "<p>Clinical docs</p>"),
461 JavascriptLeafNode("Progress Notes", "<p>Prog notes</p>"),
462 ]),
463 JavascriptLeafNode("Test PDF", "<p>Test a PDF</p>"),
464 ]
465 )
466 print(t.html())
467 print(t.js_str_html())
468 print(t.js_data())
470 """ # noqa: E501
472 def __init__(
473 self,
474 tree_id: str,
475 child_id_prefix: str,
476 children: List[JavascriptTreeNode] = None,
477 tree_class: str = "tree",
478 ) -> None:
479 """
480 Args:
481 tree_id: CSS ID for this tree
482 child_id_prefix: CSS ID prefix for children
483 children: child nodes
484 tree_class: CSS class for this tree
485 """
486 super().__init__(children=children, node_id=tree_id)
487 self.node_id_prefix = child_id_prefix
488 self.tree_class = tree_class
489 self._node_ids_set = False
491 def add_child(self, child: JavascriptTreeNode) -> None:
492 """
493 Adds a child at the end of our list.
494 """
495 self.children.append(child)
496 self._node_ids_set = False
498 def _write_child_ids(self) -> None:
499 """
500 Sets the node IDs for all our children.
501 """
502 if self._node_ids_set:
503 return
504 for i, descendant in enumerate(self.gen_descendants()):
505 descendant.set_node_id(f"{self.node_id_prefix}{i}")
506 self._node_ids_set = True
508 def html(self) -> str:
509 """
510 Returns HTML for this tree.
511 """
512 self._write_child_ids()
513 child_html = "".join(node.html() for node in self.children)
514 return (
515 f'<ul class="{self.tree_class}" id="{self.node_id}">'
516 f"{child_html}"
517 f"</ul>"
518 )
520 def js_str_html(self) -> str:
521 """
522 Returns HTML for this tree, as a quoted Javascript string, for
523 embedding in Javascript code directly.
524 """
525 return javascript_quoted_string_from_html(self.html())
527 def js_data(self) -> str:
528 """
529 Returns Javascript code for a dictionary mapping node IDs to
530 action HTML.
531 """
532 self._write_child_ids()
533 content = ",".join(
534 child.js_action_dict_key_value()
535 for child in self.gen_descendants()
536 if isinstance(child, JavascriptLeafNode)
537 )
538 return f"{{{content}}}"
540 @property
541 def tree_id(self) -> str:
542 """
543 Synonym for ``node_id`` for the root node.
544 """
545 return self.node_id