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

1""" 

2crate_anon/crateweb/core/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**Core utility functions for the web interface.** 

27 

28""" 

29 

30from abc import ABC, abstractmethod 

31import datetime 

32import logging 

33import mimetypes 

34import re 

35import urllib.parse 

36from typing import Any, Generator, List, Optional, Union 

37 

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 

45 

46from crate_anon.crateweb.userprofile.models import get_per_page 

47 

48log = logging.getLogger(__name__) 

49 

50 

51# ============================================================================= 

52# User tests/user profile 

53# ============================================================================= 

54 

55 

56def is_superuser(user: settings.AUTH_USER_MODEL) -> bool: 

57 """ 

58 Is the user a superuser? 

59 

60 Function for use with a decorator, e.g. 

61 

62 .. code-block:: python 

63 

64 @user_passes_test(is_superuser) 

65 def some_view(request: HttpRequest) -> HttpResponse: 

66 pass 

67 

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 

72 

73 

74def is_developer(user: settings.AUTH_USER_MODEL) -> bool: 

75 """ 

76 Is the user a developer? 

77 

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 

83 

84 

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 

92 

93 

94# ============================================================================= 

95# Forms 

96# ============================================================================= 

97 

98 

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. 

106 

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 

111 

112 Returns: 

113 a :class:`django.core.paginator.Page` 

114 

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) 

127 

128 

129# ============================================================================= 

130# URL creation 

131# ============================================================================= 

132 

133 

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. 

139 

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 

148 

149 Returns: 

150 the URL with query parameters 

151 

152 Note: 

153 

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 

169 

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) 

177 

178 # Calculate the query string 

179 if qd: 

180 querystring = qd.urlencode() 

181 # for kwargs: querystring = urllib.parse.urlencode(kwargs) 

182 else: 

183 querystring = "" 

184 

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: 

189 

190 components = list(pr) 

191 components[4] = querystring 

192 url = urllib.parse.urlunparse(components) 

193 # log.debug(f"OUT: {url}") 

194 return url 

195 

196 

197def site_absolute_url(path: str) -> str: 

198 """ 

199 Returns an absolute URL for the site, given a relative part. 

200 Use like: 

201 

202 .. code-block:: python 

203 

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 

209 

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. 

214 

215 See also: 

216 

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/ 

219 

220 **IMPORTANT** 

221 

222 BEWARE: :func:`reverse` will produce something different inside a request 

223 and outside it. 

224 

225 - https://stackoverflow.com/questions/32340806/django-reverse-returns-different-values-when-called-from-wsgi-or-shell 

226 

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). 

230 

231 .. code-block:: python 

232 

233 from django.conf import settings 

234 from django.urls import set_script_prefix 

235 

236 set_script_prefix(settings.FORCE_SCRIPT_NAME) 

237 

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 

244 

245 

246# ============================================================================= 

247# Formatting 

248# ============================================================================= 

249 

250 

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}]") 

261 

262 

263# ============================================================================= 

264# Date/time 

265# ============================================================================= 

266 

267 

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") 

273 

274 

275# ============================================================================= 

276# HTTP Content-Type and MIME types 

277# ============================================================================= 

278 

279 

280def guess_mimetype(filename: str, default: str = None) -> Optional[str]: 

281 """ 

282 Guesses a file's MIME type (HTTP Content-Type) from its filename. 

283 

284 Args: 

285 filename: filename 

286 default: value to return if guessing fails 

287 """ 

288 return mimetypes.guess_type(filename)[0] or default 

289 

290 

291# ============================================================================= 

292# Javascript help 

293# ============================================================================= 

294 

295HTML_WHITESPACE = re.compile("[ \n\t]+") 

296 

297 

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. 

302 

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 

310 

311 

312# ============================================================================= 

313# Javascript tree 

314# ============================================================================= 

315 

316 

317class JavascriptTreeNode(ABC): 

318 """ 

319 Represents a node of a :class:`JavascriptTree`. 

320 """ 

321 

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] 

338 

339 def __repr__(self) -> str: 

340 return auto_repr(self) 

341 

342 def set_node_id(self, node_id: str) -> None: 

343 """ 

344 Sets the node's ID. 

345 """ 

346 self.node_id = node_id 

347 

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 

356 

357 @abstractmethod 

358 def html(self) -> str: 

359 """ 

360 Returns HTML for this node. 

361 """ 

362 pass 

363 

364 

365class JavascriptLeafNode(JavascriptTreeNode): 

366 """ 

367 Represents a leaf node of a :class:`JavascriptTree`, i.e. one that launches 

368 some action. 

369 """ 

370 

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 

380 

381 def html(self) -> str: 

382 return f'<li id="{self.node_id}">{self.text}</li>' 

383 

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}' 

391 

392 

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 """ 

398 

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 

416 

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 ) 

427 

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) 

433 

434 

435class JavascriptTree(JavascriptTreeNode): 

436 """ 

437 Represents the root node of an expanding tree implemented via Javascript. 

438 

439 Demo: 

440 

441 .. code-block:: Python 

442 

443 # Django debugging preamble 

444 import os 

445 import django 

446 os.environ['DJANGO_SETTINGS_MODULE'] = 'crate_anon.crateweb.config.settings' 

447 django.setup() 

448 

449 from crate_anon.crateweb.core.utils import ( 

450 JavascriptBranchNode, 

451 JavascriptLeafNode, 

452 JavascriptTree, 

453 ) 

454 

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()) 

469 

470 """ # noqa: E501 

471 

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 

490 

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 

497 

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 

507 

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 ) 

519 

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()) 

526 

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}}}" 

539 

540 @property 

541 def tree_id(self) -> str: 

542 """ 

543 Synonym for ``node_id`` for the root node. 

544 """ 

545 return self.node_id